package app import ( "context" "fmt" "net/http" "net/http/httptest" "path" "strings" "testing" "finance-duck/internal/domain" "finance-duck/internal/quotes" ) // chartResponse is the provider's payload for one symbol. The trailing null // close is what the endpoint really returns for a day that has not settled // yet, so the price below belongs to the first timestamp, 2025-09-09. func chartResponse(currency string, price float64) string { return fmt.Sprintf(`{"chart":{"result":[{"meta":{"currency":%q},"timestamp":[1757376000,1757462400],"indicators":{"quote":[{"close":[%g,null]}]}}],"error":null}}`, currency, price) } func quoteStub(t *testing.T) *httptest.Server { t.Helper() return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch path.Base(r.URL.Path) { case "VWCE.DE": fmt.Fprint(w, chartResponse("EUR", 128.42)) case "VUSA.AS": // The same fund also lists in dollars; resolving a symbol to that // listing must not value a euro holding. fmt.Fprint(w, chartResponse("USD", 95.5)) case "BROKEN.DE": w.WriteHeader(http.StatusInternalServerError) case "SAP.DE": fmt.Fprint(w, chartResponse("EUR", 210.5)) default: t.Errorf("unexpected request for %q", r.URL.Path) w.WriteHeader(http.StatusNotFound) } })) } func seedInstruments(t *testing.T, a *App, s State) State { t.Helper() s, err := a.Mutate(context.Background(), s.Revision, func(d *domain.Dataset) error { for _, v := range []struct{ isin, name, symbol string }{ {"IE00BK5BQT80", "FTSE All-World", "VWCE.DE"}, {"IE00B3XXRP09", "S&P 500", "VUSA.AS"}, {"US0378331005", "Apple", ""}, {"LU0908500753", "Stoxx 600", "BROKEN.DE"}, {"DE0007164600", "SAP", "SAP.DE"}, } { instrument := domain.Instrument{ID: domain.InstrumentID(v.isin), ISIN: v.isin, Name: v.name, Currency: "EUR", Symbol: v.symbol} if v.symbol == "BROKEN.DE" { instrument.Quote, instrument.QuotedAt = "42.5", "2025-09-01" } d.Instruments = append(d.Instruments, instrument) } return nil }) if err != nil { t.Fatal(err) } return s } // A refresh values what it can and reports the rest: a wrong-currency listing // is the dangerous case, because writing it would misstate wealth without any // visible error. func TestRefreshQuotesWritesOnlyMatchingCurrenciesAndOutlivesOneFailure(t *testing.T) { a, s := testApp(t) stub := quoteStub(t) defer stub.Close() a.quotes = quotes.Client{BaseURL: stub.URL} s = seedInstruments(t, a, s) result, err := a.RefreshQuotes(context.Background()) if err != nil { t.Fatal(err) } if result.Updated != 2 || result.Unchanged != 0 || result.Skipped != 1 || len(result.Failures) != 2 { t.Fatalf("unexpected tally: updated %d unchanged %d skipped %d failures %+v", result.Updated, result.Unchanged, result.Skipped, result.Failures) } fresh, err := a.Snapshot(context.Background()) if err != nil { t.Fatal(err) } held := map[string]domain.Instrument{} for _, v := range fresh.Data.Instruments { held[v.ISIN] = v } if got := held["IE00BK5BQT80"]; got.Quote != "128.42" || got.QuotedAt != "2025-09-09" { t.Fatalf("accepted quote not journaled: %+v", got) } if got := held["IE00B3XXRP09"]; got.Quote != "" || got.QuotedAt != "" { t.Fatalf("a dollar quote was written onto a euro holding: %+v", got) } if got := held["LU0908500753"]; got.Quote != "42.5" || got.QuotedAt != "2025-09-01" { t.Fatalf("a failed fetch overwrote a good quote: %+v", got) } if got := held["DE0007164600"]; got.Quote != "210.5" || got.QuotedAt != "2025-09-09" { t.Fatalf("an earlier failure stopped a later instrument: %+v", got) } failures := map[string]QuoteFailure{} for _, f := range result.Failures { failures[f.ISIN] = f } mismatch, ok := failures["IE00B3XXRP09"] if !ok || mismatch.Symbol != "VUSA.AS" || !strings.Contains(mismatch.Error, "USD") || !strings.Contains(mismatch.Error, "EUR") { t.Fatalf("currency mismatch not reported usefully: %+v", result.Failures) } if _, ok = failures["LU0908500753"]; !ok { t.Fatalf("a provider failure went unreported: %+v", result.Failures) } if _, ok = failures["US0378331005"]; ok { t.Fatalf("an instrument without a symbol must be skipped, not failed: %+v", result.Failures) } // A second run finds the same closes and must leave the journal alone: a // commit per refresh would grow the journal by a revision a day for nothing. again, err := a.RefreshQuotes(context.Background()) if err != nil { t.Fatal(err) } if again.Updated != 0 || again.Unchanged != 2 { t.Fatalf("repeated refresh rewrote unchanged quotes: updated %d unchanged %d", again.Updated, again.Unchanged) } if again.State.Revision != fresh.Revision { t.Fatalf("repeated refresh committed a new revision %q after %q", again.State.Revision, fresh.Revision) } } // Cancellation must be observed between instruments so a shutdown mid-refresh // leaves the journal exactly as it was. func TestRefreshQuotesStopsOnCanceledContextWithoutWriting(t *testing.T) { a, s := testApp(t) stub := quoteStub(t) defer stub.Close() a.quotes = quotes.Client{BaseURL: stub.URL} s = seedInstruments(t, a, s) ctx, cancel := context.WithCancel(context.Background()) cancel() if _, err := a.RefreshQuotes(ctx); err == nil { t.Fatal("a canceled refresh must report the cancellation") } fresh, err := a.Snapshot(context.Background()) if err != nil { t.Fatal(err) } if fresh.Revision != s.Revision { t.Fatalf("a canceled refresh committed %q over %q", fresh.Revision, s.Revision) } }