Files
nexarch/dms/internal/versioning/diff_test.go
T
patrickandClaude Sonnet 5 76704330eb VER-03: änderungshistorie & diff-berechnung
Myers-Zeilendiff (DiffText) und Metadaten-Vorher-Nachher (DiffMetadata) im
versioning-Paket, DiffRevisions als fertige Schnittstelle für VER-04.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 23:21:00 +02:00

278 lines
9.7 KiB
Go

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"
)
// 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, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatalf("pool: %v", err)
}
t.Cleanup(func() { pool.Close() })
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)
);
`); 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"), 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)
}
}