Files
nexarch/mail/internal/folderstate/store_test.go
T
sysopsandClaude Sonnet 5 0d3779d03e ING-05: folder-state-uidvalidity-handling
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
2026-08-31 23:37:21 +02:00

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)
}
}