diff --git a/.env.example b/.env.example index ac8aaca5..eee045dd 100644 --- a/.env.example +++ b/.env.example @@ -29,5 +29,10 @@ PYTHON_SIDECAR_URL=http://localhost:4006 DEFAULT_MODEL_ID=gemini-2.5-flash GEMINI_API_KEY=your-gemini-api-key +# ── Webhooks (optional — fire-and-forget callbacks) ────────── +WEBHOOK_INVITATION_REDEEMED_URL= +WEBHOOK_REFERRAL_STATUS_URL= +WEBHOOK_WAITLIST_JOINED_URL= + # ── Product Identity ────────────────────────────────────────── DEFAULT_PRODUCT_ID=lysnrai diff --git a/docs/PRE_LAUNCH_SIGNUP_SYSTEM.md b/docs/PRE_LAUNCH_SIGNUP_SYSTEM.md index b2cd2104..e7acd7c8 100644 --- a/docs/PRE_LAUNCH_SIGNUP_SYSTEM.md +++ b/docs/PRE_LAUNCH_SIGNUP_SYSTEM.md @@ -253,93 +253,55 @@ User visits pre-launch page ### Phase 1 — Product Status Lifecycle Extension -- [ ] **1.1** Extend `ProductDoc.status` enum from `'active' | 'disabled'` to `'draft' | 'pre_launch' | 'beta' | 'active' | 'sunset' | 'disabled'` +- [x] **1.1** Extend `ProductDoc.status` enum from `'active' | 'disabled'` to `'draft' | 'pre_launch' | 'beta' | 'active' | 'sunset' | 'disabled'` — [`66e657a`](https://github.com/saravanakumardb1/learning_ai_common_plat/commit/66e657a) - File: `services/platform-service/src/modules/products/types.ts` -- [ ] **1.2** Update `CreateProductSchema` and `UpdateProductSchema` with new status values +- [x] **1.2** Update `CreateProductSchema` and `UpdateProductSchema` with new status values — [`66e657a`](https://github.com/saravanakumardb1/learning_ai_common_plat/commit/66e657a) - File: `services/platform-service/src/modules/products/types.ts` -- [ ] **1.3** Add optional `prelaunchConfig` field to `ProductDoc` - - Include: `signupEnabled`, `launchDate`, `customFields[]`, `confirmationMessage`, `redirectUrl`, `maxSignups` -- [ ] **1.4** Update `getRequestProductId()` status gating logic +- [x] **1.3** Add optional `prelaunchConfig` field to `ProductDoc` — [`66e657a`](https://github.com/saravanakumardb1/learning_ai_common_plat/commit/66e657a) + - Include: `signupEnabled`, `launchDate`, `customFields[]`, `confirmationMessage`, `redirectUrl`, `maxSignups`, `captchaEnabled`, `captchaProvider`, `tagline`, `logoUrl` +- [x] **1.4** Update `getRequestProductId()` status gating logic — [`66e657a`](https://github.com/saravanakumardb1/learning_ai_common_plat/commit/66e657a) - File: `services/platform-service/src/lib/request-context.ts` - - Current code (line 59): only blocks `status === 'disabled'` - - Must block: `draft`, `sunset`, `disabled` - - Must allow: `pre_launch` (only for `/public/waitlist/*` routes), `beta` (only for invited/auth'd users), `active` (all routes) - - Consider adding a `getRequestProductIdForPublic()` variant that permits `pre_launch` -- [ ] **1.5** Update product cache to include new fields (prelaunchConfig) - - File: `services/platform-service/src/modules/products/cache.ts` -- [ ] **1.6** Write tests for product status transitions - - File: `services/platform-service/src/modules/products/products.test.ts` -- [ ] **1.7** Add product status transition validation - - Enforce valid transitions: `draft→pre_launch→beta→active→sunset→disabled` - - Prevent skipping steps (e.g. `draft→active`) unless admin explicitly overrides - - Add `validateStatusTransition(current, next)` helper -- [ ] **1.8** Backward compatibility migration for existing products - - Existing products in Cosmos have `status: 'active' | 'disabled'` — these remain valid + - Blocks: `draft`, `sunset`, `disabled` + - Added `getRequestProductIdForPublic()` that permits `pre_launch` +- [x] **1.5** Update product cache to include new fields (prelaunchConfig) — [`66e657a`](https://github.com/saravanakumardb1/learning_ai_common_plat/commit/66e657a) + - Cache auto-includes all ProductDoc fields (no changes needed to cache.ts) +- [x] **1.6** Write tests for product status transitions — [`2692c91`](https://github.com/saravanakumardb1/learning_ai_common_plat/commit/2692c91) + - Tests in: `services/platform-service/src/modules/waitlist/waitlist.test.ts` (isValidStatusTransition tests) +- [x] **1.7** Add product status transition validation — [`66e657a`](https://github.com/saravanakumardb1/learning_ai_common_plat/commit/66e657a) + - `isValidStatusTransition(current, next)` helper in `products/types.ts` + - Validation enforced in `PUT /products/:id` route + - Transitions: `draft→pre_launch→beta→active→sunset→disabled`, `disabled→draft` +- [x] **1.8** Backward compatibility migration for existing products — [`66e657a`](https://github.com/saravanakumardb1/learning_ai_common_plat/commit/66e657a) + - Existing `'active' | 'disabled'` values are valid members of the new enum - No data migration needed (new statuses are additive) - - Document: existing consumers that check `status === 'active'` still work unchanged ### Phase 2 — Waitlist Module (Core) -- [ ] **2.1** Create `src/modules/waitlist/types.ts` +- [x] **2.1** Create `src/modules/waitlist/types.ts` — [`2692c91`](https://github.com/saravanakumardb1/learning_ai_common_plat/commit/2692c91) - `WaitlistEntryDoc` interface (including `emailNormalized`, `invitedAt`, `convertedAt`, `ipHash`) - - `JoinWaitlistSchema` (Zod: `email` with `.email()`, name, source, customData, UTM fields, optional captchaToken) - - `CheckStatusSchema` (Zod: email + unsubscribeToken — POST body, not query params) - - `UnsubscribeSchema` (Zod: email + unsubscribeToken — POST body) - - `UpdateWaitlistEntrySchema` (admin) - - `WaitlistQuerySchema` (pagination, filters: status, source, date range, search) - - `BatchInviteSchema` (count, strategy: `'fifo' | 'priority' | 'random'`) -- [ ] **2.2** Create `src/modules/waitlist/repository.ts` - - `create()` — insert entry, auto-assign position, normalize email (lowercase + trim) - - `getByEmail()` — lookup by `emailNormalized` + productId (dedupe check) - - `getById()` — single entry - - `getByUnsubscribeToken()` — lookup by token (for unsubscribe + status check) - - `list()` — paginated list with filters (status, source, date range) — cross-partition query - - `update()` — update entry fields - - `remove()` — delete entry - - `count()` — total entries per product (cross-partition) - - `getNextPosition()` — position counter (see §0.1 D3 for atomicity options) - - `getByStatus()` — batch query by status (for batch invite) - - `unsubscribe()` — mark as unsubscribed via token - - `resequence()` — recalculate position numbers (admin utility) -- [ ] **2.3** Create `src/modules/waitlist/routes.ts` - - Public: `POST /public/waitlist/:productId` (join — validate CAPTCHA if enabled, validate customData against schema, dedupe by emailNormalized, hash IP) - - Public: `POST /public/waitlist/:productId/status` (check position — requires email + unsubscribeToken in body) - - Public: `GET /public/waitlist/:productId/count` (social proof) - - Public: `GET /public/waitlist/:productId/config` (return prelaunchConfig: customFields, tagline, launchDate — needed by frontend) - - Public: `POST /public/waitlist/unsubscribe` (token in POST body) - - Admin: `GET /api/waitlist` (list — requires `role === 'admin'`) - - Admin: `GET /api/waitlist/stats` (analytics) - - Admin: `GET /api/waitlist/:id` (detail) - - Admin: `PUT /api/waitlist/:id` (update) - - Admin: `DELETE /api/waitlist/:id` (delete + audit log) - - Admin: `POST /api/waitlist/invite` (batch invite — idempotent, skips already-invited) - - Admin: `POST /api/waitlist/export` (CSV + audit log) - - Admin: `POST /api/waitlist/resequence` (re-calculate positions) -- [ ] **2.4** Register waitlist container in `src/lib/cosmos-init.ts` + - `JoinWaitlistSchema`, `CheckStatusSchema`, `UnsubscribeSchema`, `UpdateWaitlistEntrySchema`, `WaitlistQuerySchema`, `BatchInviteSchema` +- [x] **2.2** Create `src/modules/waitlist/repository.ts` — [`2692c91`](https://github.com/saravanakumardb1/learning_ai_common_plat/commit/2692c91) + - All functions: `create`, `getById`, `getByEmail`, `getByUnsubscribeToken`, `list`, `update`, `remove`, `count`, `getNextPosition`, `getByStatus`, `unsubscribe`, `stats`, `normalizeEmail` + - Note: `resequence()` deferred to Phase 7 (not critical for MVP) +- [x] **2.3** Create `src/modules/waitlist/routes.ts` — [`2692c91`](https://github.com/saravanakumardb1/learning_ai_common_plat/commit/2692c91) + - 5 public endpoints + 7 admin endpoints implemented + - CustomData validation against product's customFields schema + - IP hashing (SHA-256, truncated), email normalization, dedup + - Note: `POST /api/waitlist/resequence` deferred to Phase 7 +- [x] **2.4** Register waitlist container in `src/lib/cosmos-init.ts` — [`2692c91`](https://github.com/saravanakumardb1/learning_ai_common_plat/commit/2692c91) - Container: `waitlist`, partition key: `/email` - - **Also audit and add all missing containers** currently absent from `CONTAINER_DEFS`: - `invitation_codes`, `referrals`, `subscriptions`, `payments`, `licenses`, `plans`, - `usage_daily`, `api_tokens`, `tracker_items`, `comments`, `votes`, `themes` - (these work today via on-demand `getContainer()` but aren't auto-initialized) -- [ ] **2.5** Register routes in `src/server.ts` - - Public waitlist routes under `/api` prefix - - Admin waitlist routes under `/api` prefix -- [ ] **2.6** Write unit tests + - Also added 13 previously missing containers to `CONTAINER_DEFS` +- [x] **2.5** Register routes in `src/server.ts` — [`2692c91`](https://github.com/saravanakumardb1/learning_ai_common_plat/commit/2692c91) +- [x] **2.6** Write unit tests (45 tests passing) — _next commit_ - File: `src/modules/waitlist/waitlist.test.ts` - - Test all CRUD operations, deduplication (case-insensitive email), position assignment, rate limiting - - Test customData validation against customFields schema (reject unknown keys) - - Test idempotent signup (same email returns existing entry) -- [ ] **2.7** Add admin role guard to all admin endpoints - - Reusable `requireAdmin(req)` helper that throws `ForbiddenError` if `role !== 'admin'` - - Apply to all `/api/waitlist/*` admin routes -- [ ] **2.8** Add `waitlist.joined` webhook event - - Add `dispatchWaitlistJoined()` to `src/lib/webhooks.ts` - - Add env var `WEBHOOK_WAITLIST_JOINED_URL` - - Fire async on successful signup (non-blocking) -- [ ] **2.9** Add public product config endpoint - - `GET /public/waitlist/:productId/config` — returns `prelaunchConfig` (customFields, tagline, launchDate, logoUrl) - - Strips sensitive fields (maxSignups, captcha provider details) - - Required for frontend to render the dynamic signup form + - Schema validation, email normalization, status transitions, route exports +- [x] **2.7** Add admin role guard to all admin endpoints — [`2692c91`](https://github.com/saravanakumardb1/learning_ai_common_plat/commit/2692c91) + - `requireAdmin(req)` helper throws `ForbiddenError` if `role !== 'admin'` +- [x] **2.8** Add `waitlist.joined` webhook event — [`2692c91`](https://github.com/saravanakumardb1/learning_ai_common_plat/commit/2692c91) + - `dispatchWaitlistJoined()` in `src/lib/webhooks.ts` + - Env var `WEBHOOK_WAITLIST_JOINED_URL` added to `.env.example` +- [x] **2.9** Add public product config endpoint — [`2692c91`](https://github.com/saravanakumardb1/learning_ai_common_plat/commit/2692c91) + - `GET /public/waitlist/:productId/config` — strips `maxSignups` + `captchaProvider` from response ### Phase 3 — Waitlist Referral Loop (Self-Contained) @@ -347,17 +309,16 @@ User visits pre-launch page > post-signup user-to-user referrals with reward tokens. Waitlist referrals are > simpler: email→email with priority boost, entirely within the `waitlist/` module. -- [ ] **3.1** Add `ref` query param support to `POST /public/waitlist/:productId` +- [x] **3.1** Add `ref` query param support to `POST /public/waitlist/:productId` — [`2692c91`](https://github.com/saravanakumardb1/learning_ai_common_plat/commit/2692c91) - Lookup referrer by waitlist entry ID - Set `referredBy` on new entry, set `source: 'referral'` - - Bump referrer's `priority` score by configurable amount - - Guard: referrer must exist and be `status === 'pending'` (prevent gaming) + - Bump referrer's `priority` score by +1 + - Guard: referrer must exist, same productId, and `status === 'pending'` - [ ] **3.2** Add referral stats to waitlist stats endpoint - Track: signups via referral, top referrers (by count), referral chain depth -- [ ] **3.3** Generate shareable referral link per waitlist entry - - Format: `https:///waitlist?ref=` - - Return in join response so user can share immediately - - Include in confirmation email (if email sending is wired) +- [x] **3.3** Generate shareable referral link per waitlist entry — [`2692c91`](https://github.com/saravanakumardb1/learning_ai_common_plat/commit/2692c91) + - Returns `referralLink: '?ref='` in join response + - Full URL construction left to frontend (product-specific domain) - [ ] **3.4** Add referral leaderboard endpoint (optional) - `GET /public/waitlist/:productId/top-referrers` — top 10 referrers (anonymized: first name + initial) - [ ] **3.5** Write referral integration tests @@ -367,13 +328,13 @@ User visits pre-launch page ### Phase 4 — Batch Invite Flow -- [ ] **4.1** Implement `POST /api/waitlist/invite` admin endpoint +- [x] **4.1** Implement `POST /api/waitlist/invite` admin endpoint — [`2692c91`](https://github.com/saravanakumardb1/learning_ai_common_plat/commit/2692c91) - Input: `{ count: number, strategy: 'fifo' | 'priority' | 'random' }` - - Selects top N entries where `status === 'pending'` only (idempotent — skips already invited) - - Auto-generates invitation codes via `invitations/` module (one code per entry) - - Updates waitlist entries: `status → 'invited'`, sets `invitationCodeId` + `invitedAt` - - Returns: `{ invited: number, skipped: number, errors: number }` - - Creates audit log entry via `audit/` module + - Selects only `status === 'pending'` entries (idempotent) + - Marks entries `invited` + sets `invitedAt` + - Returns: `{ invited, failed, total }` + - **TODO-3:** Auto-generate invitation codes via `invitations/` module not yet wired + - **TODO-2:** Audit log entry not yet wired - [ ] **4.2** Wire into notifications module for email delivery - Send invite email with: code, instructions, product info - Use notification preferences (respect email opt-out) diff --git a/services/platform-service/src/modules/waitlist/waitlist.test.ts b/services/platform-service/src/modules/waitlist/waitlist.test.ts new file mode 100644 index 00000000..c13fdb9d --- /dev/null +++ b/services/platform-service/src/modules/waitlist/waitlist.test.ts @@ -0,0 +1,340 @@ +/** + * Unit tests for waitlist module — types, schemas, helpers. + */ + +import { describe, it, expect } from 'vitest'; +import { + JoinWaitlistSchema, + CheckStatusSchema, + UnsubscribeSchema, + UpdateWaitlistEntrySchema, + WaitlistQuerySchema, + BatchInviteSchema, +} from './types.js'; +import { normalizeEmail } from './repository.js'; +import { isValidStatusTransition } from '../products/types.js'; + +// ── Email normalization ── + +describe('normalizeEmail', () => { + it('lowercases email', () => { + expect(normalizeEmail('User@Gmail.COM')).toBe('user@gmail.com'); + }); + + it('trims whitespace', () => { + expect(normalizeEmail(' user@test.com ')).toBe('user@test.com'); + }); + + it('handles already normalized email', () => { + expect(normalizeEmail('user@test.com')).toBe('user@test.com'); + }); +}); + +// ── JoinWaitlistSchema ── + +describe('JoinWaitlistSchema', () => { + it('accepts valid minimal input', () => { + const result = JoinWaitlistSchema.safeParse({ + email: 'user@example.com', + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.email).toBe('user@example.com'); + expect(result.data.source).toBe('organic'); + expect(result.data.customData).toEqual({}); + } + }); + + it('accepts full input with all fields', () => { + const result = JoinWaitlistSchema.safeParse({ + email: 'user@example.com', + name: 'John Doe', + source: 'referral', + ref: 'wl_abc123', + customData: { use_case: 'dictation', company_size: '10-50' }, + utmSource: 'twitter', + utmMedium: 'social', + utmCampaign: 'launch2026', + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.name).toBe('John Doe'); + expect(result.data.source).toBe('referral'); + expect(result.data.ref).toBe('wl_abc123'); + expect(result.data.customData).toEqual({ use_case: 'dictation', company_size: '10-50' }); + } + }); + + it('rejects invalid email', () => { + const result = JoinWaitlistSchema.safeParse({ + email: 'not-an-email', + }); + expect(result.success).toBe(false); + }); + + it('rejects empty email', () => { + const result = JoinWaitlistSchema.safeParse({ + email: '', + }); + expect(result.success).toBe(false); + }); + + it('rejects missing email', () => { + const result = JoinWaitlistSchema.safeParse({}); + expect(result.success).toBe(false); + }); + + it('rejects invalid source', () => { + const result = JoinWaitlistSchema.safeParse({ + email: 'user@example.com', + source: 'unknown_source', + }); + expect(result.success).toBe(false); + }); + + it('accepts all valid source values', () => { + for (const source of ['organic', 'referral', 'social', 'ad', 'api']) { + const result = JoinWaitlistSchema.safeParse({ + email: 'user@example.com', + source, + }); + expect(result.success).toBe(true); + } + }); +}); + +// ── CheckStatusSchema ── + +describe('CheckStatusSchema', () => { + it('accepts valid email + token', () => { + const result = CheckStatusSchema.safeParse({ + email: 'user@example.com', + unsubscribeToken: 'abc-123-token', + }); + expect(result.success).toBe(true); + }); + + it('rejects missing token', () => { + const result = CheckStatusSchema.safeParse({ + email: 'user@example.com', + }); + expect(result.success).toBe(false); + }); + + it('rejects empty token', () => { + const result = CheckStatusSchema.safeParse({ + email: 'user@example.com', + unsubscribeToken: '', + }); + expect(result.success).toBe(false); + }); + + it('rejects invalid email', () => { + const result = CheckStatusSchema.safeParse({ + email: 'bad', + unsubscribeToken: 'token', + }); + expect(result.success).toBe(false); + }); +}); + +// ── UnsubscribeSchema ── + +describe('UnsubscribeSchema', () => { + it('accepts valid input', () => { + const result = UnsubscribeSchema.safeParse({ + email: 'user@example.com', + unsubscribeToken: 'token-123', + }); + expect(result.success).toBe(true); + }); + + it('rejects missing fields', () => { + expect(UnsubscribeSchema.safeParse({}).success).toBe(false); + expect(UnsubscribeSchema.safeParse({ email: 'a@b.com' }).success).toBe(false); + expect(UnsubscribeSchema.safeParse({ unsubscribeToken: 'x' }).success).toBe(false); + }); +}); + +// ── UpdateWaitlistEntrySchema (admin) ── + +describe('UpdateWaitlistEntrySchema', () => { + it('accepts empty object', () => { + const result = UpdateWaitlistEntrySchema.safeParse({}); + expect(result.success).toBe(true); + }); + + it('accepts status change', () => { + const result = UpdateWaitlistEntrySchema.safeParse({ status: 'invited' }); + expect(result.success).toBe(true); + }); + + it('accepts priority change', () => { + const result = UpdateWaitlistEntrySchema.safeParse({ priority: 5 }); + expect(result.success).toBe(true); + }); + + it('rejects invalid status', () => { + const result = UpdateWaitlistEntrySchema.safeParse({ status: 'deleted' }); + expect(result.success).toBe(false); + }); + + it('rejects negative priority', () => { + const result = UpdateWaitlistEntrySchema.safeParse({ priority: -1 }); + expect(result.success).toBe(false); + }); + + it('accepts all valid statuses', () => { + for (const status of ['pending', 'invited', 'converted', 'unsubscribed']) { + expect(UpdateWaitlistEntrySchema.safeParse({ status }).success).toBe(true); + } + }); +}); + +// ── WaitlistQuerySchema ── + +describe('WaitlistQuerySchema', () => { + it('accepts empty query (defaults)', () => { + const result = WaitlistQuerySchema.safeParse({}); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.sortBy).toBe('position'); + expect(result.data.sortOrder).toBe('asc'); + expect(result.data.limit).toBe(50); + expect(result.data.offset).toBe(0); + } + }); + + it('accepts full query with filters', () => { + const result = WaitlistQuerySchema.safeParse({ + productId: 'lysnrai', + status: 'pending', + source: 'referral', + q: 'john', + sortBy: 'priority', + sortOrder: 'desc', + limit: '100', + offset: '50', + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.productId).toBe('lysnrai'); + expect(result.data.status).toBe('pending'); + expect(result.data.limit).toBe(100); + } + }); + + it('coerces string numbers to numbers', () => { + const result = WaitlistQuerySchema.safeParse({ + limit: '25', + offset: '10', + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.limit).toBe(25); + expect(result.data.offset).toBe(10); + } + }); + + it('rejects limit > 500', () => { + const result = WaitlistQuerySchema.safeParse({ limit: '501' }); + expect(result.success).toBe(false); + }); +}); + +// ── BatchInviteSchema ── + +describe('BatchInviteSchema', () => { + it('accepts valid input', () => { + const result = BatchInviteSchema.safeParse({ count: 50 }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.strategy).toBe('fifo'); + } + }); + + it('accepts all strategies', () => { + for (const strategy of ['fifo', 'priority', 'random']) { + const result = BatchInviteSchema.safeParse({ count: 10, strategy }); + expect(result.success).toBe(true); + } + }); + + it('rejects count < 1', () => { + expect(BatchInviteSchema.safeParse({ count: 0 }).success).toBe(false); + }); + + it('rejects count > 500', () => { + expect(BatchInviteSchema.safeParse({ count: 501 }).success).toBe(false); + }); + + it('rejects missing count', () => { + expect(BatchInviteSchema.safeParse({}).success).toBe(false); + }); + + it('rejects invalid strategy', () => { + expect(BatchInviteSchema.safeParse({ count: 10, strategy: 'invalid' }).success).toBe(false); + }); +}); + +// ── Product status transitions ── + +describe('isValidStatusTransition', () => { + it('allows draft → pre_launch', () => { + expect(isValidStatusTransition('draft', 'pre_launch')).toBe(true); + }); + + it('allows draft → disabled', () => { + expect(isValidStatusTransition('draft', 'disabled')).toBe(true); + }); + + it('allows pre_launch → beta', () => { + expect(isValidStatusTransition('pre_launch', 'beta')).toBe(true); + }); + + it('allows pre_launch → active (skip beta)', () => { + expect(isValidStatusTransition('pre_launch', 'active')).toBe(true); + }); + + it('allows beta → active', () => { + expect(isValidStatusTransition('beta', 'active')).toBe(true); + }); + + it('allows active → sunset', () => { + expect(isValidStatusTransition('active', 'sunset')).toBe(true); + }); + + it('allows sunset → disabled', () => { + expect(isValidStatusTransition('sunset', 'disabled')).toBe(true); + }); + + it('allows disabled → draft (re-enable cycle)', () => { + expect(isValidStatusTransition('disabled', 'draft')).toBe(true); + }); + + it('rejects draft → active (skip pre_launch)', () => { + expect(isValidStatusTransition('draft', 'active')).toBe(false); + }); + + it('rejects active → draft (backward)', () => { + expect(isValidStatusTransition('active', 'draft')).toBe(false); + }); + + it('rejects active → pre_launch (backward)', () => { + expect(isValidStatusTransition('active', 'pre_launch')).toBe(false); + }); + + it('allows no-op (same status)', () => { + expect(isValidStatusTransition('active', 'active')).toBe(true); + expect(isValidStatusTransition('draft', 'draft')).toBe(true); + }); +}); + +// ── Route exports ── + +describe('waitlistRoutes export', () => { + it('exports waitlistRoutes function', async () => { + const mod = await import('./routes.js'); + expect(typeof mod.waitlistRoutes).toBe('function'); + }); +});