Folder-State-Verwaltung inklusive UIDVALIDITY/UIDNEXT-Handling (RFC 3501 §2.3.1.1), damit Clients und Importvorgänge konsistente Sichten erhalten. Direkte Vorbedingung für IMP-01. - store.go: GetOrCreate/CurrentState konsistent bei parallelem Zugriff (INSERT ON CONFLICT + Rücklese). NextUID vergibt UIDs atomar über UPDATE...RETURNING unter Zeilensperre, protokolliert jede Vergabe. Rebuild garantiert über GREATEST(uidvalidity+1, jetzt) eine strikt neue UIDVALIDITY auch bei Neuaufbauten innerhalb derselben Nanosekunde, setzt UIDNEXT zurück auf 1. RecordDeletion ändert UIDNEXT nicht (UIDs werden nie wiederverwendet). - Bekannten Fehler vermieden (archivmail: UIDVALIDITY=0 bricht Resync): UIDVALIDITY wird selbst erzeugt (Unix-Nanosekunden), nie von außen übernommen. - Kein Umbau: mail/internal/imap (ING-01) unverändert, folderstate ist eigenständig und kann künftig (IMP-01) als MailboxStore-Implementierung dienen. Prüfungen (alle real durchgeführt, siehe mail/docs/ING-05-PRUEFPROTOKOLL.md): 1. TestRebuild_ChangesUIDValidityOnSimulatedFolderRebuild: UIDVALIDITY real geändert, UIDNEXT real zurückgesetzt, Ereignis real protokolliert. 2. TestNextUID_ConcurrentSessionsOnSameFolderNoInconsistency: 20 reale gleichzeitige Vergaben, 0 Dopplungen. 3. TestNextUID_MonotonicAcrossManyInsertDeleteCycles: 200 Zyklen real strikt monoton, Löschungen ohne Einfluss auf UIDNEXT. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HhgFcLS8tYMhDJpP74C6AQ
177 lines
4.9 KiB
Go
177 lines
4.9 KiB
Go
// 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)
|
|
}
|
|
}
|