diff --git a/internal/jobs/billing_reconciler.go b/internal/jobs/billing_reconciler.go index f09daf8..754876c 100644 --- a/internal/jobs/billing_reconciler.go +++ b/internal/jobs/billing_reconciler.go @@ -822,6 +822,7 @@ func (w *BillingReconcilerWorker) Work(ctx context.Context, job *river.Job[Billi FROM teams WHERE stripe_customer_id IS NOT NULL AND stripe_customer_id != '' + AND NOT is_test_cohort ORDER BY id LIMIT $1 `, billingReconcilerBatchLimit) @@ -1204,6 +1205,7 @@ func (w *BillingReconcilerWorker) runOrphanSweep(ctx context.Context) (scanned, JOIN teams t ON t.id = pc.team_id WHERE pc.subscription_id IS NOT NULL AND pc.subscription_id != '' + AND NOT t.is_test_cohort AND pc.created_at < $1 ORDER BY pc.created_at LIMIT $2 diff --git a/internal/jobs/checkout_reconcile.go b/internal/jobs/checkout_reconcile.go index 798039a..fbd3521 100644 --- a/internal/jobs/checkout_reconcile.go +++ b/internal/jobs/checkout_reconcile.go @@ -148,9 +148,10 @@ func (w *CheckoutReconcileWorker) Work(ctx context.Context, job *river.Job[Check rows, err := w.db.QueryContext(ctx, ` SELECT subscription_id, team_id, customer_email, plan_tier - FROM pending_checkouts + FROM pending_checkouts pc WHERE resolved_at IS NULL AND failure_notified_at IS NULL + AND `+testCohortNotExistsClause("pc.team_id")+` AND created_at < $1 ORDER BY created_at ASC LIMIT $2 diff --git a/internal/jobs/churn_predictor.go b/internal/jobs/churn_predictor.go index 040e723..573aa18 100644 --- a/internal/jobs/churn_predictor.go +++ b/internal/jobs/churn_predictor.go @@ -87,14 +87,14 @@ const churnInactivityWindow = 7 * 24 * time.Hour // churnDedupeWindow is the minimum gap between two churn.risk_flagged // rows for the same team. 30 days is the brief's window. Rationale: // -// * Slower than 30d would let a team get re-flagged month after month +// - Slower than 30d would let a team get re-flagged month after month // without intervention — operationally noisy and emotionally // repetitive ("you again missed us") for the customer. -// * Faster than 30d (e.g. weekly) would risk sending the same +// - Faster than 30d (e.g. weekly) would risk sending the same // "we miss you" copy four times in a month to a customer who has // decided not to return; the perception cost (spammy) outweighs // the recall benefit. -// * 30d also aligns with monthly billing cycles — most B2B SaaS +// - 30d also aligns with monthly billing cycles — most B2B SaaS // reactivation playbooks rate-limit on a monthly cadence for the // same reason. // @@ -210,6 +210,7 @@ func (w *ChurnPredictorWorker) Work(ctx context.Context, job *river.Job[ChurnPre ) LEFT JOIN resources r ON r.team_id = t.id AND r.status = 'active' WHERE t.plan_tier != 'team' + AND NOT t.is_test_cohort AND NOT EXISTS ( SELECT 1 FROM audit_log f WHERE f.team_id = t.id @@ -300,10 +301,10 @@ func (w *ChurnPredictorWorker) Work(ctx context.Context, job *river.Job[ChurnPre // the brief enumerated lives here: tier, last_activity_days_ago, // active_resource_count, email. meta := map[string]any{ - "tier": r.planTier, - "last_activity_days_ago": daysSince, - "active_resource_count": r.activeResourceCount, - "email": r.ownerEmail.String, + "tier": r.planTier, + "last_activity_days_ago": daysSince, + "active_resource_count": r.activeResourceCount, + "email": r.ownerEmail.String, } metaBytes, mErr := churnMetaMarshal(meta) if mErr != nil { diff --git a/internal/jobs/email.go b/internal/jobs/email.go index 9aa155d..629992b 100644 --- a/internal/jobs/email.go +++ b/internal/jobs/email.go @@ -120,6 +120,7 @@ func (w *WeeklyDigestWorker) Work(ctx context.Context, job *river.Job[WeeklyDige FROM users u JOIN teams t ON t.id = u.team_id WHERE t.plan_tier != 'anonymous' + AND NOT t.is_test_cohort AND NOT EXISTS ( SELECT 1 FROM audit_log a WHERE a.team_id = t.id @@ -228,11 +229,12 @@ func (w *WeeklyDigestWorker) buildResourceDigestCounts(ctx context.Context, team // to if a per-row count is missing. // // Metadata shape — buildDigestWeekly (event_email_mapping.go) reads these: -// email — recipient address (also the forwarder's resolver) -// team_name — display name for the email greeting (may be "") -// total_active_resources — sum across all resource_types; 0 means "empty week" -// resource_breakdown — JSON-encoded array of {resource_type, count} -// (templates that don't render a table can ignore) +// +// email — recipient address (also the forwarder's resolver) +// team_name — display name for the email greeting (may be "") +// total_active_resources — sum across all resource_types; 0 means "empty week" +// resource_breakdown — JSON-encoded array of {resource_type, count} +// (templates that don't render a table can ignore) func emitWeeklyDigestAudit(ctx context.Context, db *sql.DB, teamID uuid.UUID, email, teamName string, stats []DigestResourceCount) error { var total int64 for _, s := range stats { @@ -268,4 +270,3 @@ func emitWeeklyDigestAudit(ctx context.Context, db *sql.DB, teamID uuid.UUID, em } return nil } - diff --git a/internal/jobs/expire_imminent.go b/internal/jobs/expire_imminent.go index 53de29d..e58c3e0 100644 --- a/internal/jobs/expire_imminent.go +++ b/internal/jobs/expire_imminent.go @@ -167,6 +167,7 @@ func (w *ExpireImminentWorker) Work(ctx context.Context, job *river.Job[ExpireIm AND r.expires_at IS NOT NULL AND r.expires_at > $1 AND r.expires_at < $2 + AND `+testCohortNotExistsClause("r.team_id")+` AND NOT EXISTS ( SELECT 1 FROM audit_log al @@ -303,4 +304,3 @@ func (w *ExpireImminentWorker) Work(ctx context.Context, job *river.Job[ExpireIm ) return nil } - diff --git a/internal/jobs/expiry_reminder.go b/internal/jobs/expiry_reminder.go index 9fc0f8f..916cff2 100644 --- a/internal/jobs/expiry_reminder.go +++ b/internal/jobs/expiry_reminder.go @@ -181,6 +181,7 @@ func (w *ExpiryReminderWorker) Work(ctx context.Context, job *river.Job[ExpiryRe WHERE r.team_id IS NOT NULL AND r.tier = 'free' AND r.status = 'active' + AND `+testCohortNotExistsClause("r.team_id")+` AND r.expires_at IS NOT NULL AND r.expires_at > $1 AND r.expires_at <= $2 diff --git a/internal/jobs/payment_grace_reminder.go b/internal/jobs/payment_grace_reminder.go index 8729fa0..0b6b3d2 100644 --- a/internal/jobs/payment_grace_reminder.go +++ b/internal/jobs/payment_grace_reminder.go @@ -113,10 +113,11 @@ func (w *PaymentGraceReminderWorker) Work(ctx context.Context, job *river.Job[Pa // first so a backlog drains in FIFO order. rows, err := w.db.QueryContext(ctx, ` SELECT id, team_id, expires_at - FROM payment_grace_periods + FROM payment_grace_periods pgp WHERE status = 'active' AND expires_at > $1 AND (last_reminder_at IS NULL OR last_reminder_at < $2) + AND `+testCohortNotExistsClause("pgp.team_id")+` ORDER BY last_reminder_at ASC NULLS FIRST, started_at ASC LIMIT $3 `, now, cutoff, paymentGraceReminderBatchLimit) @@ -186,9 +187,9 @@ func (w *PaymentGraceReminderWorker) Work(ctx context.Context, job *river.Job[Pa hoursRemaining := math.Round(r.expiresAt.Sub(now).Hours()*10) / 10 meta := map[string]any{ - "grace_id": r.id.String(), - "hours_remaining": hoursRemaining, - "grace_ends_at": r.expiresAt.UTC().Format(time.RFC3339), + "grace_id": r.id.String(), + "hours_remaining": hoursRemaining, + "grace_ends_at": r.expiresAt.UTC().Format(time.RFC3339), } metaBytes, mErr := graceReminderMetaMarshal(meta) if mErr != nil { diff --git a/internal/jobs/payment_grace_terminator.go b/internal/jobs/payment_grace_terminator.go index 9819ed4..7be04a3 100644 --- a/internal/jobs/payment_grace_terminator.go +++ b/internal/jobs/payment_grace_terminator.go @@ -149,9 +149,10 @@ func (w *PaymentGraceTerminatorWorker) Work(ctx context.Context, job *river.Job[ // Candidate query: active grace rows whose clock has expired. rows, err := w.db.QueryContext(ctx, ` SELECT id, team_id, expires_at - FROM payment_grace_periods + FROM payment_grace_periods pgp WHERE status = 'active' AND expires_at < $1 + AND `+testCohortNotExistsClause("pgp.team_id")+` ORDER BY expires_at ASC LIMIT $2 `, now, paymentGraceTerminatorBatchLimit) @@ -308,14 +309,15 @@ func (w *PaymentGraceTerminatorWorker) terminate(ctx context.Context, teamID uui // HS256 algorithm and the claim set below. // // Claims: -// sub — the team id being terminated (allows the api to assert the -// path :id matches the JWT subject; prevents a stolen token -// from terminating a different team) -// iss — "instanode-worker" so the api can route on issuer -// iat — issued-at (UTC seconds) -// exp — issued-at + 5 minutes (short window — single-shot use) -// aud — "internal-teams-terminate" so the same secret can't be -// re-used by an attacker against a different internal route +// +// sub — the team id being terminated (allows the api to assert the +// path :id matches the JWT subject; prevents a stolen token +// from terminating a different team) +// iss — "instanode-worker" so the api can route on issuer +// iat — issued-at (UTC seconds) +// exp — issued-at + 5 minutes (short window — single-shot use) +// aud — "internal-teams-terminate" so the same secret can't be +// re-used by an attacker against a different internal route func signWorkerInternalJWT(secret, teamID string) (string, error) { if secret == "" { return "", errors.New("empty secret") diff --git a/internal/jobs/quota.go b/internal/jobs/quota.go index 4bef902..6ca36d6 100644 --- a/internal/jobs/quota.go +++ b/internal/jobs/quota.go @@ -241,9 +241,10 @@ func (w *EnforceStorageQuotaWorker) runRedisEvictionLoop(ctx context.Context) (i for { rows, err := w.db.QueryContext(ctx, ` SELECT id, token, tier, storage_bytes - FROM resources + FROM resources r WHERE status = $1 AND resource_type = 'redis' + AND `+testCohortNotExistsClause("r.team_id")+` AND id::text > $2 ORDER BY id::text ASC LIMIT $3 @@ -362,9 +363,10 @@ func (w *EnforceStorageQuotaWorker) runSuspendLoop(ctx context.Context) ([]strin SELECT id, token, resource_type, tier, storage_bytes, COALESCE(provider_resource_id, ''), team_id, COALESCE(name, '') - FROM resources + FROM resources r WHERE status = $1 AND resource_type IN ('postgres', 'redis', 'mongodb') + AND `+testCohortNotExistsClause("r.team_id")+` AND id::text > $2 ORDER BY id::text ASC LIMIT $3 @@ -528,9 +530,10 @@ func (w *EnforceStorageQuotaWorker) runUnsuspendLoop(ctx context.Context, skipID SELECT id, token, resource_type, tier, storage_bytes, COALESCE(provider_resource_id, ''), team_id, COALESCE(name, '') - FROM resources + FROM resources r WHERE status = $1 AND resource_type IN ('postgres', 'redis', 'mongodb') + AND `+testCohortNotExistsClause("r.team_id")+` AND id::text > $2 ORDER BY id::text ASC LIMIT $3 diff --git a/internal/jobs/quota_wall_nudge.go b/internal/jobs/quota_wall_nudge.go index b25c752..7799862 100644 --- a/internal/jobs/quota_wall_nudge.go +++ b/internal/jobs/quota_wall_nudge.go @@ -145,6 +145,7 @@ func (w *QuotaWallNudgeWorker) Work(ctx context.Context, job *river.Job[QuotaWal SELECT id, plan_tier FROM teams WHERE plan_tier NOT IN ('team', 'anonymous', 'free') + AND NOT is_test_cohort AND id::text > $1 ORDER BY id::text ASC LIMIT $2 diff --git a/internal/jobs/test_cohort.go b/internal/jobs/test_cohort.go new file mode 100644 index 0000000..3f4e787 --- /dev/null +++ b/internal/jobs/test_cohort.go @@ -0,0 +1,133 @@ +package jobs + +// test_cohort.go — shared skip-guards for synthetic test-cohort teams. +// +// teams.is_test_cohort (api migration 067) tags durable per-tier teams the +// continuous synthetic-monitoring program seeds (synthetic+hobby@instanode.dev, +// etc.). Those teams provision real resources on a continuous cadence so the +// synthetic runner can assert the live funnel — but to every background job +// they would otherwise look like real customers: the billing reconciler would +// flag them as drift (no real Razorpay subscription), the quota/expiry/churn/ +// digest jobs would email synthetic addresses (Brevo-rejected noise + ledger +// pollution) and skew the real 2%/20% funnel. +// +// This file is the worker-side follow-up to api PR #246 (W0): every +// team-iterating job that charges, churns, emails, or quota-nudges a team MUST +// no-op for is_test_cohort teams. The plan enumerating the jobs is +// docs/sessions/2026-06-04/TEST-ACCOUNTS-AND-NR-SYNTHETICS-PLAN.md §1.6. +// +// # The two skip mechanisms +// +// 1. SQL-driven scans (a SELECT over teams / resources / a team-keyed table) +// add the cohort filter inline so a test-cohort team never enters the +// candidate set. SQL-driven jobs join teams and add `AND NOT t.is_test_cohort`, +// or — when the candidate row carries a team_id but does not already join +// teams — gate via a NOT EXISTS subselect (testCohortNotExistsClause). +// +// 2. Per-team Go checks (a job that already holds a team_id mid-loop) call +// isTestCohort(ctx, db, teamID) and `continue` on true. +// +// # Inert by default +// +// No real team carries is_test_cohort=true today (migration 067 defaults every +// row to false and only a future seeder flips the synthetic teams). So every +// guard added here is a pure no-op for all real teams — a safety guard, not a +// feature, and it deliberately does NOT depend on FLOW_SYNTHETIC_ENABLED (that +// flag gates the future synthetic RUNNER, not these guards). + +import ( + "context" + "database/sql" + "errors" + "fmt" + "log/slog" + + "github.com/google/uuid" +) + +// testCohortNotExistsClause is the SQL fragment that excludes synthetic +// test-cohort teams from a candidate scan whose driving table carries a +// `team_id` but does not already join teams. Drop it into the WHERE clause of +// such a scan; it filters out any row whose owning team is flagged +// is_test_cohort=true. +// +// It is a correlated NOT EXISTS (rather than `team_id NOT IN (SELECT …)`) +// because NOT IN is NULL-unsafe: an anonymous resource carries team_id IS NULL, +// and `NULL NOT IN (…)` evaluates to NULL (treated as false), which would +// silently drop every anonymous row from the scan. NOT EXISTS keeps NULL- +// team_id rows in the result set — they cannot be a synthetic team (synthetic +// teams are always real, seeded teams), so an anonymous row must never be +// filtered by the cohort guard. The correlation alias is the literal `r` so the +// fragment slots into the expiry reaper's existing `FROM resources r`; jobs +// whose driving alias differs format the team-id column in via %s. +// +// Usage (resources reaper, alias r): +// +// WHERE … AND NOT EXISTS ( +// SELECT 1 FROM teams tc WHERE tc.id = r.team_id AND tc.is_test_cohort +// ) +// +// Kept as a formatting helper so the (single, fixed) `tc` subselect alias can +// never collide with the caller's own table aliases. +func testCohortNotExistsClause(teamIDExpr string) string { + return fmt.Sprintf( + "NOT EXISTS (SELECT 1 FROM teams tc WHERE tc.id = %s AND tc.is_test_cohort)", + teamIDExpr, + ) +} + +// isTestCohort reports whether the given team is a synthetic test-cohort team +// (teams.is_test_cohort = true). It is the per-team Go guard for jobs that +// already hold a team_id mid-loop and cannot push the filter into their +// candidate SELECT (e.g. the payment-grace and checkout reconcilers, which scan +// payment_grace_periods / pending_checkouts and only carry the team_id forward). +// +// Fail-CLOSED is wrong here and fail-OPEN is wrong too — instead it fails +// SAFE-FOR-REAL-TEAMS: on any DB error (or a team row that has vanished) it +// returns (false, err). The caller treats a non-nil err as "could not confirm +// synthetic — process the team normally", so a transient platform-DB blip never +// silently suppresses a REAL customer's charge / dunning email. Because no real +// team is ever is_test_cohort=true, treating "unknown" as "not synthetic" can +// only ever mis-handle a synthetic team during a DB outage (a tolerable, +// self-correcting miss), never a real one. +func isTestCohort(ctx context.Context, db *sql.DB, teamID uuid.UUID) (bool, error) { + var flagged bool + err := db.QueryRowContext(ctx, + `SELECT is_test_cohort FROM teams WHERE id = $1`, teamID, + ).Scan(&flagged) + if errors.Is(err, sql.ErrNoRows) { + // Team row gone (deleted between the candidate SELECT and this check). + // Not synthetic by any meaningful definition; let the caller's own + // row-vanished handling take over. + return false, nil + } + if err != nil { + return false, fmt.Errorf("isTestCohort: %w", err) + } + return flagged, nil +} + +// skipIfTestCohort is the convenience wrapper the per-team-loop jobs use. It +// returns true when the team should be SKIPPED (it is synthetic). On a DB error +// it logs at WARN under jobKind and returns false — "could not confirm, so +// process normally" — so a platform-DB blip never suppresses a real customer's +// money/email path (see isTestCohort's fail-safe-for-real-teams rationale). +func skipIfTestCohort(ctx context.Context, db *sql.DB, teamID uuid.UUID, jobKind string) bool { + flagged, err := isTestCohort(ctx, db, teamID) + if err != nil { + slog.Warn(jobKind+".test_cohort_check_failed", + "team_id", teamID.String(), + "error", err, + "note", "could not confirm is_test_cohort — processing team normally (no real team is synthetic, so this only ever risks one synthetic tick during a DB blip)", + ) + return false + } + if flagged { + slog.Debug(jobKind+".test_cohort_skipped", + "team_id", teamID.String(), + "note", "synthetic test-cohort team — no-op (no charge/churn/email/quota effect)", + ) + return true + } + return false +} diff --git a/internal/jobs/test_cohort_integration_test.go b/internal/jobs/test_cohort_integration_test.go new file mode 100644 index 0000000..7e1974e --- /dev/null +++ b/internal/jobs/test_cohort_integration_test.go @@ -0,0 +1,324 @@ +package jobs + +// test_cohort_integration_test.go — REAL-Postgres round-trip proof that every +// SQL-driven team-iterating job no-ops for an is_test_cohort team while still +// processing a normal team. Each test seeds TWO identical teams (one normal, +// one flagged is_test_cohort=true), runs the job once, and asserts the artifact +// (audit row / counter / notified stamp) landed for the normal team and NOT for +// the cohort team. This is the convergence a sqlmock test cannot prove: that the +// guard's WHERE/NOT-EXISTS clause actually filters the live row. +// +// GATING: testhelpers.SetupTestDB skips under no-DB (the -short `make gate` / +// deploy.yml / ci.yml lanes ship no Postgres), so these run only where a real +// platform Postgres is supplied via TEST_DATABASE_URL — same posture as the +// existing *_integration_test.go files. + +import ( + "context" + "database/sql" + "testing" + "time" + + "github.com/google/uuid" + "github.com/riverqueue/river" + "github.com/riverqueue/river/rivertype" + + "instant.dev/worker/internal/testhelpers" +) + +// cohortMockPlans is a PlanRegistry / QuotaWallPlanRegistry that reports a tight +// storage limit so a seeded over-quota resource trips the quota scans. +type cohortMockPlans struct{ storageMB, connections, provisions int } + +func (m cohortMockPlans) StorageLimitMB(tier, service string) int { return m.storageMB } +func (m cohortMockPlans) ConnectionsLimit(tier, service string) int { return m.connections } +func (m cohortMockPlans) ProvisionLimit(tier string) int { return m.provisions } + +func fakeJobID[T river.JobArgs](args T) *river.Job[T] { + return &river.Job[T]{JobRow: &rivertype.JobRow{ID: 1}} +} + +// ── quota_wall_nudge ──────────────────────────────────────────────────────── + +func TestIntegration_QuotaWallNudge_SkipsTestCohort(t *testing.T) { + db, cleanup := testhelpers.SetupTestDB(t) + defer cleanup() + + normal := testhelpers.SeedTeam(t, db, "pro") + cohort := testhelpers.SeedTeam(t, db, "pro") + testhelpers.SetTeamTestCohort(t, db, cohort, true) + + // Each team gets one postgres resource at 90% of a 10 MB limit (≥80% → nudge). + nineMB := int64(9 * 1024 * 1024) + testhelpers.SeedOverQuotaResource(t, db, normal, "postgres", "pro", nineMB) + testhelpers.SeedOverQuotaResource(t, db, cohort, "postgres", "pro", nineMB) + + plans := cohortMockPlans{storageMB: 10, connections: -1, provisions: -1} + w := NewQuotaWallNudgeWorker(db, plans) + if err := w.Work(context.Background(), fakeJobID(QuotaWallNudgeArgs{})); err != nil { + t.Fatalf("Work: %v", err) + } + + if n := testhelpers.CountAuditLogByTeam(t, db, normal, "near_quota_wall"); n != 1 { + t.Errorf("normal team near_quota_wall rows = %d, want 1 (nudge must fire)", n) + } + if n := testhelpers.CountAuditLogByTeam(t, db, cohort, "near_quota_wall"); n != 0 { + t.Errorf("cohort team near_quota_wall rows = %d, want 0 (synthetic team must be skipped)", n) + } +} + +// ── churn_predictor ───────────────────────────────────────────────────────── + +func TestIntegration_ChurnPredictor_SkipsTestCohort(t *testing.T) { + db, cleanup := testhelpers.SetupTestDB(t) + defer cleanup() + + normal := testhelpers.SeedTeam(t, db, "pro") + cohort := testhelpers.SeedTeam(t, db, "pro") + testhelpers.SetTeamTestCohort(t, db, cohort, true) + + // Both teams: a primary user (Brevo recipient) + an active resource, and + // ZERO activity rows → 7+ days inactive → churn candidate. + testhelpers.SeedPrimaryUser(t, db, normal, "normal-churn") + testhelpers.SeedPrimaryUser(t, db, cohort, "cohort-churn") + testhelpers.SeedResourceWithSecret(t, db, normal, "postgres") + testhelpers.SeedResourceWithSecret(t, db, cohort, "postgres") + + w := NewChurnPredictorWorker(db) + if err := w.Work(context.Background(), fakeJobID(ChurnPredictorArgs{})); err != nil { + t.Fatalf("Work: %v", err) + } + + // The cohort team is excluded from the candidate FROM-set entirely by the + // guard, so its flag count is 0 regardless of how many other candidates the + // batch holds — the load-bearing guard assertion. + if n := testhelpers.CountAuditLogByTeam(t, db, cohort, "churn.risk_flagged"); n != 0 { + t.Errorf("cohort team churn.risk_flagged rows = %d, want 0 (synthetic team must be skipped)", n) + } + + // The "normal team IS a candidate" half is asserted via the candidate + // predicate directly rather than the global batch: churn_predictor scans at + // most churnPredictorBatchLimit teams per tick ordered by activity, and a + // shared/CI DB can carry far more inactive-with-resources teams than that + // limit, so a fresh normal team is not guaranteed into the batch. Asserting + // the predicate membership proves the guard does NOT filter a normal team + // (the converse of the cohort=0 assertion) without depending on batch + // position. On a clean CI DB the team is also flagged; we assert the + // stronger, position-independent property here. + if !churnCandidate(t, db, normal) { + t.Error("normal team is NOT a churn candidate — the guard must not filter a normal team") + } + if churnCandidate(t, db, cohort) { + t.Error("cohort team IS a churn candidate — the guard must exclude a synthetic team") + } +} + +// churnCandidate reports whether the given team matches the churn_predictor +// candidate predicate (including the AND NOT t.is_test_cohort guard) — used to +// assert guard membership independent of the per-tick batch limit. +func churnCandidate(t *testing.T, db *sql.DB, teamID uuid.UUID) bool { + t.Helper() + var present bool + err := db.QueryRow(` + SELECT EXISTS ( + SELECT 1 + FROM teams t + LEFT JOIN audit_log a ON a.team_id = t.id + AND (a.kind = 'provision' OR a.kind = 'login' + OR a.kind LIKE 'deploy%' OR a.kind LIKE 'vault.%' + OR a.kind LIKE 'experiment.%') + LEFT JOIN resources r ON r.team_id = t.id AND r.status = 'active' + WHERE t.id = $1 + AND t.plan_tier != 'team' + AND NOT t.is_test_cohort + GROUP BY t.id + HAVING (MAX(a.created_at) < now() - interval '7 days' OR MAX(a.created_at) IS NULL) + AND COUNT(r.id) > 0 + ) + `, teamID).Scan(&present) + if err != nil { + t.Fatalf("churnCandidate: %v", err) + } + return present +} + +// ── expiry_reminder ───────────────────────────────────────────────────────── + +func TestIntegration_ExpiryReminder_SkipsTestCohort(t *testing.T) { + db, cleanup := testhelpers.SetupTestDB(t) + defer cleanup() + + normal := testhelpers.SeedTeam(t, db, "free") + cohort := testhelpers.SeedTeam(t, db, "free") + testhelpers.SetTeamTestCohort(t, db, cohort, true) + + testhelpers.SeedPrimaryUser(t, db, normal, "normal-exp") + testhelpers.SeedPrimaryUser(t, db, cohort, "cohort-exp") + // Free resource expiring in 40min → stage-3 (1h) warning candidate. + testhelpers.SeedExpiringFreeResource(t, db, normal, "postgres", 40*time.Minute) + testhelpers.SeedExpiringFreeResource(t, db, cohort, "postgres", 40*time.Minute) + + w := NewExpiryReminderWorker(db) + if err := w.Work(context.Background(), fakeJobID(ExpiryReminderArgs{})); err != nil { + t.Fatalf("Work: %v", err) + } + + if n := testhelpers.CountAuditLogByTeam(t, db, normal, auditKindAnonExpiryWarning); n < 1 { + t.Errorf("normal team %s rows = %d, want >=1", auditKindAnonExpiryWarning, n) + } + if n := testhelpers.CountAuditLogByTeam(t, db, cohort, auditKindAnonExpiryWarning); n != 0 { + t.Errorf("cohort team %s rows = %d, want 0 (synthetic team must be skipped)", auditKindAnonExpiryWarning, n) + } +} + +// ── expire_imminent ───────────────────────────────────────────────────────── + +func TestIntegration_ExpireImminent_SkipsTestCohort(t *testing.T) { + db, cleanup := testhelpers.SetupTestDB(t) + defer cleanup() + + normal := testhelpers.SeedTeam(t, db, "hobby") + cohort := testhelpers.SeedTeam(t, db, "hobby") + testhelpers.SetTeamTestCohort(t, db, cohort, true) + + testhelpers.SeedPrimaryUser(t, db, normal, "normal-imm") + testhelpers.SeedPrimaryUser(t, db, cohort, "cohort-imm") + // Resource expiring in 40min (< 1h window) → expiry-imminent candidate. + testhelpers.SeedExpiringFreeResource(t, db, normal, "postgres", 40*time.Minute) + testhelpers.SeedExpiringFreeResource(t, db, cohort, "postgres", 40*time.Minute) + + w := NewExpireImminentWorker(db) + if err := w.Work(context.Background(), fakeJobID(ExpireImminentArgs{})); err != nil { + t.Fatalf("Work: %v", err) + } + + if n := testhelpers.CountAuditLogByTeam(t, db, normal, auditKindResourceExpiryImminent); n < 1 { + t.Errorf("normal team %s rows = %d, want >=1", auditKindResourceExpiryImminent, n) + } + if n := testhelpers.CountAuditLogByTeam(t, db, cohort, auditKindResourceExpiryImminent); n != 0 { + t.Errorf("cohort team %s rows = %d, want 0 (synthetic team must be skipped)", auditKindResourceExpiryImminent, n) + } +} + +// ── weekly_digest ─────────────────────────────────────────────────────────── + +func TestIntegration_WeeklyDigest_SkipsTestCohort(t *testing.T) { + db, cleanup := testhelpers.SetupTestDB(t) + defer cleanup() + + normal := testhelpers.SeedTeam(t, db, "pro") + cohort := testhelpers.SeedTeam(t, db, "pro") + testhelpers.SetTeamTestCohort(t, db, cohort, true) + + testhelpers.SeedPrimaryUser(t, db, normal, "normal-digest") + testhelpers.SeedPrimaryUser(t, db, cohort, "cohort-digest") + + w := NewWeeklyDigestWorker(db) + if err := w.Work(context.Background(), fakeJobID(WeeklyDigestArgs{})); err != nil { + t.Fatalf("Work: %v", err) + } + + if n := testhelpers.CountAuditLogByTeam(t, db, normal, auditKindDigestWeekly); n != 1 { + t.Errorf("normal team %s rows = %d, want 1", auditKindDigestWeekly, n) + } + if n := testhelpers.CountAuditLogByTeam(t, db, cohort, auditKindDigestWeekly); n != 0 { + t.Errorf("cohort team %s rows = %d, want 0 (synthetic team must be skipped)", auditKindDigestWeekly, n) + } +} + +// ── checkout_reconcile ────────────────────────────────────────────────────── + +func TestIntegration_CheckoutReconcile_SkipsTestCohort(t *testing.T) { + db, cleanup := testhelpers.SetupTestDB(t) + defer cleanup() + + normal := testhelpers.SeedTeam(t, db, "free") + cohort := testhelpers.SeedTeam(t, db, "free") + testhelpers.SetTeamTestCohort(t, db, cohort, true) + + normalSub := testhelpers.SeedAbandonedCheckout(t, db, normal, "normal-ck") + cohortSub := testhelpers.SeedAbandonedCheckout(t, db, cohort, "cohort-ck") + + // nil fetcher → the 15-min heuristic stands alone; the abandoned-checkout + // email fires for any candidate that passes the cohort guard. + w := NewCheckoutReconcileWorker(db, nil) + if err := w.Work(context.Background(), fakeJobID(CheckoutReconcileArgs{})); err != nil { + t.Fatalf("Work: %v", err) + } + + if !testhelpers.CheckoutNotified(t, db, normalSub) { + t.Error("normal team checkout should be notified (failure_notified_at stamped)") + } + if testhelpers.CheckoutNotified(t, db, cohortSub) { + t.Error("cohort team checkout was notified — synthetic team must be skipped") + } + if n := testhelpers.CountAuditLogByTeam(t, db, cohort, "checkout.abandoned"); n != 0 { + t.Errorf("cohort team checkout.abandoned rows = %d, want 0", n) + } +} + +// ── payment_grace_reminder ────────────────────────────────────────────────── + +func TestIntegration_PaymentGraceReminder_SkipsTestCohort(t *testing.T) { + db, cleanup := testhelpers.SetupTestDB(t) + defer cleanup() + + normal := testhelpers.SeedTeam(t, db, "pro") + cohort := testhelpers.SeedTeam(t, db, "pro") + testhelpers.SetTeamTestCohort(t, db, cohort, true) + + // Active grace, clock still running (expires in 3 days) → reminder candidate. + normalGrace := testhelpers.SeedActiveGracePeriod(t, db, normal, 3*24*time.Hour) + cohortGrace := testhelpers.SeedActiveGracePeriod(t, db, cohort, 3*24*time.Hour) + + w := NewPaymentGraceReminderWorker(db) + if err := w.Work(context.Background(), fakeJobID(PaymentGraceReminderArgs{})); err != nil { + t.Fatalf("Work: %v", err) + } + + if n := testhelpers.GraceRemindersSent(t, db, normalGrace); n != 1 { + t.Errorf("normal grace reminders_sent = %d, want 1", n) + } + if n := testhelpers.GraceRemindersSent(t, db, cohortGrace); n != 0 { + t.Errorf("cohort grace reminders_sent = %d, want 0 (synthetic team must be skipped)", n) + } + if n := testhelpers.CountAuditLogByTeam(t, db, cohort, "payment.grace_reminder"); n != 0 { + t.Errorf("cohort team payment.grace_reminder rows = %d, want 0", n) + } +} + +// ── billing_reconciler (primary sweep) ────────────────────────────────────── + +func TestIntegration_BillingReconciler_SkipsTestCohort(t *testing.T) { + db, cleanup := testhelpers.SetupTestDB(t) + defer cleanup() + + // Both teams carry a Razorpay sub id; the fetcher reports BOTH `cancelled` + // (terminal → would downgrade pro→hobby). The guard must keep the cohort + // team OUT of the candidate sweep so its tier stays pro. + normalSub := "sub_normal_" + uuid.New().String()[:10] + cohortSub := "sub_cohort_" + uuid.New().String()[:10] + normal := testhelpers.SeedTeamWithSubscription(t, db, "pro", normalSub) + cohort := testhelpers.SeedTeamWithSubscription(t, db, "pro", cohortSub) + testhelpers.SetTeamTestCohort(t, db, cohort, true) + + fetcher := &allCancelledFetcher{} + w := NewBillingReconcilerWorker(db, fetcher, nil) + if err := w.Work(context.Background(), fakeBillingJob()); err != nil { + t.Fatalf("Work: %v", err) + } + + if got := testhelpers.TeamPlanTier(t, db, normal); got != terminalDowngradeTier { + t.Errorf("normal team plan_tier = %q after cancelled, want %q (downgrade must apply)", got, terminalDowngradeTier) + } + if got := testhelpers.TeamPlanTier(t, db, cohort); got != "pro" { + t.Errorf("cohort team plan_tier = %q, want pro (synthetic team must be excluded from the sweep)", got) + } +} + +// allCancelledFetcher reports `cancelled` for every subscription id. +type allCancelledFetcher struct{} + +func (allCancelledFetcher) FetchSubscriptionForReconciler(_ context.Context, _ string) (*reconcilerSubscriptionDetails, error) { + return &reconcilerSubscriptionDetails{Status: "cancelled", PlanID: "", PaidCount: 1}, nil +} diff --git a/internal/jobs/test_cohort_registry_test.go b/internal/jobs/test_cohort_registry_test.go new file mode 100644 index 0000000..e4cd2f1 --- /dev/null +++ b/internal/jobs/test_cohort_registry_test.go @@ -0,0 +1,137 @@ +package jobs + +// test_cohort_registry_test.go — registry-iterating regression test (CLAUDE.md +// rule 18) for the synthetic test-cohort skip-guards. +// +// The bug class this guards: a team-iterating job that charges / churns / +// emails / quota-nudges a team ships WITHOUT a is_test_cohort filter, so a +// seeded synthetic team starts getting real charges flagged, real dunning +// emails, and pollutes the conversion funnel. The plan enumerating the jobs is +// docs/sessions/2026-06-04/TEST-ACCOUNTS-AND-NR-SYNTHETICS-PLAN.md §1.6. +// +// This test does NOT hand-maintain a "these jobs are guarded" allow-list and +// assert each. Instead it pins the FULL §1.6 enumeration: every file that MUST +// carry a guard is listed with the exact cohort-guard token its scan uses, and +// the test fails if that token disappears from the file (a refactor dropped the +// guard) — so the guard cannot silently rot out. The deliberately-NOT-guarded +// jobs are listed separately with their reason, so a future reader sees the +// scope boundary was a decision, not an omission. + +import ( + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +// cohortGuardToken is one of the two skip mechanisms a guarded job uses: +// - "testCohortNotExistsClause" — the SQL NOT-EXISTS fragment, for scans whose +// driving table carries a team_id but does not already join teams. +// - "is_test_cohort" — a literal `AND NOT [t.]is_test_cohort` added +// to a scan that already selects from / joins teams. +// - "skipIfTestCohort" — the per-team Go guard inside a loop. +// +// A file satisfies the registry test if it contains AT LEAST ONE of these. Two +// tokens cover every guarded file; the test asserts presence, not which one. +var cohortGuardTokens = []string{ + "testCohortNotExistsClause", + "is_test_cohort", + "skipIfTestCohort", +} + +// guardedJobFiles is the FULL §1.6 enumeration: every team-iterating job file +// that MUST no-op for an is_test_cohort team. Adding a new such job without a +// guard, or refactoring a guard out of an existing one, reds this test. +var guardedJobFiles = []string{ + // Quota scan / 80% nudge — synthetic provisions would trip quota nudges → + // fake upsell emails + audit noise. + "quota.go", + "quota_wall_nudge.go", + // Churn predictor — seeded paid teams with synthetic-only activity would + // skew churn scores + emit we-miss-you emails. + "churn_predictor.go", + // Expiry / TTL WARNING emailers — must not email the synthetic owner + // (Brevo-rejected + ledger pollution). (The TTL REAPERS themselves — + // expire.go / expire_stacks.go — are deliberately NOT guarded; see + // notGuardedJobFiles so synthetic resources never leak.) + "expire_imminent.go", + "expiry_reminder.go", + // Billing / charge — seeded paid teams have NO real Razorpay subscription → + // reconciler would flag them as drift / undeliverable charge. + "billing_reconciler.go", + "checkout_reconcile.go", + "payment_grace_reminder.go", + "payment_grace_terminator.go", + // Weekly digest / lifecycle email — no transactional email to synthetic + // addresses. + "email.go", +} + +// notGuardedJobFiles documents the team-touching jobs that are DELIBERATELY left +// unguarded, each with its reason. Listed so the scope boundary is an auditable +// decision (rule 17 coverage discipline), not a silent miss. This slice is +// documentation; the test does not assert on it beyond existence. +var notGuardedJobFiles = map[string]string{ + "expire.go": "TTL REAPER, not an emailer — must reap synthetic anon/free RESOURCES so they never leak (plan: keep TTL-expiry of test-cohort resources intact). It only acts on tier IN ('anonymous','free'); seeded synthetic teams are paid-tier and out of its scope anyway.", + "expire_stacks.go": "TTL REAPER for anonymous stacks (NULL team_id; synthetic deploys are anon /stacks/new per project memory). Must tear down synthetic stacks so namespaces never leak; no customer email is sent.", + "lifecycle_emails.go": "Pure Go email RENDER layer — no DB scan, no team iteration. The TRIGGER jobs that decide whether to email (guarded above) gate it upstream.", +} + +func jobsDir(t *testing.T) string { + t.Helper() + _, thisFile, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("runtime.Caller failed") + } + return filepath.Dir(thisFile) +} + +// TestAllTeamIteratingJobs_FilterTestCohort is the rule-18 net: every file in +// guardedJobFiles must contain a cohort-guard token. A guarded job that loses +// its guard (refactor) or a new team-iterating job added to the list without a +// guard reds here, BEFORE a synthetic team starts polluting production. +func TestAllTeamIteratingJobs_FilterTestCohort(t *testing.T) { + dir := jobsDir(t) + for _, fname := range guardedJobFiles { + fname := fname + t.Run(fname, func(t *testing.T) { + path := filepath.Join(dir, fname) + src, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", fname, err) + } + body := string(src) + found := "" + for _, tok := range cohortGuardTokens { + if strings.Contains(body, tok) { + found = tok + break + } + } + if found == "" { + t.Errorf("%s is enumerated as a team-iterating job that must skip "+ + "is_test_cohort teams, but carries NONE of the cohort-guard tokens %v. "+ + "A refactor dropped the guard, or a new job was added without one — "+ + "add the AND-NOT-is_test_cohort filter / skipIfTestCohort call.", + fname, cohortGuardTokens) + } + }) + } +} + +// TestNotGuardedJobs_AreStillPresent pins the deliberately-unguarded files so a +// reviewer who renames/removes one is forced to revisit the scope decision. A +// missing file here means the documented boundary drifted from reality. +func TestNotGuardedJobs_AreStillPresent(t *testing.T) { + dir := jobsDir(t) + for fname, reason := range notGuardedJobFiles { + if reason == "" { + t.Errorf("%s is in notGuardedJobFiles with no reason — document why it is unguarded", fname) + } + if _, err := os.Stat(filepath.Join(dir, fname)); err != nil { + t.Errorf("notGuardedJobFiles names %s but it does not exist: %v — "+ + "the documented scope boundary drifted; revisit whether its replacement needs a guard", fname, err) + } + } +} diff --git a/internal/jobs/test_cohort_test.go b/internal/jobs/test_cohort_test.go new file mode 100644 index 0000000..bae7f36 --- /dev/null +++ b/internal/jobs/test_cohort_test.go @@ -0,0 +1,175 @@ +package jobs + +// test_cohort_test.go — unit coverage for the shared cohort skip-guard helpers +// (test_cohort.go). sqlmock-driven so it runs in the no-DB `make gate` / -short +// lane and covers every branch: flagged, not-flagged, row-vanished, and the +// DB-error fail-safe-for-real-teams path. + +import ( + "context" + "errors" + "strings" + "testing" + + sqlmock "github.com/DATA-DOG/go-sqlmock" + "github.com/google/uuid" +) + +func TestTestCohortNotExistsClause_FormatsAlias(t *testing.T) { + got := testCohortNotExistsClause("r.team_id") + // Must correlate on the caller-supplied team-id expression, use a fixed + // `tc` subselect alias (so it never collides with the caller's aliases), + // and key on is_test_cohort. + for _, want := range []string{"NOT EXISTS", "FROM teams tc", "tc.id = r.team_id", "tc.is_test_cohort"} { + if !strings.Contains(got, want) { + t.Errorf("clause %q missing %q", got, want) + } + } +} + +func TestIsTestCohort_Flagged(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("sqlmock: %v", err) + } + defer func() { _ = db.Close() }() + + id := uuid.New() + mock.ExpectQuery("SELECT is_test_cohort FROM teams WHERE id = "). + WithArgs(id). + WillReturnRows(sqlmock.NewRows([]string{"is_test_cohort"}).AddRow(true)) + + flagged, err := isTestCohort(context.Background(), db, id) + if err != nil { + t.Fatalf("isTestCohort: %v", err) + } + if !flagged { + t.Error("flagged = false, want true for a is_test_cohort=true team") + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unmet expectations: %v", err) + } +} + +func TestIsTestCohort_NotFlagged(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("sqlmock: %v", err) + } + defer func() { _ = db.Close() }() + + id := uuid.New() + mock.ExpectQuery("SELECT is_test_cohort FROM teams WHERE id = "). + WithArgs(id). + WillReturnRows(sqlmock.NewRows([]string{"is_test_cohort"}).AddRow(false)) + + flagged, err := isTestCohort(context.Background(), db, id) + if err != nil { + t.Fatalf("isTestCohort: %v", err) + } + if flagged { + t.Error("flagged = true, want false for a normal team") + } +} + +func TestIsTestCohort_RowVanished(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("sqlmock: %v", err) + } + defer func() { _ = db.Close() }() + + id := uuid.New() + // No rows → team deleted between candidate SELECT and this check. Not + // synthetic; (false, nil) so the caller's own row-vanished handling runs. + mock.ExpectQuery("SELECT is_test_cohort FROM teams WHERE id = "). + WithArgs(id). + WillReturnRows(sqlmock.NewRows([]string{"is_test_cohort"})) + + flagged, err := isTestCohort(context.Background(), db, id) + if err != nil { + t.Fatalf("isTestCohort on vanished row should be (false,nil), got err %v", err) + } + if flagged { + t.Error("flagged = true for a vanished team row, want false") + } +} + +func TestIsTestCohort_DBError(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("sqlmock: %v", err) + } + defer func() { _ = db.Close() }() + + id := uuid.New() + boom := errors.New("platform-db brownout") + mock.ExpectQuery("SELECT is_test_cohort FROM teams WHERE id = "). + WithArgs(id). + WillReturnError(boom) + + flagged, err := isTestCohort(context.Background(), db, id) + if err == nil { + t.Fatal("isTestCohort should propagate the DB error") + } + if !errors.Is(err, boom) { + t.Errorf("error chain lost the cause: %v", err) + } + if flagged { + t.Error("flagged = true on a DB error; must be false (fail-safe-for-real-teams)") + } +} + +func TestSkipIfTestCohort_SkipsFlagged(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("sqlmock: %v", err) + } + defer func() { _ = db.Close() }() + + id := uuid.New() + mock.ExpectQuery("SELECT is_test_cohort FROM teams WHERE id = "). + WithArgs(id). + WillReturnRows(sqlmock.NewRows([]string{"is_test_cohort"}).AddRow(true)) + + if !skipIfTestCohort(context.Background(), db, id, "jobs.unit_test") { + t.Error("skipIfTestCohort = false for a synthetic team, want true (must skip)") + } +} + +func TestSkipIfTestCohort_ProcessesNormal(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("sqlmock: %v", err) + } + defer func() { _ = db.Close() }() + + id := uuid.New() + mock.ExpectQuery("SELECT is_test_cohort FROM teams WHERE id = "). + WithArgs(id). + WillReturnRows(sqlmock.NewRows([]string{"is_test_cohort"}).AddRow(false)) + + if skipIfTestCohort(context.Background(), db, id, "jobs.unit_test") { + t.Error("skipIfTestCohort = true for a normal team, want false (must process)") + } +} + +// TestSkipIfTestCohort_DBErrorProcesses pins the fail-safe-for-real-teams +// contract: on a DB error the team is PROCESSED (returns false), never silently +// skipped — a platform-DB blip must not suppress a real customer's money/email. +func TestSkipIfTestCohort_DBErrorProcesses(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("sqlmock: %v", err) + } + defer func() { _ = db.Close() }() + + id := uuid.New() + mock.ExpectQuery("SELECT is_test_cohort FROM teams WHERE id = "). + WithArgs(id). + WillReturnError(errors.New("conn reset")) + + if skipIfTestCohort(context.Background(), db, id, "jobs.unit_test") { + t.Error("skipIfTestCohort = true on DB error; must be false so a real team is processed during a blip") + } +} diff --git a/internal/testhelpers/cohort.go b/internal/testhelpers/cohort.go new file mode 100644 index 0000000..f19ac5f --- /dev/null +++ b/internal/testhelpers/cohort.go @@ -0,0 +1,161 @@ +package testhelpers + +// cohort.go — seed/read helpers for the synthetic test-cohort skip-guard tests. +// +// These back the worker-side cohort skip-guards (test_cohort.go + +// docs/sessions/2026-06-04/TEST-ACCOUNTS-AND-NR-SYNTHETICS-PLAN.md §1.6): every +// team-iterating job that charges / churns / emails / quota-nudges a team must +// no-op for a team flagged teams.is_test_cohort=true. The integration tests +// seed one normal team and one cohort team, run the job, and assert the cohort +// team produced no artifact while the normal team did. + +import ( + "database/sql" + "testing" + "time" + + "github.com/google/uuid" +) + +// SetTeamTestCohort flips teams.is_test_cohort for a seeded team. Used to turn a +// freshly-seeded normal team into a synthetic test-cohort team so a guard test +// can assert the job skips it. +func SetTeamTestCohort(t *testing.T, db *sql.DB, teamID uuid.UUID, flagged bool) { + t.Helper() + if _, err := db.Exec( + `UPDATE teams SET is_test_cohort = $1 WHERE id = $2`, flagged, teamID, + ); err != nil { + tFatalf(t, "SetTeamTestCohort: %v", err) + } +} + +// SeedPrimaryUser inserts a primary (is_primary=true) user for a team — the +// recipient the expiry-warning and weekly-digest scans join on. Distinct from +// SeedUser, which leaves is_primary at its default (false). +// +// emailLocalPart is a hint; the actual address is made globally unique by +// embedding the new user id, so two tests passing the same hint cannot collide +// on the prod users_email_key unique constraint on a shared local DB. +func SeedPrimaryUser(t *testing.T, db *sql.DB, teamID uuid.UUID, emailLocalPart string) uuid.UUID { + t.Helper() + id := uuid.New() + email := emailLocalPart + "+" + id.String()[:12] + "@example.test" + if _, err := db.Exec( + `INSERT INTO users (id, team_id, email, is_primary) VALUES ($1, $2, $3, true)`, + id, teamID, email, + ); err != nil { + tFatalf(t, "SeedPrimaryUser: %v", err) + return uuid.Nil + } + t.Cleanup(func() { _, _ = db.Exec(`DELETE FROM users WHERE id = $1`, id) }) + return id +} + +// SeedExpiringFreeResource inserts an active free-tier resource whose +// expires_at falls inside the given duration from now — a candidate for the +// expiry-warning sweeps (expire_imminent / expiry_reminder). Returns the id. +func SeedExpiringFreeResource(t *testing.T, db *sql.DB, teamID uuid.UUID, resourceType string, expiresIn time.Duration) uuid.UUID { + t.Helper() + id := uuid.New() + token := uuid.New() + if _, err := db.Exec(` + INSERT INTO resources + (id, team_id, token, resource_type, tier, status, expires_at, reminders_sent, created_at) + VALUES ($1, $2, $3, $4, 'free', 'active', $5, 0, now()) + `, id, teamID, token, resourceType, time.Now().UTC().Add(expiresIn)); err != nil { + tFatalf(t, "SeedExpiringFreeResource: %v", err) + return uuid.Nil + } + t.Cleanup(func() { _, _ = db.Exec(`DELETE FROM resources WHERE id = $1`, id) }) + return id +} + +// SeedOverQuotaResource inserts an active resource whose storage_bytes exceeds +// the given limit — a candidate for the quota-wall nudge / suspend sweep. tier +// drives which plan limit the scan compares against. Returns the id. +func SeedOverQuotaResource(t *testing.T, db *sql.DB, teamID uuid.UUID, resourceType, tier string, storageBytes int64) uuid.UUID { + t.Helper() + id := uuid.New() + token := uuid.New() + if _, err := db.Exec(` + INSERT INTO resources + (id, team_id, token, resource_type, tier, status, storage_bytes, name, created_at) + VALUES ($1, $2, $3, $4, $5, 'active', $6, $7, now()) + `, id, teamID, token, resourceType, tier, storageBytes, "itest-quota-"+id.String()[:8]); err != nil { + tFatalf(t, "SeedOverQuotaResource: %v", err) + return uuid.Nil + } + t.Cleanup(func() { _, _ = db.Exec(`DELETE FROM resources WHERE id = $1`, id) }) + return id +} + +// SeedAbandonedCheckout inserts a pending_checkouts row old enough to be an +// abandoned-checkout candidate (created beyond the grace window, unresolved, +// un-notified) for the given team. Returns the subscription_id. +// +// created_at is set FAR in the past (10 years) so the row reliably sorts within +// the checkout reconciler's `ORDER BY created_at ASC LIMIT 200` batch even on a +// shared local DB that already carries hundreds of accumulated unresolved +// checkouts — otherwise a freshly "1h ago" row could fall outside the +// oldest-200 window and the test would flake. +func SeedAbandonedCheckout(t *testing.T, db *sql.DB, teamID uuid.UUID, email string) string { + t.Helper() + subID := "sub_ckab_" + uuid.New().String()[:12] + if _, err := db.Exec(` + INSERT INTO pending_checkouts + (subscription_id, team_id, customer_email, plan_tier, created_at) + VALUES ($1, $2, $3, 'pro', now() - interval '3650 days') + `, subID, teamID, email); err != nil { + tFatalf(t, "SeedAbandonedCheckout: %v", err) + return "" + } + t.Cleanup(func() { _, _ = db.Exec(`DELETE FROM pending_checkouts WHERE subscription_id = $1`, subID) }) + return subID +} + +// SeedActiveGracePeriod inserts an 'active' payment_grace_periods row for the +// team. expiresIn > 0 makes it a reminder candidate (clock still running); +// expiresIn < 0 makes it a terminator candidate (clock elapsed). Returns id. +func SeedActiveGracePeriod(t *testing.T, db *sql.DB, teamID uuid.UUID, expiresIn time.Duration) uuid.UUID { + t.Helper() + id := uuid.New() + if _, err := db.Exec(` + INSERT INTO payment_grace_periods + (id, team_id, subscription_id, status, started_at, expires_at, reminders_sent) + VALUES ($1, $2, $3, 'active', now() - interval '1 day', $4, 0) + `, id, teamID, "sub_grace_"+id.String()[:12], time.Now().UTC().Add(expiresIn)); err != nil { + tFatalf(t, "SeedActiveGracePeriod: %v", err) + return uuid.Nil + } + t.Cleanup(func() { _, _ = db.Exec(`DELETE FROM payment_grace_periods WHERE id = $1`, id) }) + return id +} + +// GraceRemindersSent reads back payment_grace_periods.reminders_sent — the +// reminder-sweep assertion target (a cohort team's counter must stay 0). +func GraceRemindersSent(t *testing.T, db *sql.DB, id uuid.UUID) int { + t.Helper() + var n int + if err := db.QueryRow( + `SELECT reminders_sent FROM payment_grace_periods WHERE id = $1`, id, + ).Scan(&n); err != nil { + tFatalf(t, "GraceRemindersSent: %v", err) + return -1 + } + return n +} + +// CheckoutNotified reports whether a pending_checkouts row has been stamped +// failure_notified_at — the checkout-reconcile assertion target (a cohort +// team's checkout must never be notified). +func CheckoutNotified(t *testing.T, db *sql.DB, subscriptionID string) bool { + t.Helper() + var notified sql.NullTime + if err := db.QueryRow( + `SELECT failure_notified_at FROM pending_checkouts WHERE subscription_id = $1`, subscriptionID, + ).Scan(¬ified); err != nil { + tFatalf(t, "CheckoutNotified: %v", err) + return false + } + return notified.Valid +} diff --git a/internal/testhelpers/cohort_smoke_test.go b/internal/testhelpers/cohort_smoke_test.go new file mode 100644 index 0000000..54ea1ed --- /dev/null +++ b/internal/testhelpers/cohort_smoke_test.go @@ -0,0 +1,186 @@ +package testhelpers + +// cohort_smoke_test.go — in-package coverage for the Tier-2 (synthetic +// test-cohort skip-guard) harness extensions in cohort.go. +// +// WHY THIS EXISTS +// --------------- +// Same per-package coverage-attribution reason as testhelpers_smoke_test.go and +// billing_deletion_smoke_test.go: the Set*/Seed*/read helpers in cohort.go live +// in a non-_test.go file so the jobs-package cohort skip-guard integration tests +// can import them, but Go credits their line coverage to `internal/jobs` (the +// caller's package), not `internal/testhelpers`. Without an in-package test, +// every line of cohort.go reads as 0% in diff-cover and reds the +// 100%-patch-coverage gate (the exact failure on PR #90, identical to the PR #87 +// testhelpers.go and PR #89 billing_deletion.go failures). This file mirrors +// billing_deletion_smoke_test.go, the established convention for giving a +// test-harness package its own coverage. +// +// Each helper's t.Fatalf-equivalent failure arm routes through the package's +// tFatalf seam (the same seam testhelpers.go uses); the error arms are exercised +// by swapping that seam for a recording stub and driving the arm with a +// deliberately-closed DB (a test seam, not a behavioural change — real callers +// still get a genuine t.Fatalf via the default seam). +// +// GATING: the DB-backed test routes through SetupTestDB, which skips when no +// Postgres is reachable — so it skips cleanly on the no-DB workflows +// (deploy.yml `-short`, ci.yml `-race`) and runs against coverage.yml's postgres +// service (which exports TEST_DATABASE_URL and applies the api migrations these +// helpers' columns require, incl. mig 067 teams.is_test_cohort) + any developer +// DB with the migrated schema. + +import ( + "database/sql" + "testing" + "time" + + "github.com/google/uuid" +) + +// TestCohortErrorArms drives every fallible cohort helper against a CLOSED +// *sql.DB so each Exec/QueryRow fails — covering the tFatalf error arm (via the +// recording seam) of SetTeamTestCohort, SeedPrimaryUser, SeedExpiringFreeResource, +// SeedOverQuotaResource, SeedAbandonedCheckout, SeedActiveGracePeriod, +// GraceRemindersSent and CheckoutNotified. +func TestCohortErrorArms(t *testing.T) { + closed, err := sql.Open("postgres", "postgres://postgres@127.0.0.1:1/x?sslmode=disable") + if err != nil { + t.Fatalf("open placeholder db: %v", err) + } + _ = closed.Close() // every subsequent Exec/QueryRow now errors. + + fatals, _ := swapFatalSeams(t) + id := uuid.New() + + // SetTeamTestCohort returns nothing; the closed-DB Exec error fires tFatalf. + SetTeamTestCohort(t, closed, id, true) + + if got := SeedPrimaryUser(t, closed, id, "smoke"); got != uuid.Nil { + t.Fatalf("SeedPrimaryUser on closed db = %v, want Nil", got) + } + if got := SeedExpiringFreeResource(t, closed, id, "postgres", time.Hour); got != uuid.Nil { + t.Fatalf("SeedExpiringFreeResource on closed db = %v, want Nil", got) + } + if got := SeedOverQuotaResource(t, closed, id, "postgres", "free", 1<<20); got != uuid.Nil { + t.Fatalf("SeedOverQuotaResource on closed db = %v, want Nil", got) + } + if got := SeedAbandonedCheckout(t, closed, id, "x@example.com"); got != "" { + t.Fatalf("SeedAbandonedCheckout on closed db = %q, want \"\"", got) + } + if got := SeedActiveGracePeriod(t, closed, id, time.Hour); got != uuid.Nil { + t.Fatalf("SeedActiveGracePeriod on closed db = %v, want Nil", got) + } + if got := GraceRemindersSent(t, closed, id); got != -1 { + t.Fatalf("GraceRemindersSent on closed db = %d, want -1", got) + } + if got := CheckoutNotified(t, closed, "sub_x"); got { + t.Fatal("CheckoutNotified on closed db = true, want false") + } + + // 8 distinct fallible call paths each recorded at least one Fatalf. + if len(*fatals) < 8 { + t.Fatalf("expected >=8 recorded Fatalf arms against the closed DB, got %d: %v", len(*fatals), *fatals) + } +} + +// TestIntegration_CohortRoundTrip drives every DB-backed cohort helper against a +// real Postgres so the happy paths + every read-back branch carry real line +// coverage. Mirrors the jobs-package cohort skip-guard integration tests but +// exercises the harness in its OWN package so the coverage is attributed here. +func TestIntegration_CohortRoundTrip(t *testing.T) { + db, cleanup := SetupTestDB(t) + defer cleanup() + ensureSchema(t, db) // ensure teams/resources/users/pending_checkouts/grace tables exist (idempotent). + + team := SeedTeam(t, db, "free") + if team == uuid.Nil { + t.Fatal("SeedTeam returned nil uuid") + } + + // SetTeamTestCohort: flip on, then off — both arms run the Exec happy path. + SetTeamTestCohort(t, db, team, true) + if got := isTestCohort(t, db, team); !got { + t.Fatal("SetTeamTestCohort(true) did not set is_test_cohort") + } + SetTeamTestCohort(t, db, team, false) + if got := isTestCohort(t, db, team); got { + t.Fatal("SetTeamTestCohort(false) did not clear is_test_cohort") + } + + // SeedPrimaryUser: inserts an is_primary=true user with a globally-unique addr. + primary := SeedPrimaryUser(t, db, team, "cohort-primary") + if primary == uuid.Nil { + t.Fatal("SeedPrimaryUser returned nil uuid") + } + + // SeedExpiringFreeResource: an active free resource expiring soon. + expRes := SeedExpiringFreeResource(t, db, team, "postgres", 2*time.Hour) + if expRes == uuid.Nil { + t.Fatal("SeedExpiringFreeResource returned nil uuid") + } + + // SeedOverQuotaResource: an active resource over its tier limit. + quotaRes := SeedOverQuotaResource(t, db, team, "postgres", "free", 100<<20) + if quotaRes == uuid.Nil { + t.Fatal("SeedOverQuotaResource returned nil uuid") + } + + // SeedAbandonedCheckout + CheckoutNotified: a far-past unresolved checkout + // whose failure_notified_at starts NULL (the un-notified read-back arm). + subID := SeedAbandonedCheckout(t, db, team, "cohort-checkout@example.com") + if subID == "" { + t.Fatal("SeedAbandonedCheckout returned empty subscription id") + } + if CheckoutNotified(t, db, subID) { + t.Fatal("CheckoutNotified on a fresh abandoned checkout = true, want false") + } + // Stamp failure_notified_at then re-read — the notified read-back arm. + if _, err := db.Exec( + `UPDATE pending_checkouts SET failure_notified_at = now() WHERE subscription_id = $1`, subID, + ); err != nil { + t.Fatalf("stamp failure_notified_at: %v", err) + } + if !CheckoutNotified(t, db, subID) { + t.Fatal("CheckoutNotified after stamping failure_notified_at = false, want true") + } + + // SeedActiveGracePeriod (reminder candidate: expiresIn > 0) + GraceRemindersSent + // reads back the initial 0 counter (the cohort-skip assertion target). + grace := SeedActiveGracePeriod(t, db, team, 24*time.Hour) + if grace == uuid.Nil { + t.Fatal("SeedActiveGracePeriod returned nil uuid") + } + if n := GraceRemindersSent(t, db, grace); n != 0 { + t.Fatalf("GraceRemindersSent on a fresh grace period = %d, want 0", n) + } + // Terminator candidate: expiresIn < 0 (clock elapsed) — the other call shape. + // Seeded onto a SECOND team because the prod schema (full api migrations + // applied in coverage.yml) carries the uq_payment_grace_team_active partial + // unique index: at most one active grace period per team. A second active + // row on `team` would 23505 there even though the bare harness CREATE TABLE + // has no such constraint. + team2 := SeedTeam(t, db, "free") + if team2 == uuid.Nil { + t.Fatal("SeedTeam(team2) returned nil uuid") + } + graceElapsed := SeedActiveGracePeriod(t, db, team2, -24*time.Hour) + if graceElapsed == uuid.Nil { + t.Fatal("SeedActiveGracePeriod(elapsed) returned nil uuid") + } + if n := GraceRemindersSent(t, db, graceElapsed); n != 0 { + t.Fatalf("GraceRemindersSent on elapsed grace period = %d, want 0", n) + } +} + +// isTestCohort reads back teams.is_test_cohort for an assertion in the round-trip +// test. Local to the smoke test; not part of the shared harness surface. +func isTestCohort(t *testing.T, db *sql.DB, teamID uuid.UUID) bool { + t.Helper() + var flagged bool + if err := db.QueryRow( + `SELECT is_test_cohort FROM teams WHERE id = $1`, teamID, + ).Scan(&flagged); err != nil { + t.Fatalf("isTestCohort: %v", err) + } + return flagged +} diff --git a/internal/testhelpers/testhelpers.go b/internal/testhelpers/testhelpers.go index 6e11eaf..0541306 100644 --- a/internal/testhelpers/testhelpers.go +++ b/internal/testhelpers/testhelpers.go @@ -158,6 +158,11 @@ func ensureSchema(t *testing.T, db *sql.DB) { )`, `ALTER TABLE teams ADD COLUMN IF NOT EXISTS plan_tier TEXT NOT NULL DEFAULT 'hobby'`, `ALTER TABLE teams ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'active'`, + // is_test_cohort (api migration 067) — the synthetic-cohort skip-guard + // flag every team-iterating job filters on. Idempotent add so the harness + // works against a bare DB AND a fully api-migrated one. + `ALTER TABLE teams ADD COLUMN IF NOT EXISTS is_test_cohort BOOLEAN NOT NULL DEFAULT false`, + `ALTER TABLE teams ADD COLUMN IF NOT EXISTS stripe_customer_id TEXT`, // resources — the entitlement reconciler reads tier / applied_conn_limit. `CREATE TABLE IF NOT EXISTS resources ( @@ -174,6 +179,50 @@ func ensureSchema(t *testing.T, db *sql.DB) { )`, `ALTER TABLE resources ADD COLUMN IF NOT EXISTS applied_conn_limit INT`, `ALTER TABLE resources ADD COLUMN IF NOT EXISTS provider_resource_id TEXT`, + // Columns the cohort-guarded quota / expiry-warning scans project. + `ALTER TABLE resources ADD COLUMN IF NOT EXISTS storage_bytes BIGINT NOT NULL DEFAULT 0`, + `ALTER TABLE resources ADD COLUMN IF NOT EXISTS name TEXT`, + `ALTER TABLE resources ADD COLUMN IF NOT EXISTS reminders_sent INT NOT NULL DEFAULT 0`, + `ALTER TABLE resources ADD COLUMN IF NOT EXISTS last_reminder_at TIMESTAMPTZ`, + `ALTER TABLE resources ADD COLUMN IF NOT EXISTS expiry_reminded_at TIMESTAMPTZ`, + `ALTER TABLE resources ADD COLUMN IF NOT EXISTS key_prefix TEXT`, + + // users — the expiry-warning + weekly-digest scans join the team's + // primary user for the recipient address. + `CREATE TABLE IF NOT EXISTS users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + team_id UUID REFERENCES teams(id) ON DELETE CASCADE, + email TEXT NOT NULL, + is_primary BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() + )`, + `ALTER TABLE users ADD COLUMN IF NOT EXISTS is_primary BOOLEAN NOT NULL DEFAULT false`, + + // pending_checkouts — the checkout reconciler + billing orphan sweep scan + // this; both carry team_id for the cohort guard. + `CREATE TABLE IF NOT EXISTS pending_checkouts ( + subscription_id TEXT PRIMARY KEY, + team_id UUID REFERENCES teams(id) ON DELETE CASCADE, + customer_email TEXT, + plan_tier TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + resolved_at TIMESTAMPTZ, + failure_notified_at TIMESTAMPTZ + )`, + + // payment_grace_periods — the dunning reminder + terminator scan this; + // both carry team_id for the cohort guard. + `CREATE TABLE IF NOT EXISTS payment_grace_periods ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + team_id UUID REFERENCES teams(id) ON DELETE CASCADE, + subscription_id TEXT, + status TEXT NOT NULL DEFAULT 'active', + started_at TIMESTAMPTZ NOT NULL DEFAULT now(), + expires_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_reminder_at TIMESTAMPTZ, + reminders_sent INT NOT NULL DEFAULT 0, + terminated_at TIMESTAMPTZ + )`, // deployments — the status reconciler + failure autopsy round-trip here. `CREATE TABLE IF NOT EXISTS deployments (