@@ -207,6 +207,91 @@ func TestRecordWebhookEventIsIdempotent(t *testing.T) { |
| 207 | 207 | } |
| 208 | 208 | } |
| 209 | 209 | |
| 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 | + |
| 210 | 295 | func TestSyncSeatSnapshotUpdatesBillingState(t *testing.T) { |
| 211 | 296 | _, deps, org := setup(t) |
| 212 | 297 | ctx := context.Background() |