| index 3289285ae..8edd687f2 100644 |
| --- a/internal/console/billingquantity_test.go |
| +++ b/internal/console/billingquantity_test.go |
| @@ -3,6 +3,7 @@ package console |
| import ( |
| "context" |
| "encoding/json" |
| + "errors" |
| "fmt" |
| "net/http" |
| "net/http/httptest" |
| @@ -284,8 +285,13 @@ func TestQuantityWorkerChargesTheTransientSeat(t *testing.T) { |
| } |
| } |
| |
| -// The update landed but its response was lost. The next pass reads the |
| -// provider, sees the target already there, sends nothing and finishes. |
| +// The update landed but its response was lost. While the operation is |
| +// young another process could still be mid-flight with it, so nothing is |
| +// concluded. After that, the next pass reads the provider, sees the target |
| +// there, sends nothing and finishes. It cannot know whether its lost |
| +// request or someone's hand set that quantity, so the additions are |
| +// recorded as landed and unverified, the assignment is dated when it was |
| +// observed, and a review holds the account until a person has looked. |
| func TestQuantityWorkerRecoversALostResponse(t *testing.T) { |
| fx, q, now := newQuantityFixture(t, billingProrationImmediate) |
| ctx := context.Background() |
| @@ -299,7 +305,15 @@ func TestQuantityWorkerRecoversALostResponse(t *testing.T) { |
| if len(pendingAdditions(t, fx)) != 1 || !quantityHeld(t, fx) { |
| t.Fatal("an unconfirmed update was marked processed") |
| } |
| - if err := fx.srv.billingQuantityPass(ctx, now.Add(3*time.Minute)); err != nil { |
| + // At once: the operation is young and unconfirmed. Nothing is recorded. |
| + if err := fx.srv.billingQuantityPass(ctx, time.Now().UTC()); !errors.Is(err, errBillingPending) { |
| + t.Fatalf("a pass over a young unconfirmed operation: %v, want pending", err) |
| + } |
| + if state, _ := fx.st.billingAccount(OpsAccountID); state.Quantity != 10 || len(pendingAdditions(t, fx)) != 1 { |
| + t.Fatalf("a young unconfirmed operation was settled: quantity %d", state.Quantity) |
| + } |
| + op := ageQuantityOperation(t, fx, 3*time.Minute) |
| + if err := fx.srv.billingQuantityPass(ctx, time.Now().UTC()); err != nil { |
| t.Fatal(err) |
| } |
| if len(q.posts) != 1 { |
| @@ -308,9 +322,27 @@ func TestQuantityWorkerRecoversALostResponse(t *testing.T) { |
| if state, _ := fx.st.billingAccount(OpsAccountID); state.Quantity != 11 || quantityHeld(t, fx) { |
| t.Fatalf("not recovered: quantity %d held %v", state.Quantity, quantityHeld(t, fx)) |
| } |
| - // The recovered addition is recorded as what it was: charged, by this |
| - // operation. Not as a seat that happened to be billed already. |
| - assertLastAddition(t, fx, quantityOutcomeCharged, "si_team:quantity=11:always_invoice") |
| + assertLastAddition(t, fx, quantityOutcomeUnverified, "si_team:quantity=11:always_invoice") |
| + if got := quantityAssignment(t, fx); got.Quantity != 11 || got.EffectiveAt.Unix() == op.CreatedAt { |
| + t.Fatalf("an unconfirmed landing was dated at the operation's instant: %+v", got.Assignment) |
| + } |
| + if rev, err := fx.st.billingReview(OpsAccountID, "sub_team", billingQuantityDateReviewReason(op.CreatedAt)); err != nil || rev.ResolvedAt != 0 { |
| + t.Fatalf("no open review for the unverified landing: %+v %v", rev, err) |
| + } |
| +} |
| + |
| +// ageQuantityOperation moves the account's outstanding operation back in |
| +// time, past the send window: no process can still be mid-flight with it. |
| +func ageQuantityOperation(t *testing.T, fx *consoleFixture, by time.Duration) quantityOperation { |
| + t.Helper() |
| + if _, err := fx.st.db.Exec(`UPDATE billing_quantity_operations SET created_at=created_at-? WHERE account_id=? AND acked_at=0`, int64(by.Seconds()), OpsAccountID); err != nil { |
| + t.Fatal(err) |
| + } |
| + op, ok, err := fx.st.quantityOperationFor(OpsAccountID) |
| + if err != nil || !ok { |
| + t.Fatalf("no operation to age: %v", err) |
| + } |
| + return op |
| } |
| |
| // assertLastAddition checks the newest addition was processed with outcome |
| @@ -485,9 +517,9 @@ func TestQuantityWorkerClosesAdditionsOfAnEndedSubscription(t *testing.T) { |
| |
| // Two console processes. A reads a target of 11 and is paused before its |
| // request. B finishes A's operation with A's own key, then raises to 12 for |
| -// a member who joined meanwhile. A wakes and sends: the provider replays |
| -// A's key and changes nothing. The quantity never goes down, and every |
| -// addition is covered. |
| +// a member who joined meanwhile. A wakes, finds its operation is no longer |
| +// the outstanding one, and sends nothing. The quantity never goes down, |
| +// and every addition is covered. |
| func TestQuantityWorkerCannotLowerAQuantityAcrossProcesses(t *testing.T) { |
| fx, q, now := newQuantityFixture(t, billingProrationImmediate) |
| ctx := context.Background() |
| @@ -522,8 +554,8 @@ func TestQuantityWorkerCannotLowerAQuantityAcrossProcesses(t *testing.T) { |
| if q.lowered || q.quantity != 12 { |
| t.Fatalf("provider quantity %d, lowered=%v: a stale process lowered it", q.quantity, q.lowered) |
| } |
| - if q.replays != 1 || len(q.posts) != 2 || q.posts[0]["quantity"] != "11" || q.posts[1]["quantity"] != "12" { |
| - t.Fatalf("posts %+v replays %d: want 11 then 12 executed once each and one replay", q.posts, q.replays) |
| + if q.replays != 0 || len(q.posts) != 2 || q.posts[0]["quantity"] != "11" || q.posts[1]["quantity"] != "12" { |
| + t.Fatalf("posts %+v replays %d: want 11 then 12 executed once each, and nothing from the process that woke late", q.posts, q.replays) |
| } |
| if state, _ := fx.st.billingAccount(OpsAccountID); state.Quantity != 12 { |
| t.Fatalf("billed quantity %d, want 12", state.Quantity) |
| @@ -658,16 +690,17 @@ func TestBeginQuantityOperationIsACompareAndSet(t *testing.T) { |
| good := quantityOperation{AccountID: OpsAccountID, Subscription: "sub_team", PeriodStart: state.PeriodStart, PeriodEnd: state.PeriodEnd, Item: "si_team", |
| FromQuantity: 10, Target: 11, Generation: pending.Newest, Behavior: billingstripe.ProrationAlwaysInvoice, Key: "k-good", CreatedAt: time.Now().Unix(), Revision: state.Revision} |
| for name, mutate := range map[string]func(*quantityOperation){ |
| - "another subscription": func(op *quantityOperation) { op.Subscription = "sub_other" }, |
| - "another period": func(op *quantityOperation) { op.PeriodStart-- }, |
| - "another period end": func(op *quantityOperation) { op.PeriodEnd++ }, |
| - "a stale billed quantity": func(op *quantityOperation) { op.FromQuantity = 9 }, |
| - "a lower target": func(op *quantityOperation) { op.Target = 10 }, |
| - "a higher target": func(op *quantityOperation) { op.Target = 12 }, |
| - "an older generation": func(op *quantityOperation) { op.Generation-- }, |
| - "another revision": func(op *quantityOperation) { op.Revision-- }, |
| - "a date before the period": func(op *quantityOperation) { op.CreatedAt = state.PeriodStart - 1 }, |
| - "a date at the period end": func(op *quantityOperation) { op.CreatedAt = state.PeriodEnd }, |
| + "another subscription": func(op *quantityOperation) { op.Subscription = "sub_other" }, |
| + "another period": func(op *quantityOperation) { op.PeriodStart-- }, |
| + "another period end": func(op *quantityOperation) { op.PeriodEnd++ }, |
| + "a stale billed quantity": func(op *quantityOperation) { op.FromQuantity = 9 }, |
| + "a lower target": func(op *quantityOperation) { op.Target = 10 }, |
| + "a higher target": func(op *quantityOperation) { op.Target = 12 }, |
| + "an older generation": func(op *quantityOperation) { op.Generation-- }, |
| + "another revision": func(op *quantityOperation) { op.Revision-- }, |
| + "a date before the period": func(op *quantityOperation) { op.CreatedAt = state.PeriodStart - 1 }, |
| + "a date at the period end": func(op *quantityOperation) { op.CreatedAt = state.PeriodEnd }, |
| + "a date before the addition it charges for": func(op *quantityOperation) { op.CreatedAt = state.PeriodStart + 30 }, |
| } { |
| op := good |
| op.Key = "k-" + name |
| @@ -956,28 +989,92 @@ func TestQuantityRetryDatesTheAllowanceAtTheProrationInstant(t *testing.T) { |
| } |
| } |
| |
| -// The same landing, seen first by a webhook's reconcile and not by the |
| -// worker's: the assignment is dated at the operation's instant all the same. |
| +// The landing seen first by a webhook's reconcile, between the provider's |
| +// answer to the worker and the worker's own reconcile. The operation is |
| +// confirmed by then, so the webhook dates the assignment at the operation's |
| +// instant all the same. |
| func TestQuantityLandingSeenByAWebhookIsDatedAtTheOperation(t *testing.T) { |
| fx, q, op := agedQuantityFixture(t) |
| - // The provider now carries the target (the request landed; only its |
| - // response was lost), and a subscription-updated webhook arrives. |
| + webhook := false |
| + fx.srv.billing.quantityAfterSend = func() { |
| + webhook = true |
| + // The account lock is held by the worker in this process; the |
| + // webhook lands in another. |
| + other := &Server{cfg: fx.srv.cfg, store: fx.srv.store, billing: &billingRuntime{locks: newStoreWalkLocks(), client: fx.srv.billing.client, cfg: fx.srv.billing.cfg}} |
| + if err := other.reconcileBillingSubscription(context.Background(), "sub_team", "evt_quantity_webhook", time.Now().UTC()); err != nil { |
| + t.Errorf("webhook reconcile: %v", err) |
| + } |
| + } |
| + if err := fx.srv.billingQuantityPass(context.Background(), time.Now().UTC()); err != nil { |
| + t.Fatal(err) |
| + } |
| + if !webhook || len(q.posts) != 1 { |
| + t.Fatalf("webhook ran=%v posts=%+v", webhook, q.posts) |
| + } |
| + got := quantityAssignment(t, fx) |
| + if got.Quantity != 11 || got.EffectiveAt.Unix() != op.CreatedAt { |
| + t.Fatalf("webhook-observed assignment takes effect at %d, want %d", got.EffectiveAt.Unix(), op.CreatedAt) |
| + } |
| + assertLastAddition(t, fx, quantityOutcomeCharged, "si_team:quantity=11:always_invoice") |
| + if quantityHeld(t, fx) { |
| + t.Fatal("still held") |
| + } |
| +} |
| + |
| +// A coincidence. The operation (10 to 11) was recorded twenty hours ago and |
| +// never executed. Someone then sets the subscription to 11 by hand, and a |
| +// webhook reconciles it. The quantity matches the operation's target, and |
| +// nothing shows the operation put it there: the provider prorated from the |
| +// hand's instant, not the operation's. The assignment must NOT be dated |
| +// back to the operation. It is dated when observed, a review holds the |
| +// account, and the worker records the additions as landed and unverified |
| +// without sending anything. |
| +func TestQuantityCoincidentalChangeIsNotBackdated(t *testing.T) { |
| + fx, q, op := agedQuantityFixture(t) |
| + if op.ConfirmedAt != 0 { |
| + t.Fatal("fixture: the operation never executed and must be unconfirmed") |
| + } |
| q.mu.Lock() |
| q.quantity = 11 |
| q.mu.Unlock() |
| - if err := fx.srv.reconcileBillingSubscription(context.Background(), "sub_team", "evt_quantity_webhook", time.Now().UTC()); err != nil { |
| + observed := time.Now().UTC() |
| + if err := fx.srv.reconcileBillingSubscription(context.Background(), "sub_team", "evt_quantity_by_hand", observed); err != nil { |
| t.Fatal(err) |
| } |
| got := quantityAssignment(t, fx) |
| - if got.Quantity != 11 || got.EffectiveAt.Unix() != op.CreatedAt { |
| - t.Fatalf("webhook-observed assignment takes effect at %d, want %d", got.EffectiveAt.Unix(), op.CreatedAt) |
| + if got.Quantity != 11 || got.EffectiveAt.Unix() == op.CreatedAt || got.EffectiveAt.Unix() < observed.Unix()-5 { |
| + t.Fatalf("a quantity someone else set was dated at the operation's instant %d: %+v", op.CreatedAt, got.Assignment) |
| + } |
| + rev, err := fx.st.billingReview(OpsAccountID, "sub_team", billingQuantityDateReviewReason(op.CreatedAt)) |
| + if err != nil || rev.ResolvedAt != 0 || rev.Occurrences != 1 { |
| + t.Fatalf("no open review for the unproven charge date: %+v %v", rev, err) |
| } |
| - // The worker then finishes its operation without sending. |
| if err := fx.srv.billingQuantityPass(context.Background(), time.Now().UTC()); err != nil { |
| t.Fatal(err) |
| } |
| - if len(q.posts) != 0 || quantityHeld(t, fx) { |
| - t.Fatalf("posts %+v held %v", q.posts, quantityHeld(t, fx)) |
| + if len(q.posts) != 0 { |
| + t.Fatalf("the worker sent over a quantity someone else set: %+v", q.posts) |
| + } |
| + assertLastAddition(t, fx, quantityOutcomeUnverified, "si_team:quantity=11:always_invoice") |
| +} |
| + |
| +// A webhook that observes the target while the operation is young and |
| +// unconfirmed records nothing: the sender may be between the provider's |
| +// answer and writing it down. |
| +func TestQuantityWebhookOverAYoungUnconfirmedOperationWaits(t *testing.T) { |
| + fx, q, now := newQuantityFixture(t, billingProrationImmediate) |
| + if err := fx.st.AddMember(OpsAccountID, "eleventh@example.test", RoleMember, now.Add(time.Minute)); err != nil { |
| + t.Fatal(err) |
| + } |
| + q.failAfter = true |
| + if err := fx.srv.billingQuantityPass(context.Background(), time.Now().UTC()); err == nil { |
| + t.Fatal("a lost response must surface as an error") |
| + } |
| + if err := fx.srv.reconcileBillingSubscription(context.Background(), "sub_team", "evt_quantity_young", time.Now().UTC()); !errors.Is(err, errBillingPending) { |
| + t.Fatalf("webhook over a young unconfirmed operation: %v, want pending", err) |
| + } |
| + if state, _ := fx.st.billingAccount(OpsAccountID); state.Quantity != 10 { |
| + t.Fatalf("billed quantity %d: the change was recorded", state.Quantity) |
| } |
| } |
| |
| @@ -987,6 +1084,11 @@ func TestQuantityLandingSeenByAWebhookIsDatedAtTheOperation(t *testing.T) { |
| // allowance starts at another, and nobody is billed on that unseen. |
| func TestQuantityLandingThatCannotBeDatedFilesAReview(t *testing.T) { |
| fx, q, op := agedQuantityFixture(t) |
| + // The provider answered the operation's request with success, so the |
| + // operation is known to be what set the quantity. |
| + if _, err := fx.st.db.Exec(`UPDATE billing_quantity_operations SET confirmed_at=? WHERE account_id=?`, time.Now().Unix(), OpsAccountID); err != nil { |
| + t.Fatal(err) |
| + } |
| // A payment failure and recovery, both recorded after the operation's |
| // instant. |
| for i, status := range []string{"past_due", "active"} { |
| @@ -1015,6 +1117,48 @@ func TestQuantityLandingThatCannotBeDatedFilesAReview(t *testing.T) { |
| } |
| } |
| |
| +// The two other guards on dating a confirmed operation's assignment at its |
| +// instant. An instant further ahead of the observing clock than clock skew |
| +// is not dated. An instant in a month already frozen is not dated. Each is |
| +// recorded when observed, with a review. |
| +func TestQuantityConfirmedLandingIsNotDatedAheadOfTheClockOrIntoAFrozenMonth(t *testing.T) { |
| + for _, c := range []struct { |
| + name string |
| + prepare func(t *testing.T, fx *consoleFixture, op quantityOperation) time.Time // returns the observation instant |
| + }{ |
| + {"ten minutes ahead of the observing clock", func(t *testing.T, fx *consoleFixture, op quantityOperation) time.Time { |
| + return time.Unix(op.CreatedAt, 0).Add(-10 * time.Minute).UTC() |
| + }}, |
| + {"in a month already frozen", func(t *testing.T, fx *consoleFixture, op quantityOperation) time.Time { |
| + month := time.Unix(op.CreatedAt, 0).UTC().Format("2006-01") |
| + if _, err := fx.st.db.Exec(`INSERT INTO billing_months(account_id,month,calculation,digest,state) VALUES(?,?,x'00','d','frozen')`, OpsAccountID, month); err != nil { |
| + t.Fatal(err) |
| + } |
| + return time.Now().UTC() |
| + }}, |
| + } { |
| + t.Run(c.name, func(t *testing.T) { |
| + fx, q, op := agedQuantityFixture(t) |
| + if _, err := fx.st.db.Exec(`UPDATE billing_quantity_operations SET confirmed_at=? WHERE account_id=?`, time.Now().Unix(), OpsAccountID); err != nil { |
| + t.Fatal(err) |
| + } |
| + observed := c.prepare(t, fx, op) |
| + q.mu.Lock() |
| + q.quantity = 11 |
| + q.mu.Unlock() |
| + if err := fx.srv.reconcileBillingSubscription(context.Background(), "sub_team", "evt_quantity_guard", observed); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got := quantityAssignment(t, fx); got.Quantity != 11 || got.EffectiveAt.Unix() == op.CreatedAt { |
| + t.Fatalf("dated at the operation's instant: %+v", got.Assignment) |
| + } |
| + if rev, err := fx.st.billingReview(OpsAccountID, "sub_team", billingQuantityDateReviewReason(op.CreatedAt)); err != nil || rev.ResolvedAt != 0 { |
| + t.Fatalf("no open review: %+v %v", rev, err) |
| + } |
| + }) |
| + } |
| +} |
| + |
| // This console's clock is a few seconds behind the one that reconciled the |
| // renewal: the period it reads from the database starts in its future. The |
| // operation's instant is held inside the period, so the provider accepts |
| @@ -1048,6 +1192,115 @@ func TestQuantityOperationIsDatedInsideThePeriodUnderClockSkew(t *testing.T) { |
| } |
| } |
| |
| +// The interleaving of review round five. The stored period starts three |
| +// seconds ahead of this process's clock. Worker A records a 10-to-11 |
| +// operation, dated at that period start, and pauses before sending. Worker |
| +// B reads the provider at 10 and sees an operation dated ahead of its own |
| +// clock. If B retires it, a replacement can be recorded and completed |
| +// (10 to 12 for another member), and A, waking inside its send window with |
| +// a key the provider has never seen, sends 11 and LOWERS the quantity. |
| +// |
| +// B must leave it alone. A's operation stays the outstanding one, nothing |
| +// else can begin, A sends 11, and the twelfth seat follows in order. |
| +func TestQuantityFutureDatedOperationIsNotRetiredUnderItsSender(t *testing.T) { |
| + fx, q, _ := newQuantityFixture(t, billingProrationImmediate) |
| + ctx := context.Background() |
| + ahead := time.Now().Add(3 * time.Second).Unix() |
| + if _, err := fx.st.db.Exec(`UPDATE billing_accounts SET period_start=?, period_end=? WHERE account_id=?`, ahead, time.Unix(ahead, 0).UTC().AddDate(1, 0, 0).Unix(), OpsAccountID); err != nil { |
| + t.Fatal(err) |
| + } |
| + q.mu.Lock() |
| + q.start = time.Unix(ahead, 0).UTC() |
| + q.mu.Unlock() |
| + if err := fx.st.AddMember(OpsAccountID, "eleventh@example.test", RoleMember, time.Now().UTC()); err != nil { |
| + t.Fatal(err) |
| + } |
| + procB := &Server{cfg: fx.srv.cfg, store: fx.srv.store, billing: &billingRuntime{locks: newStoreWalkLocks(), client: fx.srv.billing.client, cfg: fx.srv.billing.cfg}} |
| + paused := false |
| + fx.srv.billing.quantityBeforeSend = func() { |
| + if paused { |
| + return |
| + } |
| + paused = true |
| + mine, _, _ := fx.st.quantityOperationFor(OpsAccountID) |
| + if mine.CreatedAt <= time.Now().Unix() { |
| + t.Errorf("fixture: the operation is not dated ahead of the clock (%d)", mine.CreatedAt) |
| + } |
| + if err := procB.billingQuantityPass(ctx, time.Now().UTC()); err != nil { |
| + t.Errorf("process B: %v", err) |
| + } |
| + if err := fx.st.AddMember(OpsAccountID, "twelfth@example.test", RoleMember, time.Now().UTC()); err != nil { |
| + t.Error(err) |
| + } |
| + if err := procB.billingQuantityPass(ctx, time.Now().UTC()); err != nil { |
| + t.Errorf("process B, second pass: %v", err) |
| + } |
| + after, ok, _ := fx.st.quantityOperationFor(OpsAccountID) |
| + if !ok || after.Key != mine.Key || after.AckedAt != 0 { |
| + t.Errorf("the future-dated operation was retired or replaced under its sender: %+v", after) |
| + } |
| + if len(q.posts) != 0 { |
| + t.Errorf("process B sent while another process held the operation: %+v", q.posts) |
| + } |
| + } |
| + if err := fx.srv.billingQuantityPass(ctx, time.Now().UTC()); err != nil { |
| + t.Fatal(err) |
| + } |
| + if !paused { |
| + t.Fatal("the seam never ran") |
| + } |
| + fx.srv.billing.quantityBeforeSend = nil |
| + // The twelfth seat, in order. |
| + time.Sleep(time.Until(time.Unix(ahead, 0)) + 1100*time.Millisecond) |
| + if err := fx.srv.billingQuantityPass(ctx, time.Now().UTC()); err != nil { |
| + t.Fatal(err) |
| + } |
| + if q.lowered || q.quantity != 12 || len(q.posts) != 2 || q.posts[0]["quantity"] != "11" || q.posts[1]["quantity"] != "12" { |
| + t.Fatalf("provider quantity %d lowered=%v posts=%+v", q.quantity, q.lowered, q.posts) |
| + } |
| +} |
| + |
| +// A sender whose operation was retired while it was paused does not send: |
| +// its request has never executed, so its key protects nothing. |
| +func TestQuantitySenderDoesNotSendARetiredOperation(t *testing.T) { |
| + fx, q, now := newQuantityFixture(t, billingProrationImmediate) |
| + if err := fx.st.AddMember(OpsAccountID, "eleventh@example.test", RoleMember, now.Add(time.Minute)); err != nil { |
| + t.Fatal(err) |
| + } |
| + fx.srv.billing.quantityBeforeSend = func() { |
| + if _, err := fx.st.db.Exec(`UPDATE billing_quantity_operations SET acked_at=? WHERE account_id=?`, time.Now().Unix(), OpsAccountID); err != nil { |
| + t.Error(err) |
| + } |
| + } |
| + if err := fx.srv.billingQuantityPass(context.Background(), time.Now().UTC()); err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(q.posts) != 0 || len(pendingAdditions(t, fx)) != 1 { |
| + t.Fatalf("a retired operation was sent: %+v", q.posts) |
| + } |
| +} |
| + |
| +// The member was recorded by a console whose clock is five seconds ahead |
| +// of this one. The proration is dated no earlier than the addition, so the |
| +// customer is not charged a seat for seconds before the member existed. |
| +func TestQuantityOperationIsNotDatedBeforeTheAdditionItChargesFor(t *testing.T) { |
| + fx, q, _ := newQuantityFixture(t, billingProrationImmediate) |
| + added := time.Now().UTC().Add(5 * time.Second) |
| + if err := fx.st.AddMember(OpsAccountID, "eleventh@example.test", RoleMember, added); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := fx.srv.billingQuantityPass(context.Background(), time.Now().UTC()); err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(q.posts) != 1 { |
| + t.Fatalf("posts %+v", q.posts) |
| + } |
| + date, _ := strconv.ParseInt(q.posts[0]["proration_date"], 10, 64) |
| + if date < added.Unix() { |
| + t.Fatalf("proration dated %d, before the member was added at %d", date, added.Unix()) |
| + } |
| +} |
| + |
| // An operation left unacknowledged with no addition pending (the state an |
| // older build's crash between the mark and the acknowledgement left) is |
| // still found and finished. Nothing is sent. |
| @@ -1197,9 +1450,10 @@ func TestQuantityWorkerFinishesAnOutstandingOperation(t *testing.T) { |
| if err := fx.srv.billingQuantityPass(ctx, time.Now().UTC()); err == nil { |
| t.Fatal("a lost response must surface as an error") |
| } |
| - if op, ok, _ := fx.st.quantityOperationFor(OpsAccountID); !ok || op.AckedAt != 0 || op.Target != 11 { |
| - t.Fatalf("operation not left outstanding: %+v %v", op, ok) |
| + if op, ok, _ := fx.st.quantityOperationFor(OpsAccountID); !ok || op.AckedAt != 0 || op.Target != 11 || op.ConfirmedAt != 0 { |
| + t.Fatalf("operation not left outstanding and unconfirmed: %+v %v", op, ok) |
| } |
| + ageQuantityOperation(t, fx, 3*time.Minute) |
| if err := fx.srv.billingQuantityPass(ctx, time.Now().UTC()); err != nil { |
| t.Fatal(err) |
| } |
| @@ -1209,7 +1463,7 @@ func TestQuantityWorkerFinishesAnOutstandingOperation(t *testing.T) { |
| if len(q.posts) != 1 || quantityHeld(t, fx) { |
| t.Fatalf("posts %+v held %v", q.posts, quantityHeld(t, fx)) |
| } |
| - assertLastAddition(t, fx, quantityOutcomeCharged, "si_team:quantity=11:always_invoice") |
| + assertLastAddition(t, fx, quantityOutcomeUnverified, "si_team:quantity=11:always_invoice") |
| } |
| |
| // The processing guard names the outcomes it admits. A database whose guard |