add tests

This commit is contained in:
m
2026-09-13 19:24:58 +02:00
parent c054b90c92
commit 5578e4a354
19 changed files with 1599 additions and 1 deletions
+126
View File
@@ -328,6 +328,30 @@ func TestSearchFiles(t *testing.T) {
}
}
func TestSearchFilesEscapesPercent(t *testing.T) {
r, _, repo := setup(t)
device := repository.NewID()
token, user := registerAndLogin(t, r, repo, testUserUsername(device, "sp"), "search-test-password", device)
if err := repo.Resources.InsertFile(user, repository.NewID(), "half%price.txt", "", 1, nil, nil); err != nil {
t.Fatalf("insert percent file: %v", err)
}
if err := repo.Resources.InsertFile(user, repository.NewID(), "plain.txt", "", 1, nil, nil); err != nil {
t.Fatalf("insert plain file: %v", err)
}
// q=% (encodé %25) ne doit matcher QUE le nom contenant un '%' littéral.
rec, _ := doRequest(t, r, http.MethodGet, "/api/v1/files/search?q=%25", token, nil, "")
env := expectOK(t, rec, "search-percent")
var files []fileDTO
if err := json.Unmarshal(env.Data, &files); err != nil {
t.Fatalf("search-percent: unmarshal: %v", err)
}
if len(files) != 1 || files[0].Name != "half%price.txt" {
t.Errorf("'%%' littéral : attendu 1 résultat, got %+v", files)
}
}
func TestUploadTooLarge(t *testing.T) {
r, _, repo := setup(t)
device := repository.NewID()
@@ -337,6 +361,30 @@ func TestUploadTooLarge(t *testing.T) {
expectError(t, rec, http.StatusRequestEntityTooLarge, "FILE_TOO_LARGE", "upload-big")
}
func TestUploadIntoUnknownFolder(t *testing.T) {
r, _, repo := setup(t)
device := repository.NewID()
token, _ := registerAndLogin(t, r, repo, testUserUsername(device, "uf"), "upload-test-password", device)
rec := uploadMultipart(t, r, token, repository.NewID(), "orphan.txt", []byte("hi"))
expectError(t, rec, http.StatusNotFound, "NOT_FOUND", "upload-unknown-folder")
}
func TestUploadNameConflict(t *testing.T) {
r, _, repo := setup(t)
device := repository.NewID()
token, _ := registerAndLogin(t, r, repo, testUserUsername(device, "uc"), "upload-test-password", device)
rec := uploadMultipart(t, r, token, "", "dupe.txt", []byte("hi"))
if rec.Code != http.StatusOK {
t.Fatalf("premier upload: status %d body=%s", rec.Code, rec.Body.String())
}
// Même nom à la racine → conflit d'unicité (parent_id NULL)
rec = uploadMultipart(t, r, token, "", "dupe.txt", []byte("hi"))
expectError(t, rec, http.StatusConflict, "NAME_CONFLICT", "upload-dupe")
}
func TestFoldersListAndScoping(t *testing.T) {
r, _, repo := setup(t)
deviceA := repository.NewID()
@@ -367,3 +415,81 @@ func TestFoldersListAndScoping(t *testing.T) {
t.Errorf("folders A: %+v", folders)
}
}
func TestFilesPaginationDefaultsAndClamp(t *testing.T) {
r, _, repo := setup(t)
device := repository.NewID()
token, _ := registerAndLogin(t, r, repo, testUserUsername(device, "pg"), "pagination-test-password", device)
// pageSize au-delà de 200 → clampé à 200
rec, _ := doRequest(t, r, http.MethodGet, "/api/v1/files?pageSize=9999", token, nil, "")
env := expectOK(t, rec, "pageSize-clamp")
if env.Meta == nil || env.Meta.Page != 1 || env.Meta.PageSize != 200 {
t.Errorf("clamp pageSize: %+v", env.Meta)
}
// Paramètres invalides → défauts (page=1, pageSize=50)
rec, _ = doRequest(t, r, http.MethodGet, "/api/v1/files?page=0&pageSize=-5", token, nil, "")
env = expectOK(t, rec, "invalid-params")
if env.Meta == nil || env.Meta.Page != 1 || env.Meta.PageSize != 50 {
t.Errorf("defauts page/pageSize: %+v", env.Meta)
}
// sort/order inconnus → pas d'erreur (défaut created_at desc)
rec, _ = doRequest(t, r, http.MethodGet, "/api/v1/files?sort=zzz&order=up&pageSize=10", token, nil, "")
expectOK(t, rec, "invalid-sort")
}
func TestFilesListSortBySize(t *testing.T) {
r, _, repo := setup(t)
device := repository.NewID()
token, user := registerAndLogin(t, r, repo, testUserUsername(device, "sr"), "sort-test-password", device)
// Tri volontairement désordonné : 30, 10, 20.
if err := repo.Resources.InsertFile(user, repository.NewID(), "a.txt", "", 30, nil, nil); err != nil {
t.Fatalf("insert a: %v", err)
}
if err := repo.Resources.InsertFile(user, repository.NewID(), "b.txt", "", 10, nil, nil); err != nil {
t.Fatalf("insert b: %v", err)
}
if err := repo.Resources.InsertFile(user, repository.NewID(), "c.txt", "", 20, nil, nil); err != nil {
t.Fatalf("insert c: %v", err)
}
rec, _ := doRequest(t, r, http.MethodGet, "/api/v1/files?sort=size&order=asc", token, nil, "")
env := expectOK(t, rec, "sort-size")
var files []fileDTO
if err := json.Unmarshal(env.Data, &files); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if len(files) != 3 || files[0].Size != 10 || files[1].Size != 20 || files[2].Size != 30 {
t.Errorf("ordre size asc inattendu: %+v", files)
}
}
func TestFilesGetDeleteRejectInvalidID(t *testing.T) {
r, _, repo := setup(t)
device := repository.NewID()
token, user := registerAndLogin(t, r, repo, testUserUsername(device, "ii"), "invalid-id-test-password", device)
// ID non 32-hex → 404 avant toute requête.
rec, _ := doRequest(t, r, http.MethodGet, "/api/v1/files/NOT-HEX-ID", token, nil, "")
expectError(t, rec, http.StatusNotFound, "NOT_FOUND", "get-invalid-id")
rec, _ = doRequest(t, r, http.MethodDelete, "/api/v1/files/xyz", token, nil, "")
expectError(t, rec, http.StatusNotFound, "NOT_FOUND", "delete-invalid-id")
// ID 32-hex mais inconnu (du même user) → 404.
rec, _ = doRequest(t, r, http.MethodGet, "/api/v1/files/"+repository.NewID(), token, nil, "")
expectError(t, rec, http.StatusNotFound, "NOT_FOUND", "get-unknown")
// Le user ne voit jamais les fichiers d'un autre même avec un ID valide.
fileID := repository.NewID()
if err := repo.Resources.InsertFile(user, fileID, "mine.txt", "", 4, nil, nil); err != nil {
t.Fatalf("insert: %v", err)
}
otherDevice := repository.NewID()
otherToken, _ := registerAndLogin(t, r, repo, testUserUsername(otherDevice, "ii2"), "invalid-id-test-password-b", otherDevice)
rec, _ = doRequest(t, r, http.MethodGet, "/api/v1/files/"+fileID, otherToken, nil, "")
expectError(t, rec, http.StatusNotFound, "NOT_FOUND", "get-cross-user")
}
+32
View File
@@ -0,0 +1,32 @@
package handlers_test
import (
"encoding/json"
"net/http"
"testing"
)
func TestHealth(t *testing.T) {
r, _, _ := setup(t)
rec, _ := doRequest(t, r, http.MethodGet, "/api/v1/health", "", nil, "")
if rec.Code != http.StatusOK {
t.Fatalf("health: status %d body=%s", rec.Code, rec.Body.String())
}
var env struct {
Data struct {
Status string `json:"status"`
} `json:"data"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &env); err != nil {
t.Fatalf("health: unmarshal: %v", err)
}
if env.Data.Status != "healthy" {
t.Errorf("health status = %q, attendu healthy", env.Data.Status)
}
}
func TestUnknownRouteReturnsJsonEnvelope(t *testing.T) {
r, _, _ := setup(t)
rec, _ := doRequest(t, r, http.MethodGet, "/api/v1/does-not-exist", "", nil, "")
expectError(t, rec, http.StatusNotFound, "NOT_FOUND", "unknown-route")
}
+8
View File
@@ -1,9 +1,12 @@
package handlers
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/vaultdrop/backend/pkg/api"
)
// RegisterRoutes wires the full /api/v1 surface (public + protected).
@@ -36,6 +39,11 @@ func RegisterRoutes(r *gin.Engine) {
protected.POST("/sync/ops", SyncOpsPush)
protected.GET("/sync/permissions", SyncPermissionsGet)
}
// Route inconnue → enveloppe d'erreur du contrat (jamais de HTML).
r.NoRoute(func(c *gin.Context) {
api.Error(c, http.StatusNotFound, "NOT_FOUND", "route not found")
})
}
// userID lit la clé de scoping posée par RequireAuth. Un empty string est
+103
View File
@@ -151,6 +151,109 @@ func TestSyncOpsCreateResourceWithParent(t *testing.T) {
}
}
func TestSyncOpsRejectsMalformedBody(t *testing.T) {
r, _, repo := setup(t)
device := repository.NewID()
token, _ := registerAndLogin(t, r, repo, testUserUsername(device, "sm"), "sync-test-password", device)
rec, _ := doRequest(t, r, http.MethodPost, "/api/v1/sync/ops", token, []byte(`{invalid`), "application/json")
expectError(t, rec, http.StatusBadRequest, "INVALID_REQUEST", "sync-malformed")
}
func TestSyncOpsEmptyBatch(t *testing.T) {
r, _, repo := setup(t)
device := repository.NewID()
token, _ := registerAndLogin(t, r, repo, testUserUsername(device, "se"), "sync-test-password", device)
rec, _ := doRequest(t, r, http.MethodPost, "/api/v1/sync/ops", token, syncOpsBody(nil), "application/json")
env := expectOK(t, rec, "sync-empty")
var result struct {
Applied int `json:"applied"`
}
if err := json.Unmarshal(env.Data, &result); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if result.Applied != 0 {
t.Errorf("batch vide: applied attendu 0, got %d", result.Applied)
}
}
func TestSyncOpsReplayCreateIsNoop(t *testing.T) {
r, _, repo := setup(t)
device := repository.NewID()
token, user := registerAndLogin(t, r, repo, testUserUsername(device, "srn"), "sync-test-password", device)
fileID := repository.NewID()
send := func(batch []map[string]any) int {
t.Helper()
rec, _ := doRequest(t, r, http.MethodPost, "/api/v1/sync/ops", token, syncOpsBody(batch), "application/json")
env := expectOK(t, rec, "sync-send")
var result struct {
Applied int `json:"applied"`
}
if err := json.Unmarshal(env.Data, &result); err != nil {
t.Fatalf("unmarshal: %v body=%s", err, rec.Body.String())
}
return result.Applied
}
ops := []map[string]any{
op(repository.NewID(), fileID, "create_resource", "file", map[string]any{"name": "note.txt"}),
}
if applied := send(ops); applied != 1 {
t.Fatalf("premier envoi: applied attendu 1, got %d", applied)
}
// Rejeu avec le MÊME resource_id mais un operation_id neuf → idempotent
// (la ressource existe déjà : no-op), pas de doublon côté ressources.
if applied := send([]map[string]any{
op(repository.NewID(), fileID, "create_resource", "file", map[string]any{"name": "note.txt"}),
}); applied != 1 {
t.Fatalf("rejeu: applied attendu 1, got %d", applied)
}
files, total, err := repo.Resources.ListFiles(user, "", 10, 0, "created_at", "desc")
if err != nil {
t.Fatalf("list: %v", err)
}
if total != 1 || len(files) != 1 || files[0].ID != fileID {
t.Errorf("pas de doublon attendu: total=%d files=%+v", total, files)
}
}
func TestSyncOpsLargeBatchAccepted(t *testing.T) {
r, _, repo := setup(t)
device := repository.NewID()
token, user := registerAndLogin(t, r, repo, testUserUsername(device, "sl"), "sync-test-password", device)
ops := make([]map[string]any, 0, 25)
for i := 0; i < 25; i++ {
ops = append(ops, op(repository.NewID(), repository.NewID(), "create_resource", "file", map[string]any{"name": fmt.Sprintf("f%02d.txt", i)}))
}
rec, _ := doRequest(t, r, http.MethodPost, "/api/v1/sync/ops", token, syncOpsBody(ops), "application/json")
env := expectOK(t, rec, "sync-large")
var result struct {
Applied int `json:"applied"`
Failed any `json:"failed"`
}
if err := json.Unmarshal(env.Data, &result); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if result.Applied != 25 || result.Failed != nil {
t.Errorf("attendu applied=25 failed=null, got %+v", result)
}
files, total, err := repo.Resources.ListFiles(user, "", 50, 0, "created_at", "desc")
if err != nil {
t.Fatalf("list: %v", err)
}
if total != 25 || len(files) != 25 {
t.Errorf("les 25 ressources doivent être persistées, total=%d files=%d", total, len(files))
}
}
func TestSyncOpsStopsAtFirstNonIdempotentFailure(t *testing.T) {
r, _, repo := setup(t)
device := repository.NewID()