Use compact classification IDs and extend preview lifetime

This commit is contained in:
Lars Nolden
2026-09-14 09:31:44 +02:00
parent 77f4ea5655
commit 46e02d95cb
8 changed files with 424 additions and 142 deletions
+60 -1
View File
@@ -360,6 +360,65 @@ func TestPreviewIsReadOnlySelectedApplyPreservesFactsAndOtherFields(t *testing.T
t.Fatal("consumed preview applied twice")
}
}
func TestPreviewExpiresAfterTwentyFourHours(t *testing.T) {
for _, tc := range []struct {
name string
age time.Duration
newPreview bool
expired bool
}{
{name: "apply before expiry", age: 24*time.Hour - time.Minute},
{name: "apply after expiry", age: 24*time.Hour + time.Minute, expired: true},
{name: "new preview retains unexpired review", age: 24*time.Hour - time.Minute, newPreview: true},
{name: "new preview discards expired review", age: 24*time.Hour + time.Minute, newPreview: true, expired: true},
} {
t.Run(tc.name, func(t *testing.T) {
ctx := context.Background()
a, s := testApp(t)
s = seed(t, a, s)
mockClassifier(t, a)
p, err := runPreview(t, a, PreviewRequest{Revision: s.Revision, From: "2026-09-01", To: "2026-09-30", Model: "test/model", Fields: Fields{Category: true}})
if err != nil {
t.Fatal(err)
}
if len(p.Changes) != 2 {
t.Fatalf("expected two proposed changes: %+v", p)
}
a.mu.Lock()
p.created = time.Now().Add(-tc.age)
a.previews[p.ID] = p
a.mu.Unlock()
if tc.newPreview {
// Completing another run performs expired-preview cleanup.
// An empty range needs no additional provider request.
if _, err := runPreview(t, a, PreviewRequest{Revision: s.Revision, From: "2025-01-01", To: "2025-01-31", Model: "test/model", Fields: Fields{Category: true}}); err != nil {
t.Fatal(err)
}
}
change := p.Changes[0]
_, err = a.ApplyPreview(ctx, p.ID, p.Revision, []string{change.ID}, nil)
if (err != nil) != tc.expired {
t.Fatalf("apply at age %s: error = %v, expired = %t", tc.age, err, tc.expired)
}
after, err := a.Snapshot(ctx)
if err != nil {
t.Fatal(err)
}
expected := domain.Clone(s.Data)
if !tc.expired {
for i := range expected.Transactions {
if expected.Transactions[i].Facts.ID == change.ID {
expected.Transactions[i].Enrichment = change.After
}
}
}
if !reflect.DeepEqual(after.Data, expected) {
t.Fatal("expiry handling did not preserve the expected transaction state")
}
})
}
}
func TestStalePreviewCannotOverwriteManualCorrection(t *testing.T) {
a, s := testApp(t)
s = seed(t, a, s)
@@ -491,7 +550,7 @@ func TestApplyPreviewHonoursReviewerEdits(t *testing.T) {
func TestImportNeverAutoAppliesLowConfidenceCategory(t *testing.T) {
a, s := testApp(t)
provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
content := `{"merchant_id":null,"new_merchant":"REWE","category_id":"groceries","tag_ids":[],"confidence":"low"}`
content := `{"merchant_id":null,"new_merchant":"REWE","category_id":"c1","tag_ids":[],"confidence":"low"}`
json.NewEncoder(w).Encode(map[string]any{"choices": []any{map[string]any{
"finish_reason": "stop",
"message": map[string]any{"content": content},
+4 -2
View File
@@ -13,6 +13,8 @@ import (
"finance-duck/internal/domain"
)
const previewLifetime = 24 * time.Hour
type Fields struct {
Merchant bool `json:"merchant"`
Category bool `json:"category"`
@@ -173,7 +175,7 @@ func (a *App) runPreview(ctx context.Context, cancel context.CancelFunc, client
return
}
for id, old := range a.previews {
if time.Since(old.created) > time.Hour {
if time.Since(old.created) > previewLifetime {
delete(a.previews, id)
}
}
@@ -343,7 +345,7 @@ func (a *App) ApplyPreview(ctx context.Context, id, rev string, ids []string, ed
a.mu.Lock()
defer a.mu.Unlock()
p, ok := a.previews[id]
if !ok || time.Since(p.created) > time.Hour {
if !ok || time.Since(p.created) > previewLifetime {
return State{}, errors.New("preview expired or unknown; analyse again")
}
if rev != p.Revision {