This commit is contained in:
Lars Nolden
2026-09-10 12:30:42 +02:00
commit 9843fe0c50
79 changed files with 16318 additions and 0 deletions
+549
View File
@@ -0,0 +1,549 @@
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")
}
}
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)
}