Skip to content

fix(api): fail the dashboard summary on a purchase-history store error - #1301

Draft
IAmJSD wants to merge 1 commit into
LeanerCloud:mainfrom
IAmJSD:fix/issue-72
Draft

IAmJSD wants to merge 1 commit into
LeanerCloud:mainfrom
IAmJSD:fix/issue-72

Conversation

@IAmJSD

@IAmJSD IAmJSD commented Oct 23, 2026

Copy link
Copy Markdown
Collaborator

Root cause

fetchCommitmentPurchases (internal/api/handler_dashboard.go) logged a GetActivePurchaseHistory error ("KPIs will be zeroed") and returned ok=false. calculateCommitmentMetrics turned that into 0, 0, 0, nil, and getDashboardSummary sent a 200 with active_commitments: 0, committed_monthly: 0 and ytd_savings: 0. A store outage looked exactly like an empty portfolio.

cristim's comment on the issue points out a second effect. calculateCurrentCoverage returned a hard-coded 100.0 whenever potentialSavings == 0, even when nothing was committed. So the zeroed commitment half plus an empty recommendations half rendered as "100% coverage, $0 committed". That figure looks consistent, is made up, and reassures instead of alarming. The existing unit test even had a subtest named both zero returns 0% that asserted 100.0.

Fix

