package journal import ( "bytes" "encoding/json" "errors" "fmt" "os" "path/filepath" "reflect" "slices" "strings" "testing" "finance-duck/internal/domain" ) func fixtureDataset(t *testing.T) (domain.Dataset, []byte) { t.Helper() d := domain.NewDataset() d.Accounts = []domain.Account{{ID: "acc_main", DisplayName: "Main", Currency: "EUR", Active: true}, {ID: "acc_save", DisplayName: "Savings", Currency: "EUR", Active: true}, {ID: "acc_usd", DisplayName: "Dollars", Currency: "USD", Active: true}, {ID: "acc_gbp", DisplayName: "Pounds", Currency: "GBP", Active: true}} d.Categories = append(d.Categories, domain.Category{ID: "cat_grocery", Name: "Groceries", Kind: "expense", ParentID: "cat_expenses"}) d.Tags = []domain.Tag{{ID: "tag_food", Name: "Food"}, {ID: "tag_recurring", Name: "Recurring"}} d.Merchants = []domain.Merchant{{ID: "mer_cafe", Name: "Café", Aliases: []string{"Cafe", "Café GmbH"}, DefaultCategoryID: "cat_grocery", DefaultTagIDs: []string{"tag_food"}}} slices.SortFunc(d.Accounts, func(a, b domain.Account) int { return strings.Compare(a.ID, b.ID) }) entries, err := os.ReadDir("testdata") if err != nil { t.Fatal(err) } var all bytes.Buffer for _, entry := range entries { if !strings.HasSuffix(entry.Name(), ".finance") { continue } raw, err := os.ReadFile(filepath.Join("testdata", entry.Name())) if err != nil { t.Fatal(err) } doc, err := parseDocument(entry.Name(), raw) if err != nil { t.Fatal(err) } for _, p := range doc.pieces { if p.block != nil { d.Transactions = append(d.Transactions, p.block.value.(domain.Transaction)) } } all.Write(raw) } if err = domain.Validate(d); err != nil { t.Fatal(err) } return d, all.Bytes() } func openTestStore(t *testing.T) *Store { t.Helper() s, err := Open(t.TempDir()) if err != nil { t.Fatal(err) } t.Cleanup(func() { _ = s.Close() }) return s } func loadTestStore(t *testing.T, s *Store) (domain.Dataset, string) { t.Helper() d, r, err := s.Load() if err != nil { t.Fatal(err) } return d, r } func commitTestStore(t *testing.T, s *Store, revision string, d domain.Dataset) string { t.Helper() r, err := s.Commit(revision, d) if err != nil { t.Fatal(err) } return r } func writeTestFile(t *testing.T, path string, raw []byte) { t.Helper() if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { t.Fatal(err) } if err := os.WriteFile(path, raw, 0600); err != nil { t.Fatal(err) } } func readTestFile(t *testing.T, path string) []byte { t.Helper() raw, err := os.ReadFile(path) if err != nil { t.Fatal(err) } return raw } func TestFixturesRoundTripAndCommentsSurviveEnrichmentEdit(t *testing.T) { d, fixtureRaw := fixtureDataset(t) s := openTestStore(t) _, r := loadTestStore(t, s) commitTestStore(t, s, r, d) monthly := filepath.Join(s.dir, "journal", "2026", "2026-01.finance") writeTestFile(t, monthly, fixtureRaw) loaded, r := loadTestStore(t, s) if !reflect.DeepEqual(domain.Clone(d), loaded) { t.Fatal("fixture semantics changed on load") } if next := commitTestStore(t, s, r, loaded); next != r { t.Fatal("no-op changed revision") } if !bytes.Equal(readTestFile(t, monthly), fixtureRaw) { t.Fatal("no-op rewrote fixture bytes") } loaded.Transactions[3].Enrichment.CategoryID = "cat_grocery" loaded.Transactions[3].Enrichment.TagIDs = []string{"tag_food"} loaded.Transactions[3].Enrichment.Classification = domain.Provenance{Source: "manual"} commitTestStore(t, s, r, loaded) updated := readTestFile(t, monthly) beforeDoc, err := parseDocument("before", fixtureRaw) if err != nil { t.Fatal(err) } afterDoc, err := parseDocument("after", updated) if err != nil { t.Fatal(err) } for i, p := range beforeDoc.pieces { q := afterDoc.pieces[i] if p.block == nil { if p.text != q.text { t.Fatal("outside comment changed") } continue } b, a := p.block, q.block if b.id != "tx_fixture_04" { if strings.Join(b.lines, "") != strings.Join(a.lines, "") { t.Fatalf("untouched block %s rewritten", b.id) } continue } oldSpan, newSpan := b.fields["facts"], a.fields["facts"] if strings.Join(b.lines[oldSpan.start:oldSpan.end+1], "") != strings.Join(a.lines[newSpan.start:newSpan.end+1], "") { t.Fatal("multiline immutable facts rewritten") } for _, line := range b.lines { if comment(line) && !bytes.Contains(updated, []byte(line)) { t.Fatal("inner comment lost") } } } reloaded, revision := loadTestStore(t, s) if !reflect.DeepEqual(reloaded, loaded) { t.Fatal("enrichment edit failed to persist") } if got := commitTestStore(t, s, revision, reloaded); got != revision { t.Fatal("second round trip is not stable") } } func TestParserRejectsMalformedFieldsAtTheirSourceLine(t *testing.T) { cases := []struct { name, raw string line int }{ {"syntax", "tag {\n id: \"tag_a\"\n name: not-json\n}\n", 3}, {"unknown", "tag {\n id: \"tag_a\"\n surprise: true\n}\n", 3}, {"duplicate field", "tag {\n id: \"tag_a\"\n id: \"tag_b\"\n}\n", 3}, {"duplicate nested key", "transaction {\n facts: {\"id\":\"tx_a\",\"id\":\"tx_b\"}\n}\n", 2}, {"wrong type", "tag {\n id: 123\n}\n", 2}, {"unclosed", "tag {\n id: \"tag_a\"\n", 1}, {"unknown block", "mystery {\n}\n", 1}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { _, err := parseDocument("bad.finance", []byte(tc.raw)) if err == nil || !strings.Contains(err.Error(), fmt.Sprintf("bad.finance:%d:", tc.line)) { t.Fatalf("expected source line %d, got %v", tc.line, err) } }) } } func TestExternalInvalidFileRejectsWholeDatasetAndRetainsEditedBytes(t *testing.T) { s := openTestStore(t) d, r := loadTestStore(t, s) d.Tags = append(d.Tags, domain.Tag{ID: "tag_valid", Name: "Valid"}) r = commitTestStore(t, s, r, d) path := filepath.Join(s.dir, "tags.finance") original := readTestFile(t, path) invalid := append(append([]byte{}, original...), []byte("\ntag {\n id: \"tag_bad\"\n name: broken\n}\n")...) writeTestFile(t, path, invalid) loaded, revision, err := s.Load() if err == nil || !strings.Contains(err.Error(), "tags.finance:") { t.Fatalf("expected file/line failure, got %v", err) } if len(loaded.Categories) != 0 || revision != "" { t.Fatal("returned partial or previously cached dataset") } if _, err = s.Commit(r, d); err == nil { t.Fatal("commit replaced invalid external edit") } if !bytes.Equal(readTestFile(t, path), invalid) { t.Fatal("invalid external edit was destroyed") } writeTestFile(t, path, original) restored, restoredRevision := loadTestStore(t, s) if restoredRevision != r || !reflect.DeepEqual(restored, domain.Clone(d)) { t.Fatal("corrected external file did not restore journal") } } func TestExternalSemanticErrorIncludesFileAndLine(t *testing.T) { s := openTestStore(t) d, _ := fixtureDataset(t) _, r := loadTestStore(t, s) commitTestStore(t, s, r, d) path := filepath.Join(s.dir, "journal", "2026", "2026-01.finance") raw := readTestFile(t, path) raw = bytes.Replace(raw, []byte(`"account_id":"acc_main"`), []byte(`"account_id":"acc_missing"`), 1) writeTestFile(t, path, raw) if _, _, err := s.Load(); err == nil || !strings.Contains(err.Error(), "journal/2026/2026-01.finance:1:") { t.Fatalf("missing transaction source location: %v", err) } } func TestStaleRevisionIncludesCommentOnlyExternalEdits(t *testing.T) { s := openTestStore(t) d, r := loadTestStore(t, s) path := filepath.Join(s.dir, "accounts.finance") raw := append([]byte("# user's independent edit\n"), readTestFile(t, path)...) writeTestFile(t, path, raw) d.Tags = append(d.Tags, domain.Tag{ID: "tag_new", Name: "New"}) if _, err := s.Commit(r, d); !errors.Is(err, ErrConflict) { t.Fatalf("stale commit: %v", err) } if !bytes.Equal(readTestFile(t, path), raw) { t.Fatal("external comment lost") } current, newRevision := loadTestStore(t, s) current.Tags = d.Tags r = commitTestStore(t, s, newRevision, current) if _, err := s.Commit(newRevision, current); !errors.Is(err, ErrConflict) { t.Fatalf("stale app revision: %v", err) } if r == newRevision { t.Fatal("actual mutation did not advance revision") } } func TestFactsImmutableButRegistryAndEnrichmentRemainEditable(t *testing.T) { s := openTestStore(t) d, _ := fixtureDataset(t) _, r := loadTestStore(t, s) r = commitTestStore(t, s, r, d) d, r = loadTestStore(t, s) for _, which := range []string{"amount", "description", "remove"} { t.Run(which, func(t *testing.T) { next := domain.Clone(d) switch which { case "amount": next.Transactions[0].Facts.Amount = "-999.00" case "description": next.Transactions[0].Facts.RawDescription = "Edited" case "remove": next.Transactions = next.Transactions[1:] } if _, err := s.Commit(r, next); !errors.Is(err, ErrImmutable) { t.Fatalf("fact mutation accepted or wrong error: %v", err) } _, after := loadTestStore(t, s) if after != r { t.Fatal("rejected fact mutation changed revision") } }) } next := domain.Clone(d) next.Accounts[0].DisplayName = "Renamed" next.Transactions[0].Enrichment.CategoryID = "cat_grocery" next.Transactions[0].Enrichment.Classification.Source = "manual" f := next.Transactions[0].Facts f.ID = "tx_february" f.Fingerprint = "fp_february" f.BookingDate = "2026-02-01" next.Transactions = append(next.Transactions, domain.Transaction{Facts: f, Enrichment: domain.Fallback(f)}) commitTestStore(t, s, r, next) loaded, _ := loadTestStore(t, s) if !reflect.DeepEqual(loaded, domain.Clone(next)) { t.Fatal("permitted changes not persisted") } if _, err := os.Stat(filepath.Join(s.dir, "journal", "2026", "2026-02.finance")); err != nil { t.Fatalf("missing deterministic monthly path: %v", err) } } // stageCrash writes exactly the durable intent and payload that survive power // loss, optionally installing a prefix of the targets before abandoning it. func stageCrash(t *testing.T, s *Store, next domain.Dataset, installed int) (map[string][]byte, map[string][]byte) { t.Helper() before, err := s.snapshot() if err != nil { t.Fatal(err) } output, err := renderFiles(next, before.docs) if err != nil { t.Fatal(err) } wal := filepath.Join(s.dir, walName) if err = os.Mkdir(wal, 0700); err != nil { t.Fatal(err) } m := manifest{Version: 1, Revision: before.revision, Files: []walEntry{}} for i, path := range sortedPaths(output) { stage := fmt.Sprintf("%06d", i) old := "" if raw, ok := before.raw[path]; ok { old = hash(raw) } m.Files = append(m.Files, walEntry{Path: path, Before: old, After: hash(output[path]), Stage: stage}) writeTestFile(t, filepath.Join(wal, stage), output[path]) if i < installed { writeTestFile(t, filepath.Join(s.dir, path), output[path]) } } raw, err := json.Marshal(m) if err != nil { t.Fatal(err) } writeTestFile(t, filepath.Join(wal, "manifest.json"), raw) return before.raw, output } func TestRecoveryCompletesEveryInterruptedGeneration(t *testing.T) { for _, installed := range []int{0, 2, 100} { t.Run(fmt.Sprintf("installed_%d", installed), func(t *testing.T) { s := openTestStore(t) d, _ := fixtureDataset(t) _, output := stageCrash(t, s, d, installed) dir := s.dir if err := s.Close(); err != nil { t.Fatal(err) } reopened, err := Open(dir) if err != nil { t.Fatal(err) } defer reopened.Close() loaded, r := loadTestStore(t, reopened) if r != revision(output) || !reflect.DeepEqual(loaded, domain.Clone(d)) { t.Fatal("recovered generation is incomplete") } for path, want := range output { if !bytes.Equal(readTestFile(t, filepath.Join(dir, path)), want) { t.Fatalf("recovery omitted %s", path) } } if _, err = os.Stat(filepath.Join(dir, walName)); !errors.Is(err, os.ErrNotExist) { t.Fatal("recovery intent not retired") } }) } } func TestRecoveryValidatesAllPayloadsBeforeChangingAnyFile(t *testing.T) { s := openTestStore(t) d, _ := fixtureDataset(t) before, _ := stageCrash(t, s, d, 0) writeTestFile(t, filepath.Join(s.dir, walName, "000004"), []byte("corrupt final payload")) if _, _, err := s.Load(); err == nil { t.Fatal("corrupt staged generation accepted") } for path, want := range before { if !bytes.Equal(readTestFile(t, filepath.Join(s.dir, path)), want) { t.Fatalf("partially installed corrupt generation at %s", path) } } if _, err := os.Stat(filepath.Join(s.dir, walName)); err != nil { t.Fatal("recovery evidence discarded") } } func TestRecoveryRefusesConflictingManualEdit(t *testing.T) { s := openTestStore(t) d, _ := fixtureDataset(t) stageCrash(t, s, d, 2) path := filepath.Join(s.dir, "accounts.finance") edit := append([]byte("# edit after crash\n"), readTestFile(t, path)...) writeTestFile(t, path, edit) if _, _, err := s.Load(); !errors.Is(err, ErrConflict) { t.Fatalf("expected recovery conflict, got %v", err) } if !bytes.Equal(readTestFile(t, path), edit) { t.Fatal("recovery destroyed conflicting manual edit") } if _, err := os.Stat(filepath.Join(s.dir, walName)); err != nil { t.Fatal("pending generation discarded") } } func TestLockPermissionsAndSymlinks(t *testing.T) { s := openTestStore(t) if other, err := Open(s.dir); err == nil { other.Close() t.Fatal("second process lock acquired") } for _, path := range []string{s.dir, filepath.Join(s.dir, ".lock"), filepath.Join(s.dir, "categories.finance")} { info, err := os.Stat(path) if err != nil { t.Fatal(err) } want := os.FileMode(0600) if info.IsDir() { want = 0700 } if info.Mode().Perm() != want { t.Fatalf("%s mode %o, want %o", path, info.Mode().Perm(), want) } } target := filepath.Join(t.TempDir(), "private.finance") writeTestFile(t, target, []byte("do not overwrite")) path := filepath.Join(s.dir, "tags.finance") if err := os.Remove(path); err != nil { t.Fatal(err) } if err := os.Symlink(target, path); err != nil { t.Fatal(err) } if _, _, err := s.Load(); err == nil { t.Fatal("followed registry symlink") } if string(readTestFile(t, target)) != "do not overwrite" { t.Fatal("symlink target modified") } if err := s.Close(); err != nil { t.Fatal(err) } if _, _, err := s.Load(); !errors.Is(err, ErrClosed) { t.Fatalf("closed load: %v", err) } } func TestRecoveryRejectsSemanticallyInvalidStagedGeneration(t *testing.T) { s := openTestStore(t) d, _ := fixtureDataset(t) before, _ := stageCrash(t, s, d, 0) manifestPath := filepath.Join(s.dir, walName, "manifest.json") var m manifest if err := json.Unmarshal(readTestFile(t, manifestPath), &m); err != nil { t.Fatal(err) } for i, e := range m.Files { if e.Path != "categories.finance" { continue } invalid := []byte("# required categories removed\n") writeTestFile(t, filepath.Join(s.dir, walName, e.Stage), invalid) m.Files[i].After = hash(invalid) } raw, err := json.Marshal(m) if err != nil { t.Fatal(err) } writeTestFile(t, manifestPath, raw) if _, _, err = s.Load(); err == nil { t.Fatal("semantically invalid recovery generation accepted") } for path, want := range before { if !bytes.Equal(readTestFile(t, filepath.Join(s.dir, path)), want) { t.Fatalf("invalid recovery modified %s", path) } } } func TestPreparedButUncommittedGenerationRemainsInvisible(t *testing.T) { s := openTestStore(t) original, r := loadTestStore(t, s) d, _ := fixtureDataset(t) stageCrash(t, s, d, 0) if err := os.Rename(filepath.Join(s.dir, walName), filepath.Join(s.dir, ".prepare")); err != nil { t.Fatal(err) } if err := s.Close(); err != nil { t.Fatal(err) } reopened, err := Open(s.dir) if err != nil { t.Fatal(err) } defer reopened.Close() loaded, got := loadTestStore(t, reopened) if got != r || !reflect.DeepEqual(loaded, original) { t.Fatal("uncommitted staging became visible") } d.Tags = append(d.Tags, domain.Tag{ID: "tag_next", Name: "Next"}) commitTestStore(t, reopened, r, d) } func TestUnexpectedMonthlyLayoutAndRegistryFilesAreNotIgnored(t *testing.T) { for _, path := range []string{"unexpected.finance", "journal/2026/2025-01.finance", "journal/2026/2026-13.finance"} { t.Run(path, func(t *testing.T) { s := openTestStore(t) writeTestFile(t, filepath.Join(s.dir, path), []byte("# misplaced file\n")) if _, _, err := s.Load(); err == nil || !strings.Contains(err.Error(), path+":1:") { t.Fatalf("misplaced plaintext file was ignored: %v", err) } }) } } func TestNullListsPreserveUntouchedExternalBlockBytes(t *testing.T) { s := openTestStore(t) raw := []byte("# deliberate hand formatting\nmerchant {\n id: \"mer_empty\"\n name: \"Empty defaults\"\n aliases: null\n default_tag_ids: null\n use_defaults: false\n}\n") path := filepath.Join(s.dir, "merchants.finance") writeTestFile(t, path, raw) d, r := loadTestStore(t, s) commitTestStore(t, s, r, d) if !bytes.Equal(raw, readTestFile(t, path)) { t.Fatal("loading empty lists rewrote untouched block") } d.Merchants[0].Name = "Renamed" commitTestStore(t, s, r, d) expected := bytes.Replace(raw, []byte(" name: \"Empty defaults\""), []byte(" name: \"Renamed\""), 1) if !bytes.Equal(expected, readTestFile(t, path)) { t.Fatal("renaming merchant rewrote unrelated fields or comments") } } // An asset is a registry entity like any other: committed to its own file and // identical after a fresh load, or the wealth it backs vanishes on restart. func TestAssetsSurviveCommitAndReload(t *testing.T) { s := openTestStore(t) d, r := loadTestStore(t, s) d.Assets = []domain.Asset{{ID: "asset_house", Name: "House", Kind: "Real estate", Currency: "EUR", Value: "250000.00", ValuedAt: "2026-09-01"}} commitTestStore(t, s, r, d) if err := s.Close(); err != nil { t.Fatal(err) } fresh, err := Open(s.dir) if err != nil { t.Fatal(err) } defer fresh.Close() loaded, _ := loadTestStore(t, fresh) if !reflect.DeepEqual(loaded.Assets, d.Assets) { t.Errorf("assets after reload %+v, want %+v", loaded.Assets, d.Assets) } if raw := readTestFile(t, filepath.Join(s.dir, "assets.finance")); !bytes.Contains(raw, []byte(`asset {`)) { t.Errorf("assets.finance holds no asset block: %s", raw) } } func TestOversizedCommitCannotPublishUnreadableRecoveryIntent(t *testing.T) { s := openTestStore(t) original, r := loadTestStore(t, s) next := domain.Clone(original) next.Accounts = append(next.Accounts, domain.Account{ID: "acc_large", DisplayName: strings.Repeat("x", maxFileBytes), Currency: "EUR", Active: true}) if _, err := s.Commit(r, next); err == nil { t.Fatal("oversized canonical file accepted") } loaded, got := loadTestStore(t, s) if got != r || !reflect.DeepEqual(loaded, original) { t.Fatal("oversized rejection changed canonical journal") } for _, name := range []string{walName, ".prepare"} { if _, err := os.Stat(filepath.Join(s.dir, name)); !errors.Is(err, os.ErrNotExist) { t.Fatalf("oversized rejection left %s: %v", name, err) } } if err := s.Close(); err != nil { t.Fatal(err) } reopened, err := Open(s.dir) if err != nil { t.Fatal(err) } defer reopened.Close() loaded, got = loadTestStore(t, reopened) if got != r || !reflect.DeepEqual(loaded, original) { t.Fatal("oversized rejection prevented clean reopen") } next = domain.Clone(original) next.Tags = append(next.Tags, domain.Tag{ID: "tag_after", Name: "After failed commit"}) commitTestStore(t, reopened, r, next) }