trashapi (Liste mit Restlaufzeit, Massen-/Einzelwiederherstellung, sofortige endgueltige Loeschung) als Backend-Voraussetzung, Papierkorb-Seite im dms-browser mit Bestaetigungsdialog vor jeder endgueltigen Loeschung. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
258 lines
9.4 KiB
Go
258 lines
9.4 KiB
Go
package trashapi
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"gitea.perlbach24.de/scripte/nexarch/dms/internal/storage"
|
|
)
|
|
|
|
type noopUsageReporter struct{}
|
|
|
|
func (noopUsageReporter) Report(ctx context.Context, tenantSlug, metric string, delta int64) error {
|
|
return nil
|
|
}
|
|
|
|
type testEnv struct {
|
|
pool *pgxpool.Pool
|
|
storage *storage.Service
|
|
server *httptest.Server
|
|
}
|
|
|
|
func setupTest(t *testing.T) (*testEnv, 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 folders (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), parent_folder_id UUID REFERENCES folders(id) ON DELETE CASCADE,
|
|
name TEXT NOT NULL, path TEXT NOT NULL DEFAULT '', created_by UUID NOT NULL REFERENCES users(id),
|
|
trashed_at TIMESTAMPTZ, trashed_via_folder_id UUID REFERENCES folders(id) ON DELETE SET NULL,
|
|
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 REFERENCES folders(id) ON DELETE SET NULL,
|
|
title TEXT NOT NULL, current_revision_id UUID, created_by UUID NOT NULL REFERENCES users(id),
|
|
trashed_at TIMESTAMPTZ, trashed_via_folder_id UUID REFERENCES folders(id) ON DELETE SET NULL,
|
|
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,
|
|
UNIQUE (document_id, revision_number)
|
|
);
|
|
`); err != nil {
|
|
t.Fatalf("schema: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
_, _ = pool.Exec(context.Background(), `TRUNCATE file_revisions, documents, folders, users CASCADE`)
|
|
})
|
|
|
|
storageSvc := storage.NewService(storage.NewLocalDriver(t.TempDir(), []byte("secret"), "https://files.example.test"), noopUsageReporter{}, "acme")
|
|
|
|
var userID string
|
|
if err := pool.QueryRow(ctx, `INSERT INTO users (email, name) VALUES ($1, 'A') RETURNING id`, fmt.Sprintf("trashapi-%d@example.test", time.Now().UnixNano())).Scan(&userID); err != nil {
|
|
t.Fatalf("user: %v", err)
|
|
}
|
|
|
|
server := NewServer(pool, storageSvc, 30*24*time.Hour)
|
|
mux := http.NewServeMux()
|
|
server.RegisterRoutes(mux)
|
|
httpServer := httptest.NewServer(mux)
|
|
t.Cleanup(httpServer.Close)
|
|
|
|
return &testEnv{pool: pool, storage: storageSvc, server: httpServer}, userID
|
|
}
|
|
|
|
func createTrashedDocument(t *testing.T, env *testEnv, userID, title string, trashedAt time.Time) string {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
var documentID string
|
|
if err := env.pool.QueryRow(ctx, `
|
|
INSERT INTO documents (title, created_by, trashed_at) VALUES ($1, $2, $3) RETURNING id
|
|
`, title, userID, trashedAt).Scan(&documentID); err != nil {
|
|
t.Fatalf("dokument anlegen: %v", err)
|
|
}
|
|
key := storage.ObjectKey(documentID, "1")
|
|
content := "inhalt"
|
|
if _, err := env.storage.Put(ctx, key, strings.NewReader(content), int64(len(content)), "text/plain"); err != nil {
|
|
t.Fatalf("objekt ablegen: %v", err)
|
|
}
|
|
var revID string
|
|
if err := env.pool.QueryRow(ctx, `
|
|
INSERT INTO file_revisions (document_id, revision_number, storage_key, checksum_sha256, size_bytes, mime_type, created_by)
|
|
VALUES ($1, 1, $2, 'x', $3, 'text/plain', $4) RETURNING id
|
|
`, documentID, key, len(content), userID).Scan(&revID); err != nil {
|
|
t.Fatalf("revision anlegen: %v", err)
|
|
}
|
|
return documentID
|
|
}
|
|
|
|
// Akzeptanzkriterium 1: Restlaufzeit je Eintrag sichtbar.
|
|
func TestHandleList_ReturnsRemainingSeconds(t *testing.T) {
|
|
env, userID := setupTest(t)
|
|
trashedAt := time.Now().Add(-10 * 24 * time.Hour)
|
|
createTrashedDocument(t, env, userID, "alt.txt", trashedAt)
|
|
|
|
resp, err := http.Get(env.server.URL + "/trash")
|
|
if err != nil {
|
|
t.Fatalf("get: %v", err)
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
var entries []entryDTO
|
|
if err := json.NewDecoder(resp.Body).Decode(&entries); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
if len(entries) != 1 {
|
|
t.Fatalf("erwartet 1 eintrag, habe %d", len(entries))
|
|
}
|
|
// 30 tage frist - 10 tage vergangen = ~20 tage rest.
|
|
wantApprox := int64(20 * 24 * 60 * 60)
|
|
diff := entries[0].RemainingSeconds - wantApprox
|
|
if diff < -60 || diff > 60 {
|
|
t.Fatalf("remaining_seconds = %d, want etwa %d", entries[0].RemainingSeconds, wantApprox)
|
|
}
|
|
}
|
|
|
|
func TestHandleList_EmptyTrashReturnsEmptyList(t *testing.T) {
|
|
env, _ := setupTest(t)
|
|
resp, err := http.Get(env.server.URL + "/trash")
|
|
if err != nil {
|
|
t.Fatalf("get: %v", err)
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
var entries []entryDTO
|
|
if err := json.NewDecoder(resp.Body).Decode(&entries); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
if entries == nil || len(entries) != 0 {
|
|
t.Fatalf("erwartet leere liste, habe %v", entries)
|
|
}
|
|
}
|
|
|
|
// Akzeptanzkriterium 2 / Pflichtpruefung 2: Massenwiederherstellung mit 100
|
|
// Objekten geprueft.
|
|
func TestHandleRestore_RestoresOneHundredDocumentsAtOnce(t *testing.T) {
|
|
env, userID := setupTest(t)
|
|
ids := make([]string, 100)
|
|
for i := range ids {
|
|
ids[i] = createTrashedDocument(t, env, userID, fmt.Sprintf("dok-%d.txt", i), time.Now())
|
|
}
|
|
|
|
body, _ := json.Marshal(selectionRequest{DocumentIDs: ids})
|
|
resp, err := http.Post(env.server.URL+"/trash/restore", "application/json", bytes.NewReader(body))
|
|
if err != nil {
|
|
t.Fatalf("post: %v", err)
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
var result selectionResult
|
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
if len(result.RestoredDocumentIDs) != 100 || len(result.Failed) != 0 {
|
|
t.Fatalf("erwartet 100 wiederhergestellt, 0 fehler, habe restored=%d failed=%d", len(result.RestoredDocumentIDs), len(result.Failed))
|
|
}
|
|
|
|
var stillTrashedCount int
|
|
if err := env.pool.QueryRow(context.Background(), `SELECT count(*) FROM documents WHERE trashed_at IS NOT NULL`).Scan(&stillTrashedCount); err != nil {
|
|
t.Fatalf("zaehlen: %v", err)
|
|
}
|
|
if stillTrashedCount != 0 {
|
|
t.Fatalf("erwartet 0 noch im papierkorb, habe %d", stillTrashedCount)
|
|
}
|
|
}
|
|
|
|
// Akzeptanzkriterium 3: endgültiges Löschen entfernt Dateiobjekt UND
|
|
// Datenbankzeile, funktioniert einzeln (Auswahl mit einem Element).
|
|
func TestHandlePurge_RemovesDocumentAndFileObject(t *testing.T) {
|
|
env, userID := setupTest(t)
|
|
docID := createTrashedDocument(t, env, userID, "loeschen.txt", time.Now())
|
|
|
|
var storageKey string
|
|
if err := env.pool.QueryRow(context.Background(), `SELECT storage_key FROM file_revisions WHERE document_id = $1`, docID).Scan(&storageKey); err != nil {
|
|
t.Fatalf("storage-key lesen: %v", err)
|
|
}
|
|
|
|
body, _ := json.Marshal(selectionRequest{DocumentIDs: []string{docID}})
|
|
resp, err := http.Post(env.server.URL+"/trash/purge", "application/json", bytes.NewReader(body))
|
|
if err != nil {
|
|
t.Fatalf("post: %v", err)
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
var result selectionResult
|
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
if len(result.PurgedDocumentIDs) != 1 {
|
|
t.Fatalf("erwartet 1 endgueltig geloescht, habe: %+v", result)
|
|
}
|
|
|
|
var exists bool
|
|
if err := env.pool.QueryRow(context.Background(), `SELECT EXISTS(SELECT 1 FROM documents WHERE id = $1)`, docID).Scan(&exists); err != nil {
|
|
t.Fatalf("existenz pruefen: %v", err)
|
|
}
|
|
if exists {
|
|
t.Fatalf("dokument haette endgueltig entfernt sein muessen")
|
|
}
|
|
if _, err := env.storage.Get(context.Background(), storageKey); err == nil {
|
|
t.Fatalf("dateiobjekt haette geloescht sein muessen")
|
|
}
|
|
}
|
|
|
|
func TestHandlePurge_RejectsNonTrashedDocument(t *testing.T) {
|
|
env, userID := setupTest(t)
|
|
ctx := context.Background()
|
|
var docID string
|
|
if err := env.pool.QueryRow(ctx, `INSERT INTO documents (title, created_by) VALUES ('aktiv.txt', $1) RETURNING id`, userID).Scan(&docID); err != nil {
|
|
t.Fatalf("dokument anlegen: %v", err)
|
|
}
|
|
|
|
body, _ := json.Marshal(selectionRequest{DocumentIDs: []string{docID}})
|
|
resp, err := http.Post(env.server.URL+"/trash/purge", "application/json", bytes.NewReader(body))
|
|
if err != nil {
|
|
t.Fatalf("post: %v", err)
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
var result selectionResult
|
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
if len(result.PurgedDocumentIDs) != 0 || len(result.Failed) != 1 {
|
|
t.Fatalf("erwartet abgelehnt (nicht im papierkorb), habe: %+v", result)
|
|
}
|
|
|
|
var exists bool
|
|
if err := env.pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM documents WHERE id = $1)`, docID).Scan(&exists); err != nil {
|
|
t.Fatalf("existenz pruefen: %v", err)
|
|
}
|
|
if !exists {
|
|
t.Fatalf("aktives dokument haette NICHT geloescht werden duerfen")
|
|
}
|
|
}
|