package versioning import ( "bytes" "context" "fmt" "os" "strings" "testing" "time" "github.com/jackc/pgx/v5/pgxpool" dmscrypto "gitea.perlbach24.de/scripte/nexarch/dms/internal/crypto" "gitea.perlbach24.de/scripte/nexarch/dms/internal/storage" "gitea.perlbach24.de/scripte/nexarch/dms/internal/testaudit" "gitea.perlbach24.de/scripte/nexarch/dms/internal/testdb" ) // Akzeptanzkriterium 1: Textunterschiede werden zeilenweise geliefert. func TestDiffText_LineLevelChanges(t *testing.T) { old := "eins\nzwei\ndrei" new := "eins\nZWEI\ndrei\nvier" got := DiffText(old, new) var deletes, inserts, equals []string for _, op := range got { switch op.Op { case DiffDelete: deletes = append(deletes, op.Text) case DiffInsert: inserts = append(inserts, op.Text) case DiffEqual: equals = append(equals, op.Text) } } if len(deletes) != 1 || deletes[0] != "zwei" { t.Fatalf("deletes = %v, want [zwei]", deletes) } if len(inserts) != 2 || inserts[0] != "ZWEI" || inserts[1] != "vier" { t.Fatalf("inserts = %v, want [ZWEI vier]", inserts) } if len(equals) != 2 { t.Fatalf("equals = %v, want 2 unveraenderte zeilen", equals) } } // Pflichtpruefung 2: Vergleich zweier identischer Revisionen liefert leeres // Ergebnis (nur DiffEqual-Zeilen, keine Insert/Delete-Operationen). func TestDiffText_IdenticalTextsProduceNoChanges(t *testing.T) { text := "zeile a\nzeile b\nzeile c" got := DiffText(text, text) for _, op := range got { if op.Op != DiffEqual { t.Fatalf("erwartet nur DiffEqual bei identischem text, habe: %+v", got) } } if len(got) != 3 { t.Fatalf("len = %d, want 3", len(got)) } } func TestDiffText_EmptyInputs(t *testing.T) { if got := DiffText("", ""); len(got) != 0 { t.Fatalf("leer/leer: %v, want []", got) } got := DiffText("", "neu") if len(got) != 1 || got[0].Op != DiffInsert || got[0].Text != "neu" { t.Fatalf("leer/neu: %+v", got) } } // Akzeptanzkriterium 2: Metadatenänderungen werden als Vorher-Nachher // ausgegeben. func TestDiffMetadata_ReportsChangedFieldsOnly(t *testing.T) { commentBefore := "erste fassung" commentAfter := "zweite fassung" before := Revision{MimeType: "application/pdf", SizeBytes: 100, Comment: &commentBefore, CreatedBy: "user-a"} after := Revision{MimeType: "application/pdf", SizeBytes: 250, Comment: &commentAfter, CreatedBy: "user-b"} got := DiffMetadata(before, after) byField := map[string]MetadataDiff{} for _, d := range got { byField[d.Field] = d } if _, ok := byField["mime_type"]; ok { t.Fatalf("mime_type unveraendert, sollte nicht im diff stehen: %+v", got) } if d, ok := byField["size_bytes"]; !ok || d.Before != "100" || d.After != "250" { t.Fatalf("size_bytes diff falsch: %+v", byField["size_bytes"]) } if d, ok := byField["comment"]; !ok || d.Before != commentBefore || d.After != commentAfter { t.Fatalf("comment diff falsch: %+v", byField["comment"]) } if d, ok := byField["created_by"]; !ok || d.Before != "user-a" || d.After != "user-b" { t.Fatalf("created_by diff falsch: %+v", byField["created_by"]) } } func TestDiffMetadata_IdenticalRevisionsProduceEmptyResult(t *testing.T) { comment := "gleich" rev := Revision{MimeType: "application/pdf", SizeBytes: 42, Comment: &comment, CreatedBy: "user-a"} got := DiffMetadata(rev, rev) if len(got) != 0 { t.Fatalf("erwartet leeres ergebnis bei identischen revisionen, habe: %+v", got) } } // Pflichtpruefung 3: Laufzeit bei einem 200-seitigen Dokument gemessen. // Eine Buchseite hat ueberschlagen ~50 Zeilen Text, 200 Seiten also // ~10.000 Zeilen je Revision. func TestDiffText_PerformanceOn200PageDocument(t *testing.T) { const linesPerPage = 50 const pages = 200 var oldBuf, newBuf strings.Builder for i := 0; i < linesPerPage*pages; i++ { line := fmt.Sprintf("Zeile %d: lorem ipsum dolor sit amet, konsistenter beispieltext fuer diff-messung.", i) oldBuf.WriteString(line) oldBuf.WriteByte('\n') if i == 5000 { newBuf.WriteString("Zeile 5000: GEAENDERT fuer den vergleichstest.\n") continue } newBuf.WriteString(line) newBuf.WriteByte('\n') } start := time.Now() got := DiffText(oldBuf.String(), newBuf.String()) elapsed := time.Since(start) t.Logf("200-seitiges Dokument (%d Zeilen je Revision): Diff in %s berechnet", linesPerPage*pages, elapsed) if elapsed > 2*time.Second { t.Fatalf("diff hat %s gedauert, erwartet deutlich unter 2s fuer 200 seiten", elapsed) } var changed int for _, op := range got { if op.Op != DiffEqual { changed++ } } if changed == 0 { t.Fatalf("erwartet mindestens eine erkannte aenderung") } } func setupDiffTest(t *testing.T) (*pgxpool.Pool, *storage.Service, *dmscrypto.Service, string) { t.Helper() dsn := os.Getenv("TEST_TENANT_DSN") if dsn == "" { t.Skip("TEST_TENANT_DSN nicht gesetzt, Integrationstest uebersprungen") } ctx := context.Background() pool := testdb.Pool(t, dsn) t.Cleanup(func() { pool.Close() }) testaudit.Ensure(t, pool) if _, err := pool.Exec(ctx, ` CREATE EXTENSION IF NOT EXISTS pgcrypto; CREATE TABLE IF NOT EXISTS users ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), email TEXT NOT NULL UNIQUE, name TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'active', created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE TABLE IF NOT EXISTS documents ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), folder_id UUID, title TEXT NOT NULL, current_revision_id UUID, created_by UUID NOT NULL REFERENCES users(id), created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), deleted_at TIMESTAMPTZ ); CREATE TABLE IF NOT EXISTS file_revisions ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE, revision_number INT NOT NULL, storage_key TEXT NOT NULL, checksum_sha256 TEXT NOT NULL, size_bytes BIGINT NOT NULL, mime_type TEXT NOT NULL, created_by UUID NOT NULL REFERENCES users(id), created_at TIMESTAMPTZ NOT NULL DEFAULT now(), wrapped_dek BYTEA, comment TEXT, UNIQUE (document_id, revision_number) ); CREATE TABLE IF NOT EXISTS document_locks ( document_id UUID PRIMARY KEY REFERENCES documents(id) ON DELETE CASCADE, locked_by UUID NOT NULL REFERENCES users(id), locked_at TIMESTAMPTZ NOT NULL DEFAULT now(), lease_until TIMESTAMPTZ NOT NULL ); `); err != nil { t.Fatalf("schema: %v", err) } t.Cleanup(func() { _, _ = pool.Exec(context.Background(), `TRUNCATE file_revisions, documents, users CASCADE`) }) storageSvc := storage.NewService(storage.NewLocalDriver(t.TempDir(), []byte("secret"), "https://files.example.test", 0), noopUsageReporter{}, "acme") cryptoSvc := dmscrypto.NewService(&fakeKEKProvider{kek: bytes.Repeat([]byte{0x77}, dmscrypto.KEKSize)}) var userID string if err := pool.QueryRow(ctx, `INSERT INTO users (email, name) VALUES ($1, 'A') RETURNING id`, fmt.Sprintf("diff-%d@example.test", time.Now().UnixNano())).Scan(&userID); err != nil { t.Fatalf("user: %v", err) } return pool, storageSvc, cryptoSvc, userID } // Pflichtpruefung 1: Diff an Referenzpaaren gegen erwartetes Ergebnis // geprueft — Ende-zu-Ende ueber echte Check-in-Revisionen inkl. // Ver-/Entschluesselung. func TestDiffRevisions_ReferencePairMatchesExpectedResult(t *testing.T) { pool, storageSvc, cryptoSvc, userID := setupDiffTest(t) ctx := context.Background() var documentID string if err := pool.QueryRow(ctx, `INSERT INTO documents (title, created_by) VALUES ('vertrag.txt', $1) RETURNING id`, userID).Scan(&documentID); err != nil { t.Fatalf("dokument anlegen: %v", err) } if _, err := CheckOut(ctx, pool, documentID, userID, DefaultLeaseDuration); err != nil { t.Fatalf("checkout 1: %v", err) } revA, err := CheckIn(ctx, pool, storageSvc, cryptoSvc, "acme", documentID, userID, "erste fassung", "text/plain", strings.NewReader("Zeile 1\nZeile 2\nZeile 3")) if err != nil { t.Fatalf("checkin 1: %v", err) } if _, err := CheckOut(ctx, pool, documentID, userID, DefaultLeaseDuration); err != nil { t.Fatalf("checkout 2: %v", err) } revB, err := CheckIn(ctx, pool, storageSvc, cryptoSvc, "acme", documentID, userID, "zweite fassung", "text/plain", strings.NewReader("Zeile 1\nZeile 2 geaendert\nZeile 3\nZeile 4")) if err != nil { t.Fatalf("checkin 2: %v", err) } textDiff, metadataDiff, err := DiffRevisions(ctx, pool, storageSvc, cryptoSvc, "acme", documentID, revA.ID, revB.ID) if err != nil { t.Fatalf("DiffRevisions: %v", err) } var deletes, inserts int for _, op := range textDiff { switch op.Op { case DiffDelete: deletes++ case DiffInsert: inserts++ } } if deletes != 1 || inserts != 2 { t.Fatalf("erwartet 1 loeschung, 2 einfuegungen (geaenderte + neue zeile), habe deletes=%d inserts=%d: %+v", deletes, inserts, textDiff) } byField := map[string]MetadataDiff{} for _, d := range metadataDiff { byField[d.Field] = d } if d, ok := byField["comment"]; !ok || d.Before != "erste fassung" || d.After != "zweite fassung" { t.Fatalf("comment-diff falsch: %+v", byField["comment"]) } if d, ok := byField["size_bytes"]; !ok { t.Fatalf("size_bytes haette sich aendern muessen: %+v", metadataDiff) } else if d.Before == d.After { t.Fatalf("size_bytes vorher/nachher identisch, erwartet unterschied: %s", d.Before) } } func TestDiffRevisions_UnknownRevisionReturnsNotFound(t *testing.T) { pool, storageSvc, cryptoSvc, userID := setupDiffTest(t) ctx := context.Background() var documentID string if err := pool.QueryRow(ctx, `INSERT INTO documents (title, created_by) VALUES ('x.txt', $1) RETURNING id`, userID).Scan(&documentID); err != nil { t.Fatalf("dokument anlegen: %v", err) } if _, err := CheckOut(ctx, pool, documentID, userID, DefaultLeaseDuration); err != nil { t.Fatalf("checkout: %v", err) } revA, err := CheckIn(ctx, pool, storageSvc, cryptoSvc, "acme", documentID, userID, "c", "text/plain", strings.NewReader("a")) if err != nil { t.Fatalf("checkin: %v", err) } _, _, err = DiffRevisions(ctx, pool, storageSvc, cryptoSvc, "acme", documentID, revA.ID, "00000000-0000-0000-0000-000000000000") if err != ErrRevisionNotFound { t.Fatalf("err = %v, want ErrRevisionNotFound", err) } }