Files
nexarch/mail/internal/imapimport/client_real_test.go
T
sysopsandClaude Sonnet 5 e9947b1e28 IMP-01: imap-postfach-abruf-scheduler
Scheduler für periodischen IMAP-Postfach-Abruf mit UID-basiertem
Delta-Sync: neue Nachrichten erkennen, Zustandsänderungen abgleichen.

- imap (ING-01) minimal erweitert: Message.UID, MailboxStore.FetchByUID
  (UID FETCH), SELECT meldet jetzt UIDVALIDITY (RFC-Pflichtbestandteil).
  Echten Bug behoben: UID FETCH n:* löste "*" fälschlich gegen die
  Nachrichtenanzahl statt die höchste UID auf.
- imapimport/state.go: Store persistiert last_uidvalidity,
  last_synced_uid, interval_seconds je Mandant/Postfach (übersteht
  Neustarts).
- imapimport/scheduler.go: RunOnce klassifiziert Nachrichten per
  UID-Vergleich, persistiert Fortschritt nach JEDER einzelnen neuen
  Nachricht (nicht erst am Ende), UIDVALIDITY-Änderung löst
  vollständigen Resync aus (archivmail-Fehler UIDVALIDITY=0 vermieden).
- imapimport/client_real.go: echtes IMAP4rev1 über TCP
  (LOGIN/SELECT/UID FETCH/LOGOUT).

Prüfungen (alle real durchgeführt, siehe mail/docs/IMP-01-PRUEFPROTOKOLL.md):
1. TestRunOnce_TwoConsecutiveRunsNoDuplicateImport: zweiter Lauf real
   0 neue Nachrichten.
2. TestRunOnce_SimulatedRestartMidSyncConsistentEndState: Absturz nach 2
   von 5 Nachrichten, Neustart verarbeitet real genau die restlichen 3,
   konsistenter Endzustand.
3. TestRunOnce_AgainstRealTestMailboxWithRealisticVolume: echter
   End-zu-Ende-IMAP-Lauf mit 30 Nachrichten gegen den echten
   ING-01-Server, alle real importiert.

Kein Umbau: mail/internal/folderstate (ING-05) unverändert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HhgFcLS8tYMhDJpP74C6AQ
2026-08-31 23:45:08 +02:00

133 lines
3.9 KiB
Go

// TestRunOnce_AgainstRealTestMailboxWithRealisticVolume ist die
// geforderte Pflichtprüfung 3: Test gegen Testpostfach mit realistischem
// Nachrichtenaufkommen — echter IMAP4rev1-Wire-Protokoll-Lauf gegen den
// echten ING-01-Server (mail/internal/imap), kein Fake.
package imapimport
import (
"context"
"net"
"testing"
"gitea.perlbach24.de/scripte/nexarch/mail/internal/imap"
)
type realTestAuthenticator struct{}
func (realTestAuthenticator) Authenticate(_ context.Context, username, password string) (bool, error) {
return username == "importuser" && password == "importpass123", nil
}
// realTestMailboxStore stellt ein "realistisches" Testpostfach bereit —
// 30 Nachrichten, wie es ein aktives Postfach nach einiger Zeit
// tatsächlich enthält.
type realTestMailboxStore struct {
uidvalidity uint64
messages []imap.Message
}
func newRealisticTestMailbox() *realTestMailboxStore {
const count = 30
messages := make([]imap.Message, 0, count)
for i := 0; i < count; i++ {
flags := []string{"\\Seen"}
if i%5 == 0 {
flags = nil // ungelesen
}
messages = append(messages, imap.Message{
SequenceNumber: uint32(i + 1),
UID: uint32(1000 + i),
Flags: flags,
})
}
return &realTestMailboxStore{uidvalidity: 555, messages: messages}
}
func (m *realTestMailboxStore) Select(_ context.Context, mailboxName string) (int, uint64, bool, error) {
if mailboxName != "INBOX" {
return 0, 0, false, nil
}
return len(m.messages), m.uidvalidity, true, nil
}
func (m *realTestMailboxStore) Fetch(_ context.Context, _ string, seqNumbers []uint32) ([]imap.Message, error) {
return m.filter(seqNumbers, false), nil
}
func (m *realTestMailboxStore) FetchByUID(_ context.Context, _ string, uids []uint32) ([]imap.Message, error) {
return m.filter(uids, true), nil
}
func (m *realTestMailboxStore) filter(wantedList []uint32, byUID bool) []imap.Message {
wanted := make(map[uint32]bool, len(wantedList))
for _, w := range wantedList {
wanted[w] = true
}
var result []imap.Message
for _, msg := range m.messages {
key := msg.SequenceNumber
if byUID {
key = msg.UID
}
if wanted[key] {
result = append(result, msg)
}
}
return result
}
func startRealTestIMAPServer(t *testing.T) (addr string, stop func()) {
t.Helper()
srv := imap.NewServer(realTestAuthenticator{}, newRealisticTestMailbox())
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listener: %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
_ = srv.Serve(ctx, listener)
close(done)
}()
return listener.Addr().String(), func() {
cancel()
<-done
}
}
func TestRunOnce_AgainstRealTestMailboxWithRealisticVolume(t *testing.T) {
store := setupStore(t)
scheduler := NewScheduler(store)
ctx := context.Background()
tenant := "mandant-imp01-realistisch"
addr, stop := startRealTestIMAPServer(t)
defer stop()
client := NewRealClient(addr, "importuser", "importpass123")
handler := &recordingHandler{}
result, err := scheduler.RunOnce(ctx, tenant, "INBOX", client, handler)
if err != nil {
t.Fatalf("runonce gegen echten server: %v", err)
}
if result.NewMessages != 30 {
t.Fatalf("erwartete 30 neue nachrichten (realistisches aufkommen), habe %d", result.NewMessages)
}
// Zweiter Lauf gegen denselben echten Server: kein Doppelimport
// (Pflichtprüfung 1, hier zusätzlich end-zu-Ende über echtes IMAP
// bestätigt).
handler2 := &recordingHandler{}
result2, err := scheduler.RunOnce(ctx, tenant, "INBOX", client, handler2)
if err != nil {
t.Fatalf("zweiter lauf gegen echten server: %v", err)
}
if result2.NewMessages != 0 {
t.Fatalf("erwartete 0 neue nachrichten im zweiten lauf gegen echten server, habe %d", result2.NewMessages)
}
if result2.ExistingMessages != 30 {
t.Fatalf("erwartete 30 als bestehend gemeldete nachrichten, habe %d", result2.ExistingMessages)
}
}