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>
278 lines
9.7 KiB
Go
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)
|
|
}
|
|
}
|