diff --git a/mail/docs/MAI-02-PRUEFPROTOKOLL.md b/mail/docs/MAI-02-PRUEFPROTOKOLL.md new file mode 100644 index 00000000..25f3c45a --- /dev/null +++ b/mail/docs/MAI-02-PRUEFPROTOKOLL.md @@ -0,0 +1,54 @@ +# MAI-02 – Prüfprotokoll: Nachrichten- und Anhang-Migration + +Datum: 2026-10-14 +Umgebung: Host 204, Wegwerf-Arbeitsverzeichnis (`/root/mai02-work-*`), Go-Modul `mail` +Alle Prüfungen laufen ausschließlich gegen **synthetische** Testfixtures (frei erfundene Beispiel-E-Mails/Anhänge) — keine echten archivmail-Daten wurden angefasst oder kontaktiert (DSGVO-Hartregel). + +## Architektur-Entscheidung + +Im Mail-Zielsystem existiert keine separate Postgres-Metadatentabelle für Nachrichten: `mail/internal/mailapi` liest Nachrichtenmetadaten bereits heute ausschließlich über `search.Client.GetByMessageID` (Manticore-Index, SRC-01) und Anhänge über `storage.Service.GetVerified` (ARC-01, unverschlüsselt — `encstorage`/ARC-02 ist bislang in keinem realen Schreibpfad des Mail-Moduls verdrahtet, nur in Tests referenziert). Der neue Migrator (`mail/internal/messagemigration`) übernimmt deshalb: + +- Binärdaten (Anhänge) per Objekt-Storage-Kopie über `storage.Service` (gleicher Schlüssel `storage.ObjectKey(messageID, index)`, den auch `mailapi`/`mailexport` verwenden), mit Prüfsummen-Vergleich (`mail/internal/dedup.HashAndBuffer`) gegen die von `Source` gelieferte Quell-Prüfsumme. +- Metadaten/Referenzen per SQL-Export/Import über ein `Source`-Interface (Muster aus MAI-01 `mailboxmigration`/DOK-01 `migrationdocument`), geschrieben als `search.Document` über den bestehenden, unveränderten `search.Client` (`Index`/`GetByMessageID`). +- `indexed_at` aus der Quelle (`SourceMessage.IndexedAt`) wird nirgendwo gelesen und existiert im Ziel-`search.Document` strukturell nicht — es wird nicht übernommen (Akzeptanzkriterium 3). +- Deterministische Ziel-`messageID` (`MessageID(tenantSlug, sourceMessageID)`) macht erneute Läufe idempotent (keine Duplikate, bereits migrierte Nachrichten werden übersprungen). + +CUT-05 (Suchindex-Aufbau) ist nicht Bestandteil dieser Kachel — der Migrator ruft lediglich die bereits bestehende `search.Client.Index`-Schnittstelle für migrierte Nachrichten auf, baut keine generische Indexierungslogik. + +## Automatisierte Tests (je Akzeptanzkriterium mindestens einer) + +Datei: `mail/internal/messagemigration/messagemigration_test.go`, `messagemigration_mailapi_test.go` + +| Test | Deckt ab | Ergebnis | +|---|---|---| +| `TestMigrateAll_MessagesAndAttachmentsReferenceable` | AK1: Nachricht+Anhänge vollständig referenzierbar | PASS | +| `TestMigrateAll_AttachmentChecksumMatchesSource` | Prüfung 1 (Hash-Vergleich gegen Quelle), AK2 | PASS | +| `TestMigrateAll_ChecksumMismatchRejected` | AK2: Konsistenz Binär/Metadaten (Ablehnung bei Abweichung) | PASS | +| `TestMigrateAll_RowCountPerTenantMatchesSource` | Prüfung 2 (Zeilenzahl je Tenant deckungsgleich) | PASS | +| `TestMigrateAll_IndexedAtNotCarriedOver` | AK3: `indexed_at` nicht übernommen | PASS | +| `TestMigrateAll_RerunIsIdempotent` | Zusatz: kein Duplikat bei erneutem Lauf | PASS | +| `TestMigrateAll_MigratedMessageReadableThroughRealMailAPI` | Prüfung 3 (echter Lesezugriff über bestehende Mail-API, Nachricht + Anhang) | PASS | + +Lauf: `go test ./internal/messagemigration/... -v` → **7/7 PASS**. +Regressionslauf des gesamten Mail-Moduls: `go test ./...` im Modul `mail` → **alle Pakete PASS** (keine Seiteneffekte auf bestehende Pakete). + +## Prüfungen vor der Abnahme (real ausgeführt) + +1. **Stichprobenweiser Hash-Vergleich migrierter Anhänge gegen (synthetische) Quelle** + Zielwert: SHA-256 des migrierten Anhangs == SHA-256 des synthetischen Quellanhangs. + Ergebnis: `TestMigrateAll_AttachmentChecksumMatchesSource` — Hashes identisch, PASS. + +2. **Zeilenzahl migrierter Nachrichten je Tenant deckungsgleich mit Quelle** + Zielwert: Anzahl migrierter Nachrichten == Anzahl Quellnachrichten (2 synthetische Nachrichten im Fixture). + Ergebnis: `TestMigrateAll_RowCountPerTenantMatchesSource` — 2 == 2, PASS. + +3. **Öffnen einer Stichprobe migrierter Nachrichten samt Anhang über NEXARCH erfolgreich (echter Lesezugriff über bestehende Mail-API)** + Zielwert: `GET /api/v1/mail/messages/{messageID}` liefert 200 mit korrektem Betreff; `GET /api/v1/mail/messages/{messageID}/attachments/0` liefert 200 mit byte-identischem Anhangsinhalt — über einen echten, unveränderten `*mailapi.Server` samt Modul-Token-Prüfung (`moduleauth`), nicht nur ein DB-/Objekt-Check. + Ergebnis: `TestMigrateAll_MigratedMessageReadableThroughRealMailAPI` — beide Anfragen 200, Betreff und Anhangsinhalt korrekt, PASS. + +## Zusammenfassung + +- Tests gesamt: 7 (messagemigration) — alle PASS. +- Regressionstests Mail-Modul: alle PASS. +- Prüfungen 1–3: alle real ausgeführt, Zielwerte erreicht. +- Keine neue SQL-Migrationsdatei nötig (keine neue Postgres-Tabelle — Metadaten laufen über den bestehenden Manticore-Index). diff --git a/mail/internal/messagemigration/id.go b/mail/internal/messagemigration/id.go new file mode 100644 index 00000000..d37ff8c6 --- /dev/null +++ b/mail/internal/messagemigration/id.go @@ -0,0 +1,19 @@ +package messagemigration + +import ( + "crypto/sha256" + "fmt" +) + +// deriveID erzeugt eine deterministische ID aus Namespace und beliebigen +// Schlüsselteilen — Grundlage für die Idempotenz von MigrateAll (siehe +// MessageID). +func deriveID(namespace string, parts ...string) string { + h := sha256.New() + h.Write([]byte(namespace)) + for _, p := range parts { + h.Write([]byte{0}) + h.Write([]byte(p)) + } + return fmt.Sprintf("mig-%x", h.Sum(nil)[:16]) +} diff --git a/mail/internal/messagemigration/messagemigration.go b/mail/internal/messagemigration/messagemigration.go new file mode 100644 index 00000000..09d612ff --- /dev/null +++ b/mail/internal/messagemigration/messagemigration.go @@ -0,0 +1,193 @@ +// Package messagemigration implementiert MAI-02: Übernahme aller +// Nachrichten und Anhänge (archivmail email_refs) nach Mail ARC-01/ARC-04 +// — Binärdaten per Objekt-Storage-Kopie über mail/internal/storage, +// Metadaten/Referenzen per SQL-Export/Import über Source in den +// bestehenden Manticore-Suchindex (mail/internal/search, SRC-01) — im +// Zielsystem gibt es keine separate Postgres-Metadatentabelle für +// Nachrichten, mailapi liest Metadaten bereits heute ausschließlich über +// search.Client.GetByMessageID. +// +// Storage-Service statt encstorage: mailapi.handleGetAttachment +// (bestehender, unveränderter Lese-Endpunkt) liest Anhänge aktuell über +// storage.Service.GetVerified OHNE Entschlüsselung — encstorage ist im +// Mail-Modul bislang in keinem realen Schreibpfad verdrahtet (Prüfung per +// Repository-Suche: kein Aufrufer außer Tests). Ein über encstorage +// abgelegter Anhang wäre über den bestehenden Endpunkt nicht lesbar und +// würde Prüfung 3 (echter Lesezugriff über die Mail-API) verfehlen. +// +// SourceMessage trägt IndexedAt bewusst nur zur Dokumentation der +// Quellstruktur — das Feld wird von MigrateAll nirgendwo gelesen +// (Akzeptanzkriterium 3: der Suchindex wird komplett neu aufgebaut, +// siehe CUT-05, nicht Teil dieser Kachel). +package messagemigration + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + + "gitea.perlbach24.de/scripte/nexarch/mail/internal/dedup" + "gitea.perlbach24.de/scripte/nexarch/mail/internal/search" + "gitea.perlbach24.de/scripte/nexarch/mail/internal/storage" +) + +// SourceMessage ist eine Zeile aus archivmails email_refs (SQL-Export). +// IndexedAt wird absichtlich nicht übernommen (Akzeptanzkriterium 3). +type SourceMessage struct { + SourceMessageID string + Mailbox string + Sender string + Subject string + Body string + SentAtUnixEpoch int64 + IndexedAt *int64 +} + +// SourceAttachment ist eine Zeile aus archivmails Anhangstabelle, +// ObjectKey verweist auf die Binärdatei im Quell-Objekt-Storage. +type SourceAttachment struct { + SourceAttachmentID string + Index int + MimeType string + SizeBytes int64 + ChecksumSHA256 string + ObjectKey string +} + +// Source kapselt den lesenden Zugriff auf archivmail: Metadaten per +// SQL-Export (Messages/Attachments), Binärdaten per Objekt-Storage-Kopie +// (OpenObject). Eine echte Implementierung liest gegen archivmail, Tests +// verwenden ausschließlich synthetische Fixtures. +type Source interface { + Messages(ctx context.Context) ([]SourceMessage, error) + Attachments(ctx context.Context, sourceMessageID string) ([]SourceAttachment, error) + OpenObject(ctx context.Context, objectKey string) (io.ReadCloser, error) +} + +// SearchIndexer ist die für die Migration benötigte Teilmenge von +// *search.Client — als Schnittstelle, damit Tests einen Fake verwenden +// können, ohne eine echte Manticore-Instanz zu brauchen. +type SearchIndexer interface { + GetByMessageID(ctx context.Context, tenantSlug, messageID string) (search.Document, bool, error) + Index(ctx context.Context, doc search.Document) error +} + +// ErrChecksumMismatch meldet eine Abweichung zwischen der beim Kopieren +// tatsächlich berechneten Prüfsumme und der von Source gelieferten +// Metadaten-Prüfsumme (Prüfung 1: Hash-Vergleich gegen Quelle). +var ErrChecksumMismatch = errors.New("messagemigration: prüfsumme weicht von quelle ab") + +// Migrator überträgt Nachrichten+Anhänge einer Quelle in genau einen +// Ziel-Mandanten (ARC-06: physisch getrennter Objekt-Storage je Mandant). +type Migrator struct { + source Source + storageSvc *storage.Service + indexer SearchIndexer + tenantSlug string +} + +func NewMigrator(source Source, storageSvc *storage.Service, indexer SearchIndexer, tenantSlug string) *Migrator { + return &Migrator{source: source, storageSvc: storageSvc, indexer: indexer, tenantSlug: tenantSlug} +} + +// Result fasst den Ausgang eines Migrationslaufs zusammen. +type Result struct { + MessagesMigrated int + MessagesSkipped int + AttachmentsMigrated int +} + +// MessageID leitet deterministisch die Ziel-Nachrichten-ID aus der +// Quell-ID ab — ein erneuter Lauf gegen dieselbe Quelle trifft so immer +// dieselbe Nachricht (Idempotenz), ohne zusätzliche Mapping-Tabelle. +func MessageID(tenantSlug, sourceMessageID string) string { + return deriveID("message", tenantSlug, sourceMessageID) +} + +// MigrateAll überträgt alle Nachrichten samt Anhängen (Akzeptanzkriterium +// 1). Eine bereits migrierte Nachricht (gleiche abgeleitete MessageID im +// Suchindex gefunden) wird übersprungen, statt erneut geschrieben zu +// werden. +func (m *Migrator) MigrateAll(ctx context.Context) (Result, error) { + var res Result + messages, err := m.source.Messages(ctx) + if err != nil { + return res, fmt.Errorf("messagemigration: nachrichten aus quelle lesen: %w", err) + } + + for _, msg := range messages { + if err := m.migrateMessage(ctx, msg, &res); err != nil { + return res, err + } + } + return res, nil +} + +func (m *Migrator) migrateMessage(ctx context.Context, msg SourceMessage, res *Result) error { + messageID := MessageID(m.tenantSlug, msg.SourceMessageID) + + if _, found, err := m.indexer.GetByMessageID(ctx, m.tenantSlug, messageID); err != nil { + return fmt.Errorf("messagemigration: bestand von nachricht %q prüfen: %w", msg.SourceMessageID, err) + } else if found { + res.MessagesSkipped++ + return nil + } + + attachments, err := m.source.Attachments(ctx, msg.SourceMessageID) + if err != nil { + return fmt.Errorf("messagemigration: anhänge für nachricht %q lesen: %w", msg.SourceMessageID, err) + } + + for _, att := range attachments { + if err := m.migrateAttachment(ctx, messageID, msg.SourceMessageID, att); err != nil { + return err + } + res.AttachmentsMigrated++ + } + + doc := search.Document{ + ID: search.DocumentID(m.tenantSlug, messageID), + TenantSlug: m.tenantSlug, + MessageID: messageID, + Subject: msg.Subject, + Body: msg.Body, + Sender: msg.Sender, + Mailbox: msg.Mailbox, + SentAtUnixEpoch: msg.SentAtUnixEpoch, + } + if err := m.indexer.Index(ctx, doc); err != nil { + return fmt.Errorf("messagemigration: nachricht %q indexieren: %w", msg.SourceMessageID, err) + } + + res.MessagesMigrated++ + return nil +} + +func (m *Migrator) migrateAttachment(ctx context.Context, messageID, sourceMessageID string, att SourceAttachment) error { + raw, err := m.source.OpenObject(ctx, att.ObjectKey) + if err != nil { + return fmt.Errorf("messagemigration: binärobjekt für anhang %q (nachricht %q) öffnen: %w", att.SourceAttachmentID, sourceMessageID, err) + } + defer func() { _ = raw.Close() }() + + checksum, buffered, err := dedup.HashAndBuffer(raw) + if err != nil { + return fmt.Errorf("messagemigration: anhang %q (nachricht %q) hashen: %w", att.SourceAttachmentID, sourceMessageID, err) + } + if checksum != att.ChecksumSHA256 { + return fmt.Errorf("%w: anhang %q (nachricht %q, quelle %s, kopiert %s)", ErrChecksumMismatch, att.SourceAttachmentID, sourceMessageID, att.ChecksumSHA256, checksum) + } + + content, err := io.ReadAll(buffered) + if err != nil { + return fmt.Errorf("messagemigration: anhang %q (nachricht %q) puffern: %w", att.SourceAttachmentID, sourceMessageID, err) + } + + key := storage.ObjectKey(messageID, att.Index) + if _, err := m.storageSvc.Put(ctx, key, bytes.NewReader(content), int64(len(content)), att.MimeType); err != nil { + return fmt.Errorf("messagemigration: anhang %q (nachricht %q) ablegen: %w", att.SourceAttachmentID, sourceMessageID, err) + } + return nil +} diff --git a/mail/internal/messagemigration/messagemigration_mailapi_test.go b/mail/internal/messagemigration/messagemigration_mailapi_test.go new file mode 100644 index 00000000..823577c1 --- /dev/null +++ b/mail/internal/messagemigration/messagemigration_mailapi_test.go @@ -0,0 +1,130 @@ +package messagemigration + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + + "gitea.perlbach24.de/scripte/nexarch/mail/internal/mailapi" + "gitea.perlbach24.de/scripte/nexarch/mail/internal/moduleauth" + "gitea.perlbach24.de/scripte/nexarch/mail/internal/search" + "gitea.perlbach24.de/scripte/nexarch/mail/internal/storage" +) + +// realSearchIndexer bündelt GetByMessageID/Index/Search in einem +// Fake, der zusätzlich mailapi.SearchClient erfüllt — damit Prüfung 3 +// (echter Lesezugriff über die bestehende Mail-API) einen ECHTEN +// *mailapi.Server bedient statt nur die Migrator-internen Strukturen zu +// vergleichen. +type realSearchIndexer struct { + docs map[string]search.Document +} + +func newRealSearchIndexer() *realSearchIndexer { + return &realSearchIndexer{docs: make(map[string]search.Document)} +} + +func (r *realSearchIndexer) GetByMessageID(_ context.Context, tenantSlug, messageID string) (search.Document, bool, error) { + doc, ok := r.docs[tenantSlug+"|"+messageID] + return doc, ok, nil +} + +func (r *realSearchIndexer) Index(_ context.Context, doc search.Document) error { + r.docs[doc.TenantSlug+"|"+doc.MessageID] = doc + return nil +} + +func (r *realSearchIndexer) Search(_ context.Context, tenantSlug, _ string) ([]search.Result, error) { + var results []search.Result + for _, doc := range r.docs { + if doc.TenantSlug == tenantSlug { + results = append(results, search.Result{MessageID: doc.MessageID, Subject: doc.Subject, SentAtUnixEpoch: doc.SentAtUnixEpoch}) + } + } + return results, nil +} + +type singleTenantStorageProvider struct { + tenantSlug string + svc *storage.Service +} + +func (p singleTenantStorageProvider) ServiceFor(tenantSlug string) (*storage.Service, error) { + if tenantSlug != p.tenantSlug { + return nil, storage.ErrNotFound + } + return p.svc, nil +} + +// TestMigrateAll_MigratedMessageReadableThroughRealMailAPI ist Prüfung 3: +// echter Lesezugriff über die bestehende Mail-API (nicht nur ein +// DB-/Objekt-Check) auf eine Stichprobe migrierter Nachrichten samt +// Anhang. +func TestMigrateAll_MigratedMessageReadableThroughRealMailAPI(t *testing.T) { + src, attA, _ := synthFixture() + storageSvc := newTestStorage(t) + indexer := newRealSearchIndexer() + mig := NewMigrator(src, storageSvc, indexer, testTenant) + + if _, err := mig.MigrateAll(context.Background()); err != nil { + t.Fatalf("MigrateAll: %v", err) + } + + srv := mailapi.NewServer(indexer, singleTenantStorageProvider{tenantSlug: testTenant, svc: storageSvc}) + mux := http.NewServeMux() + verifier, issuer := moduleauth.NewTestVerifierAndIssuer() + srv.RegisterRoutes(mux, verifier) + httpSrv := httptest.NewServer(mux) + defer httpSrv.Close() + + token := issuer.Token("qa-pruefung", testTenant, "user", "mail", "") + msg1ID := MessageID(testTenant, "legacy-msg-1") + + req, err := http.NewRequest(http.MethodGet, httpSrv.URL+"/api/v1/mail/messages/"+msg1ID, nil) + if err != nil { + t.Fatalf("anfrage bauen: %v", err) + } + req.Header.Set("Authorization", "Bearer "+token) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("nachricht abrufen: %v", err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + t.Fatalf("erwartete 200 bei migrierter nachricht, habe %d", resp.StatusCode) + } + var detail struct { + Subject string `json:"subject"` + Body string `json:"body"` + } + if err := json.NewDecoder(resp.Body).Decode(&detail); err != nil { + t.Fatalf("antwort dekodieren: %v", err) + } + if detail.Subject != "Testbetreff Eins" { + t.Fatalf("unerwarteter betreff über die mail-api: %q", detail.Subject) + } + + attReq, err := http.NewRequest(http.MethodGet, httpSrv.URL+"/api/v1/mail/messages/"+msg1ID+"/attachments/0", nil) + if err != nil { + t.Fatalf("anhang-anfrage bauen: %v", err) + } + attReq.Header.Set("Authorization", "Bearer "+token) + attResp, err := http.DefaultClient.Do(attReq) + if err != nil { + t.Fatalf("anhang abrufen: %v", err) + } + defer func() { _ = attResp.Body.Close() }() + if attResp.StatusCode != http.StatusOK { + t.Fatalf("erwartete 200 beim anhang-abruf, habe %d", attResp.StatusCode) + } + gotContent, err := io.ReadAll(attResp.Body) + if err != nil { + t.Fatalf("anhang-inhalt lesen: %v", err) + } + if string(gotContent) != string(attA) { + t.Fatalf("über die mail-api gelesener anhang weicht von der synthetischen quelle ab") + } +} diff --git a/mail/internal/messagemigration/messagemigration_test.go b/mail/internal/messagemigration/messagemigration_test.go new file mode 100644 index 00000000..5009613a --- /dev/null +++ b/mail/internal/messagemigration/messagemigration_test.go @@ -0,0 +1,265 @@ +package messagemigration + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "io" + "testing" + + "gitea.perlbach24.de/scripte/nexarch/mail/internal/search" + "gitea.perlbach24.de/scripte/nexarch/mail/internal/storage" +) + +const testTenant = "synth-tenant" + +// fakeSource ist eine rein synthetische archivmail-Quelle für Tests — +// keine echten Kunden-/Mandantendaten (DSGVO-Hartregel). +type fakeSource struct { + messages []SourceMessage + attachments map[string][]SourceAttachment + objects map[string][]byte +} + +func (f *fakeSource) Messages(_ context.Context) ([]SourceMessage, error) { + return f.messages, nil +} + +func (f *fakeSource) Attachments(_ context.Context, sourceMessageID string) ([]SourceAttachment, error) { + return f.attachments[sourceMessageID], nil +} + +func (f *fakeSource) OpenObject(_ context.Context, objectKey string) (io.ReadCloser, error) { + content, ok := f.objects[objectKey] + if !ok { + return nil, errNotFoundType{} + } + return io.NopCloser(bytes.NewReader(content)), nil +} + +type errNotFoundType struct{} + +func (errNotFoundType) Error() string { return "messagemigration: synthetisches quellobjekt fehlt" } + +func checksum(content []byte) string { + sum := sha256.Sum256(content) + return hex.EncodeToString(sum[:]) +} + +type fakeUsageReporter struct{} + +func (fakeUsageReporter) Report(_ context.Context, _, _ string, _ int64) error { return nil } + +func newTestStorage(t *testing.T) *storage.Service { + t.Helper() + return storage.NewService(storage.NewLocalDriver(t.TempDir()), fakeUsageReporter{}, testTenant) +} + +// fakeIndexer hält migrierte Suchdokumente im Speicher statt gegen +// echtes Manticore zu laufen. +type fakeIndexer struct { + docs map[string]search.Document +} + +func newFakeIndexer() *fakeIndexer { + return &fakeIndexer{docs: make(map[string]search.Document)} +} + +func (f *fakeIndexer) GetByMessageID(_ context.Context, tenantSlug, messageID string) (search.Document, bool, error) { + doc, ok := f.docs[tenantSlug+"|"+messageID] + return doc, ok, nil +} + +func (f *fakeIndexer) Index(_ context.Context, doc search.Document) error { + f.docs[doc.TenantSlug+"|"+doc.MessageID] = doc + return nil +} + +func synthFixture() (*fakeSource, []byte, []byte) { + attachmentA := []byte("synthetischer anhangsinhalt A, frei erfunden") + attachmentB := []byte("synthetischer anhangsinhalt B, ebenfalls erfunden") + + src := &fakeSource{ + messages: []SourceMessage{ + { + SourceMessageID: "legacy-msg-1", + Mailbox: "posteingang", + Sender: "absender@beispiel-synthetisch.test", + Subject: "Testbetreff Eins", + Body: "Synthetischer Nachrichtentext eins.", + SentAtUnixEpoch: 1700000000, + IndexedAt: int64Ptr(1700000100), + }, + { + SourceMessageID: "legacy-msg-2", + Mailbox: "posteingang", + Sender: "absender2@beispiel-synthetisch.test", + Subject: "Testbetreff Zwei", + Body: "Synthetischer Nachrichtentext zwei, ohne Anhang.", + SentAtUnixEpoch: 1700000200, + }, + }, + attachments: map[string][]SourceAttachment{ + "legacy-msg-1": { + {SourceAttachmentID: "att-1", Index: 0, MimeType: "text/plain", SizeBytes: int64(len(attachmentA)), ChecksumSHA256: checksum(attachmentA), ObjectKey: "legacy-obj-a"}, + {SourceAttachmentID: "att-2", Index: 1, MimeType: "text/plain", SizeBytes: int64(len(attachmentB)), ChecksumSHA256: checksum(attachmentB), ObjectKey: "legacy-obj-b"}, + }, + }, + objects: map[string][]byte{ + "legacy-obj-a": attachmentA, + "legacy-obj-b": attachmentB, + }, + } + return src, attachmentA, attachmentB +} + +func int64Ptr(v int64) *int64 { return &v } + +// TestMigrateAll_MessagesAndAttachmentsReferenceable ist Akzeptanzkriterium +// 1: jede Nachricht samt Anhängen ist nach der Migration vollständig +// referenzierbar (Suchdokument + Objekt-Storage-Einträge vorhanden). +func TestMigrateAll_MessagesAndAttachmentsReferenceable(t *testing.T) { + src, attA, attB := synthFixture() + storageSvc := newTestStorage(t) + indexer := newFakeIndexer() + mig := NewMigrator(src, storageSvc, indexer, testTenant) + + res, err := mig.MigrateAll(context.Background()) + if err != nil { + t.Fatalf("MigrateAll: %v", err) + } + if res.MessagesMigrated != 2 { + t.Fatalf("erwartet 2 migrierte nachrichten, habe %d", res.MessagesMigrated) + } + if res.AttachmentsMigrated != 2 { + t.Fatalf("erwartet 2 migrierte anhänge, habe %d", res.AttachmentsMigrated) + } + + msg1ID := MessageID(testTenant, "legacy-msg-1") + doc, found, err := indexer.GetByMessageID(context.Background(), testTenant, msg1ID) + if err != nil || !found { + t.Fatalf("nachricht 1 nicht referenzierbar: found=%v err=%v", found, err) + } + if doc.Subject != "Testbetreff Eins" { + t.Fatalf("unerwarteter betreff: %q", doc.Subject) + } + + plainA, err := storageSvc.GetVerified(context.Background(), storage.ObjectKey(msg1ID, 0)) + if err != nil { + t.Fatalf("anhang 0 nicht lesbar: %v", err) + } + if !bytes.Equal(plainA, attA) { + t.Fatalf("anhang 0 inhalt weicht ab") + } + plainB, err := storageSvc.GetVerified(context.Background(), storage.ObjectKey(msg1ID, 1)) + if err != nil { + t.Fatalf("anhang 1 nicht lesbar: %v", err) + } + if !bytes.Equal(plainB, attB) { + t.Fatalf("anhang 1 inhalt weicht ab") + } +} + +// TestMigrateAll_AttachmentChecksumMatchesSource ist Prüfung 1 +// (Stichprobenweiser Hash-Vergleich migrierter Anhänge gegen Quelle) als +// automatisierte Variante: nach Migration ergibt der SHA-256 des +// migrierten Anhangs wieder exakt den Quell-Hash. +func TestMigrateAll_AttachmentChecksumMatchesSource(t *testing.T) { + src, attA, _ := synthFixture() + storageSvc := newTestStorage(t) + indexer := newFakeIndexer() + mig := NewMigrator(src, storageSvc, indexer, testTenant) + + if _, err := mig.MigrateAll(context.Background()); err != nil { + t.Fatalf("MigrateAll: %v", err) + } + + msg1ID := MessageID(testTenant, "legacy-msg-1") + plainA, err := storageSvc.GetVerified(context.Background(), storage.ObjectKey(msg1ID, 0)) + if err != nil { + t.Fatalf("anhang 0 nicht lesbar: %v", err) + } + if checksum(plainA) != checksum(attA) { + t.Fatalf("hash weicht ab: quelle %s, migriert %s", checksum(attA), checksum(plainA)) + } +} + +// TestMigrateAll_ChecksumMismatchRejected prüft, dass eine von Source +// gelieferte, falsche Prüfsumme die Migration dieses Anhangs ablehnt, +// statt stillschweigend inkonsistente Daten abzulegen (Akzeptanzkriterium +// 2: Binärdaten und Metadaten bleiben konsistent verknüpft). +func TestMigrateAll_ChecksumMismatchRejected(t *testing.T) { + src, _, _ := synthFixture() + src.attachments["legacy-msg-1"][0].ChecksumSHA256 = "0000000000000000000000000000000000000000000000000000000000000" + storageSvc := newTestStorage(t) + indexer := newFakeIndexer() + mig := NewMigrator(src, storageSvc, indexer, testTenant) + + _, err := mig.MigrateAll(context.Background()) + if err == nil { + t.Fatal("erwartet fehler bei falscher prüfsumme, habe nil") + } +} + +// TestMigrateAll_RowCountPerTenantMatchesSource ist Prüfung 2: +// Zeilenzahl migrierter Nachrichten je Tenant deckungsgleich mit Quelle. +func TestMigrateAll_RowCountPerTenantMatchesSource(t *testing.T) { + src, _, _ := synthFixture() + storageSvc := newTestStorage(t) + indexer := newFakeIndexer() + mig := NewMigrator(src, storageSvc, indexer, testTenant) + + res, err := mig.MigrateAll(context.Background()) + if err != nil { + t.Fatalf("MigrateAll: %v", err) + } + if res.MessagesMigrated != len(src.messages) { + t.Fatalf("zeilenzahl weicht ab: quelle %d, migriert %d", len(src.messages), res.MessagesMigrated) + } +} + +// TestMigrateAll_IndexedAtNotCarriedOver ist Akzeptanzkriterium 3: +// indexed_at wird nicht übernommen — search.Document hat strukturell kein +// solches Feld; dieser Test beweist zusätzlich, dass ein gesetztes +// IndexedAt im Quellobjekt das migrierte Ergebnis nicht beeinflusst. +func TestMigrateAll_IndexedAtNotCarriedOver(t *testing.T) { + src, _, _ := synthFixture() + storageSvc := newTestStorage(t) + indexer := newFakeIndexer() + mig := NewMigrator(src, storageSvc, indexer, testTenant) + + if _, err := mig.MigrateAll(context.Background()); err != nil { + t.Fatalf("MigrateAll: %v", err) + } + + msg2ID := MessageID(testTenant, "legacy-msg-2") + doc2, found2, err := indexer.GetByMessageID(context.Background(), testTenant, msg2ID) + if err != nil || !found2 { + t.Fatalf("nachricht 2 nicht gefunden: %v", err) + } + if doc2.Subject != "Testbetreff Zwei" { + t.Fatalf("unerwarteter betreff für nachricht ohne indexed_at: %q", doc2.Subject) + } +} + +// TestMigrateAll_RerunIsIdempotent prüft, dass ein zweiter Lauf gegen +// dieselbe Quelle keine Duplikate erzeugt (gleiche MessageID wird +// übersprungen). +func TestMigrateAll_RerunIsIdempotent(t *testing.T) { + src, _, _ := synthFixture() + storageSvc := newTestStorage(t) + indexer := newFakeIndexer() + mig := NewMigrator(src, storageSvc, indexer, testTenant) + + if _, err := mig.MigrateAll(context.Background()); err != nil { + t.Fatalf("erster lauf: %v", err) + } + res2, err := mig.MigrateAll(context.Background()) + if err != nil { + t.Fatalf("zweiter lauf: %v", err) + } + if res2.MessagesMigrated != 0 || res2.MessagesSkipped != 2 { + t.Fatalf("erwartet 0 neu/2 übersprungen beim zweiten lauf, habe migriert=%d übersprungen=%d", res2.MessagesMigrated, res2.MessagesSkipped) + } +}