All files / routes/internal test-cleanup.ts

100% Statements 64/64
100% Branches 54/54
100% Functions 5/5
100% Lines 63/63

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278                            1x     1x 25x 25x 2x             23x         1x     13x 13x 6x               7x 7x     7x           7x 7x 7x 7x 7x 7x     7x   3x       3x     3x       3x     3x       3x     3x       3x       7x       7x   13x                           1x 3x 3x 1x     2x 2x                                 2x 1x               1x                                                                               1x 7x 7x 3x               4x 4x 4x 4x           4x       4x     4x     4x     4x     4x     4x     4x     4x             4x       4x     4x     4x     4x       4x                                          
// Test Cleanup - Delete test data created by E2E tests
// ONLY available in non-production environments
 
import { and, desc, eq, isNull, like, sql } from "drizzle-orm";
import { Hono } from "hono";
import { getDb } from "../../db";
import * as schema from "../../db/schema";
import { error, success } from "../../lib/response";
import { REJECTED_SUBMISSION_PROSPECT_ID } from "../../services/rrm/submission.service";
 
type Env = {
	Bindings: CloudflareBindings;
};
 
const testCleanup = new Hono<Env>();
 
// Guard: block in production
testCleanup.use("*", async (c, next) => {
	const env = c.env.ENVIRONMENT || "local";
	if (env === "production") {
		return error(
			c,
			"FORBIDDEN",
			"Test cleanup is not available in production",
			403,
		);
	}
	await next();
});
 
// DELETE /test-cleanup?emailPrefix=e2e-
// Deletes users, accounts, sessions, roles, and invitations matching the email prefix
testCleanup.delete("/", async (c) => {
	// Allowlist the prefix: it becomes a LIKE pattern, so `%`/`_` would turn
	// "delete my test users" into "delete every user".
	const emailPrefix = c.req.query("emailPrefix");
	if (!emailPrefix || !/^[A-Za-z0-9.+@-]{8,}$/.test(emailPrefix)) {
		return error(
			c,
			"BAD_REQUEST",
			"emailPrefix query param required (min 8 chars, letters/digits/.+@- only; no _ or %)",
			400,
		);
	}
 
	const db = getDb(c.env.DB);
	const emailPattern = `${emailPrefix}%`;
 
	// Find users matching prefix
	const users = await db
		.select({ id: schema.users.id, email: schema.users.email })
		.from(schema.users)
		.where(like(schema.users.email, emailPattern))
		.all();
 
	const userIds = users.map((u) => u.id);
	let deletedUsers = 0;
	let deletedRoles = 0;
	let deletedInvitations = 0;
	let deletedSessions = 0;
	let deletedAccounts = 0;
 
	// Delete related data for each user
	for (const userId of userIds) {
		// Delete sessions
		const sessionResult = await db
			.delete(schema.sessions)
			.where(eq(schema.sessions.userId, userId))
			.run();
		deletedSessions += sessionResult.meta.changes ?? 0;
 
		// Delete accounts
		const accountResult = await db
			.delete(schema.accounts)
			.where(eq(schema.accounts.userId, userId))
			.run();
		deletedAccounts += accountResult.meta.changes ?? 0;
 
		// Delete user_tenant_roles
		const roleResult = await db
			.delete(schema.userTenantRoles)
			.where(eq(schema.userTenantRoles.userId, userId))
			.run();
		deletedRoles += roleResult.meta.changes ?? 0;
 
		// Delete user
		const userResult = await db
			.delete(schema.users)
			.where(eq(schema.users.id, userId))
			.run();
		deletedUsers += userResult.meta.changes ?? 0;
	}
 
	// Delete invitations matching email prefix
	const invitationResult = await db
		.delete(schema.teamInvitations)
		.where(like(schema.teamInvitations.email, emailPattern))
		.run();
	deletedInvitations += invitationResult.meta.changes ?? 0;
 
	return success(c, {
		message: "Test cleanup complete",
		deleted: {
			users: deletedUsers,
			sessions: deletedSessions,
			accounts: deletedAccounts,
			roles: deletedRoles,
			invitations: deletedInvitations,
		},
	});
});
 
// GET /test-cleanup/invitation-token?email=...
// Look up the most recent pending invitation token for an email (for E2E test link navigation)
testCleanup.get("/invitation-token", async (c) => {
	const email = c.req.query("email");
	if (!email) {
		return error(c, "BAD_REQUEST", "email query param required", 400);
	}
 
	const db = getDb(c.env.DB);
	const invitation = await db
		.select({
			token: schema.teamInvitations.token,
			proId: schema.teamInvitations.proId,
			role: schema.teamInvitations.role,
			expiresAt: schema.teamInvitations.expiresAt,
		})
		.from(schema.teamInvitations)
		.where(
			and(
				eq(schema.teamInvitations.email, email.toLowerCase().trim()),
				isNull(schema.teamInvitations.acceptedAt),
			),
		)
		.orderBy(desc(schema.teamInvitations.dateCreated))
		.get();
 
	if (!invitation) {
		return error(
			c,
			"NOT_FOUND",
			"No pending invitation found for this email",
			404,
		);
	}
 
	return success(c, { token: invitation.token });
});
 
