// Integrationstest (ING-05): echte Postgres-Instanz, folgt derselben // Testhost-Konvention wie mail/internal/dedup/indexworker/savedsearch — // TEST_TENANT_DSN. package folderstate import ( "context" "os" "sync" "testing" "github.com/jackc/pgx/v5/pgxpool" ) func setupStore(t *testing.T) *Store { t.Helper() dsn := os.Getenv("TEST_TENANT_DSN") if dsn == "" { t.Skip("TEST_TENANT_DSN nicht gesetzt, Integrationstest übersprungen") } ctx := context.Background() pool, err := pgxpool.New(ctx, dsn) if err != nil { t.Fatalf("pool: %v", err) } t.Cleanup(func() { pool.Close() }) store := NewStore(pool) if err := store.EnsureSchema(ctx); err != nil { t.Fatalf("schema: %v", err) } t.Cleanup(func() { ctx := context.Background() _, _ = pool.Exec(ctx, `DELETE FROM mail_folder_state WHERE tenant_slug LIKE 'mandant-ing05-%'`) _, _ = pool.Exec(ctx, `DELETE FROM mail_folder_state_events WHERE tenant_slug LIKE 'mandant-ing05-%'`) }) return store } // TestRebuild_ChangesUIDValidityOnSimulatedFolderRebuild ist die // geforderte Pflichtprüfung 1: automatisierter Test für // UIDVALIDITY-Änderung bei simuliertem Ordner-Neuaufbau. func TestRebuild_ChangesUIDValidityOnSimulatedFolderRebuild(t *testing.T) { store := setupStore(t) ctx := context.Background() tenant := "mandant-ing05-rebuild" initial, err := store.GetOrCreate(ctx, tenant, "INBOX") if err != nil { t.Fatalf("getorcreate: %v", err) } if initial.UIDValidity == 0 { t.Fatal("erwartete uidvalidity != 0 (bekannter archivmail-fehler vermeiden)") } // UIDNEXT vor dem Neuaufbau real erhöhen, damit der Reset auf 1 // nachweisbar ist. if _, err := store.NextUID(ctx, tenant, "INBOX"); err != nil { t.Fatalf("nextuid: %v", err) } rebuilt, err := store.Rebuild(ctx, tenant, "INBOX") if err != nil { t.Fatalf("rebuild: %v", err) } if rebuilt.UIDValidity == initial.UIDValidity { t.Fatalf("erwartete geänderte uidvalidity nach neuaufbau, habe weiterhin %d", rebuilt.UIDValidity) } if rebuilt.UIDNext != 1 { t.Fatalf("erwartete uidnext=1 nach neuaufbau, habe %d", rebuilt.UIDNext) } events, err := store.Events(ctx, tenant, "INBOX") if err != nil { t.Fatalf("events: %v", err) } found := false for _, e := range events { if e.EventType == EventRebuilt { found = true } } if !found { t.Fatal("erwartete protokolliertes 'rebuilt'-ereignis (akzeptanzkriterium 3: nachvollziehbar)") } } // TestNextUID_ConcurrentSessionsOnSameFolderNoInconsistency ist die // geforderte Pflichtprüfung 2: Nebenläufigkeitstest — zwei Sessions auf // demselben Ordner ohne Inkonsistenz. func TestNextUID_ConcurrentSessionsOnSameFolderNoInconsistency(t *testing.T) { store := setupStore(t) ctx := context.Background() tenant := "mandant-ing05-concurrent" if _, err := store.GetOrCreate(ctx, tenant, "INBOX"); err != nil { t.Fatalf("getorcreate: %v", err) } const parallelSessions = 20 var wg sync.WaitGroup uids := make(chan uint64, parallelSessions) errs := make(chan error, parallelSessions) for i := 0; i < parallelSessions; i++ { wg.Add(1) go func() { defer wg.Done() uid, err := store.NextUID(ctx, tenant, "INBOX") if err != nil { errs <- err return } uids <- uid }() } wg.Wait() close(uids) close(errs) for err := range errs { t.Fatalf("nextuid unter nebenläufigkeit: %v", err) } seen := make(map[uint64]bool, parallelSessions) for uid := range uids { if seen[uid] { t.Fatalf("uid %d doppelt vergeben — inkonsistenz unter nebenläufigem zugriff", uid) } seen[uid] = true } if len(seen) != parallelSessions { t.Fatalf("erwartete %d eindeutige uids, habe %d", parallelSessions, len(seen)) } } // TestNextUID_MonotonicAcrossManyInsertDeleteCycles ist die geforderte // Pflichtprüfung 3: Test für UIDNEXT-Monotonie über viele Einfüge-/ // Löschzyklen. func TestNextUID_MonotonicAcrossManyInsertDeleteCycles(t *testing.T) { store := setupStore(t) ctx := context.Background() tenant := "mandant-ing05-monotonie" if _, err := store.GetOrCreate(ctx, tenant, "INBOX"); err != nil { t.Fatalf("getorcreate: %v", err) } var lastUID uint64 for i := 0; i < 200; i++ { uid, err := store.NextUID(ctx, tenant, "INBOX") if err != nil { t.Fatalf("nextuid (zyklus %d): %v", i, err) } if i > 0 && uid <= lastUID { t.Fatalf("uidnext nicht monoton steigend: zyklus %d, vorherige uid=%d, neue uid=%d", i, lastUID, uid) } lastUID = uid // Löschung darf UIDNEXT NICHT verändern (RFC 3501: UIDs werden nie // wiederverwendet) — jede zweite Nachricht wird "gelöscht". if i%2 == 0 { if err := store.RecordDeletion(ctx, tenant, "INBOX", uid); err != nil { t.Fatalf("recorddeletion (zyklus %d): %v", i, err) } } } final, err := store.CurrentState(ctx, tenant, "INBOX") if err != nil { t.Fatalf("currentstate: %v", err) } if final.UIDNext != lastUID+1 { t.Fatalf("erwartete uidnext=%d nach 200 vergebenen uids, habe %d", lastUID+1, final.UIDNext) } }