From faf6de56509b80284851ecd77a0074b6714f80d7 Mon Sep 17 00:00:00 2001 From: Fury03 Date: Sun, 6 Sep 2026 19:27:16 +0100 Subject: [PATCH] Fix main: repair the damage from a batch of unverified merges main has not compiled since #416. Both CI and the measure workflow have failed on every commit since, so the scheduled sweep has not run and no history has accumulated in that window. The damage is one failure mode repeated: pull requests merged while red, most of them auto-generated and carrying the line "Not verified locally ... GitHub CI is the source of truth - please check the CI status on this PR before merging". The CI status was red and they were merged anyway. Restored functions deleted while their callers remained. #416 removed FiatPeg's two-value form, IsFiatToken, HomeDomain and KnownCodes; server/api.go, server/trend.go, checks/runner.go and asset/class.go all still called them. FiatPeg is back to returning the peg code because a bare boolean cannot tell a caller which currency a token tracks, which is what scoring it requires. Repaired six files whose functions had been spliced together mid-body. A test function truncated inside a struct literal with another pasted into it, a duplicated for statement with one closing brace, a fragment referencing an undefined variable, and in one case a bare string used as a statement next to the comment "// wait, error check below". snapshot_test.go had a block of test body pasted inside its import block. None of this could ever have compiled. Resolved two merged pull requests that contradict each other. One test requires ?pretty=1 to return 200, another requires it to return 400. pretty is implemented in writeJSON and has its own dedicated test, so the rejection case now uses a genuinely unknown parameter. Restored two features whose implementations were lost while their tests survived: the pretty parameter was missing from the checkParams allowlist, and /healthz no longer reported the age of the data it serves. On a history-first deployment that health check is the only thing that would notice measurements silently ageing out, so it reports each corridor's newest record, omits a corridor with no history rather than guessing, and returns null rather than a fabricated zero. sep38 now rejects a response carrying trailing data. json.Decoder.Decode stops at the end of the first value, so a body like {"price":"5.00"} and then some parsed cleanly and produced a quote. Arithmetic that succeeds on a partially-understood body is the fee-denomination lesson again. Removed four fabricated snapshots added by #417. They are not weak evidence, they are invented: the BRLC issuer account is not valid base32 and decodes to nothing, every recorded_at is exactly midnight, git_revision is absent, the recorded body hashes do not match the committed bodies, and every record set is empty. In a project whose thesis is that every published figure traces to recorded bytes a reader can verify, committing fabricated fixtures is a more serious defect than a broken build. hop-analysis now skips a snapshot from which no probe parsed. testdata carries a deliberately malformed fixture, honestly declared as such in its manifest notes, which declares the same corridor as the real NGNC snapshot - so it appeared as a second, contradictory report for it derived entirely from payloads designed to be rejected. The discriminator is whether a probe parsed, not whether it found a path: KESC finds no path at any size and that is a finding, while a response that will not parse taught us nothing. Verification: gofmt, go vet, go test, go test -race and make offline-test all clean across 17 packages. Co-Authored-By: Claude Opus 5 --- .gitignore | 1 + asset/known.go | 51 ++- asset/known_test.go | 37 ++- cmd/hop-analysis/main.go | 39 ++- cmd/ladder/json_test.go | 2 +- cmd/ladder/smoke_test.go | 4 +- cmd/wayfared/main.go | 1 - refrate/cross_test.go | 300 +++--------------- route/cost_test.go | 91 +++++- route/route_test.go | 3 + sep38/sep38.go | 16 +- sep38/sep38_edge_test.go | 20 +- server/api.go | 78 ++++- server/api_schema_test.go | 15 +- server/api_test.go | 8 +- server/stale_test.go | 1 - server/trend.go | 20 +- snapshot/snapshot_test.go | 134 +++++++- .../usdc-brlc-20260823T000000Z/manifest.json | 41 --- .../responses/001-paths.json | 1 - .../usdc-inrc-20260823T000000Z/manifest.json | 41 --- .../responses/001-paths.json | 1 - .../usdc-mxnc-20260823T000000Z/manifest.json | 41 --- .../responses/001-paths.json | 1 - .../usdc-phpc-20260823T000000Z/manifest.json | 41 --- .../responses/001-paths.json | 1 - 26 files changed, 499 insertions(+), 490 deletions(-) delete mode 100644 testdata/snapshots/usdc-brlc-20260823T000000Z/manifest.json delete mode 100644 testdata/snapshots/usdc-brlc-20260823T000000Z/responses/001-paths.json delete mode 100644 testdata/snapshots/usdc-inrc-20260823T000000Z/manifest.json delete mode 100644 testdata/snapshots/usdc-inrc-20260823T000000Z/responses/001-paths.json delete mode 100644 testdata/snapshots/usdc-mxnc-20260823T000000Z/manifest.json delete mode 100644 testdata/snapshots/usdc-mxnc-20260823T000000Z/responses/001-paths.json delete mode 100644 testdata/snapshots/usdc-phpc-20260823T000000Z/manifest.json delete mode 100644 testdata/snapshots/usdc-phpc-20260823T000000Z/responses/001-paths.json diff --git a/.gitignore b/.gitignore index f22d449..eeda4e3 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ /wayfare /wayfared /ladder +/hop-analysis # Test and coverage artifacts *.out diff --git a/asset/known.go b/asset/known.go index 4a7d542..da4ca48 100644 --- a/asset/known.go +++ b/asset/known.go @@ -420,6 +420,20 @@ func HomeDomains() map[string]string { return out } +// KnownCodes lists the verified token codes, sorted. +// +// Sorted because callers render it to a user in error messages naming the +// valid assets, and an unstable order makes that output differ run to run for +// no reason. server/api.go and server/trend.go both depend on it. +func KnownCodes() []string { + codes := make([]string, 0, len(known)) + for c := range known { + codes = append(codes, c) + } + sort.Strings(codes) + return codes +} + // IsKnown reports whether an asset is explicitly registered. func IsKnown(a Asset) bool { if a.Kind != KindStellar { @@ -429,9 +443,40 @@ func IsKnown(a Asset) bool { return ok } -// FiatPeg returns the ISO currency code pegged by a registered asset, if any. -func FiatPeg(a Asset) bool { - _, ok := fiatPegs[a.Code+":"+a.Issuer] +// FiatPeg returns the ISO-4217 currency a registered Stellar token tracks, and +// whether the token is a known fiat-pegged asset at all. +// +// The peg code is the return value that matters: callers score a token against +// the currency it claims to track, and a bare boolean cannot tell them which +// currency that is. server/api.go depends on this shape. +// +// An unknown token reports false rather than guessing from its code. "NGNC" +// from an unrecognised issuer is not assumed to track the naira. +func FiatPeg(a Asset) (string, bool) { + if a.Kind != KindStellar || a.Issuer == "" { + return "", false + } + peg, ok := fiatPegs[a.Code+":"+a.Issuer] + return peg, ok +} + +// HomeDomain reports the domain publishing an asset's stellar.toml, when the +// association has been verified. +// +// Returns false rather than guessing. A checker with no domain reports that it +// could not determine something, which is correct; one sent to a guessed +// domain would report a confident finding about the wrong anchor. +func HomeDomain(a Asset) (string, bool) { + if a.Kind != KindStellar || a.Issuer == "" { + return "", false + } + d, ok := homeDomains[a.Issuer] + return d, ok +} + +// IsFiatToken reports whether a is a known fiat-pegged Stellar token. +func IsFiatToken(a Asset) bool { + _, ok := FiatPeg(a) return ok } diff --git a/asset/known_test.go b/asset/known_test.go index 96a409d..54ef881 100644 --- a/asset/known_test.go +++ b/asset/known_test.go @@ -1,6 +1,8 @@ package asset import ( + "reflect" + "strings" "testing" ) @@ -162,13 +164,41 @@ func TestRegistryCompleteness(t *testing.T) { } } +// TestValidateEntryRequiresVerificationDate pins the field that makes a +// registry entry auditable. +// +// An entry without a verification date records that somebody believed the +// issuer, not that anybody checked it. Issuers rotate accounts, so an +// undated claim cannot be re-verified or expired — which is why this is a +// required field rather than a nice-to-have. func TestValidateEntryRequiresVerificationDate(t *testing.T) { e := Entry{ Code: "TEST", Issuer: "GBTEST", + Peg: "TST", Status: "live", SourceURL: "https://example.com/.well-known/stellar.toml", HomeDomain: "example.com", + // VerificationDate deliberately omitted. + } + + err := ValidateEntry(e) + if err == nil { + t.Fatal("ValidateEntry accepted an entry with no verification date; " + + "an undated registration cannot be re-verified or expired") + } + if !strings.Contains(err.Error(), "verification date") { + t.Errorf("error %q does not name the missing field", err) + } + + // Control: the same entry with a date must pass, or the test above + // would be satisfied by a validator that rejected everything. + e.VerificationDate = "2026-08-08" + if err := ValidateEntry(e); err != nil { + t.Errorf("ValidateEntry rejected a complete entry: %v", err) + } +} + // TestHalfRegisteredEntryFails tests that ValidateEntry fails loudly when // any required field is missing from a registration entry, preventing // silent misclassification of corridor assets. @@ -359,11 +389,4 @@ func TestLookupEntry(t *testing.T) { if _, ok := LookupEntryByCode("UNKNOWN"); ok { t.Error("LookupEntryByCode(\"UNKNOWN\") must return false") } - err := ValidateEntry(e) - if err == nil { - "expected validation error for missing verification date" // wait, error check below - } - if err == nil { - t.Fatal("expected error for missing verification date, got nil") - } } diff --git a/cmd/hop-analysis/main.go b/cmd/hop-analysis/main.go index 1893dc7..5aa1249 100644 --- a/cmd/hop-analysis/main.go +++ b/cmd/hop-analysis/main.go @@ -63,10 +63,15 @@ type SizeBreakdown struct { // CorridorReport is the per-corridor rollup: hop-composition counts across // every size, and one SizeBreakdown per size for reproducibility. type CorridorReport struct { - Snapshot string `json:"snapshot"` - SendCode string `json:"send"` - ReceiveCode string `json:"receive"` - SizesMeasured int `json:"sizes_measured"` + Snapshot string `json:"snapshot"` + SendCode string `json:"send"` + ReceiveCode string `json:"receive"` + SizesMeasured int `json:"sizes_measured"` + // SizesParsed counts probes whose response could be read at all. It is + // distinct from SizesWithAnyPath: a parsed response with no paths is the + // NO-MARKET finding, while a response that would not parse taught us + // nothing about the corridor. + SizesParsed int `json:"sizes_parsed"` SizesWithAnyPath int `json:"sizes_with_any_path"` SizesBestUsesXLM int `json:"sizes_best_uses_xlm"` SizesWithNonXLM int `json:"sizes_with_non_xlm_alt"` @@ -138,6 +143,24 @@ func Analyse(snapshotsDir string) (*Report, error) { fmt.Fprintf(os.Stderr, "skip %s: %v\n", e.Name(), err) continue } + + // A snapshot from which no probe parsed is not a corridor + // measurement. testdata carries deliberately malformed fixtures — + // declared as such in their manifest notes — so the route layer can + // be tested against payloads that must be rejected. Reporting one as + // a corridor would publish hop analysis derived entirely from + // responses designed to be invalid, and it declares the same corridor + // as a real snapshot, so it would appear as a second contradictory + // entry for it. + // + // This is the Failed-versus-NO-MARKET distinction again: nothing was + // learned here, which is different from learning there is no path. + if cr.SizesParsed == 0 { + fmt.Fprintf(os.Stderr, + "skip %s: not one probe parsed; nothing was learned about this corridor\n", + e.Name()) + continue + } out.Corridors = append(out.Corridors, cr) } return out, nil @@ -172,6 +195,7 @@ func analyseSnapshot(m *snapshot.Manifest) (CorridorReport, error) { SizesMeasured: len(sizes), } + var parsed int for _, size := range sizes { paths, err := c.StrictSendPaths(ctx, send, size, recv) if err != nil { @@ -182,6 +206,11 @@ func analyseSnapshot(m *snapshot.Manifest) (CorridorReport, error) { continue } + // The response parsed. Whether it contained a path is a separate + // question: zero paths is a finding about the corridor (NO-MARKET), + // while a response that would not parse taught us nothing at all. + parsed++ + sb := SizeBreakdown{SendAmount: size.String(), NumPaths: len(paths)} if len(paths) > 0 { cr.SizesWithAnyPath++ @@ -216,10 +245,12 @@ func analyseSnapshot(m *snapshot.Manifest) (CorridorReport, error) { Mul(decimal.NewFromInt(100)) sb.XLMAdvantagePc = adv.StringFixed(2) } + cr.SizesParsed = parsed cr.Sizes = append(cr.Sizes, sb) } cr.SummaryLine = summariseCorridor(cr) + cr.SizesParsed = parsed return cr, nil } diff --git a/cmd/ladder/json_test.go b/cmd/ladder/json_test.go index 35f2577..27158f4 100644 --- a/cmd/ladder/json_test.go +++ b/cmd/ladder/json_test.go @@ -80,7 +80,7 @@ func sampleLadderResult() *route.LadderResult { Determined: true, }, { - Component: route.CostFees, + Component: route.CostNetworkFees, Determined: false, Reason: "network fee not measured", }, diff --git a/cmd/ladder/smoke_test.go b/cmd/ladder/smoke_test.go index 3c992a4..ae1f0f8 100644 --- a/cmd/ladder/smoke_test.go +++ b/cmd/ladder/smoke_test.go @@ -241,7 +241,7 @@ func TestGitRevisionNonEmpty(t *testing.T) { func TestDirtyFilesReturnsSlice(t *testing.T) { // dirtyFiles should not panic, regardless of whether the tree is clean - files, err := dirtyFiles() + files, err := dirtyFiles(".") if err != nil { // Not in a git repo is acceptable t.Skipf("dirtyFiles error (may not be in a git repo): %v", err) @@ -255,7 +255,7 @@ func TestDirtyFilesReturnsSlice(t *testing.T) { // --------------------------------------------------------------------------- func TestRequireCleanTreeAllowDirtyAlwaysReturnsNil(t *testing.T) { - dirty, err := requireCleanTree(true) + dirty, err := requireCleanTree(".", true) if err != nil { t.Fatalf("requireCleanTree(allowDirty=true): %v", err) } diff --git a/cmd/wayfared/main.go b/cmd/wayfared/main.go index 0665a39..5261d77 100644 --- a/cmd/wayfared/main.go +++ b/cmd/wayfared/main.go @@ -158,7 +158,6 @@ func main() { Engine: engine, Store: store, Timeout: *timeout, - ErrorCode: func(error) string { return "internal_error" }, HistoryFirst: *histFirst, Checks: &checks.Runner{HorizonURL: *horizon}, } diff --git a/refrate/cross_test.go b/refrate/cross_test.go index 18893ad..7dfd68b 100644 --- a/refrate/cross_test.go +++ b/refrate/cross_test.go @@ -20,6 +20,16 @@ type mockProvider struct { func (m mockProvider) Name() string { return m.name } +// fakeProvider answers with a fixed rate or a fixed error. +type fakeProvider struct { + name string + mid string + asOf time.Time + err error +} + +func (f *fakeProvider) Name() string { return f.name } + func (f *fakeProvider) Rate(_ context.Context, base, quote string) (Rate, error) { if f.err != nil { return Rate{}, f.err @@ -338,276 +348,58 @@ func TestStaleIsReportedDistinctly(t *testing.T) { } } -// TestNeverAverageTwoProviderMids verifies the invariant that a blended or cross-referenced -// reference rate does not average multiple independent provider mids together, or if it composes -// them, it explicitly tracks provenance and never masquerades as a single named provider. +// TestNeverAverageTwoProviderMids pins the rule that two provider mids are +// never blended. +// +// A blended rate names no provider. Every figure this project publishes has to +// be traceable to a source a reader can check, and the mean of two feeds is +// exactly the unattributable number that cannot be. So Cross always returns +// one provider's mid and records which — never their midpoint. +// +// The previous version of this test called a NewParallel constructor that does +// not exist, asserted an error while both providers returned rates, and left +// the reasoning that produced it in a comment. It tested nothing. func TestNeverAverageTwoProviderMids(t *testing.T) { - // The rule: a blended mid names no provider (or is handled via explicit cross/fallback logic - // without silent arithmetic averaging between competing direct providers). - p1 := mockProvider{name: "providerA", rate: decimal.NewFromInt(100)} - p2 := mockProvider{name: "providerB", rate: decimal.NewFromInt(200)} - - // Verify that if we use a Parallel or composite reference rate or custom cross logic, - // it doesn't average p1 and p2 (which would yield 150, naming neither or falsely blending). - // Let's test the Parallel rate provider behavior or implement an explicit check on Parallel. - par := NewParallel(p1, p2) - rate, _, _, err := par.MidWithSource(ctx(), "USD", "NGN") - if err == nil { - t.Fatal("expected an error when neither provider answered") - } - for _, want := range []string{"primary", "secondary", "unavailable", "timeout", "connection refused"} { - if !strings.Contains(err.Error(), want) { - t.Errorf("error %q should mention %q", err, want) - } - } - - // Additionally, test a dedicated unit check or assertion ensuring that any multi-provider - // composition explicitly rejects averaging. - rateA := decimal.NewFromInt(100) - rateB := decimal.NewFromInt(200) - avg := rateA.Add(rateB).Div(decimal.NewFromInt(2)) - if avg.Equal(decimal.NewFromInt(150)) { - // Arithmetic check: ensure our test harness catches what averaging looks like, - // and confirm that no production refrate function performs this midpoint blend. - } -} - -// TestClassifyError verifies the error taxonomy classifier for every typed -// error the project defines. -func TestClassifyError(t *testing.T) { cases := []struct { - name string - err error - want errorClass - label string + name string + primary, secondar string + average string }{ - { - name: "ErrUnavailable", - err: &ErrUnavailable{Source: "test", Err: errors.New("timeout")}, - want: errClassUnavailable, - label: "unavailable", - }, - { - name: "ErrUnparseable", - err: &ErrUnparseable{Source: "test", Err: errors.New("bad json")}, - want: errClassUnparseable, - label: "returned an unparseable response", - }, - { - name: "wrapped ErrUnavailable", - err: fmt.Errorf("refrate: %w", &ErrUnavailable{Source: "test", Err: errors.New("timeout")}), - want: errClassUnavailable, - label: "unavailable", - }, - { - name: "wrapped ErrUnparseable", - err: fmt.Errorf("refrate: %w", &ErrUnparseable{Source: "test", Err: errors.New("bad json")}), - want: errClassUnparseable, - label: "returned an unparseable response", - }, - { - name: "ErrNoRate is unknown", - err: &ErrNoRate{Base: "USD", Quote: "NGN", Source: "test"}, - want: errClassUnknown, - label: "unavailable", - }, - { - name: "ErrRateLimited is unknown", - err: &ErrRateLimited{Source: "test"}, - want: errClassUnknown, - label: "unavailable", - }, - { - name: "plain error is unknown", - err: errors.New("something broke"), - want: errClassUnknown, - label: "unavailable", - }, - { - name: "nil is unknown", - err: nil, - want: errClassUnknown, - label: "unavailable", - }, + // 3% apart: a genuine disagreement, scored conservatively. + {"disagreement", "100", "103", "101.5"}, + // 100% apart: one feed is broken, and nothing is scored. + {"malfunction", "100", "200", "150"}, } + for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - got := classifyError(tc.err) - if got != tc.want { - t.Errorf("classifyError = %d, want %d", got, tc.want) - } - if got := errorDescription(tc.err); got != tc.label { - t.Errorf("errorDescription = %q, want %q", got, tc.label) - } - }) - } -} - -// TestDegradationNoteNamesUnavailable pins that a single-provider failure -// using *ErrUnavailable produces a note that says "unavailable". -func TestDegradationNoteNamesUnavailable(t *testing.T) { - c := &Cross{ - Primary: &fakeProvider{name: "primary", mid: "1348"}, - Secondary: &fakeProvider{name: "secondary", err: &ErrUnavailable{Source: "secondary", Err: errors.New("timeout")}}, - } - r := rateOf(t, c) - - if r.Agreement != AgreementSingle { - t.Fatalf("Agreement = %s, want SINGLE", r.Agreement) - } - if !strings.Contains(r.Note, "secondary was unavailable") { - t.Errorf("Note = %q, want it to say 'secondary was unavailable'", r.Note) - } -} - -// TestDegradationNoteNamesUnparseable pins that a single-provider failure -// using *ErrUnparseable produces a note that says "unparseable response". -func TestDegradationNoteNamesUnparseable(t *testing.T) { - c := &Cross{ - Primary: &fakeProvider{name: "primary", mid: "1348"}, - Secondary: &fakeProvider{name: "secondary", err: &ErrUnparseable{Source: "secondary", Err: errors.New("bad json")}}, - } - r := rateOf(t, c) - - if r.Agreement != AgreementSingle { - t.Fatalf("Agreement = %s, want SINGLE", r.Agreement) - } - if !strings.Contains(r.Note, "secondary was returned an unparseable response") { - t.Errorf("Note = %q, want it to say 'secondary was returned an unparseable response'", r.Note) - } -} - -// TestBothUnavailableErrorText confirms the combined error message names -// "unavailable" when both providers fail with *ErrUnavailable. -func TestBothUnavailableErrorText(t *testing.T) { - c := &Cross{ - Primary: &fakeProvider{name: "primary", err: &ErrUnavailable{Source: "primary", Err: errors.New("timeout")}}, - Secondary: &fakeProvider{name: "secondary", err: &ErrUnavailable{Source: "secondary", Err: errors.New("refused")}}, - } - _, err := c.Rate(context.Background(), "USD", "NGN") - if err == nil { - t.Fatal("expected an error") - } - msg := err.Error() - if !strings.Contains(msg, "primary unavailable") { - t.Errorf("error %q should say 'primary unavailable'", msg) - } - if !strings.Contains(msg, "secondary unavailable") { - t.Errorf("error %q should say 'secondary unavailable'", msg) - } -} - -// TestBothUnparseableErrorText confirms the combined error message names -// "unparseable response" when both providers fail with *ErrUnparseable. -func TestBothUnparseableErrorText(t *testing.T) { - c := &Cross{ - Primary: &fakeProvider{name: "primary", err: &ErrUnparseable{Source: "primary", Err: errors.New("bad json")}}, - Secondary: &fakeProvider{name: "secondary", err: &ErrUnparseable{Source: "secondary", Err: errors.New("not decimal")}}, - } - _, err := c.Rate(context.Background(), "USD", "NGN") - if err == nil { - t.Fatal("expected an error") - } - msg := err.Error() - if !strings.Contains(msg, "primary was returned an unparseable response") { - t.Errorf("error %q should say 'primary was returned an unparseable response'", msg) - } - if !strings.Contains(msg, "secondary was returned an unparseable response") { - t.Errorf("error %q should say 'secondary was returned an unparseable response'", msg) - } -} + r := rateOf(t, crossOf(tc.primary, tc.secondar)) -// TestMixedErrorClassesErrorText confirms the combined error message -// differentiates when the two providers fail in different ways. -func TestMixedErrorClassesErrorText(t *testing.T) { - c := &Cross{ - Primary: &fakeProvider{name: "primary", err: &ErrUnavailable{Source: "primary", Err: errors.New("timeout")}}, - Secondary: &fakeProvider{name: "secondary", err: &ErrUnparseable{Source: "secondary", Err: errors.New("bad json")}}, - } - _, err := c.Rate(context.Background(), "USD", "NGN") - if err == nil { - t.Fatal("expected an error") - } - msg := err.Error() - if !strings.Contains(msg, "primary unavailable") { - t.Errorf("error %q should say 'primary unavailable'", msg) - } - if !strings.Contains(msg, "secondary was returned an unparseable response") { - t.Errorf("error %q should say 'secondary was returned an unparseable response'", msg) - } -} + avg := decimal.RequireFromString(tc.average) + if r.Mid.Equal(avg) { + t.Fatalf("Mid = %s, the midpoint of %s and %s — a blended rate names "+ + "no provider and cannot be traced to a source", + r.Mid, tc.primary, tc.secondar) + } -// TestDegradationNoteFallbackToUnavailable covers errors that are not in the -// taxonomy (ErrNoRate, ErrRateLimited, plain errors). The note must still say -// "unavailable" as a safe fallback — the taxonomy enriches but never breaks. -func TestDegradationNoteFallbackToUnavailable(t *testing.T) { - cases := []struct { - name string - err error - }{ - {"ErrNoRate", &ErrNoRate{Base: "USD", Quote: "NGN", Source: "secondary"}}, - {"ErrRateLimited", &ErrRateLimited{Source: "secondary"}}, - {"plain error", errors.New("something broke")}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - c := &Cross{ - Primary: &fakeProvider{name: "primary", mid: "1348"}, - Secondary: &fakeProvider{name: "secondary", err: tc.err}, + // The mid returned must be one of the two actually observed. + p := decimal.RequireFromString(tc.primary) + sec := decimal.RequireFromString(tc.secondar) + if !r.Mid.Equal(p) && !r.Mid.Equal(sec) { + t.Errorf("Mid = %s, which is neither provider's figure", r.Mid) } - r := rateOf(t, c) - if r.Agreement != AgreementSingle { - t.Fatalf("Agreement = %s, want SINGLE", r.Agreement) + // And both are carried, so a reader can see what was set aside. + if r.SecondaryMid.IsZero() || r.SecondarySource == "" { + t.Error("the unused provider's mid and source must still be recorded") } - if !strings.Contains(r.Note, "secondary was unavailable") { - t.Errorf("Note = %q, want it to say 'secondary was unavailable' for %T", - r.Note, tc.err) + if r.Source == "" { + t.Error("the scored rate must name which provider produced it") } }) } } -// TestPrimaryUnavailableFallsToSecondary mirrors the taxonomy on the primary -// side: an unavailable primary degrades to the secondary's rate. -func TestPrimaryUnavailableFallsToSecondary(t *testing.T) { - c := &Cross{ - Primary: &fakeProvider{name: "primary", err: &ErrUnavailable{Source: "primary", Err: errors.New("503")}}, - Secondary: &fakeProvider{name: "secondary", mid: "1350"}, - } - r := rateOf(t, c) - - if r.Agreement != AgreementSingle { - t.Fatalf("Agreement = %s, want SINGLE", r.Agreement) - } - if r.Source != "secondary" || !r.Mid.Equal(decimal.RequireFromString("1350")) { - t.Errorf("got %s from %s, want 1350 from secondary", r.Mid, r.Source) - } - if !strings.Contains(r.Note, "primary was unavailable") { - t.Errorf("Note = %q, want it to say 'primary was unavailable'", r.Note) - } -} - -// TestPrimaryUnparseableFallsToSecondary confirms an unparseable primary -// degrades to the secondary with the correct note. -func TestPrimaryUnparseableFallsToSecondary(t *testing.T) { - c := &Cross{ - Primary: &fakeProvider{name: "primary", err: &ErrUnparseable{Source: "primary", Err: errors.New("bad json")}}, - Secondary: &fakeProvider{name: "secondary", mid: "1350"}, - } - r := rateOf(t, c) - - if r.Agreement != AgreementSingle { - t.Fatalf("Agreement = %s, want SINGLE", r.Agreement) - } - if r.Source != "secondary" || !r.Mid.Equal(decimal.RequireFromString("1350")) { - t.Errorf("got %s from %s, want 1350 from secondary", r.Mid, r.Source) - } - if !strings.Contains(r.Note, "primary was returned an unparseable response") { - t.Errorf("Note = %q, want it to say 'primary was returned an unparseable response'", r.Note) - } -} - // TestClassifyError verifies the error taxonomy classifier for every typed // error the project defines. func TestClassifyError(t *testing.T) { diff --git a/route/cost_test.go b/route/cost_test.go index 64271bb..c1ac331 100644 --- a/route/cost_test.go +++ b/route/cost_test.go @@ -1,15 +1,73 @@ package route import ( + "encoding/json" + "errors" + "fmt" + "strings" "testing" "github.com/shopspring/decimal" + + "github.com/Wayfare-labs/wayfare/asset" ) -func TestDecomposeExpectedFailureCostUndetermined(t *testing.T) { func testUSDC() asset.Asset { return asset.USDC() } func testNGNC() asset.Asset { return asset.NGNC() } +// TestDecomposeExpectedFailureCostUndetermined pins the one cost component +// that must never acquire a number. +// +// Expected failure cost is a layer 3 quantity: it needs observed failures to +// estimate, and none have been collected. Reporting it as zero would state +// that a corridor never fails, which is a much stronger claim than "we do not +// know" and the opposite of what the data supports. +// +// So it stays undetermined, and it carries a reason saying why — an unexplained +// blank invites a reader to assume the cost is negligible rather than unmeasured. +func TestDecomposeExpectedFailureCostUndetermined(t *testing.T) { + q := Quote{ + Kind: KindDEX, + Description: "USDC -> XLM -> NGNC", + Source: "stellar-dex", + SendAsset: testUSDC(), + SendAmount: decimal.NewFromInt(100), + ReceiveAsset: testNGNC(), + ReceiveAmount: decimal.RequireFromString("112800.51"), + EffectiveRate: decimal.RequireFromString("1128.0051"), + ReferenceMid: decimal.RequireFromString("1500"), + LossPct: decimal.RequireFromString("24.80"), + LossAmount: decimal.RequireFromString("37199.49"), + Verdict: VerdictUnusable, + } + + d := Decompose(q, decimal.RequireFromString("1500")) + + var found bool + for _, p := range d.Parts { + if p.Component != CostExpectedFailure { + continue + } + found = true + + if p.Determined { + t.Error("expected failure cost reported as determined; it needs observed " + + "failures to estimate, and none have been collected") + } + if !p.Amount.IsZero() { + t.Errorf("undetermined expected failure cost carries amount %s; an "+ + "undetermined component must hold no figure at all", p.Amount) + } + if strings.TrimSpace(p.Reason) == "" { + t.Error("undetermined expected failure cost carries no reason; an " + + "unexplained blank invites a reader to assume the cost is negligible") + } + } + if !found { + t.Fatal("decomposition omits the expected-failure component entirely") + } +} + func TestCostDecomposeSplitsCorrectly(t *testing.T) { q := Quote{ Kind: KindDEX, @@ -92,15 +150,27 @@ func TestCostDecomposeSplitsCorrectly(t *testing.T) { } } +// TestCostDecomposeZeroLoss covers a route that achieves mid exactly. +// +// Zero loss is a real measurement, not a missing one: the route was priced and +// found to cost nothing against the benchmark. It must therefore report a +// determined zero rather than an undetermined component, which is the +// distinction the rest of this file exists to protect. func TestCostDecomposeZeroLoss(t *testing.T) { q := Quote{ - LossPct: decimal.NewFromFloat(1.25), - LossAmount: decimal.NewFromFloat(0.50), + Kind: KindDEX, + SendAsset: testUSDC(), + SendAmount: decimal.NewFromInt(100), + ReceiveAsset: testNGNC(), + ReceiveAmount: decimal.RequireFromString("150000"), + EffectiveRate: decimal.RequireFromString("1500"), + ReferenceMid: decimal.RequireFromString("1500"), + LossPct: decimal.Zero, + LossAmount: decimal.Zero, + Verdict: VerdictGood, } - mid := decimal.NewFromFloat(100.0) - decomp := Decompose(q, mid) - d := Decompose(q, decimal.NewFromInt(1500)) + d := Decompose(q, decimal.RequireFromString("1500")) if !d.TotalLossPct.IsZero() { t.Errorf("TotalLossPct = %s, want zero", d.TotalLossPct) } @@ -282,7 +352,8 @@ func TestCostBlockJSONShape(t *testing.T) { } assertDeterminedDecimalStrings(t, parts[0], "fx_loss") - for _, idx := range []int{1, 2, 3} { + // parts[0] is fx_loss and is determined; 1..4 are the components that + // must stay undetermined until there is data behind them. for _, idx := range []int{1, 2, 3, 4} { p := parts[idx] if got := componentOf(t, p); got == string(CostFXLoss) { @@ -488,8 +559,6 @@ func TestCostNoDeterminedComponentDefaultsToZero(t *testing.T) { if !p.Determined { t.Error("fx_loss is computed from observed rates and must be determined") } - if part.Reason == "" { - t.Error("expected_failure component must provide a reason why it is undetermined, but reason is empty") case CostNetworkFees, CostAnchorFee, CostSlippage, CostExpectedFailure: if p.Determined { t.Errorf( @@ -501,8 +570,4 @@ func TestCostNoDeterminedComponentDefaultsToZero(t *testing.T) { } } } - - if !found { - t.Fatal("CostDecomposition missing expected_failure component") - } } diff --git a/route/route_test.go b/route/route_test.go index 87c6a32..6b98d61 100644 --- a/route/route_test.go +++ b/route/route_test.go @@ -807,6 +807,9 @@ func TestRecordedMalformedPayloadsReportUnknown(t *testing.T) { res.Notes, tc.reason) } }) + } +} + // TestUnknownOnlyPathIsTheDocumentedFalseNegative pins the bounded // false-negative written down in asset/known.go: a corridor whose only hops // are unregistered is classified DIRECT, because an unrecognised fiat token diff --git a/sep38/sep38.go b/sep38/sep38.go index 6e5175d..f88a575 100644 --- a/sep38/sep38.go +++ b/sep38/sep38.go @@ -38,7 +38,9 @@ package sep38 import ( "context" "encoding/json" + "errors" "fmt" + "io" "net/http" "net/url" "strings" @@ -352,8 +354,20 @@ func (c *Client) do(req *http.Request, out any) error { } return fmt.Errorf("sep38: %s returned HTTP %d", req.URL.Host, resp.StatusCode) } - if err := json.NewDecoder(resp.Body).Decode(out); err != nil { + dec := json.NewDecoder(resp.Body) + if err := dec.Decode(out); err != nil { return fmt.Errorf("sep38: decoding response: %w", err) } + + // Decode stops at the end of the first JSON value, so a body like + // `{"price":"5.00"} and then some` would otherwise parse cleanly and + // yield a quote. Trailing bytes mean the response is not what it claims + // to be, and a partially-understood body is not a safe basis for a price: + // this is the fee-denomination lesson again, where arithmetic that + // succeeds on a misread input is worse than an error. + if _, err := dec.Token(); !errors.Is(err, io.EOF) { + return fmt.Errorf("sep38: response carries trailing data after the JSON body; " + + "the anchor sent something other than a single quote object") + } return nil } diff --git a/sep38/sep38_edge_test.go b/sep38/sep38_edge_test.go index 8fbfc26..53632f7 100644 --- a/sep38/sep38_edge_test.go +++ b/sep38/sep38_edge_test.go @@ -25,11 +25,11 @@ import ( // proceed on fabricated pricing. func TestGetPriceRejectsMalformedJSON(t *testing.T) { cases := map[string]string{ - "truncated object": `{"price": "5.00", "sell_amount":`, - "not json": `not json at all`, - "price wrong type": `{"price": 5.00}`, // number where a string is expected - "array not object": `["price", "5.00"]`, - "trailing garbage": `{"price":"5.00"} and then some`, + "truncated object": `{"price": "5.00", "sell_amount":`, + "not json": `not json at all`, + "price wrong type": `{"price": 5.00}`, // number where a string is expected + "array not object": `["price", "5.00"]`, + "trailing garbage": `{"price":"5.00"} and then some`, } for name, body := range cases { t.Run(name, func(t *testing.T) { @@ -83,9 +83,9 @@ func TestGetPriceTimeoutReturnsError(t *testing.T) { // fabricate a zero-valued quote and present it as a real price. func TestGetPriceRejectsEmptyResponse(t *testing.T) { cases := map[string]string{ - "empty body": ``, - "empty object": `{}`, - "whitespace only": " \n\t ", + "empty body": ``, + "empty object": `{}`, + "whitespace only": " \n\t ", } for name, body := range cases { t.Run(name, func(t *testing.T) { @@ -113,8 +113,8 @@ func TestGetPriceRejectsEmptyResponse(t *testing.T) { func TestGetPriceRejectsMissingRequiredFields(t *testing.T) { cases := map[string]string{ "no price field": `{"sell_amount":"542","buy_amount":"100","fee":{"total":"42","asset":"iso4217:BRL"}}`, - "empty price string": `{"price":"","sell_amount":"542","buy_amount":"100"}`, - "fee only": `{"fee":{"total":"42","asset":"iso4217:BRL"}}`, + "empty price string": `{"price":"","sell_amount":"542","buy_amount":"100"}`, + "fee only": `{"fee":{"total":"42","asset":"iso4217:BRL"}}`, } for name, body := range cases { t.Run(name, func(t *testing.T) { diff --git a/server/api.go b/server/api.go index 68b00d8..292fa3a 100644 --- a/server/api.go +++ b/server/api.go @@ -128,7 +128,7 @@ func (s *Server) handleCorridor(w http.ResponseWriter, r *http.Request) { writeError(w, r, http.StatusMethodNotAllowed, codeMethodNotAllowed, "only GET is supported") return } - if err := checkParams(r, "from", "to", "sizes", "live"); err != nil { + if err := checkParams(r, "from", "to", "sizes", "live", "pretty"); err != nil { writeError(w, r, http.StatusBadRequest, codeInvalidQuery, err.Error()) return } @@ -272,6 +272,19 @@ func (s *Server) handleAssets(w http.ResponseWriter, r *http.Request) { writeError(w, r, http.StatusBadRequest, codeInvalidQuery, err.Error()) return } + // corridorState is the pricing history for a receive asset. The UI needs + // this to build a corridor selector that reflects what has actually been + // measured rather than what is theoretically possible. + // + // Omitted entirely for non-destination assets and when no store is + // configured, so the wire never carries a fabricated false: "not measured" + // and "measured, no history" are different claims. + type corridorState struct { + HasHistory bool `json:"has_history"` + LastIntegrity string `json:"last_integrity,omitempty"` + LastMeasured string `json:"last_measured,omitempty"` + } + type entry struct { route.AssetJSON Corridor bool `json:"can_be_destination"` @@ -330,12 +343,68 @@ func (s *Server) handleAssets(w http.ResponseWriter, r *http.Request) { writeJSON(w, r, http.StatusOK, map[string]any{"assets": out}) } +// dataAgeJSON is the freshness of one corridor's newest stored record. +type dataAgeJSON struct { + RecordedAt string `json:"recorded_at"` + AgeSeconds int64 `json:"age_seconds"` + AgeHuman string `json:"age_human"` +} + func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { if err := checkParams(r); err != nil { writeError(w, r, http.StatusBadRequest, codeInvalidQuery, err.Error()) return } - writeJSON(w, r, http.StatusOK, map[string]string{"status": "ok"}) + + out := map[string]any{"status": "ok"} + + // On a history-first deployment the thing at risk is not whether the + // process is up, it is how old the data it serves has become. A health + // check that only says "ok" would stay green while the measurements + // silently aged out. + // + // A corridor with no history is absent rather than reported as zero, and + // the whole block is null when there is nothing to report: an unavailable + // age is unknown, never a fabricated "now". + out["data"] = s.dataAges(r.Context()) + + writeJSON(w, r, http.StatusOK, out) +} + +// dataAges reports the freshness of each corridor's newest record, or nil when +// nothing is stored. +func (s *Server) dataAges(ctx context.Context) map[string]dataAgeJSON { + if s.Store == nil { + return nil + } + corridors, err := s.Store.Corridors(ctx) + if err != nil || len(corridors) == 0 { + // A store that cannot be listed is an unknown age, not a zero one. + return nil + } + + now := time.Now().UTC() + ages := make(map[string]dataAgeJSON, len(corridors)) + for _, c := range corridors { + rec, err := s.Store.Latest(ctx, c) + if err != nil || rec == nil { + continue + } + recorded := rec.RecordedAt.UTC() + age := now.Sub(recorded) + if age < 0 { + age = 0 + } + ages[c] = dataAgeJSON{ + RecordedAt: recorded.Format(time.RFC3339), + AgeSeconds: int64(age.Seconds()), + AgeHuman: humanAge(age), + } + } + if len(ages) == 0 { + return nil + } + return ages } // helpers -------------------------------------------------------------------- @@ -354,6 +423,9 @@ const ( codeUpstreamTimeout = "upstream_timeout" codeInvalidQuery = "invalid_query" codeInternalError = "internal_error" + codeInvalidLimit = "invalid_limit" + codeStoreRead = "store_read_error" + codeDivergenceHistory = "divergence_history_error" ) // checkParams rejects any query parameter outside the endpoint's allow-list. @@ -783,4 +855,4 @@ func humanAge(d time.Duration) string { default: return fmt.Sprintf("%dd ago", int(d.Hours()/24)) } -} \ No newline at end of file +} diff --git a/server/api_schema_test.go b/server/api_schema_test.go index 9fffd08..4922ea1 100644 --- a/server/api_schema_test.go +++ b/server/api_schema_test.go @@ -170,10 +170,17 @@ func TestErrorResponseShape(t *testing.T) { t.Fatalf("decoding error body: %v", err) } - // Exactly one key: "error". A measurement-looking body on an error path - // would let an SRE dashboard accidentally parse it as data. - if len(body) != 1 { - t.Errorf("error body has %d keys, want exactly 1: %v", len(body), body) + // Exactly two keys: "error" and "code". A measurement-looking body on an + // error path would let an SRE dashboard accidentally parse it as data, so + // the shape is pinned rather than merely checked for the fields it needs. + // + // "code" is the machine-readable discriminator: clients switch on it + // rather than matching the human-readable message, which is free to change. + if len(body) != 2 { + t.Errorf("error body has %d keys, want exactly 2 (error, code): %v", len(body), body) + } + if _, ok := body["code"]; !ok { + t.Error("error body must carry the machine-readable code key") } raw, ok := body["error"] if !ok { diff --git a/server/api_test.go b/server/api_test.go index 45e1357..3fbfd79 100644 --- a/server/api_test.go +++ b/server/api_test.go @@ -237,9 +237,13 @@ func TestUnknownQueryParamsAreRejected(t *testing.T) { path: "/api/corridor?tp=NGNC", wantMsg: `"tp"`, }, + // "pretty" is deliberately NOT listed here: it is a supported + // parameter with its own test (TestPrettyOptInIndents). An earlier + // merge left both a test asserting it returns 200 and this one + // asserting it returns 400, which cannot both hold. "corridor extra param": { - path: "/api/corridor?to=NGNC&pretty=1", - wantMsg: `"pretty"`, + path: "/api/corridor?to=NGNC&verbose=1", + wantMsg: `"verbose"`, }, "corridor multiple unknown": { path: "/api/corridor?to=NGNC&tp=NGNC&fmt=json", diff --git a/server/stale_test.go b/server/stale_test.go index 9c3d52d..e402593 100644 --- a/server/stale_test.go +++ b/server/stale_test.go @@ -11,7 +11,6 @@ import ( "github.com/shopspring/decimal" - "github.com/Wayfare-labs/wayfare/asset" "github.com/Wayfare-labs/wayfare/refrate" "github.com/Wayfare-labs/wayfare/route" "github.com/Wayfare-labs/wayfare/runstore" diff --git a/server/trend.go b/server/trend.go index d46352d..e0e45c7 100644 --- a/server/trend.go +++ b/server/trend.go @@ -227,11 +227,11 @@ func toTrendRunJSON(rec *runstore.Record) TrendRunJSON { // deployment. func (s *Server) handleTrend(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { - writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "only GET is supported") + writeError(w, r, http.StatusMethodNotAllowed, codeMethodNotAllowed, "only GET is supported") return } if err := checkParams(r, "from", "to", "limit"); err != nil { - writeError(w, http.StatusBadRequest, "INVALID_QUERY_PARAM", err.Error()) + writeError(w, r, http.StatusBadRequest, codeInvalidQuery, err.Error()) return } @@ -240,20 +240,20 @@ func (s *Server) handleTrend(w http.ResponseWriter, r *http.Request) { sendAsset, ok := asset.Lookup(from) if !ok { - writeError(w, http.StatusBadRequest, "UNKNOWN_ASSET", fmt.Sprintf( + writeError(w, r, http.StatusBadRequest, codeUnknownSendAsset, fmt.Sprintf( "unknown send asset %q; verified assets are %s", from, strings.Join(asset.KnownCodes(), ", "))) return } recvAsset, ok := asset.Lookup(to) if !ok { - writeError(w, http.StatusBadRequest, "UNKNOWN_ASSET", fmt.Sprintf( + writeError(w, r, http.StatusBadRequest, codeUnknownReceiveAsset, fmt.Sprintf( "unknown receive asset %q; verified assets are %s", to, strings.Join(asset.KnownCodes(), ", "))) return } if _, ok := asset.FiatPeg(recvAsset); !ok { - writeError(w, http.StatusBadRequest, "NO_FIAT_PEG", fmt.Sprintf( + writeError(w, r, http.StatusBadRequest, codeNoFiatPeg, fmt.Sprintf( "no verified fiat peg for %s, so there is no independent rate to score it against", recvAsset.Code)) return @@ -261,7 +261,7 @@ func (s *Server) handleTrend(w http.ResponseWriter, r *http.Request) { limit, err := parseTrendLimit(r.URL.Query().Get("limit")) if err != nil { - writeError(w, http.StatusBadRequest, "BAD_LIMIT", err.Error()) + writeError(w, r, http.StatusBadRequest, codeInvalidLimit, err.Error()) return } @@ -279,7 +279,7 @@ func (s *Server) handleTrend(w http.ResponseWriter, r *http.Request) { if s.Store != nil { recent, err := s.Store.Recent(r.Context(), key, limit) if err != nil { - writeError(w, http.StatusInternalServerError, "STORE_READ_ERROR", "reading stored history: "+err.Error()) + writeError(w, r, http.StatusInternalServerError, codeStoreRead, "reading stored history: "+err.Error()) return } // Recent is newest first because its callers want the latest @@ -297,13 +297,13 @@ func (s *Server) handleTrend(w http.ResponseWriter, r *http.Request) { // A stored divergence_pct that fails to parse is a corrupt record, // not an absent observation — the correct output is an error, not a // history that silently omits the bad run. - writeError(w, http.StatusInternalServerError, "DIVERGENCE_HISTORY_ERROR", + writeError(w, r, http.StatusInternalServerError, codeDivergenceHistory, "computing reference-divergence history: "+err.Error()) return } trend.DivergenceStats = toDivergenceStatsJSON(stats) - writeJSON(w, http.StatusOK, trend) + writeJSON(w, r, http.StatusOK, trend) } // parseTrendLimit bounds how much history one request may read. @@ -327,4 +327,4 @@ func parseTrendLimit(raw string) (int, error) { n = maxTrendLimit } return n, nil -} \ No newline at end of file +} diff --git a/snapshot/snapshot_test.go b/snapshot/snapshot_test.go index 741d5d2..41695b2 100644 --- a/snapshot/snapshot_test.go +++ b/snapshot/snapshot_test.go @@ -1,4 +1,4 @@ -package snapshot_test +package snapshot import ( "encoding/json" @@ -12,6 +12,122 @@ import ( "path/filepath" "strings" "testing" + "time" +) + +// ngncCorridor is the corridor every fixture in this file describes. +func ngncCorridor() Corridor { + return Corridor{ + Send: AssetRef{Code: "USDC", Issuer: "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN"}, + Receive: AssetRef{Code: "NGNC", Issuer: "GASBV6W7GGED66MXEVC7YZHTWWYMSVYEY35USF2HJZBLABLYIFQGXZY6"}, + ReferencePair: "USD/NGN", + } +} + +// recordAgainst runs a recorder over a stub server and saves the result. +func recordAgainst(t *testing.T, handler http.HandlerFunc, paths ...string) (string, *Recorder) { + t.Helper() + srv := httptest.NewServer(handler) + t.Cleanup(srv.Close) + + rec := &Recorder{Corridor: ngncCorridor(), Sizes: []string{"100"}} + client := &http.Client{Transport: rec} + for _, p := range paths { + resp, err := client.Get(srv.URL + p) + if err != nil { + t.Fatalf("GET %s: %v", p, err) + } + if _, err := io.ReadAll(resp.Body); err != nil { + t.Fatalf("reading body: %v", err) + } + resp.Body.Close() + } + + dir := filepath.Join(t.TempDir(), DirName(ngncCorridor(), time.Now())) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + if err := rec.Save(dir); err != nil { + t.Fatalf("Save: %v", err) + } + return dir, rec +} + +func TestRoundTripPreservesBytesExactly(t *testing.T) { + // Deliberately awkward: significant trailing digits and unusual spacing, + // the kind of thing a reformatting round trip would quietly normalise. + const body = `{"_embedded":{"records":[{"destination_amount":"62890.8300000"}]}}` + + dir, _ := recordAgainst(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/hal+json; charset=utf-8") + _, _ = io.WriteString(w, body) + }, "/paths/strict-send?source_amount=100") + + m, err := Load(dir) + if err != nil { + t.Fatalf("Load: %v", err) + } + + u, _ := url.Parse("https://horizon.stellar.org/paths/strict-send?source_amount=100") + got, ok := m.Body(Key("GET", u)) + if !ok { + t.Fatalf("no body recorded; keys were %v", m.Keys()) + } + if string(got) != body { + t.Errorf("body was not preserved verbatim:\n got %s\nwant %s", got, body) + } +} + +func TestKeyIsHostIndependentAndQuerySorted(t *testing.T) { + a, _ := url.Parse("https://horizon.stellar.org/paths/strict-send?source_amount=100&destination_assets=NGNC") + b, _ := url.Parse("http://127.0.0.1:54321/paths/strict-send?destination_assets=NGNC&source_amount=100") + + if Key("GET", a) != Key("GET", b) { + t.Errorf("keys differ across host and query order:\n%s\n%s", Key("GET", a), Key("GET", b)) + } + // The host must not appear at all — this is what lets one snapshot drive + // an httptest.Server and a live-shaped URL alike. + if strings.Contains(Key("GET", a), "horizon.stellar.org") { + t.Errorf("key leaked the host: %s", Key("GET", a)) + } +} + +func TestReplayServesRecordedResponse(t *testing.T) { + const body = `{"result":"success","rates":{"NGN":1348.058467}}` + dir, _ := recordAgainst(t, func(w http.ResponseWriter, r *http.Request) { + _, _ = io.WriteString(w, body) + }, "/v6/latest/USD") + + m, err := Load(dir) + if err != nil { + t.Fatalf("Load: %v", err) + } + + // Replayed against a completely different base URL, which is the point. + resp, err := m.HTTPClient().Get("https://open.er-api.com/v6/latest/USD") + if err != nil { + t.Fatalf("replay: %v", err) + } + defer resp.Body.Close() + + got, _ := io.ReadAll(resp.Body) + if string(got) != body { + t.Errorf("replayed body = %s, want %s", got, body) + } + if resp.StatusCode != http.StatusOK { + t.Errorf("status = %d, want 200", resp.StatusCode) + } +} + +func TestUnrecordedRequestErrorsRatherThanReachingTheNetwork(t *testing.T) { + dir, _ := recordAgainst(t, func(w http.ResponseWriter, r *http.Request) { + _, _ = io.WriteString(w, `{}`) + }, "/paths/strict-send?source_amount=100") + + m, err := Load(dir) + if err != nil { + t.Fatalf("Load: %v", err) + } _, err = m.HTTPClient().Get("https://horizon.stellar.org/paths/strict-send?source_amount=999") if err == nil { @@ -240,12 +356,18 @@ func TestSizesSurviveAsDecimalStrings(t *testing.T) { _, _ = io.WriteString(w, `{}`) }, "/paths/strict-send?source_amount=0.1") -func TestSnapshotsCoverage(t *testing.T) { - s, err := snapshot.LoadAll("../testdata/snapshots") + raw, err := os.ReadFile(filepath.Join(dir, ManifestFile)) if err != nil { - t.Fatalf("failed to load snapshots: %v", err) + t.Fatal(err) } - if len(s) == 0 { - t.Fatal("expected at least one snapshot fixture") + if !strings.Contains(string(raw), `"100"`) { + t.Errorf("sizes should be decimal strings, manifest was:\n%s", raw) + } +} + +func TestDirNameConvention(t *testing.T) { + at := time.Date(2026, 8, 21, 14, 3, 55, 0, time.UTC) + if got, want := DirName(ngncCorridor(), at), "usdc-ngnc-20260821T140355Z"; got != want { + t.Errorf("DirName = %q, want %q", got, want) } } diff --git a/testdata/snapshots/usdc-brlc-20260823T000000Z/manifest.json b/testdata/snapshots/usdc-brlc-20260823T000000Z/manifest.json deleted file mode 100644 index 7a148a8..0000000 --- a/testdata/snapshots/usdc-brlc-20260823T000000Z/manifest.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "format": "wayfare.snapshot", - "version": 1, - "recorded_at": "2026-08-23T00:00:00Z", - "corridor": { - "send": { - "code": "USDC", - "issuer": "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN" - }, - "receive": { - "code": "BRLC", - "issuer": "GB7GGYVRZZ5YXZLV2X3N7H9RJK5E5JFY5Q42M66H6XGQ7Z6XZ4JXXJ67" - }, - "reference_pair": "" - }, - "sizes": ["1"], - "sources": { - "horizon": { - "base_url": "https://horizon.stellar.org" - }, - "reference": { - "base_url": "" - } - }, - "notes": [ - "Snapshot fixture for USDC-BRLC corridor research." - ], - "interactions": [ - { - "kind": "horizon", - "method": "GET", - "key": "GET /paths/strict-send?destination_assets=BRLC%3AGB7GGYVRZZ5YXZLV2X3N7H9RJK5E5JFY5Q42M66H6XGQ7Z6XZ4JXXJ67&source_amount=1&source_asset_code=USDC&source_asset_issuer=GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN&source_asset_type=credit_alphanum4", - "url": "https://horizon.stellar.org/paths/strict-send?destination_assets=BRLC%3AGB7GGYVRZZ5YXZLV2X3N7H9RJK5E5JFY5Q42M66H6XGQ7Z6XZ4JXXJ67&source_amount=1&source_asset_code=USDC&source_asset_issuer=GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN&source_asset_type=credit_alphanum4", - "status": 200, - "content_type": "application/hal+json", - "recorded_at": "2026-08-23T00:00:00Z", - "body_file": "responses/001-paths.json", - "body_sha256": "sha256:cb8391a385f66a1a9eecc2ab0110eccf561aa565c48f8c709b897fc1e4fe230d" - } - ] -} diff --git a/testdata/snapshots/usdc-brlc-20260823T000000Z/responses/001-paths.json b/testdata/snapshots/usdc-brlc-20260823T000000Z/responses/001-paths.json deleted file mode 100644 index 9b37991..0000000 --- a/testdata/snapshots/usdc-brlc-20260823T000000Z/responses/001-paths.json +++ /dev/null @@ -1 +0,0 @@ -{"_embedded":{"records":[]}} diff --git a/testdata/snapshots/usdc-inrc-20260823T000000Z/manifest.json b/testdata/snapshots/usdc-inrc-20260823T000000Z/manifest.json deleted file mode 100644 index a067de7..0000000 --- a/testdata/snapshots/usdc-inrc-20260823T000000Z/manifest.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "format": "wayfare.snapshot", - "version": 1, - "recorded_at": "2026-08-23T00:00:00Z", - "corridor": { - "send": { - "code": "USDC", - "issuer": "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN" - }, - "receive": { - "code": "INRC", - "issuer": "GB7GGYVRZZ5YXZLV2X3N7H9RJK5E5JFY5Q42M66H6XGQ7Z6XZ4JXXJ67" - }, - "reference_pair": "" - }, - "sizes": ["1"], - "sources": { - "horizon": { - "base_url": "https://horizon.stellar.org" - }, - "reference": { - "base_url": "" - } - }, - "notes": [ - "Snapshot fixture for USDC-INRC corridor research." - ], - "interactions": [ - { - "kind": "horizon", - "method": "GET", - "key": "GET /paths/strict-send?destination_assets=INRC%3AGB7GGYVRZZ5YXZLV2X3N7H9RJK5E5JFY5Q42M66H6XGQ7Z6XZ4JXXJ67&source_amount=1&source_asset_code=USDC&source_asset_issuer=GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN&source_asset_type=credit_alphanum4", - "url": "https://horizon.stellar.org/paths/strict-send?destination_assets=INRC%3AGB7GGYVRZZ5YXZLV2X3N7H9RJK5E5JFY5Q42M66H6XGQ7Z6XZ4JXXJ67&source_amount=1&source_asset_code=USDC&source_asset_issuer=GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN&source_asset_type=credit_alphanum4", - "status": 200, - "content_type": "application/hal+json", - "recorded_at": "2026-08-23T00:00:00Z", - "body_file": "responses/001-paths.json", - "body_sha256": "sha256:cb8391a385f66a1a9eecc2ab0110eccf561aa565c48f8c709b897fc1e4fe230d" - } - ] -} diff --git a/testdata/snapshots/usdc-inrc-20260823T000000Z/responses/001-paths.json b/testdata/snapshots/usdc-inrc-20260823T000000Z/responses/001-paths.json deleted file mode 100644 index 9b37991..0000000 --- a/testdata/snapshots/usdc-inrc-20260823T000000Z/responses/001-paths.json +++ /dev/null @@ -1 +0,0 @@ -{"_embedded":{"records":[]}} diff --git a/testdata/snapshots/usdc-mxnc-20260823T000000Z/manifest.json b/testdata/snapshots/usdc-mxnc-20260823T000000Z/manifest.json deleted file mode 100644 index a7ce10e..0000000 --- a/testdata/snapshots/usdc-mxnc-20260823T000000Z/manifest.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "format": "wayfare.snapshot", - "version": 1, - "recorded_at": "2026-08-23T00:00:00Z", - "corridor": { - "send": { - "code": "USDC", - "issuer": "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN" - }, - "receive": { - "code": "MXNC", - "issuer": "GB7GGYVRZZ5YXZLV2X3N7H9RJK5E5JFY5Q42M66H6XGQ7Z6XZ4JXXJ67" - }, - "reference_pair": "" - }, - "sizes": ["1"], - "sources": { - "horizon": { - "base_url": "https://horizon.stellar.org" - }, - "reference": { - "base_url": "" - } - }, - "notes": [ - "Snapshot fixture for USDC-MXNC corridor research." - ], - "interactions": [ - { - "kind": "horizon", - "method": "GET", - "key": "GET /paths/strict-send?destination_assets=MXNC%3AGB7GGYVRZZ5YXZLV2X3N7H9RJK5E5JFY5Q42M66H6XGQ7Z6XZ4JXXJ67&source_amount=1&source_asset_code=USDC&source_asset_issuer=GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN&source_asset_type=credit_alphanum4", - "url": "https://horizon.stellar.org/paths/strict-send?destination_assets=MXNC%3AGB7GGYVRZZ5YXZLV2X3N7H9RJK5E5JFY5Q42M66H6XGQ7Z6XZ4JXXJ67&source_amount=1&source_asset_code=USDC&source_asset_issuer=GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN&source_asset_type=credit_alphanum4", - "status": 200, - "content_type": "application/hal+json", - "recorded_at": "2026-08-23T00:00:00Z", - "body_file": "responses/001-paths.json", - "body_sha256": "sha256:cb8391a385f66a1a9eecc2ab0110eccf561aa565c48f8c709b897fc1e4fe230d" - } - ] -} diff --git a/testdata/snapshots/usdc-mxnc-20260823T000000Z/responses/001-paths.json b/testdata/snapshots/usdc-mxnc-20260823T000000Z/responses/001-paths.json deleted file mode 100644 index 9b37991..0000000 --- a/testdata/snapshots/usdc-mxnc-20260823T000000Z/responses/001-paths.json +++ /dev/null @@ -1 +0,0 @@ -{"_embedded":{"records":[]}} diff --git a/testdata/snapshots/usdc-phpc-20260823T000000Z/manifest.json b/testdata/snapshots/usdc-phpc-20260823T000000Z/manifest.json deleted file mode 100644 index 07abef2..0000000 --- a/testdata/snapshots/usdc-phpc-20260823T000000Z/manifest.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "format": "wayfare.snapshot", - "version": 1, - "recorded_at": "2026-08-23T00:00:00Z", - "corridor": { - "send": { - "code": "USDC", - "issuer": "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN" - }, - "receive": { - "code": "PHPC", - "issuer": "GB7GGYVRZZ5YXZLV2X3N7H9RJK5E5JFY5Q42M66H6XGQ7Z6XZ4JXXJ67" - }, - "reference_pair": "" - }, - "sizes": ["1"], - "sources": { - "horizon": { - "base_url": "https://horizon.stellar.org" - }, - "reference": { - "base_url": "" - } - }, - "notes": [ - "Snapshot fixture for USDC-PHPC corridor research." - ], - "interactions": [ - { - "kind": "horizon", - "method": "GET", - "key": "GET /paths/strict-send?destination_assets=PHPC%3AGB7GGYVRZZ5YXZLV2X3N7H9RJK5E5JFY5Q42M66H6XGQ7Z6XZ4JXXJ67&source_amount=1&source_asset_code=USDC&source_asset_issuer=GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN&source_asset_type=credit_alphanum4", - "url": "https://horizon.stellar.org/paths/strict-send?destination_assets=PHPC%3AGB7GGYVRZZ5YXZLV2X3N7H9RJK5E5JFY5Q42M66H6XGQ7Z6XZ4JXXJ67&source_amount=1&source_asset_code=USDC&source_asset_issuer=GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN&source_asset_type=credit_alphanum4", - "status": 200, - "content_type": "application/hal+json", - "recorded_at": "2026-08-23T00:00:00Z", - "body_file": "responses/001-paths.json", - "body_sha256": "sha256:cb8391a385f66a1a9eecc2ab0110eccf561aa565c48f8c709b897fc1e4fe230d" - } - ] -} diff --git a/testdata/snapshots/usdc-phpc-20260823T000000Z/responses/001-paths.json b/testdata/snapshots/usdc-phpc-20260823T000000Z/responses/001-paths.json deleted file mode 100644 index 9b37991..0000000 --- a/testdata/snapshots/usdc-phpc-20260823T000000Z/responses/001-paths.json +++ /dev/null @@ -1 +0,0 @@ -{"_embedded":{"records":[]}}