test(waitlist): add 45 unit tests + update doc checkboxes with commit links
- 45 tests: schema validation, email normalization, status transitions, route exports - Update Phase 1, 2, 3, 4 checkboxes in PRE_LAUNCH_SIGNUP_SYSTEM.md with commit SHAs - Add webhook env vars to .env.example
This commit is contained in:
parent
2692c918ce
commit
6a996cc11d
@ -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
|
||||
|
||||
@ -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://<product-url>/waitlist?ref=<entryId>`
|
||||
- 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=<entryId>'` 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)
|
||||
|
||||
340
services/platform-service/src/modules/waitlist/waitlist.test.ts
Normal file
340
services/platform-service/src/modules/waitlist/waitlist.test.ts
Normal file
@ -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');
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user