Error contract: propagate, and the request returns a 500. I picked this by matching how getDashboardSummary already treats its dependencies:

  • Hard data sources fail the request. A ListRecommendations error (failed to get recommendations), an account-scope error and a getAccountScope error all return an error, which becomes a 500 (handleRequestError sends a generic Internal server error body, so store details don't leak). Purchase history is the only source for the commitment KPIs, the same way recommendations are the only source for the savings KPIs. It now follows the same rule.
  • Refinements degrade to a documented, non-fabricated default. The per-account coverage cap (resolveCoverageByAccountKey) falls back to un-scaled totals, and the target (resolveTargetCoverage) falls back to the 80% default. Neither invents a $0. The summary response has no partial/unavailable flag anywhere, and adding one would put a new contract on the frontend.
  • The frontend already handles a 500 here. loadDashboard treats a rejected summary fetch as an error and renders Failed to load dashboard: …, never zeros.

Changes:

  • fetchCommitmentPurchases now returns ([]PurchaseHistoryRecord, error). A store error comes back wrapped as failed to get active purchase history: %w. The explicit zero-account scope sentinel (issue sec(auth): an admin-scoped API key ignores its account and amount constraints on non-strict checks #956) still returns (nil, nil) without querying, because for a scoped user with no accessible accounts the zeros are real.
  • calculateCommitmentMetrics gained an error return. getDashboardSummary returns that error.
  • Coverage (cristim's compounding effect): calculateCurrentCoverage no longer special-cases potentialSavings == 0. It computes committed / (committed + potential) in every case and returns nil for 0/0. DashboardSummaryResponse.CurrentCoverage is now *float64, so current_coverage is serialized as an explicit null. With commitments and nothing left to buy, coverage is still 100%, now from the ratio itself. Only the case with nothing to measure changed, from an invented 100% to null. openapi.yaml marks the field nullable: true.

Closes #72

Verification

Unit/mock evidence only (testify mocks of the config store and scheduler). The bug is in the handler's error handling, not in SQL, so there's no DB change and no integration test. No concurrent-caller concerns: this adds no writes and no shared state.

New/changed tests in internal/api/handler_dashboard_test.go:

  • TestHandleRequest_DashboardSummary_PurchaseHistoryErrorIs500: goes through the router (HandleRequest) using the issue's compounding scenario (no recommendations plus a store error). It asserts a 500, no KPI payload, and that the store error text doesn't leak.
  • TestGetDashboardSummary_NothingToMeasure_CoverageIsNull: 0/0 serializes as "current_coverage":null.
  • TestHandler_getDashboardSummary_Errors/purchase_history_error: errors.Is the store error, with a nil result.
  • TestHandler_calculateCommitmentMetrics/purchase_history_error_is_returned,_not_zeroed: replaces the old purchase history error returns zeros subtest, which pinned the bug.
  • TestHandler_calculateCurrentCoverage: pointer results. both zero returns nil, not 100% replaces the mislabeled both zero returns 0%, which asserted 100.
  • Mechanical: the existing calculateCommitmentMetrics call sites now also take err and require.NoError.

Fail-before: I copied the two new top-level tests unchanged into a detached origin/main worktree (a14dc8f) and ran:

go test ./internal/api/ -count=1 -run 'TestHandleRequest_DashboardSummary_PurchaseHistoryErrorIs500|TestGetDashboardSummary_NothingToMeasure_CoverageIsNull'
--- FAIL: TestHandleRequest_DashboardSummary_PurchaseHistoryErrorIs500
    expected: 500  actual: 200
    body: {"by_service":{},"potential_monthly_savings":0,"total_recommendations":0,"active_commitments":0,"committed_monthly":0,"current_coverage":100,"target_coverage":80,"ytd_savings":0}
--- FAIL: TestGetDashboardSummary_NothingToMeasure_CoverageIsNull
    Expected nil, but got: 100

That body is the "100% coverage, $0 committed" response from the issue, produced on main.

Pass-after (this branch, Go 1.26.9, AWS_CONFIG_FILE=/dev/null AWS_SHARED_CREDENTIALS_FILE=/dev/null AWS_EC2_METADATA_DISABLED=true):

go build ./...                                   # ok
go vet ./... && go vet -tags integration ./...   # ok
go test ./internal/api/ -count=1                 # ok
go test ./internal/mocks/ -count=1               # ok
golangci-lint run --allow-parallel-runners ./internal/api/                         # 0 issues (v2.10.1)
golangci-lint run --allow-parallel-runners --build-tags integration ./internal/api/ # 0 issues
gocyclo -over 10 -ignore "_test\.go" internal/api/handler_dashboard.go internal/api/types.go  # none (getDashboardSummary 7)

Combined-tree verification. This branch was tested together with the other queued branches on a local, never-pushed tree: queue/platform @ 936996ad = origin/main a14dc8f2 plus these heads, merged in this order:

Branch Tested head
fix/issue-337 ff3420c
fix/issue-169 7d75eb5
fix/issue-342 66b221f
fix/issue-310 5e11c21
fix/issue-326 5f0ea59
fix/issue-243 365e959
fix/issue-343 6c94036
fix/issue-312 d2848a8
fix/issue-316 acd60dd
fix/issue-339 75ea9b9
fix/issue-339-commitment-type 73bdb19
fix/issue-353 5d25fcd
fix/issue-313 3156bc4
fix/issue-357 53d9e58
fix/issue-368 0ba727d
fix/issue-419 61648c9
fix/issue-111 b04c372
fix/issue-686 0ad90b7
fix/issue-674 793095d
fix/issue-1055 4ec96f1
fix/issue-559 dfa9525
fix/issue-558 034eed4
fix/issue-874 1720539
fix/issue-666 1e120da
fix/issue-668 fb26345
fix/issue-568 ce04173
fix/issue-311 e54f250
fix/issue-177 0cbb88a
fix/issue-151 c21377c
fix/issue-166 41fb77b
fix/issue-27 2d67622
fix/issue-323 69288ae
fix/issue-59 36463e8
fix/issue-43 35e4069
fix/issue-158 6f76f8a
fix/issue-104 bb00491
fix/issue-55 971ef1b
fix/issue-361 89ac220
fix/issue-120 b458719
fix/issue-36 42bac6e
fix/issue-525 57c97dd
fix/issue-712 e5bcda0
fix/issue-72 6cc63ed

fix/issue-72 and fix/issue-312 conflict on one line in internal/api/handler_dashboard.go (both change the calculateCommitmentMetrics call). The tree used #72's side with #312's validated provider variable; whichever PR merges second takes that same resolution.

Linux, Go 1.26.9, AWS_CONFIG_FILE=/dev/null AWS_SHARED_CREDENTIALS_FILE=/dev/null AWS_EC2_METADATA_DISABLED=true; Docker Postgres via testcontainers:

  • Passed at 936996ad: go build ./..., go vet ./..., go vet -tags integration ./... and the full go test -race -short -count=1 ./....
  • Static checks at 936996ad: golangci-lint v2.10.1 found 0 issues on every changed package, with and without --build-tags integration. tests/e2e is a separate module, so it was vetted under its own e2e tag. gocyclo -over 10 is silent on the changed non-test files.
  • Integration tests, every changed package, one package at a time (-tags integration -count=1, 60-minute local timeout because the VM was shared):
    • At 936996ad, these pass: internal/server, internal/credentials, internal/scheduler, internal/database/postgres/migrations, internal/purchase and internal/api.
    • internal/config passes except TestPostgresStoreDB_ResolveStaleRIExchange_ValidatesCloudCheck, which fails the same way on unmodified main (fixed by fix(config): compare resolve checked_at at the database's microsecond precision #1252).
    • These were run on the first build of this tree (26795376, same branches at earlier heads): internal/analytics, internal/auth, internal/commitmentopts, internal/database and cmd/lambda/clear-rate-limit all pass. The later fixes don't touch those packages.
  • What the combined run caught, each fixed on its own branch before this PR opened:
    • a compile break between fix/issue-104's test and fix/issue-326's httpToLambdaRequest signature change;
    • fix/issue-316 rejecting legacy-keyed plans;
    • fix/issue-120 versus main's global-filter test.
  • Secrets: git-secrets, using the repo's scripts/setup-git-secrets.sh patterns in a fresh repo with an isolated HOME, found nothing in the changed files, and a planted fake key was flagged. scripts/test-git-secrets-allowlist.sh: 137 PASS, 0 FAIL.
  • Nothing was applied to any cloud.

API contract change

GET /api/dashboard/summary:

  • returns 500 ({"error":"Internal server error"}) when the purchase-history read fails, where it used to return a 200 with zeroed commitment KPIs;
  • current_coverage is null when committed and potential monthly savings are both 0, where it used to be 100.

A Go client that decodes into a float64 reads null as 0 without error.

Out of scope

  • Sibling in handler_inventory.go (~:224-230), from cristim's comment. A failed ListRecommendations leaves onDemandByKey empty, so coveragePct(covered, 0) gives 100% on every Coverage-tab row. cristim recorded it in the grouped internal/api medium/low issue, and it has a different endpoint and response shape, so I didn't fix it here.
  • An empty recommendations cache. If collection has never run or is stale while commitments exist, coverage still computes to 100% (committed / (committed + 0)). Telling "nothing left to buy" apart from "never collected" needs a collection-freshness signal in the summary, which is a separate design question.
  • Other best-effort fallbacks in the same handler. resolveCoverageByAccountKey (un-scaled totals) and resolveTargetCoverage (80% default) are documented trade-offs that don't produce a fabricated $0, so I left them unchanged.

Merge note

This touches the calculateCommitmentMetrics call line in getDashboardSummary, which our not-yet-opened branch fix/issue-312 also edits (params["provider"] becomes provider). The conflict is that one line. Resolve it by keeping this branch's side, including the new if err != nil block, and using provider as the argument. I checked that resolution: it builds and go test ./internal/api/ passes. It merges cleanly with #1200.

Frontend follow-up for the UI owner

None of this needs a UI change to be correct: dashboard.ts already renders a rejected summary as Failed to load dashboard: …, and a null current_coverage as -- (data.current_coverage ?? null). Suggested follow-ups:

  • frontend/src/api/types.ts: change current_coverage: number to current_coverage: number | null so the type matches the API. frontend/src/types.ts already has it optional.
  • Add a jest case for current_coverage: null with total_recommendations > 0, expecting --. Today nothingTracked covers only the case where both counts are 0.
  • Optional, product decision: a store error now blanks the whole KPI row. If you'd rather show the recommendation tiles and mark only the commitment tiles unavailable, the backend would need a per-section degraded flag. That's a follow-up, not part of this fix.

Could a maintainer please mirror the issue's triage labels onto this PR: severity/medium, priority/p3, urgency/this-quarter, impact/many, effort/s, type/bug, triaged.

🤖 Generated with Claude Code

fetchCommitmentPurchases logged GetActivePurchaseHistory errors and
returned ok=false, so the dashboard shipped a 200 with 0 active
commitments, $0 committed and $0 YTD savings, indistinguishable from an
empty portfolio. The error now propagates through
calculateCommitmentMetrics to getDashboardSummary and becomes a 500, the
same contract the handler already uses for a ListRecommendations error.
The explicit zero-account scope (issue LeanerCloud#956) still short-circuits to
genuine zeros without querying.

calculateCurrentCoverage also returned a hard-coded 100% whenever
potential savings were 0, even with nothing committed, so a zeroed
commitment half rendered as "100% coverage, $0 committed". It now
computes the ratio in every case and returns nil (JSON null) for 0/0;
committed savings with nothing left to buy still compute to 100%.

Closes LeanerCloud#72

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@IAmJSD IAmJSD added severity/medium Moderate harm triaged Item has been triaged priority/p3 Polish / idea / may never ship urgency/this-quarter Within the quarter impact/many Affects most users effort/s Hours type/bug Defect labels Oct 23, 2026
@coderabbitai

coderabbitai Bot commented Oct 23, 2026

Copy link
Copy Markdown
Contributor

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Comment @coderabbitai help to get the list of available commands.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/s Hours impact/many Affects most users priority/p3 Polish / idea / may never ship severity/medium Moderate harm triaged Item has been triaged type/bug Defect urgency/this-quarter Within the quarter

Projects

None yet

Development

Successfully merging this pull request may close these issues.

api: fetchCommitmentPurchases swallows store errors into zeroed dashboard KPIs

1 participant