MAI-02: Nachrichten- und Anhang-Migration #263

Merged
patrick merged 1 commits from feature/mai-02-nachrichten-anhang-migration into main 2026-10-14 16:42:20 +02:00
5 changed files with 661 additions and 0 deletions

No files matched your search

+54
View File
@@ -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).
+19
View File
@@ -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])
}
@@ -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
}
@@ -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")
}
}
@@ -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)
}
}