mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:41:09 +02:00
Refactor: HTTP handler becomes a thin shim over a pure WriteBackup(w, userID, createdAt, iter) function. RowIterator abstracts the store, so tests feed synthetic ChangeRow slices and production feeds StreamAllUserChanges. Zero behavior change in production — same bytes on the wire. Tests (all pass): - TestWriteBackup_Roundtrip: three rows across two apps, assert zip has 2 entries, events.jsonl has 3 JSON lines in order, insert omits fieldTimestamps, update surfaces them, manifest apps are sorted, eventsSha256 equals a recomputed sha of the decompressed body. - TestWriteBackup_EmptyUser: empty userID refused up-front. - TestWriteBackup_NoRows: zero-row export still produces a valid zip with an empty events.jsonl and a manifest with eventCount=0 and a non-empty sha (sha of empty input). - TestWriteBackup_DefaultsSchemaVersionZeroRowsToOne: legacy rows with schema_version=0 clamp to 1 so the manifest never claims a protocol version that never existed. Paired with the vitest zip parser suite on the TS side, this closes the Go-writes / JS-reads round-trip without needing live mana-sync. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
251 lines
7.1 KiB
Go
251 lines
7.1 KiB
Go
package backup
|
|
|
|
import (
|
|
"archive/zip"
|
|
"bytes"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"io"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/mana/mana-sync/internal/store"
|
|
)
|
|
|
|
// rowsIterator returns a RowIterator that walks a fixed slice of rows.
|
|
// Used in place of the Postgres store so tests exercise the writer
|
|
// end-to-end without a live DB.
|
|
func rowsIterator(rows []store.ChangeRow) RowIterator {
|
|
return func(fn func(store.ChangeRow) error) error {
|
|
for _, r := range rows {
|
|
if err := fn(r); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func sampleRows() []store.ChangeRow {
|
|
ts := func(s string) time.Time {
|
|
t, err := time.Parse(time.RFC3339Nano, s)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
return t
|
|
}
|
|
return []store.ChangeRow{
|
|
{
|
|
ID: "evt-1",
|
|
AppID: "todo",
|
|
TableName: "tasks",
|
|
RecordID: "task-1",
|
|
Op: "insert",
|
|
Data: map[string]any{"title": "Buy milk"},
|
|
ClientID: "client-a",
|
|
CreatedAt: ts("2026-04-14T10:00:00.000Z"),
|
|
SchemaVersion: 1,
|
|
},
|
|
{
|
|
ID: "evt-2",
|
|
AppID: "todo",
|
|
TableName: "tasks",
|
|
RecordID: "task-1",
|
|
Op: "update",
|
|
Data: map[string]any{"completed": true},
|
|
FieldTimestamps: map[string]string{"completed": "2026-04-14T10:05:00.000Z"},
|
|
ClientID: "client-a",
|
|
CreatedAt: ts("2026-04-14T10:05:00.000Z"),
|
|
SchemaVersion: 1,
|
|
},
|
|
{
|
|
ID: "evt-3",
|
|
AppID: "calendar",
|
|
TableName: "events",
|
|
RecordID: "evt-42",
|
|
Op: "insert",
|
|
Data: map[string]any{"title": "Meeting"},
|
|
ClientID: "client-b",
|
|
CreatedAt: ts("2026-04-14T11:00:00.000Z"),
|
|
SchemaVersion: 1,
|
|
},
|
|
}
|
|
}
|
|
|
|
func TestWriteBackup_Roundtrip(t *testing.T) {
|
|
var buf bytes.Buffer
|
|
createdAt := time.Date(2026, 4, 14, 12, 0, 0, 0, time.UTC)
|
|
|
|
if err := WriteBackup(&buf, "user-123", createdAt, rowsIterator(sampleRows())); err != nil {
|
|
t.Fatalf("WriteBackup: %v", err)
|
|
}
|
|
|
|
// Archive must parse as a valid zip with exactly two entries.
|
|
zr, err := zip.NewReader(bytes.NewReader(buf.Bytes()), int64(buf.Len()))
|
|
if err != nil {
|
|
t.Fatalf("zip.NewReader: %v", err)
|
|
}
|
|
if len(zr.File) != 2 {
|
|
t.Fatalf("expected 2 entries, got %d", len(zr.File))
|
|
}
|
|
|
|
events := readZipEntry(t, zr, "events.jsonl")
|
|
manifestBytes := readZipEntry(t, zr, "manifest.json")
|
|
|
|
// events.jsonl: three newline-separated JSON records in input order.
|
|
lines := strings.Split(strings.TrimRight(string(events), "\n"), "\n")
|
|
if len(lines) != 3 {
|
|
t.Fatalf("expected 3 events, got %d", len(lines))
|
|
}
|
|
|
|
// Event 1 is insert with data, no fieldTimestamps.
|
|
var e1 map[string]any
|
|
if err := json.Unmarshal([]byte(lines[0]), &e1); err != nil {
|
|
t.Fatalf("parse line 0: %v", err)
|
|
}
|
|
if e1["op"] != "insert" || e1["eventId"] != "evt-1" || e1["appId"] != "todo" {
|
|
t.Fatalf("event 0 unexpected: %#v", e1)
|
|
}
|
|
if _, ok := e1["fieldTimestamps"]; ok {
|
|
t.Fatalf("event 0 should omit fieldTimestamps (insert)")
|
|
}
|
|
|
|
// Event 2 is update with fieldTimestamps surfaced.
|
|
var e2 map[string]any
|
|
if err := json.Unmarshal([]byte(lines[1]), &e2); err != nil {
|
|
t.Fatalf("parse line 1: %v", err)
|
|
}
|
|
ft, ok := e2["fieldTimestamps"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("event 1 fieldTimestamps missing")
|
|
}
|
|
if ft["completed"] != "2026-04-14T10:05:00.000Z" {
|
|
t.Fatalf("event 1 fieldTimestamps wrong: %#v", ft)
|
|
}
|
|
|
|
// Manifest: all declared fields match what we wrote.
|
|
var m manifestFile
|
|
if err := json.Unmarshal(manifestBytes, &m); err != nil {
|
|
t.Fatalf("parse manifest: %v", err)
|
|
}
|
|
if m.FormatVersion != BackupFormatVersion {
|
|
t.Fatalf("formatVersion=%d want %d", m.FormatVersion, BackupFormatVersion)
|
|
}
|
|
if m.UserID != "user-123" {
|
|
t.Fatalf("userId=%q want user-123", m.UserID)
|
|
}
|
|
if m.EventCount != 3 {
|
|
t.Fatalf("eventCount=%d want 3", m.EventCount)
|
|
}
|
|
if m.SchemaVersionMin != 1 || m.SchemaVersionMax != 1 {
|
|
t.Fatalf("schemaVersion range=[%d,%d] want [1,1]", m.SchemaVersionMin, m.SchemaVersionMax)
|
|
}
|
|
if len(m.Apps) != 2 || m.Apps[0] != "calendar" || m.Apps[1] != "todo" {
|
|
t.Fatalf("apps=%v want sorted [calendar todo]", m.Apps)
|
|
}
|
|
if m.ProducedBy != "mana-sync" {
|
|
t.Fatalf("producedBy=%q want mana-sync", m.ProducedBy)
|
|
}
|
|
|
|
// eventsSha256 must match a fresh SHA of the decompressed events body.
|
|
h := sha256.New()
|
|
h.Write(events)
|
|
want := hex.EncodeToString(h.Sum(nil))
|
|
if m.EventsSHA256 != want {
|
|
t.Fatalf("eventsSha256 mismatch: manifest=%s recomputed=%s", m.EventsSHA256, want)
|
|
}
|
|
}
|
|
|
|
func TestWriteBackup_EmptyUser(t *testing.T) {
|
|
var buf bytes.Buffer
|
|
err := WriteBackup(&buf, "", time.Now(), rowsIterator(nil))
|
|
if err == nil {
|
|
t.Fatal("expected error for empty userID")
|
|
}
|
|
if !strings.Contains(err.Error(), "empty userID") {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestWriteBackup_NoRows(t *testing.T) {
|
|
var buf bytes.Buffer
|
|
createdAt := time.Date(2026, 4, 14, 12, 0, 0, 0, time.UTC)
|
|
|
|
if err := WriteBackup(&buf, "user-x", createdAt, rowsIterator(nil)); err != nil {
|
|
t.Fatalf("WriteBackup: %v", err)
|
|
}
|
|
|
|
zr, err := zip.NewReader(bytes.NewReader(buf.Bytes()), int64(buf.Len()))
|
|
if err != nil {
|
|
t.Fatalf("zip.NewReader: %v", err)
|
|
}
|
|
|
|
events := readZipEntry(t, zr, "events.jsonl")
|
|
if len(events) != 0 {
|
|
t.Fatalf("expected empty events.jsonl, got %d bytes", len(events))
|
|
}
|
|
|
|
manifestBytes := readZipEntry(t, zr, "manifest.json")
|
|
var m manifestFile
|
|
if err := json.Unmarshal(manifestBytes, &m); err != nil {
|
|
t.Fatalf("parse manifest: %v", err)
|
|
}
|
|
if m.EventCount != 0 {
|
|
t.Fatalf("eventCount=%d want 0", m.EventCount)
|
|
}
|
|
if len(m.Apps) != 0 {
|
|
t.Fatalf("apps=%v want empty", m.Apps)
|
|
}
|
|
// Empty body still needs a valid sha.
|
|
if m.EventsSHA256 == "" {
|
|
t.Fatal("eventsSha256 empty even for zero-row export")
|
|
}
|
|
}
|
|
|
|
func TestWriteBackup_DefaultsSchemaVersionZeroRowsToOne(t *testing.T) {
|
|
// Legacy rows stored before the schema_version column existed scan as
|
|
// 0. The writer must clamp them to 1 so the manifest's
|
|
// schemaVersionMin/Max never claims a nonexistent protocol version.
|
|
rows := []store.ChangeRow{{
|
|
ID: "e1", AppID: "todo", TableName: "tasks", RecordID: "t1",
|
|
Op: "insert", Data: map[string]any{"x": 1}, ClientID: "c",
|
|
CreatedAt: time.Now(), SchemaVersion: 0,
|
|
}}
|
|
var buf bytes.Buffer
|
|
if err := WriteBackup(&buf, "u", time.Now(), rowsIterator(rows)); err != nil {
|
|
t.Fatalf("WriteBackup: %v", err)
|
|
}
|
|
zr, err := zip.NewReader(bytes.NewReader(buf.Bytes()), int64(buf.Len()))
|
|
if err != nil {
|
|
t.Fatalf("zip.NewReader: %v", err)
|
|
}
|
|
events := readZipEntry(t, zr, "events.jsonl")
|
|
if !strings.Contains(string(events), `"schemaVersion":1`) {
|
|
t.Fatalf("expected schemaVersion:1 in events body, got: %s", events)
|
|
}
|
|
}
|
|
|
|
// readZipEntry reads the named entry out of a zip archive in full. Fails
|
|
// the test if the entry is missing or cannot be decompressed.
|
|
func readZipEntry(t *testing.T, zr *zip.Reader, name string) []byte {
|
|
t.Helper()
|
|
for _, f := range zr.File {
|
|
if f.Name != name {
|
|
continue
|
|
}
|
|
rc, err := f.Open()
|
|
if err != nil {
|
|
t.Fatalf("open %s: %v", name, err)
|
|
}
|
|
defer rc.Close()
|
|
body, err := io.ReadAll(rc)
|
|
if err != nil {
|
|
t.Fatalf("read %s: %v", name, err)
|
|
}
|
|
return body
|
|
}
|
|
t.Fatalf("entry %q not found in zip", name)
|
|
return nil
|
|
}
|