550 lines
18 KiB
Go
550 lines
18 KiB
Go
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)
|
|
}
|