tenseleyflow/shithub / a00f957

Browse files

billing+orgs tests: lock webhook receipt subject + ListFailedWebhookEvents

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
a00f9577eaba69128c4907f499bbb4ef06dbb851
Parents
0c3c4d9
Tree
f408c70

2 changed files

StatusFile+-
M internal/billing/billing_test.go 85 0
M internal/web/handlers/orgs/billing_test.go 7 0
internal/billing/billing_test.gomodified
@@ -207,6 +207,91 @@ func TestRecordWebhookEventIsIdempotent(t *testing.T) {
207207
 	}
208208
 }
209209
 
210
+// TestSetWebhookEventSubjectForPrincipalRecordsAuditTrail locks PRO08
211
+// A2: after a successful resolve in the webhook apply path, the
212
+// receipt row carries (subject_kind, subject_id) so the operator
213
+// query for "which subject did this event apply to" works.
214
+func TestSetWebhookEventSubjectForPrincipalRecordsAuditTrail(t *testing.T) {
215
+	_, deps, org := setup(t)
216
+	ctx := context.Background()
217
+	if _, _, err := billing.RecordWebhookEvent(ctx, deps, billing.WebhookEvent{
218
+		ProviderEventID: "evt_subject_record",
219
+		EventType:       "customer.subscription.updated",
220
+		APIVersion:      "2024-06-20",
221
+		Payload:         []byte(`{"id":"evt_subject_record"}`),
222
+	}); err != nil {
223
+		t.Fatalf("RecordWebhookEvent: %v", err)
224
+	}
225
+	if err := billing.SetWebhookEventSubjectForPrincipal(ctx, deps, "evt_subject_record", billing.PrincipalForOrg(org.ID)); err != nil {
226
+		t.Fatalf("SetWebhookEventSubjectForPrincipal: %v", err)
227
+	}
228
+	receipt, err := billing.GetWebhookEventReceipt(ctx, deps, "evt_subject_record")
229
+	if err != nil {
230
+		t.Fatalf("GetWebhookEventReceipt: %v", err)
231
+	}
232
+	if !receipt.SubjectKind.Valid || receipt.SubjectKind.BillingSubjectKind != billingdb.BillingSubjectKindOrg {
233
+		t.Fatalf("subject_kind: got %+v, want org", receipt.SubjectKind)
234
+	}
235
+	if !receipt.SubjectID.Valid || receipt.SubjectID.Int64 != org.ID {
236
+		t.Fatalf("subject_id: got %+v, want %d", receipt.SubjectID, org.ID)
237
+	}
238
+}
239
+
240
+// TestListFailedWebhookEventsReturnsErroredAndStuckEntries locks the
241
+// operator inspection query used in the runbook. A receipt with a
242
+// non-empty process_error or with processing_attempts > 0 but no
243
+// processed_at must appear; a clean unprocessed row (attempts=0)
244
+// must NOT appear.
245
+func TestListFailedWebhookEventsReturnsErroredAndStuckEntries(t *testing.T) {
246
+	_, deps, _ := setup(t)
247
+	ctx := context.Background()
248
+
249
+	// 1. failed row (process_error non-empty)
250
+	if _, _, err := billing.RecordWebhookEvent(ctx, deps, billing.WebhookEvent{
251
+		ProviderEventID: "evt_failed", EventType: "x", Payload: []byte(`{}`),
252
+	}); err != nil {
253
+		t.Fatalf("record failed: %v", err)
254
+	}
255
+	if _, err := billing.MarkWebhookEventFailed(ctx, deps, "evt_failed", "boom"); err != nil {
256
+		t.Fatalf("mark failed: %v", err)
257
+	}
258
+
259
+	// 2. clean processed row — must NOT appear.
260
+	if _, _, err := billing.RecordWebhookEvent(ctx, deps, billing.WebhookEvent{
261
+		ProviderEventID: "evt_clean", EventType: "x", Payload: []byte(`{}`),
262
+	}); err != nil {
263
+		t.Fatalf("record clean: %v", err)
264
+	}
265
+	if _, err := billing.MarkWebhookEventProcessed(ctx, deps, "evt_clean"); err != nil {
266
+		t.Fatalf("mark clean: %v", err)
267
+	}
268
+
269
+	// 3. brand-new untouched row — must NOT appear.
270
+	if _, _, err := billing.RecordWebhookEvent(ctx, deps, billing.WebhookEvent{
271
+		ProviderEventID: "evt_new", EventType: "x", Payload: []byte(`{}`),
272
+	}); err != nil {
273
+		t.Fatalf("record new: %v", err)
274
+	}
275
+
276
+	rows, err := billing.ListFailedWebhookEvents(ctx, deps, 50)
277
+	if err != nil {
278
+		t.Fatalf("ListFailedWebhookEvents: %v", err)
279
+	}
280
+	got := map[string]bool{}
281
+	for _, r := range rows {
282
+		got[r.ProviderEventID] = true
283
+	}
284
+	if !got["evt_failed"] {
285
+		t.Errorf("expected evt_failed in failed list, got %v", got)
286
+	}
287
+	if got["evt_clean"] {
288
+		t.Errorf("evt_clean (processed, no error) leaked into failed list: %v", got)
289
+	}
290
+	if got["evt_new"] {
291
+		t.Errorf("evt_new (untouched) leaked into failed list: %v", got)
292
+	}
293
+}
294
+
210295
 func TestSyncSeatSnapshotUpdatesBillingState(t *testing.T) {
211296
 	_, deps, org := setup(t)
212297
 	ctx := context.Background()
internal/web/handlers/orgs/billing_test.gomodified
@@ -320,6 +320,13 @@ func TestOrgBillingWebhookProcessesSubscriptionAndStaysIdempotent(t *testing.T)
320320
 	if !receipt.ProcessedAt.Valid || receipt.ProcessingAttempts != 1 {
321321
 		t.Fatalf("unexpected receipt after first processing: %+v", receipt)
322322
 	}
323
+	// PRO08 A2: subject must be recorded on the receipt after resolve.
324
+	if !receipt.SubjectKind.Valid || receipt.SubjectKind.BillingSubjectKind != billingdb.BillingSubjectKindOrg {
325
+		t.Fatalf("receipt subject_kind: got %+v, want org", receipt.SubjectKind)
326
+	}
327
+	if !receipt.SubjectID.Valid || receipt.SubjectID.Int64 != orgID {
328
+		t.Fatalf("receipt subject_id: got %+v, want %d", receipt.SubjectID, orgID)
329
+	}
323330
 
324331
 	req = httptest.NewRequest(http.MethodPost, "/stripe/webhook", strings.NewReader(`{"id":"evt_sub_active"}`))
325332
 	req.Header.Set("Stripe-Signature", "sig_test")