| 1 | package console |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "errors" |
| 7 | "net/http" |
| 8 | "net/url" |
| 9 | "os" |
| 10 | "testing" |
| 11 | "time" |
| 12 | |
| 13 | "github.com/carlosframework/platform/internal/economics" |
| 14 | ) |
| 15 | |
| 16 | // TestBillPassPullsAndManualWins: billPass pulls current + previous month |
| 17 | // through the ceCosts seam and writes each SourceCostExplorer, then a |
| 18 | // manually-entered bill for the current month (written directly, the way |
| 19 | // the write doors this task adds do it) survives a second billPass run |
| 20 | // byte-identical — manual always wins over an automatic pull. |
| 21 | func TestBillPassPullsAndManualWins(t *testing.T) { |
| 22 | fx := newConsoleFixture(t) |
| 23 | ctx := context.Background() |
| 24 | now := time.Date(2026, 8, 11, 3, 0, 0, 0, time.UTC) |
| 25 | |
| 26 | fx.srv.ceCosts = func(ctx context.Context, start, end time.Time) (map[string]float64, error) { |
| 27 | return map[string]float64{"AmazonEC2": 55.5}, nil |
| 28 | } |
| 29 | |
| 30 | if err := fx.srv.billPass(ctx, now); err != nil { |
| 31 | t.Fatal(err) |
| 32 | } |
| 33 | |
| 34 | getBill := func(month string) economics.Bill { |
| 35 | t.Helper() |
| 36 | raw, _, err := fx.rel.Get(ctx, economics.BillKey(month)) |
| 37 | if err != nil { |
| 38 | t.Fatalf("bill %s: %v", month, err) |
| 39 | } |
| 40 | var b economics.Bill |
| 41 | if err := json.Unmarshal(raw, &b); err != nil { |
| 42 | t.Fatal(err) |
| 43 | } |
| 44 | return b |
| 45 | } |
| 46 | |
| 47 | for _, month := range []string{"2026-08", "2026-07"} { |
| 48 | b := getBill(month) |
| 49 | if b.Source != economics.SourceCostExplorer { |
| 50 | t.Fatalf("%s source = %q, want %q", month, b.Source, economics.SourceCostExplorer) |
| 51 | } |
| 52 | if b.TotalUSD != 55.5 { |
| 53 | t.Fatalf("%s total = %v, want 55.5", month, b.TotalUSD) |
| 54 | } |
| 55 | if b.Services["AmazonEC2"] != 55.5 { |
| 56 | t.Fatalf("%s services = %v, want AmazonEC2=55.5", month, b.Services) |
| 57 | } |
| 58 | } |
| 59 | |
| 60 | // Write a manual bill for the current month straight to the bucket |
| 61 | // (read-etag-then-put, since the object already exists from the pull |
| 62 | // above), then confirm a second billPass run leaves it byte-identical. |
| 63 | _, etag, err := fx.rel.Get(ctx, economics.BillKey("2026-08")) |
| 64 | if err != nil { |
| 65 | t.Fatal(err) |
| 66 | } |
| 67 | manual := economics.Bill{Schema: 1, Month: "2026-08", TotalUSD: 999, Source: economics.SourceManual} |
| 68 | manualRaw, err := json.Marshal(manual) |
| 69 | if err != nil { |
| 70 | t.Fatal(err) |
| 71 | } |
| 72 | if _, err := fx.rel.Put(ctx, economics.BillKey("2026-08"), manualRaw, etag); err != nil { |
| 73 | t.Fatal(err) |
| 74 | } |
| 75 | |
| 76 | if err := fx.srv.billPass(ctx, now); err != nil { |
| 77 | t.Fatal(err) |
| 78 | } |
| 79 | after, _, err := fx.rel.Get(ctx, economics.BillKey("2026-08")) |
| 80 | if err != nil { |
| 81 | t.Fatal(err) |
| 82 | } |
| 83 | if string(after) != string(manualRaw) { |
| 84 | t.Fatalf("manual bill overwritten by billPass:\n got %s\n want %s", after, manualRaw) |
| 85 | } |
| 86 | } |
| 87 | |
| 88 | // TestBillPassNoCEIsQuiet: a ceCosts failure (no IAM grant, network down, |
| 89 | // whatever) degrades billPass to a logged no-op — never a fatal error, and |
| 90 | // never a written bill for either month. |
| 91 | func TestBillPassNoCEIsQuiet(t *testing.T) { |
| 92 | fx := newConsoleFixture(t) |
| 93 | ctx := context.Background() |
| 94 | now := time.Date(2026, 8, 11, 3, 0, 0, 0, time.UTC) |
| 95 | |
| 96 | fx.srv.ceCosts = func(ctx context.Context, start, end time.Time) (map[string]float64, error) { |
| 97 | return nil, errors.New("AccessDeniedException: user is not authorized") |
| 98 | } |
| 99 | |
| 100 | if err := fx.srv.billPass(ctx, now); err != nil { |
| 101 | t.Fatal(err) |
| 102 | } |
| 103 | for _, month := range []string{"2026-08", "2026-07"} { |
| 104 | if _, _, err := fx.rel.Get(ctx, economics.BillKey(month)); !errors.Is(err, os.ErrNotExist) { |
| 105 | t.Fatalf("bill %s written despite a ceCosts failure: err=%v", month, err) |
| 106 | } |
| 107 | } |
| 108 | } |
| 109 | |
| 110 | // TestBillPassHandlesMonthBoundary is the review-round regression for the |
| 111 | // day-1 bug: CE's TimePeriod.End is EXCLUSIVE and must be strictly after |
| 112 | // Start. On day 1 of any month, the naive "current month" period |
| 113 | // (firstOfThisMonth .. now) collapses Start==End (both format to the same |
| 114 | // calendar date), which CE would refuse outright — and that refusal used to |
| 115 | // latch ceDisabled and kill the PREVIOUS month's pull in the very same |
| 116 | // tick, the most valuable and just-closed one. The fake fails loudly if it |
| 117 | // is ever asked for a same-day period, so this proves billPass never sends |
| 118 | // one; both months must land. |
| 119 | func TestBillPassHandlesMonthBoundary(t *testing.T) { |
| 120 | fx := newConsoleFixture(t) |
| 121 | ctx := context.Background() |
| 122 | now := time.Date(2026, 9, 1, 3, 0, 0, 0, time.UTC) |
| 123 | |
| 124 | fx.srv.ceCosts = func(ctx context.Context, start, end time.Time) (map[string]float64, error) { |
| 125 | if start.Format("2006-01-02") == end.Format("2006-01-02") { |
| 126 | t.Fatalf("ceCosts called with start == end == %s (day 1 collapse)", start.Format("2006-01-02")) |
| 127 | } |
| 128 | return map[string]float64{"AmazonEC2": 10}, nil |
| 129 | } |
| 130 | |
| 131 | if err := fx.srv.billPass(ctx, now); err != nil { |
| 132 | t.Fatal(err) |
| 133 | } |
| 134 | for _, month := range []string{"2026-09", "2026-08"} { |
| 135 | if _, _, err := fx.rel.Get(ctx, economics.BillKey(month)); err != nil { |
| 136 | t.Fatalf("bill %s not written on day 1 of the month: %v", month, err) |
| 137 | } |
| 138 | } |
| 139 | } |
| 140 | |
| 141 | // TestBillPassSkipsSecondPullSameDay: CE bills $0.01/request, so an hourly |
| 142 | // pass tick must pull each month AT MOST ONCE per UTC day — a second |
| 143 | // billPass call the same day must not touch the ceCosts seam again at all. |
| 144 | func TestBillPassSkipsSecondPullSameDay(t *testing.T) { |
| 145 | fx := newConsoleFixture(t) |
| 146 | ctx := context.Background() |
| 147 | now := time.Date(2026, 8, 11, 3, 0, 0, 0, time.UTC) |
| 148 | |
| 149 | var calls int |
| 150 | fx.srv.ceCosts = func(ctx context.Context, start, end time.Time) (map[string]float64, error) { |
| 151 | calls++ |
| 152 | return map[string]float64{"AmazonEC2": 10}, nil |
| 153 | } |
| 154 | |
| 155 | if err := fx.srv.billPass(ctx, now); err != nil { |
| 156 | t.Fatal(err) |
| 157 | } |
| 158 | first := calls |
| 159 | if first != 2 { |
| 160 | t.Fatalf("first billPass made %d ceCosts calls, want 2 (current + previous month)", first) |
| 161 | } |
| 162 | |
| 163 | if err := fx.srv.billPass(ctx, now.Add(4*time.Hour)); err != nil { |
| 164 | t.Fatal(err) |
| 165 | } |
| 166 | if calls != first { |
| 167 | t.Fatalf("second billPass the same UTC day made %d more ceCosts calls, want 0", calls-first) |
| 168 | } |
| 169 | } |
| 170 | |
| 171 | // TestBillPassStopsPullingSettledClosedMonth: a closed (previous) month |
| 172 | // that was already pulled on or after day 3 of the following month has |
| 173 | // seen CE's late-arriving line items settle — billPass must stop pulling |
| 174 | // it forever, rather than paying for it every day for the rest of time. |
| 175 | // The still-open current month, on a later day, still pulls normally. |
| 176 | func TestBillPassStopsPullingSettledClosedMonth(t *testing.T) { |
| 177 | fx := newConsoleFixture(t) |
| 178 | ctx := context.Background() |
| 179 | |
| 180 | settled := economics.Bill{ |
| 181 | Schema: 1, Month: "2026-07", TotalUSD: 10, |
| 182 | Services: map[string]float64{"AmazonEC2": 10}, Source: economics.SourceCostExplorer, |
| 183 | PulledAt: time.Date(2026, 8, 5, 3, 0, 0, 0, time.UTC), // day 5 >= the day-3 grace |
| 184 | } |
| 185 | putStoreJSON(t, fx.rel, economics.BillKey("2026-07"), settled) |
| 186 | |
| 187 | var calls int |
| 188 | fx.srv.ceCosts = func(ctx context.Context, start, end time.Time) (map[string]float64, error) { |
| 189 | calls++ |
| 190 | return map[string]float64{"AmazonEC2": 99}, nil |
| 191 | } |
| 192 | |
| 193 | if err := fx.srv.billPass(ctx, time.Date(2026, 8, 20, 3, 0, 0, 0, time.UTC)); err != nil { |
| 194 | t.Fatal(err) |
| 195 | } |
| 196 | if calls != 1 { |
| 197 | t.Fatalf("billPass made %d ceCosts calls, want 1 (current month only; the settled closed month must be skipped)", calls) |
| 198 | } |
| 199 | raw, _, err := fx.rel.Get(ctx, economics.BillKey("2026-07")) |
| 200 | if err != nil { |
| 201 | t.Fatal(err) |
| 202 | } |
| 203 | var got economics.Bill |
| 204 | if err := json.Unmarshal(raw, &got); err != nil { |
| 205 | t.Fatal(err) |
| 206 | } |
| 207 | if got.TotalUSD != 10 { |
| 208 | t.Fatalf("settled closed-month bill total = %v, want unchanged 10 (must not have been re-pulled)", got.TotalUSD) |
| 209 | } |
| 210 | } |
| 211 | |
| 212 | // TestBillPassRepullsJustClosedMonth is the regression for the "own last |
| 213 | // daily pull looks settled" bug: a month's last pull WHILE STILL OPEN (e.g. |
| 214 | // July 31, Day()==31) must not be mistaken for the day-3-of-the-FOLLOWING- |
| 215 | // month settle-and-stop rule just because Day() happens to be >= |
| 216 | // billCEDayGrace. The moment the month closes (billPass ticks on Aug 1) it |
| 217 | // must still be re-pulled — CE's ~24h-lagged final line items for July |
| 218 | // haven't landed yet at the instant July turns into August. |
| 219 | func TestBillPassRepullsJustClosedMonth(t *testing.T) { |
| 220 | fx := newConsoleFixture(t) |
| 221 | ctx := context.Background() |
| 222 | |
| 223 | notYetSettled := economics.Bill{ |
| 224 | Schema: 1, Month: "2026-07", TotalUSD: 10, |
| 225 | Services: map[string]float64{"AmazonEC2": 10}, Source: economics.SourceCostExplorer, |
| 226 | PulledAt: time.Date(2026, 7, 31, 3, 0, 0, 0, time.UTC), // the month's own last daily pull |
| 227 | } |
| 228 | putStoreJSON(t, fx.rel, economics.BillKey("2026-07"), notYetSettled) |
| 229 | |
| 230 | var julyPulled bool |
| 231 | fx.srv.ceCosts = func(ctx context.Context, start, end time.Time) (map[string]float64, error) { |
| 232 | if start.Format("2006-01") == "2026-07" { |
| 233 | julyPulled = true |
| 234 | } |
| 235 | return map[string]float64{"AmazonEC2": 99}, nil |
| 236 | } |
| 237 | |
| 238 | if err := fx.srv.billPass(ctx, time.Date(2026, 8, 1, 3, 0, 0, 0, time.UTC)); err != nil { |
| 239 | t.Fatal(err) |
| 240 | } |
| 241 | if !julyPulled { |
| 242 | t.Fatal("billPass did not re-pull July on Aug 1, though July's own PulledAt.Day()==31 satisfies the day-3 grace by coincidence — the just-closed month must still be re-pulled") |
| 243 | } |
| 244 | |
| 245 | raw, _, err := fx.rel.Get(ctx, economics.BillKey("2026-07")) |
| 246 | if err != nil { |
| 247 | t.Fatal(err) |
| 248 | } |
| 249 | var got economics.Bill |
| 250 | if err := json.Unmarshal(raw, &got); err != nil { |
| 251 | t.Fatal(err) |
| 252 | } |
| 253 | if got.TotalUSD != 99 { |
| 254 | t.Fatalf("July bill total = %v, want 99 (re-pulled with the fresh ceCosts value)", got.TotalUSD) |
| 255 | } |
| 256 | } |
| 257 | |
| 258 | // TestBillPassRetriesAfterTransientCEFailure: a non-auth-shaped ceCosts |
| 259 | // failure (a throttle, a network blip) must not disable further attempts — |
| 260 | // realCECosts' auth-shaped latch (cepull.go, isAuthShapedError) is the only |
| 261 | // thing that may do that, and the seam bypasses realCECosts entirely, so |
| 262 | // this proves billPass itself adds no independent latch of its own: a tick |
| 263 | // whose calls all fail leaves nothing written, and the very next tick's |
| 264 | // calls still reach the seam and succeed. |
| 265 | func TestBillPassRetriesAfterTransientCEFailure(t *testing.T) { |
| 266 | fx := newConsoleFixture(t) |
| 267 | ctx := context.Background() |
| 268 | now := time.Date(2026, 8, 11, 3, 0, 0, 0, time.UTC) |
| 269 | |
| 270 | var calls int |
| 271 | fx.srv.ceCosts = func(ctx context.Context, start, end time.Time) (map[string]float64, error) { |
| 272 | calls++ |
| 273 | if calls <= 2 { // both months fail on the first tick |
| 274 | return nil, errors.New("ThrottlingException: rate exceeded") |
| 275 | } |
| 276 | return map[string]float64{"AmazonEC2": 42}, nil |
| 277 | } |
| 278 | |
| 279 | if err := fx.srv.billPass(ctx, now); err != nil { |
| 280 | t.Fatal(err) |
| 281 | } |
| 282 | if _, _, err := fx.rel.Get(ctx, economics.BillKey("2026-08")); !errors.Is(err, os.ErrNotExist) { |
| 283 | t.Fatal("bill written despite every ceCosts call failing on the first tick") |
| 284 | } |
| 285 | |
| 286 | if err := fx.srv.billPass(ctx, now.Add(time.Hour)); err != nil { |
| 287 | t.Fatal(err) |
| 288 | } |
| 289 | raw, _, err := fx.rel.Get(ctx, economics.BillKey("2026-08")) |
| 290 | if err != nil { |
| 291 | t.Fatal(err) |
| 292 | } |
| 293 | var b economics.Bill |
| 294 | if err := json.Unmarshal(raw, &b); err != nil { |
| 295 | t.Fatal(err) |
| 296 | } |
| 297 | if b.TotalUSD != 42 { |
| 298 | t.Fatalf("bill total = %v, want 42 (the retried tick's data)", b.TotalUSD) |
| 299 | } |
| 300 | } |
| 301 | |
| 302 | // TestEconomicsWriteDoorsRejectNonPOST: the three write doors are |
| 303 | // registered WITHOUT a method prefix (server.go) specifically so a |
| 304 | // wrong-method request never gets Go's default 405 + Allow — which would |
| 305 | // itself confirm the route exists to someone who merely guessed the URL. |
| 306 | // Every one of them must answer a GET with the exact same 404 an unknown |
| 307 | // route gets, signed out AND signed in as an operator (the method check |
| 308 | // runs before the session/operator gate). |
| 309 | func TestEconomicsWriteDoorsRejectNonPOST(t *testing.T) { |
| 310 | fx := newConsoleFixture(t) |
| 311 | out := newBrowser(t) |
| 312 | ghostStatus, _, ghostBody := get(t, out, fx.ts.URL+"/this-route-does-not-exist") |
| 313 | |
| 314 | consolePaths := []string{"/fleet/economics/scenarios", "/fleet/economics/bill"} |
| 315 | for _, path := range consolePaths { |
| 316 | if status, _, body := get(t, out, fx.ts.URL+path); status != ghostStatus || body != ghostBody { |
| 317 | t.Fatalf("signed-out GET %s = %d %q, want the same 404 as a missing route (%d %q)", path, status, body, ghostStatus, ghostBody) |
| 318 | } |
| 319 | } |
| 320 | |
| 321 | c := signInPaul(t, fx) |
| 322 | if err := fx.st.SetOperator("paul@keymail.dev", true); err != nil { |
| 323 | t.Fatal(err) |
| 324 | } |
| 325 | for _, path := range consolePaths { |
| 326 | if status, _, body := get(t, c, fx.ts.URL+path); status != ghostStatus || body != ghostBody { |
| 327 | t.Fatalf("operator GET %s = %d %q, want the same 404 as a missing route (%d %q)", path, status, body, ghostStatus, ghostBody) |
| 328 | } |
| 329 | } |
| 330 | |
| 331 | // The API door: an unauthenticated GET (no Authorization header) must |
| 332 | // be the same ghost 404 too, not cliUser's 401 — that would confirm |
| 333 | // the route exists just as surely as a 405 would. |
| 334 | if status, _, body := get(t, out, fx.ts.URL+"/api/economics/bill"); status != ghostStatus || body != ghostBody { |
| 335 | t.Fatalf("unauthenticated GET /api/economics/bill = %d %q, want the same 404 as a missing route (%d %q)", status, body, ghostStatus, ghostBody) |
| 336 | } |
| 337 | } |
| 338 | |
| 339 | // TestScenarioSaveGateAndValidation pins the gate order (session -> |
| 340 | // IsOperator -> byte-identical 404, matching TestFleetOperatorOnly's |
| 341 | // comparison shape) BEFORE csrf, then bad-JSON and happy-path behaviour. |
| 342 | func TestScenarioSaveGateAndValidation(t *testing.T) { |
| 343 | fx := newConsoleFixture(t) |
| 344 | |
| 345 | out := newBrowser(t) |
| 346 | _, _, bodyGhost := get(t, out, fx.ts.URL+"/this-route-does-not-exist") |
| 347 | |
| 348 | c := signInPaul(t, fx) |
| 349 | csrf := sessionToken(t, fx, c) |
| 350 | sc := economics.DefaultScenario() |
| 351 | scRaw, err := json.Marshal(sc) |
| 352 | if err != nil { |
| 353 | t.Fatal(err) |
| 354 | } |
| 355 | |
| 356 | // Signed in, owns kass, but is not an operator: the same 404 a missing |
| 357 | // route gets. |
| 358 | form := url.Values{"csrf": {csrf}, "name": {"launch"}, "scenario": {string(scRaw)}} |
| 359 | if status, _, body := postForm(t, c, fx.ts.URL+"/fleet/economics/scenarios", form); status != http.StatusNotFound || body != bodyGhost { |
| 360 | t.Fatalf("non-operator scenario save = %d %q, want the same 404 as a missing route (%q)", status, body, bodyGhost) |
| 361 | } |
| 362 | |
| 363 | if err := fx.st.SetOperator("paul@keymail.dev", true); err != nil { |
| 364 | t.Fatal(err) |
| 365 | } |
| 366 | |
| 367 | // Bad JSON: a 4xx, and nothing gets written. |
| 368 | badForm := url.Values{"csrf": {csrf}, "name": {"launch"}, "scenario": {"{not valid json"}} |
| 369 | if status, _, _ := postForm(t, c, fx.ts.URL+"/fleet/economics/scenarios", badForm); status < 400 || status >= 500 { |
| 370 | t.Fatalf("bad scenario JSON = %d, want 4xx", status) |
| 371 | } |
| 372 | if _, _, err := fx.rel.Get(context.Background(), economics.ScenarioKey("launch")); !errors.Is(err, os.ErrNotExist) { |
| 373 | t.Fatal("a bad-JSON scenario save still wrote a scenario object") |
| 374 | } |
| 375 | |
| 376 | // Valid scenario + activate=1: both objects land, Active.Name is right. |
| 377 | okForm := url.Values{"csrf": {csrf}, "name": {"launch"}, "scenario": {string(scRaw)}, "activate": {"1"}} |
| 378 | status, loc, _ := postForm(t, c, fx.ts.URL+"/fleet/economics/scenarios", okForm) |
| 379 | if status != http.StatusSeeOther || loc != "/fleet/economics" { |
| 380 | t.Fatalf("valid scenario save = %d -> %q, want 303 -> /fleet/economics", status, loc) |
| 381 | } |
| 382 | raw, _, err := fx.rel.Get(context.Background(), economics.ScenarioKey("launch")) |
| 383 | if err != nil { |
| 384 | t.Fatal(err) |
| 385 | } |
| 386 | var saved economics.Scenario |
| 387 | if err := json.Unmarshal(raw, &saved); err != nil { |
| 388 | t.Fatal(err) |
| 389 | } |
| 390 | if saved.Name != "launch" { |
| 391 | t.Fatalf("saved scenario name = %q, want %q (form name must win over the JSON body's)", saved.Name, "launch") |
| 392 | } |
| 393 | activeRaw, _, err := fx.rel.Get(context.Background(), economics.ActiveKey) |
| 394 | if err != nil { |
| 395 | t.Fatal(err) |
| 396 | } |
| 397 | var active economics.Active |
| 398 | if err := json.Unmarshal(activeRaw, &active); err != nil { |
| 399 | t.Fatal(err) |
| 400 | } |
| 401 | if active.Name != "launch" { |
| 402 | t.Fatalf("active.Name = %q, want %q", active.Name, "launch") |
| 403 | } |
| 404 | } |
| 405 | |
| 406 | // TestAPIBillDoor: the CLI's bearer door mirrors the console form's gate |
| 407 | // order (cliUser -> IsOperator -> byte-identical 404) and manual-write |
| 408 | // semantics. |
| 409 | func TestAPIBillDoor(t *testing.T) { |
| 410 | fx := newConsoleFixture(t) |
| 411 | token := cliToken(t, fx, "paul@keymail.dev", "code-apibill") |
| 412 | |
| 413 | body := `{"month":"2026-08","total_usd":123.45,"services":{"AmazonEC2":55.0}}` |
| 414 | |
| 415 | ghostStatus, ghostBody := postBody(t, fx.ts.URL+"/this-api-route-does-not-exist", token, body) |
| 416 | status, out := postBody(t, fx.ts.URL+"/api/economics/bill", token, body) |
| 417 | if status != http.StatusNotFound || status != ghostStatus || string(out) != string(ghostBody) { |
| 418 | t.Fatalf("non-operator api bill = %d %q, want the same 404 as a missing route (%d %q)", status, out, ghostStatus, ghostBody) |
| 419 | } |
| 420 | |
| 421 | if err := fx.st.SetOperator("paul@keymail.dev", true); err != nil { |
| 422 | t.Fatal(err) |
| 423 | } |
| 424 | |
| 425 | status, out = postBody(t, fx.ts.URL+"/api/economics/bill", token, body) |
| 426 | if status != http.StatusOK { |
| 427 | t.Fatalf("operator api bill = %d %s, want 200", status, out) |
| 428 | } |
| 429 | var b economics.Bill |
| 430 | if err := json.Unmarshal(out, &b); err != nil { |
| 431 | t.Fatalf("bill response: %v (%s)", err, out) |
| 432 | } |
| 433 | if b.Month != "2026-08" || b.TotalUSD != 123.45 || b.Source != economics.SourceManual { |
| 434 | t.Fatalf("bill response = %+v", b) |
| 435 | } |
| 436 | |
| 437 | raw, _, err := fx.rel.Get(context.Background(), economics.BillKey("2026-08")) |
| 438 | if err != nil { |
| 439 | t.Fatal(err) |
| 440 | } |
| 441 | var stored economics.Bill |
| 442 | if err := json.Unmarshal(raw, &stored); err != nil { |
| 443 | t.Fatal(err) |
| 444 | } |
| 445 | if stored.Source != economics.SourceManual || stored.TotalUSD != 123.45 || stored.Services["AmazonEC2"] != 55.0 { |
| 446 | t.Fatalf("stored bill = %+v", stored) |
| 447 | } |
| 448 | } |
| 449 | |