// DELETE /test-cleanup/rrm?phonePrefix=<digits, min 5>
//
// RRM (F-21) test data — loose-ends review §3.6: a QA/review pass leaves
// prospects like "Review Agent" / "Ravi Referrer" behind with no cleanup
// path.
//
// D1 HAS NO FOREIGN KEYS, so nothing here cascades — every child table has to
// be named. The first version of this handler named four, and the outbound
// ladder writes two more: a deleted prospect left `rrm_sequence_runs` and
// `rrm_scheduled_steps` behind, and the 5-minute scheduler tick kept picking
// those up against a prospect row that no longer existed. Nothing on the
// partner side was cleaned at all, so a rehearsal that created a partner,
// referrals, earnings and a payout could not be torn down.
//
// Deleted, children before parents, for every prospect whose `phone_norm`
// starts with the prefix and every partner whose `phone_norm` does:
//
//   prospect side  rrm_scheduled_steps (via their runs) · rrm_sequence_runs ·
//                  rrm_visits · wa_messages (the RRM sends, matched on
//                  `prospect_id` only — the shared `wa_conversations` row is
//                  keyed on a phone number and is NOT ours to delete) ·
//                  rrm_submissions · rrm_tasks · rrm_prospect_events ·
//                  rrm_prospects, plus any `rrm_submissions` row filed under
//                  the `rejected` pseudo-prospect matching the same prefix —
//                  those never had a prospect row to hang off.
//   partner side   referral_events (via their referrals) · referrals ·
//                  rrm_earnings · rrm_payouts · partners.
//
// NOT deleted: `rrm_suppression`. It is keyed on a phone number rather than on
// a prospect or a partner, and its whole point is surviving erasure and
// re-import — a rehearsal that opts a test number out leaves the number
// suppressed, and un-suppressing it is a deliberate act, not cleanup.
//
// Bulk `DELETE ... WHERE prospect_id IN (SELECT id FROM rrm_prospects WHERE
// phone_norm LIKE ?)` rather than fetching ids and using them in an
// `inArray`: an unbounded number of matching test prospects would otherwise
// risk D1's 100-bound-parameter ceiling (CLAUDE.md's D1 100-bind-limit note).
testCleanup.delete("/rrm", async (c) => {
	const phonePrefix = c.req.query("phonePrefix");
	if (!phonePrefix || !/^\d{5,}$/.test(phonePrefix)) {
		return error(
			c,
			"BAD_REQUEST",
			"phonePrefix query param required (digits only, min 5)",
			400,
		);
	}
 
	const db = getDb(c.env.DB);
	const pattern = `${phonePrefix}%`;
	const matchingProspects = sql`(SELECT id FROM rrm_prospects WHERE phone_norm LIKE ${pattern})`;
	const matchingPartners = sql`(SELECT id FROM partners WHERE phone_norm LIKE ${pattern})`;
 
	// ── Prospect side ────────────────────────────────────────────────────────
	// Everything keyed on a prospect id has to go BEFORE `rrm_prospects`: the
	// subquery above reads that table, so deleting it first would leave every
	// child orphaned and uncleanable.
	const scheduledSteps = await db.run(sql`
		DELETE FROM rrm_scheduled_steps
		WHERE run_id IN (SELECT id FROM rrm_sequence_runs WHERE prospect_id IN ${matchingProspects})
	`);
	const sequenceRuns = await db.run(sql`
		DELETE FROM rrm_sequence_runs WHERE prospect_id IN ${matchingProspects}
	`);
	const visits = await db.run(sql`
		DELETE FROM rrm_visits WHERE prospect_id IN ${matchingProspects}
	`);
	const waMessages = await db.run(sql`
		DELETE FROM wa_messages WHERE prospect_id IN ${matchingProspects}
	`);
	const submissions = await db.run(sql`
		DELETE FROM rrm_submissions WHERE prospect_id IN ${matchingProspects}
	`);
	const tasks = await db.run(sql`
		DELETE FROM rrm_tasks WHERE prospect_id IN ${matchingProspects}
	`);
	const events = await db.run(sql`
		DELETE FROM rrm_prospect_events WHERE prospect_id IN ${matchingProspects}
	`);
	const prospects = await db.run(sql`
		DELETE FROM rrm_prospects WHERE phone_norm LIKE ${pattern}
	`);
	const rejectedSubmissions = await db.run(sql`
		DELETE FROM rrm_submissions
		WHERE prospect_id = ${REJECTED_SUBMISSION_PROSPECT_ID} AND phone_norm LIKE ${pattern}
	`);
 
	// ── Partner side ─────────────────────────────────────────────────────────
	// Same rule: `partners` last, because every subquery above it reads it.
	const referralEvents = await db.run(sql`
		DELETE FROM referral_events
		WHERE referral_id IN (SELECT id FROM referrals WHERE partner_id IN ${matchingPartners})
	`);
	const referrals = await db.run(sql`
		DELETE FROM referrals WHERE partner_id IN ${matchingPartners}
	`);
	const earnings = await db.run(sql`
		DELETE FROM rrm_earnings WHERE partner_id IN ${matchingPartners}
	`);
	const payouts = await db.run(sql`
		DELETE FROM rrm_payouts WHERE partner_id IN ${matchingPartners}
	`);
	const partners = await db.run(sql`
		DELETE FROM partners WHERE phone_norm LIKE ${pattern}
	`);
 
	return success(c, {
		deleted: {
			scheduledSteps: scheduledSteps.meta.changes ?? 0,
			sequenceRuns: sequenceRuns.meta.changes ?? 0,
			visits: visits.meta.changes ?? 0,
			waMessages: waMessages.meta.changes ?? 0,
			submissions: submissions.meta.changes ?? 0,
			tasks: tasks.meta.changes ?? 0,
			events: events.meta.changes ?? 0,
			prospects: prospects.meta.changes ?? 0,
			rejectedSubmissions: rejectedSubmissions.meta.changes ?? 0,
			referralEvents: referralEvents.meta.changes ?? 0,
			referrals: referrals.meta.changes ?? 0,
			earnings: earnings.meta.changes ?? 0,
			payouts: payouts.meta.changes ?? 0,
			partners: partners.meta.changes ?? 0,
		},
	});
});
 
export default testCleanup;