Files
Kazier/backend/db/migrations_test.go
T
m 5d96814853 feat(api): sync réel — outbox POST /sync/ops idempotente + snapshot GET /sync/permissions (owner V1)
- operations.operation_id/ref_id BIGINT (= id client pending_operations), UNIQUE(device_id, operation_id) idempotente ; migration 000004 corrigée + test
- service/sync.go : ApplyBatch séquentiel (no-op si déjà traitée ; create déjà présent / update/move sur ressource absente / delete absent = no-op) ;
  arrêt à la première erreur non-idempotente (NAME_CONFLICT, NOT_FOUND, INVALID_REQUEST) + trace après succès ; ops share/share_link accusées sans état (V1)
- handlers/sync.go : POST /sync/ops → {applied, failed|null} ; GET /sync/permissions?after= → snapshot delta (effectiveAccess owner, cachedAt ms)
- snapshot : repo.ListOwned (delta updated_at en ms, trié) ; index partiel racine (owner_id, name) WHERE parent_id IS NULL
- GET /files sans folderId = racine uniquement (convention useFiles(undefined)) ; tests files_test corrigés (codes d'erreur imbriqués, tailles)
- fix dbtest : ping la connexion maintenance (création de la DB cible possible) — les tests handlers/repo tournent enfin contre PG réel
- tests handlers end-to-end : batch appliqué + retry idempotent, arrêt sur NAME_CONFLICT, delete idempotent, snapshot + delta
2026-09-10 19:50:23 +02:00

165 lines
4.4 KiB
Go

package db
import (
"database/sql"
"os"
"strings"
"testing"
_ "github.com/lib/pq"
)
const defaultTestDatabaseURL = "postgres://vaultdrop:vaultdrop@localhost:5432/vaultdrop_migrations_test?sslmode=disable"
func testDatabaseURL(t *testing.T) string {
t.Helper()
url := os.Getenv("TEST_DATABASE_URL")
if url == "" {
url = defaultTestDatabaseURL
}
conn, err := sql.Open("postgres", url)
if err != nil {
t.Fatalf("open: %v", err)
}
defer conn.Close()
if err := conn.Ping(); err != nil {
t.Skipf("postgres indisponible (%v) — lancez `docker compose up postgres -d`", err)
}
return url
}
func resetSchema(t *testing.T, url string) {
t.Helper()
conn, err := sql.Open("postgres", url)
if err != nil {
t.Fatalf("open: %v", err)
}
defer conn.Close()
if _, err := conn.Exec(`DROP SCHEMA public CASCADE; CREATE SCHEMA public;`); err != nil {
t.Fatalf("reset schema: %v", err)
}
}
func tableNames(t *testing.T, conn *sql.DB) map[string]bool {
t.Helper()
rows, err := conn.Query(`
SELECT table_name FROM information_schema.tables
WHERE table_schema = 'public' AND table_type = 'BASE TABLE'`)
if err != nil {
t.Fatalf("list tables: %v", err)
}
defer rows.Close()
names := map[string]bool{}
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
t.Fatalf("scan: %v", err)
}
names[name] = true
}
return names
}
func assertTables(t *testing.T, conn *sql.DB, expected ...string) {
t.Helper()
got := tableNames(t, conn)
for _, table := range expected {
if !got[table] {
t.Errorf("table manquante après migrate up : %s", table)
}
}
}
func assertHexCheck(t *testing.T, conn *sql.DB, table, column string) {
t.Helper()
var def string
err := conn.QueryRow(`
SELECT pg_get_constraintdef(c.oid)
FROM pg_constraint c
JOIN pg_class t ON t.oid = c.conrelid
WHERE t.relname = $1 AND c.contype = 'c'
AND pg_get_constraintdef(c.oid) LIKE '%' || $2 || '%'`,
table, column).Scan(&def)
if err != nil {
t.Fatalf("contrainte CHECK(hex) manquante sur %s.%s: %v", table, column, err)
}
if !strings.Contains(def, "^[0-9a-f]{32}$") {
t.Errorf("CHECK %s.%s attendu avec pattern 32-hex, obtenu : %s", table, column, def)
}
}
func TestMigrationsUpDown(t *testing.T) {
url := testDatabaseURL(t)
resetSchema(t, url)
if err := MigrateDatabase(url); err != nil {
t.Fatalf("migrate up: %v", err)
}
conn, err := sql.Open("postgres", url)
if err != nil {
t.Fatalf("open: %v", err)
}
defer conn.Close()
if err := conn.Ping(); err != nil {
t.Fatalf("ping: %v", err)
}
assertTables(t, conn, "users", "devices", "resources", "operations", "ocr_jobs", "schema_migrations")
assertHexCheck(t, conn, "devices", "device_id")
assertHexCheck(t, conn, "resources", "resource_id")
assertHexCheck(t, conn, "ocr_jobs", "job_id")
// operation_id outbox = id client (INTEGER) — cf. docs/api-v1.md §6.1
var opType string
err = conn.QueryRow(`
SELECT data_type FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'operations' AND column_name = 'operation_id'`).Scan(&opType)
if err != nil {
t.Fatalf("operation_id type: %v", err)
}
if opType != "bigint" {
t.Errorf("operation_id attendu bigint, got %s", opType)
}
var resourceTypeCheck int
err = conn.QueryRow(`
SELECT COUNT(*) FROM pg_constraint c
JOIN pg_class t ON t.oid = c.conrelid
WHERE t.relname = 'resources'
AND pg_get_constraintdef(c.oid) LIKE '%''file''%'
AND pg_get_constraintdef(c.oid) LIKE '%''folder''%'`).Scan(&resourceTypeCheck)
if err != nil {
t.Fatalf("check type resources: %v", err)
}
if resourceTypeCheck == 0 {
t.Error("CHECK (type IN ('file','folder')) manquant sur resources")
}
var opUnique int
err = conn.QueryRow(`
SELECT COUNT(*) FROM pg_index i
JOIN pg_class t ON t.oid = i.indrelid
WHERE t.relname = 'operations' AND i.indisunique
AND ARRAY(SELECT a.attname FROM unnest(i.indkey) WITH ORDINALITY k(attnum, ord)
JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = k.attnum
ORDER BY k.ord)::text[] = ARRAY['device_id','operation_id']`).Scan(&opUnique)
if err != nil {
t.Fatalf("unique operations: %v", err)
}
if opUnique == 0 {
t.Error("contrainte UNIQUE(device_id, operation_id) manquante sur operations")
}
if err := MigrateDownDatabase(url); err != nil {
t.Fatalf("migrate down: %v", err)
}
remaining := tableNames(t, conn)
delete(remaining, "schema_migrations")
if len(remaining) > 0 {
t.Errorf("tables restantes après migrate down : %v", remaining)
}
}