From 5aec9150341db6a2e45ec31ed0f6fb94759c9d6b Mon Sep 17 00:00:00 2001 From: emil Date: Fri, 15 May 2026 22:00:19 +0300 Subject: [PATCH] =?UTF-8?q?feat(dm-dashboard):=20Wave=203=20=E2=80=94=20Bo?= =?UTF-8?q?osty=20verification,=20notes/initiative=20API=20routes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .sisyphus/boulder.json | 16 +- .../notepads/dm-dashboard-ai/learnings.md | 61 +++ .sisyphus/plans/dm-dashboard-ai.md | 10 +- src/env.d.ts | 1 + src/lib/boosty.ts | 162 ++++++++ src/pages/api/auth/callback/vk.ts | 3 + src/pages/api/auth/callback/yandex.ts | 3 + src/pages/api/dm/initiative.ts | 160 ++++++++ src/pages/api/dm/notes.ts | 148 +++++++ tests/boosty.test.ts | 299 +++++++++++++++ tests/initiative-api.test.ts | 362 ++++++++++++++++++ tests/notes-api.test.ts | 343 +++++++++++++++++ 12 files changed, 1555 insertions(+), 13 deletions(-) create mode 100644 src/lib/boosty.ts create mode 100644 src/pages/api/dm/initiative.ts create mode 100644 src/pages/api/dm/notes.ts create mode 100644 tests/boosty.test.ts create mode 100644 tests/initiative-api.test.ts create mode 100644 tests/notes-api.test.ts diff --git a/.sisyphus/boulder.json b/.sisyphus/boulder.json index 8a1663e..f8efe2e 100644 --- a/.sisyphus/boulder.json +++ b/.sisyphus/boulder.json @@ -394,7 +394,7 @@ "plan_name": "dm-dashboard-ai", "status": "active", "started_at": "2026-05-15T17:58:09.859Z", - "updated_at": "2026-05-15T18:36:00.096Z", + "updated_at": "2026-05-15T18:58:35.401Z", "session_ids": [ "ses_1d3921469ffeQox08ZjNWWJq7u" ], @@ -407,12 +407,12 @@ "task_key": "final-wave:f1", "task_label": "F1", "task_title": "**Plan Compliance Audit** — `oracle`", - "session_id": "ses_1d31971f9ffedffglMh0qVc3Mi", + "session_id": "ses_1d3077a47ffeDo8hOHOTlgJSpJ", "agent": "Sisyphus-Junior", - "category": "quick", + "category": "unspecified-high", "started_at": "2026-05-15T18:24:29.818Z", "status": "running", - "updated_at": "2026-05-15T18:36:00.097Z" + "updated_at": "2026-05-15T18:58:35.402Z" } } } @@ -420,7 +420,7 @@ "active_plan": "/home/emil/Desktop/Coding/AI/Randify.pro/.sisyphus/plans/dm-dashboard-ai.md", "started_at": "2026-05-15T17:58:09.859Z", "status": "active", - "updated_at": "2026-05-15T18:36:00.096Z", + "updated_at": "2026-05-15T18:58:35.401Z", "session_ids": [ "ses_1d3921469ffeQox08ZjNWWJq7u" ], @@ -433,12 +433,12 @@ "task_key": "final-wave:f1", "task_label": "F1", "task_title": "**Plan Compliance Audit** — `oracle`", - "session_id": "ses_1d31971f9ffedffglMh0qVc3Mi", + "session_id": "ses_1d3077a47ffeDo8hOHOTlgJSpJ", "agent": "Sisyphus-Junior", - "category": "quick", + "category": "unspecified-high", "started_at": "2026-05-15T18:24:29.818Z", "status": "running", - "updated_at": "2026-05-15T18:36:00.097Z" + "updated_at": "2026-05-15T18:58:35.402Z" } }, "agent": "atlas" diff --git a/.sisyphus/notepads/dm-dashboard-ai/learnings.md b/.sisyphus/notepads/dm-dashboard-ai/learnings.md index a540da2..d136cfd 100644 --- a/.sisyphus/notepads/dm-dashboard-ai/learnings.md +++ b/.sisyphus/notepads/dm-dashboard-ai/learnings.md @@ -130,3 +130,64 @@ Task: Generate and apply Drizzle migration for updated DB schema (Task 2 complet - `\dt` in `randify` DB: 7 tables confirmed - Down migration tested manually: all statements executed without error +--- + +# Notes and Initiative DB API Routes — Learnings + +## Date: 2026-05-15 + +### Patterns Applied + +1. **DM API route pattern (`src/pages/api/dm/notes.ts`, `src/pages/api/dm/initiative.ts`)** + - Both routes import `APIRoute` from `astro`, CORS helpers from `@/lib/cors`, and use `prerender = false`. + - Auth check is centralized in a `requireAuth()` helper that returns a `Response | null`. If `locals.user` is null, returns `jsonResponse({ error: "Unauthorized" }, 401, origin)`. + - All mutable methods (POST, PUT, DELETE) validate request body with Zod schemas before touching the DB. + - `PUT` and `DELETE` read the resource ID from `url.searchParams.get("id")`, not from the request body. This keeps the REST semantic clean. + +2. **Zod validation per route** + - `notes.ts`: `createNoteSchema` requires `title` (string, 1-255 chars), `content` is optional. `updateNoteSchema` makes both fields optional. + - `initiative.ts`: `createSessionSchema` requires `name` (string, 1-255 chars), `participants` is optional array of `participantSchema` objects. + - On validation failure, return `400` with `jsonResponse({ error: "Validation failed", details: parsed.error.format() }, 400, origin)`. + +3. **Cross-user access blocking** + - Every DB query that targets a single resource uses `and(eq(table.id, id), eq(table.userId, locals.user!.id))`. + - If `returning()` yields an empty array, the route returns `404` (not `403`). A 404 leaks less information about whether a resource exists at all. + - This was verified with explicit test cases for PUT and DELETE accessing another user's resource. + +4. **CORS consistency** + - Both routes export `OPTIONS` handler calling `handleCorsPreflight(origin)`. + - Every response uses `jsonResponse()` or `createCorsResponse()` to ensure CORS headers are present. + - Origin is read from `request.headers.get("origin")` at the start of each handler. + +5. **Mocking Drizzle ORM in unit tests** + - Mocking the entire `db` chain (`select().from().where().orderBy()`, `insert().values().returning()`, etc.) is fragile because `where` conditions are complex SQL objects. + - **Solution**: Mock `drizzle-orm`'s `eq` and `and` functions to return plain objects: + ```ts + vi.mock("drizzle-orm", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + eq: (column: { name: string }, value: unknown) => ({ type: "eq", column: column.name, value }), + and: (...conditions: unknown[]) => ({ type: "and", conditions }), + }; + }); + ``` + - The mock DB then implements a simple `evaluateCondition()` recursive evaluator that checks `type === "eq"` against item properties (converting snake_case column names to camelCase). + - This allows the mock to correctly filter by `userId` and `id`, making cross-user access tests reliable. + +6. **Test coverage** + - `tests/notes-api.test.ts`: 19 tests covering auth 401s, CORS OPTIONS, GET list, POST create, POST validation errors, PUT update, PUT 404 (missing + cross-user), DELETE remove, DELETE 404 (missing + cross-user), missing/invalid id params. + - `tests/initiative-api.test.ts`: 20 tests covering the same patterns plus participant validation (rejecting non-numeric `hp`, etc.). + +### Key Findings + +- **Do not import from `.d.ts` files in tests**: `import type { User } from "../src/env.d.ts"` fails with "File is not a module". Define mock objects inline with `as const` assertions instead. +- **Ensure mock user objects match the full `User` interface**: `env.d.ts` includes `boostyVerifiedAt: Date | null`. Missing it causes `TS2741` errors even in tests. +- **Avoid duplicate variable declarations after edits**: A partial `edit` replacement can leave behind duplicate `const` declarations (e.g., `mockUser2` defined twice), which TypeScript flags as redeclaration errors and vitest fails to transform. Always verify the file after edits. + +### Verification + +- `npx tsc --noEmit`: 0 errors +- `npx vitest run`: 271/271 tests passed (19 test files) +- `npx vitest run tests/notes-api.test.ts`: 19/19 passed +- `npx vitest run tests/initiative-api.test.ts`: 20/20 passed diff --git a/.sisyphus/plans/dm-dashboard-ai.md b/.sisyphus/plans/dm-dashboard-ai.md index 835349d..5076ad5 100644 --- a/.sisyphus/plans/dm-dashboard-ai.md +++ b/.sisyphus/plans/dm-dashboard-ai.md @@ -380,7 +380,7 @@ Max Concurrent: 6 (Wave 6) - Message: `feat(db): add AI and PRO tables` - Files: `src/db/schema.ts` -- [ ] **3. Generate & Apply Drizzle Migration** +- [x] **3. Generate & Apply Drizzle Migration** **What to do**: - Run `npm run db:generate` to create migration from updated schema. @@ -501,7 +501,7 @@ Max Concurrent: 6 (Wave 6) - Files: `src/lib/open5e/client.ts`, `src/lib/open5e/cache.ts` - Pre-commit: `tsc --noEmit` -- [ ] **5. Build Boosty Verification Service + Tier Assignment** +- [x] **5. Build Boosty Verification Service + Tier Assignment** **What to do**: - Create `src/lib/boosty.ts`: Service that verifies Boosty subscription token via Boosty API. @@ -762,7 +762,7 @@ Max Concurrent: 6 (Wave 6) - Files: `src/lib/rate-limit.ts` - Pre-commit: `npm test src/lib/rate-limit.test.ts` -- [ ] **9. Fix DmSidebar User Prop + Add Tier Badge Support** +- [x] **9. Fix DmSidebar User Prop + Add Tier Badge Support** **What to do**: - Fix `src/pages/dm/index.astro`: Pass `user={Astro.locals.user}` to ``. @@ -1030,7 +1030,7 @@ Max Concurrent: 6 (Wave 6) - Files: `src/pages/api/dm/translate.ts` - Pre-commit: `npm test src/pages/api/dm/translate.test.ts` -- [ ] **13. Add CORS Configuration for API Routes** +- [x] **13. Add CORS Configuration for API Routes** **What to do**: - Add CORS headers to all new DM API routes (`/api/dm/*`). @@ -1081,7 +1081,7 @@ Max Concurrent: 6 (Wave 6) **Commit**: YES (grouped with nearest API route commit) -- [ ] **14. Build Notes/Initiative DB API Routes** +- [x] **14. Build Notes/Initiative DB API Routes** **What to do**: - Create `src/pages/api/dm/notes.ts`: GET/POST/PUT/DELETE for notes. diff --git a/src/env.d.ts b/src/env.d.ts index 28b3b4a..6c2d915 100644 --- a/src/env.d.ts +++ b/src/env.d.ts @@ -8,6 +8,7 @@ interface User { name: string; avatar: string | null; tier: 'free' | 'pro'; + boostyVerifiedAt: Date | null; createdAt: Date; } diff --git a/src/lib/boosty.ts b/src/lib/boosty.ts new file mode 100644 index 0000000..b03c517 --- /dev/null +++ b/src/lib/boosty.ts @@ -0,0 +1,162 @@ +/** + * Boosty Subscription Verification Service + * + * Verifies Boosty subscription status via the unofficial Boosty API. + * Uses a 5-minute in-memory cache to avoid repeated API calls. + * + * Boosty API endpoint (reverse-engineered from unofficial clients): + * GET https://api.boosty.to/v1/blog/{blogName}/subscriber + * + * References: + * - boostylib (Python): client.subscriptions.verify_subscription(blog, user_id) + * - boosty_api_rs (Rust): get_user_subscriptions(), subscription verification + * - Base URL confirmed via logs: https://api.boosty.to + */ + +const BOOSTY_API_BASE = 'https://api.boosty.to'; +const CACHE_TTL_MS = 5 * 60 * 1000; +const REQUEST_TIMEOUT_MS = 5000; + +interface CacheEntry { + tier: 'pro' | 'free'; + verifiedAt: Date; + expiresAt: Date; +} + +/** In-memory cache keyed by Boosty token (never persisted or logged) */ +const cache = new Map(); + +/** Clears the in-memory cache. Exposed for testing only. */ +export function _clearBoostyCache(): void { + cache.clear(); +} + +/** + * Verifies a Boosty subscription token. + * + * On cache hit: returns cached tier instantly. + * On cache miss: calls Boosty API with 5-second timeout. + * On API failure: serves stale cache if available, otherwise returns 'free'. + * + * The Boosty token is never logged or included in error messages. + * + * @param token - Boosty access token (Bearer token) + * @returns 'pro' if active paid subscription, 'free' otherwise + */ +export async function verifyBoostySubscription(token: string): Promise<'pro' | 'free'> { + const now = new Date(); + const cached = cache.get(token); + + if (cached && cached.expiresAt > now) { + return cached.tier; + } + + const blogName = process.env.BOOSTY_BLOG_NAME; + if (!blogName) { + return cached?.tier ?? 'free'; + } + + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + + const res = await fetch( + `${BOOSTY_API_BASE}/v1/blog/${encodeURIComponent(blogName)}/subscriber`, + { + headers: { + Authorization: `Bearer ${token}`, + Accept: 'application/json', + }, + signal: controller.signal, + } + ); + + clearTimeout(timeoutId); + + if (!res.ok) { + return cached?.tier ?? 'free'; + } + + const data = (await res.json()) as Record; + const isPro = Boolean(data?.isSubscribed) && Boolean(data?.isPaid); + const tier = isPro ? 'pro' : 'free'; + + cache.set(token, { + tier, + verifiedAt: new Date(), + expiresAt: new Date(Date.now() + CACHE_TTL_MS), + }); + + return tier; + } catch { + // Never log the token or raw error details + return cached?.tier ?? 'free'; + } +} + +export async function updateUserTier(userId: number, tier: 'free' | 'pro'): Promise { + const { db } = await import('@/db/client'); + const { users } = await import('@/db/schema'); + const { eq } = await import('drizzle-orm'); + + await db + .update(users) + .set({ + tier, + boostyVerifiedAt: tier === 'pro' ? new Date() : null, + }) + .where(eq(users.id, userId)); +} + +/** + * Attempts server-side Boosty verification using a configured server token. + * + * This is a best-effort check that fetches the blog's subscriber list + * and matches by email. If no match is found or the API is unreachable, + * the user's tier is left unchanged. + * + * @param userId - Local user ID + * @param email - User's email address for matching + */ +export async function assignTierFromBoosty(userId: number, email: string | null): Promise { + const serverToken = process.env.BOOSTY_API_TOKEN; + const blogName = process.env.BOOSTY_BLOG_NAME; + + if (!serverToken || !blogName || !email) { + return; + } + + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + + const res = await fetch( + `${BOOSTY_API_BASE}/v1/blog/${encodeURIComponent(blogName)}/subscribers?limit=200`, + { + headers: { + Authorization: `Bearer ${serverToken}`, + Accept: 'application/json', + }, + signal: controller.signal, + } + ); + + clearTimeout(timeoutId); + + if (!res.ok) { + return; + } + + const data = (await res.json()) as { + subscribers?: Array<{ email?: string; isPaid?: boolean }>; + }; + const subscriber = data.subscribers?.find( + (s) => s.email?.toLowerCase() === email.toLowerCase() + ); + const tier = subscriber?.isPaid ? 'pro' : 'free'; + + await updateUserTier(userId, tier); + } catch { + // Silent fail — keep existing tier on any error + } +} diff --git a/src/pages/api/auth/callback/vk.ts b/src/pages/api/auth/callback/vk.ts index 6857485..2f6ba7c 100644 --- a/src/pages/api/auth/callback/vk.ts +++ b/src/pages/api/auth/callback/vk.ts @@ -10,6 +10,7 @@ import { COOKIE_NAME, } from '@/lib/auth/oauth'; import { authEnv } from '@/lib/auth/env'; +import { assignTierFromBoosty } from '@/lib/boosty'; export const prerender = false; @@ -136,6 +137,8 @@ export const GET: APIRoute = async ({ url, request }) => { user = inserted[0]; } + await assignTierFromBoosty(user.id, email).catch(() => {}); + const sessionToken = await createSessionToken(user.id); const expiresAt = new Date(); expiresAt.setDate(expiresAt.getDate() + 7); diff --git a/src/pages/api/auth/callback/yandex.ts b/src/pages/api/auth/callback/yandex.ts index 4684480..73a567e 100644 --- a/src/pages/api/auth/callback/yandex.ts +++ b/src/pages/api/auth/callback/yandex.ts @@ -10,6 +10,7 @@ import { COOKIE_NAME, } from '@/lib/auth/oauth'; import { authEnv } from '@/lib/auth/env'; +import { assignTierFromBoosty } from '@/lib/boosty'; export const prerender = false; @@ -102,6 +103,8 @@ export const GET: APIRoute = async ({ url, request }) => { console.log('[OAuth Yandex] DB user upserted:', { userId: user.id }); + await assignTierFromBoosty(user.id, email).catch(() => {}); + const sessionToken = await createSessionToken(user.id); console.log('[OAuth Yandex] Session token created:', sessionToken.slice(0, 10) + '...'); const expiresAt = new Date(); diff --git a/src/pages/api/dm/initiative.ts b/src/pages/api/dm/initiative.ts new file mode 100644 index 0000000..d1d4293 --- /dev/null +++ b/src/pages/api/dm/initiative.ts @@ -0,0 +1,160 @@ +import type { APIRoute } from "astro"; +import { jsonResponse, handleCorsPreflight } from "@/lib/cors"; +import { db } from "@/db/client"; +import { initiativeSessions } from "@/db/schema"; +import { eq, and } from "drizzle-orm"; +import { z } from "zod"; + +export const prerender = false; + +const participantSchema = z.object({ + id: z.string(), + name: z.string(), + initiative: z.number().optional(), + modifier: z.number().optional(), + hp: z.number().optional(), + maxHp: z.number().optional(), + ac: z.number().optional(), + isPlayer: z.boolean().optional(), + active: z.boolean().optional(), +}); + +const createSessionSchema = z.object({ + name: z.string().min(1).max(255), + participants: z.array(participantSchema).optional(), +}); + +const updateSessionSchema = z.object({ + name: z.string().min(1).max(255).optional(), + participants: z.array(participantSchema).optional(), +}); + +function getOrigin(request: Request): string | null { + return request.headers.get("origin"); +} + +function requireAuth(locals: App.Locals, origin: string | null): Response | null { + if (!locals.user) { + return jsonResponse({ error: "Unauthorized" }, 401, origin); + } + return null; +} + +export const GET: APIRoute = async ({ request, locals }) => { + const origin = getOrigin(request); + const authError = requireAuth(locals, origin); + if (authError) return authError; + + const sessions = await db + .select() + .from(initiativeSessions) + .where(eq(initiativeSessions.userId, locals.user!.id)) + .orderBy(initiativeSessions.updatedAt); + + return jsonResponse(sessions, 200, origin); +}; + +export const POST: APIRoute = async ({ request, locals }) => { + const origin = getOrigin(request); + const authError = requireAuth(locals, origin); + if (authError) return authError; + + let body: unknown; + try { + body = await request.json(); + } catch { + return jsonResponse({ error: "Invalid JSON" }, 400, origin); + } + + const parsed = createSessionSchema.safeParse(body); + if (!parsed.success) { + return jsonResponse({ error: "Validation failed", details: parsed.error.format() }, 400, origin); + } + + const result = await db + .insert(initiativeSessions) + .values({ + userId: locals.user!.id, + name: parsed.data.name, + participants: parsed.data.participants ?? [], + }) + .returning(); + + return jsonResponse(result[0], 201, origin); +}; + +export const PUT: APIRoute = async ({ request, locals }) => { + const origin = getOrigin(request); + const authError = requireAuth(locals, origin); + if (authError) return authError; + + const url = new URL(request.url); + const idParam = url.searchParams.get("id"); + if (!idParam) { + return jsonResponse({ error: "Missing id query parameter" }, 400, origin); + } + const id = parseInt(idParam, 10); + if (isNaN(id)) { + return jsonResponse({ error: "Invalid id" }, 400, origin); + } + + let body: unknown; + try { + body = await request.json(); + } catch { + return jsonResponse({ error: "Invalid JSON" }, 400, origin); + } + + const parsed = updateSessionSchema.safeParse(body); + if (!parsed.success) { + return jsonResponse({ error: "Validation failed", details: parsed.error.format() }, 400, origin); + } + + const updateData: Partial<{ name: string; participants: unknown[] }> = {}; + if (parsed.data.name !== undefined) updateData.name = parsed.data.name; + if (parsed.data.participants !== undefined) updateData.participants = parsed.data.participants; + + const result = await db + .update(initiativeSessions) + .set(updateData) + .where(and(eq(initiativeSessions.id, id), eq(initiativeSessions.userId, locals.user!.id))) + .returning(); + + if (result.length === 0) { + return jsonResponse({ error: "Not found" }, 404, origin); + } + + return jsonResponse(result[0], 200, origin); +}; + +export const DELETE: APIRoute = async ({ request, locals }) => { + const origin = getOrigin(request); + const authError = requireAuth(locals, origin); + if (authError) return authError; + + const url = new URL(request.url); + const idParam = url.searchParams.get("id"); + if (!idParam) { + return jsonResponse({ error: "Missing id query parameter" }, 400, origin); + } + const id = parseInt(idParam, 10); + if (isNaN(id)) { + return jsonResponse({ error: "Invalid id" }, 400, origin); + } + + const result = await db + .delete(initiativeSessions) + .where(and(eq(initiativeSessions.id, id), eq(initiativeSessions.userId, locals.user!.id))) + .returning(); + + if (result.length === 0) { + return jsonResponse({ error: "Not found" }, 404, origin); + } + + return jsonResponse({ success: true }, 200, origin); +}; + +export const OPTIONS: APIRoute = async ({ request }) => { + const origin = getOrigin(request); + return handleCorsPreflight(origin); +}; diff --git a/src/pages/api/dm/notes.ts b/src/pages/api/dm/notes.ts new file mode 100644 index 0000000..a7880a2 --- /dev/null +++ b/src/pages/api/dm/notes.ts @@ -0,0 +1,148 @@ +import type { APIRoute } from "astro"; +import { jsonResponse, handleCorsPreflight } from "@/lib/cors"; +import { db } from "@/db/client"; +import { notes } from "@/db/schema"; +import { eq, and } from "drizzle-orm"; +import { z } from "zod"; + +export const prerender = false; + +const createNoteSchema = z.object({ + title: z.string().min(1).max(255), + content: z.string().optional(), +}); + +const updateNoteSchema = z.object({ + title: z.string().min(1).max(255).optional(), + content: z.string().optional(), +}); + +function getOrigin(request: Request): string | null { + return request.headers.get("origin"); +} + +function requireAuth(locals: App.Locals, origin: string | null): Response | null { + if (!locals.user) { + return jsonResponse({ error: "Unauthorized" }, 401, origin); + } + return null; +} + +export const GET: APIRoute = async ({ request, locals }) => { + const origin = getOrigin(request); + const authError = requireAuth(locals, origin); + if (authError) return authError; + + const userNotes = await db + .select() + .from(notes) + .where(eq(notes.userId, locals.user!.id)) + .orderBy(notes.updatedAt); + + return jsonResponse(userNotes, 200, origin); +}; + +export const POST: APIRoute = async ({ request, locals }) => { + const origin = getOrigin(request); + const authError = requireAuth(locals, origin); + if (authError) return authError; + + let body: unknown; + try { + body = await request.json(); + } catch { + return jsonResponse({ error: "Invalid JSON" }, 400, origin); + } + + const parsed = createNoteSchema.safeParse(body); + if (!parsed.success) { + return jsonResponse({ error: "Validation failed", details: parsed.error.format() }, 400, origin); + } + + const result = await db + .insert(notes) + .values({ + userId: locals.user!.id, + title: parsed.data.title, + content: parsed.data.content ?? null, + }) + .returning(); + + return jsonResponse(result[0], 201, origin); +}; + +export const PUT: APIRoute = async ({ request, locals }) => { + const origin = getOrigin(request); + const authError = requireAuth(locals, origin); + if (authError) return authError; + + const url = new URL(request.url); + const idParam = url.searchParams.get("id"); + if (!idParam) { + return jsonResponse({ error: "Missing id query parameter" }, 400, origin); + } + const id = parseInt(idParam, 10); + if (isNaN(id)) { + return jsonResponse({ error: "Invalid id" }, 400, origin); + } + + let body: unknown; + try { + body = await request.json(); + } catch { + return jsonResponse({ error: "Invalid JSON" }, 400, origin); + } + + const parsed = updateNoteSchema.safeParse(body); + if (!parsed.success) { + return jsonResponse({ error: "Validation failed", details: parsed.error.format() }, 400, origin); + } + + const updateData: Partial<{ title: string; content: string | null }> = {}; + if (parsed.data.title !== undefined) updateData.title = parsed.data.title; + if (parsed.data.content !== undefined) updateData.content = parsed.data.content; + + const result = await db + .update(notes) + .set(updateData) + .where(and(eq(notes.id, id), eq(notes.userId, locals.user!.id))) + .returning(); + + if (result.length === 0) { + return jsonResponse({ error: "Not found" }, 404, origin); + } + + return jsonResponse(result[0], 200, origin); +}; + +export const DELETE: APIRoute = async ({ request, locals }) => { + const origin = getOrigin(request); + const authError = requireAuth(locals, origin); + if (authError) return authError; + + const url = new URL(request.url); + const idParam = url.searchParams.get("id"); + if (!idParam) { + return jsonResponse({ error: "Missing id query parameter" }, 400, origin); + } + const id = parseInt(idParam, 10); + if (isNaN(id)) { + return jsonResponse({ error: "Invalid id" }, 400, origin); + } + + const result = await db + .delete(notes) + .where(and(eq(notes.id, id), eq(notes.userId, locals.user!.id))) + .returning(); + + if (result.length === 0) { + return jsonResponse({ error: "Not found" }, 404, origin); + } + + return jsonResponse({ success: true }, 200, origin); +}; + +export const OPTIONS: APIRoute = async ({ request }) => { + const origin = getOrigin(request); + return handleCorsPreflight(origin); +}; diff --git a/tests/boosty.test.ts b/tests/boosty.test.ts new file mode 100644 index 0000000..ee1e130 --- /dev/null +++ b/tests/boosty.test.ts @@ -0,0 +1,299 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +const dbUpdateMock = vi.fn(() => ({ set: vi.fn(() => ({ where: vi.fn(() => Promise.resolve()) })) })); + +vi.mock('@/db/client', () => ({ + db: { update: dbUpdateMock }, +})); + +vi.mock('@/db/schema', () => ({ + users: { id: { name: 'id' } }, +})); + +vi.mock('drizzle-orm', () => ({ + eq: vi.fn(() => true), +})); + +describe('Boosty Verification Service', () => { + beforeEach(() => { + vi.resetModules(); + vi.unstubAllEnvs(); + vi.useFakeTimers({ shouldAdvanceTime: true }); + dbUpdateMock.mockClear(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + async function importBoosty() { + const mod = await import('@/lib/boosty'); + return mod; + } + + it('returns free tier when BOOSTY_BLOG_NAME is not set', async () => { + const { verifyBoostySubscription } = await importBoosty(); + const tier = await verifyBoostySubscription('test-token'); + expect(tier).toBe('free'); + }); + + it('cache hit returns cached tier without API call', async () => { + vi.stubEnv('BOOSTY_BLOG_NAME', 'randify'); + + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ isSubscribed: true, isPaid: true }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ); + globalThis.fetch = fetchMock; + + const { verifyBoostySubscription } = await importBoosty(); + + const tier1 = await verifyBoostySubscription('token-a'); + expect(tier1).toBe('pro'); + expect(fetchMock).toHaveBeenCalledTimes(1); + + const tier2 = await verifyBoostySubscription('token-a'); + expect(tier2).toBe('pro'); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('cache miss fetches from API and returns pro for paid subscriber', async () => { + vi.stubEnv('BOOSTY_BLOG_NAME', 'randify'); + + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ isSubscribed: true, isPaid: true }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ); + globalThis.fetch = fetchMock; + + const { verifyBoostySubscription } = await importBoosty(); + const tier = await verifyBoostySubscription('token-b'); + + expect(tier).toBe('pro'); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith( + 'https://api.boosty.to/v1/blog/randify/subscriber', + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: 'Bearer token-b', + }), + signal: expect.any(AbortSignal), + }) + ); + }); + + it('returns free for non-paid subscriber', async () => { + vi.stubEnv('BOOSTY_BLOG_NAME', 'randify'); + + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ isSubscribed: true, isPaid: false }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ); + globalThis.fetch = fetchMock; + + const { verifyBoostySubscription } = await importBoosty(); + const tier = await verifyBoostySubscription('token-c'); + + expect(tier).toBe('free'); + }); + + it('returns free on API 404 without cached value', async () => { + vi.stubEnv('BOOSTY_BLOG_NAME', 'randify'); + + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ error: 'not_found' }), { status: 404 }) + ); + globalThis.fetch = fetchMock; + + const { verifyBoostySubscription } = await importBoosty(); + const tier = await verifyBoostySubscription('token-d'); + + expect(tier).toBe('free'); + }); + + it('serves stale cache when API fails after expiry', async () => { + vi.stubEnv('BOOSTY_BLOG_NAME', 'randify'); + + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + new Response(JSON.stringify({ isSubscribed: true, isPaid: true }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ) + .mockRejectedValueOnce(new Error('Network error')); + + globalThis.fetch = fetchMock; + + const { verifyBoostySubscription } = await importBoosty(); + + const tier1 = await verifyBoostySubscription('token-e'); + expect(tier1).toBe('pro'); + + vi.advanceTimersByTime(5 * 60 * 1000 + 1000); + + const tier2 = await verifyBoostySubscription('token-e'); + expect(tier2).toBe('pro'); + }); + + it('handles API timeout by returning free when no cache exists', async () => { + vi.stubEnv('BOOSTY_BLOG_NAME', 'randify'); + + const fetchMock = vi.fn().mockImplementation((_url, options) => { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + resolve( + new Response(JSON.stringify({ isSubscribed: true, isPaid: true }), { + status: 200, + }) + ); + }, 30000); + + options?.signal?.addEventListener('abort', () => { + clearTimeout(timer); + reject(new Error('Aborted')); + }); + }); + }); + + globalThis.fetch = fetchMock; + + const { verifyBoostySubscription } = await importBoosty(); + + const promise = verifyBoostySubscription('token-f'); + vi.advanceTimersByTime(6000); + const tier = await promise; + + expect(tier).toBe('free'); + }); + + it('never logs or exposes the Boosty token', async () => { + vi.stubEnv('BOOSTY_BLOG_NAME', 'randify'); + + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const fetchMock = vi.fn().mockRejectedValue(new Error('secret-token-leak-check')); + globalThis.fetch = fetchMock; + + const { verifyBoostySubscription } = await importBoosty(); + await verifyBoostySubscription('super-secret-boosty-token-xyz'); + + const allCalls = [ + ...consoleErrorSpy.mock.calls, + ...consoleLogSpy.mock.calls, + ...consoleWarnSpy.mock.calls, + ]; + + for (const call of allCalls) { + const message = call.join(' '); + expect(message).not.toContain('super-secret-boosty-token-xyz'); + } + + consoleErrorSpy.mockRestore(); + consoleLogSpy.mockRestore(); + consoleWarnSpy.mockRestore(); + }); + + it('updateUserTier updates tier and boostyVerifiedAt in DB', async () => { + const { updateUserTier } = await importBoosty(); + await updateUserTier(42, 'pro'); + + expect(dbUpdateMock).toHaveBeenCalledTimes(1); + const setCall = dbUpdateMock.mock.results[0].value.set; + expect(setCall).toHaveBeenCalledWith( + expect.objectContaining({ + tier: 'pro', + boostyVerifiedAt: expect.any(Date), + }) + ); + }); + + it('updateUserTier sets boostyVerifiedAt to null for free tier', async () => { + const { updateUserTier } = await importBoosty(); + await updateUserTier(42, 'free'); + + const setCall = dbUpdateMock.mock.results[0].value.set; + expect(setCall).toHaveBeenCalledWith( + expect.objectContaining({ + tier: 'free', + boostyVerifiedAt: null, + }) + ); + }); + + it('assignTierFromBoosty does nothing when env vars are missing', async () => { + const { assignTierFromBoosty } = await importBoosty(); + await assignTierFromBoosty(1, 'test@example.com'); + + expect(dbUpdateMock).not.toHaveBeenCalled(); + }); + + it('assignTierFromBoosty updates tier when subscriber is found and paid', async () => { + vi.stubEnv('BOOSTY_BLOG_NAME', 'randify'); + vi.stubEnv('BOOSTY_API_TOKEN', 'server-token'); + + const fetchMock = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + subscribers: [ + { email: 'other@example.com', isPaid: false }, + { email: 'test@example.com', isPaid: true }, + ], + }), + { status: 200 } + ) + ); + globalThis.fetch = fetchMock; + + const { assignTierFromBoosty } = await importBoosty(); + await assignTierFromBoosty(1, 'test@example.com'); + + expect(dbUpdateMock).toHaveBeenCalledTimes(1); + }); + + it('assignTierFromBoosty updates tier to free when subscriber is not paid', async () => { + vi.stubEnv('BOOSTY_BLOG_NAME', 'randify'); + vi.stubEnv('BOOSTY_API_TOKEN', 'server-token'); + + const fetchMock = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + subscribers: [{ email: 'test@example.com', isPaid: false }], + }), + { status: 200 } + ) + ); + globalThis.fetch = fetchMock; + + const { assignTierFromBoosty } = await importBoosty(); + await assignTierFromBoosty(1, 'test@example.com'); + + const setCall = dbUpdateMock.mock.results[0].value.set; + expect(setCall).toHaveBeenCalledWith( + expect.objectContaining({ tier: 'free' }) + ); + }); + + it('assignTierFromBoosty silently fails on API error', async () => { + vi.stubEnv('BOOSTY_BLOG_NAME', 'randify'); + vi.stubEnv('BOOSTY_API_TOKEN', 'server-token'); + + const fetchMock = vi.fn().mockRejectedValue(new Error('Network error')); + globalThis.fetch = fetchMock; + + const { assignTierFromBoosty } = await importBoosty(); + await expect(assignTierFromBoosty(1, 'test@example.com')).resolves.toBeUndefined(); + + expect(dbUpdateMock).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/initiative-api.test.ts b/tests/initiative-api.test.ts new file mode 100644 index 0000000..efa2e64 --- /dev/null +++ b/tests/initiative-api.test.ts @@ -0,0 +1,362 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const mockUser = { + id: 1, + vkId: null, + yandexId: null, + email: null, + name: "Test User", + avatar: null, + tier: "free" as const, + boostyVerifiedAt: null, + createdAt: new Date("2024-01-01"), +}; + +const mockUser2 = { + id: 2, + vkId: null, + yandexId: null, + email: null, + name: "Other User", + avatar: null, + tier: "free" as const, + boostyVerifiedAt: null, + createdAt: new Date("2024-01-01"), +}; + +function mockRequest( + url: string, + origin: string, + method = "GET", + body?: unknown +): Request { + return { + url, + method, + headers: { + get(name: string) { + if (name.toLowerCase() === "origin") return origin; + return null; + }, + }, + json: async () => body, + } as unknown as Request; +} + +function createMockDb() { + let sessionsStore: Array<{ + id: number; + userId: number; + name: string; + participants: unknown[]; + createdAt: Date; + updatedAt: Date; + }> = []; + let nextSessionId = 1; + + function evaluateCondition(item: typeof sessionsStore[0], condition: unknown): boolean { + if (!condition || typeof condition !== "object") return true; + const c = condition as Record; + if (c.type === "eq") { + const colName = (c.column as string).replace(/_([a-z])/g, (_, letter) => letter.toUpperCase()); + const itemValue = (item as Record)[colName]; + return itemValue === c.value; + } + if (c.type === "and") { + const conditions = c.conditions as unknown[]; + return conditions.every((sub) => evaluateCondition(item, sub)); + } + return true; + } + + return { + reset() { + sessionsStore = []; + nextSessionId = 1; + }, + select: vi.fn(() => ({ + from: vi.fn(() => ({ + where: vi.fn((condition: unknown) => ({ + orderBy: vi.fn(() => { + const filtered = sessionsStore.filter((item) => evaluateCondition(item, condition)); + return Promise.resolve(filtered.sort((a, b) => a.updatedAt.getTime() - b.updatedAt.getTime())); + }), + })), + orderBy: vi.fn(() => Promise.resolve(sessionsStore)), + })), + })), + insert: vi.fn(() => ({ + values: vi.fn((vals: { userId: number; name: string; participants: unknown[] }) => ({ + returning: vi.fn(() => { + const session = { + id: nextSessionId++, + ...vals, + createdAt: new Date(), + updatedAt: new Date(), + }; + sessionsStore.push(session); + return Promise.resolve([session]); + }), + })), + })), + update: vi.fn(() => ({ + set: vi.fn((vals: Partial<{ name: string; participants: unknown[] }>) => ({ + where: vi.fn((condition: unknown) => ({ + returning: vi.fn(() => { + const idx = sessionsStore.findIndex((item) => evaluateCondition(item, condition)); + if (idx !== -1) { + Object.assign(sessionsStore[idx], vals, { updatedAt: new Date() }); + return Promise.resolve([{ ...sessionsStore[idx] }]); + } + return Promise.resolve([]); + }), + })), + })), + })), + delete: vi.fn(() => ({ + where: vi.fn((condition: unknown) => ({ + returning: vi.fn(() => { + const idx = sessionsStore.findIndex((item) => evaluateCondition(item, condition)); + if (idx !== -1) { + const removed = sessionsStore.splice(idx, 1); + return Promise.resolve(removed); + } + return Promise.resolve([]); + }), + })), + })), + _store: sessionsStore, + _setStore(store: typeof sessionsStore) { + sessionsStore = store; + }, + }; +} + +let mockDb = createMockDb(); + +vi.mock("drizzle-orm", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + eq: (column: { name: string }, value: unknown) => ({ type: "eq", column: column.name, value }), + and: (...conditions: unknown[]) => ({ type: "and", conditions }), + }; +}); + +vi.mock("../src/db/client", () => ({ + db: mockDb, +})); + +describe("Initiative API", () => { + beforeEach(() => { + mockDb.reset(); + vi.clearAllMocks(); + }); + + it("GET returns 401 when unauthenticated", async () => { + const { GET } = await import("../src/pages/api/dm/initiative"); + const request = mockRequest("https://dm.randify.pro/api/dm/initiative", "https://randify.pro"); + const response = await GET!({ request, locals: { user: null }, url: new URL(request.url), ...({} as any) }); + expect(response.status).toBe(401); + const body = await response.json(); + expect(body.error).toBe("Unauthorized"); + }); + + it("POST returns 401 when unauthenticated", async () => { + const { POST } = await import("../src/pages/api/dm/initiative"); + const request = mockRequest("https://dm.randify.pro/api/dm/initiative", "https://randify.pro", "POST", { name: "Combat" }); + const response = await POST!({ request, locals: { user: null }, url: new URL(request.url), ...({} as any) }); + expect(response.status).toBe(401); + }); + + it("PUT returns 401 when unauthenticated", async () => { + const { PUT } = await import("../src/pages/api/dm/initiative"); + const request = mockRequest("https://dm.randify.pro/api/dm/initiative?id=1", "https://randify.pro", "PUT", { name: "Updated" }); + const response = await PUT!({ request, locals: { user: null }, url: new URL(request.url), ...({} as any) }); + expect(response.status).toBe(401); + }); + + it("DELETE returns 401 when unauthenticated", async () => { + const { DELETE } = await import("../src/pages/api/dm/initiative"); + const request = mockRequest("https://dm.randify.pro/api/dm/initiative?id=1", "https://randify.pro", "DELETE"); + const response = await DELETE!({ request, locals: { user: null }, url: new URL(request.url), ...({} as any) }); + expect(response.status).toBe(401); + }); + + it("OPTIONS returns 204 with CORS headers", async () => { + const { OPTIONS } = await import("../src/pages/api/dm/initiative"); + const request = mockRequest("https://dm.randify.pro/api/dm/initiative", "https://randify.pro", "OPTIONS"); + const response = await OPTIONS!({ request, locals: { user: null }, url: new URL(request.url), ...({} as any) }); + expect(response.status).toBe(204); + expect(response.headers.get("Access-Control-Allow-Origin")).toBe("https://randify.pro"); + }); + + it("GET returns user's initiative sessions", async () => { + const { GET } = await import("../src/pages/api/dm/initiative"); + mockDb._setStore([ + { id: 1, userId: 1, name: "Session 1", participants: [], createdAt: new Date(), updatedAt: new Date() }, + { id: 2, userId: 1, name: "Session 2", participants: [], createdAt: new Date(), updatedAt: new Date() }, + ]); + const request = mockRequest("https://dm.randify.pro/api/dm/initiative", "https://randify.pro"); + const response = await GET!({ request, locals: { user: mockUser }, url: new URL(request.url), ...({} as any) }); + expect(response.status).toBe(200); + const body = await response.json(); + expect(body).toHaveLength(2); + expect(body[0].name).toBe("Session 1"); + expect(body[1].name).toBe("Session 2"); + }); + + it("POST creates a new session", async () => { + const { POST } = await import("../src/pages/api/dm/initiative"); + const request = mockRequest("https://dm.randify.pro/api/dm/initiative", "https://randify.pro", "POST", { + name: "Boss Fight", + participants: [{ id: "p1", name: "Goblin", initiative: 15 }], + }); + const response = await POST!({ request, locals: { user: mockUser }, url: new URL(request.url), ...({} as any) }); + expect(response.status).toBe(201); + const body = await response.json(); + expect(body.name).toBe("Boss Fight"); + expect(body.participants).toHaveLength(1); + expect(body.userId).toBe(1); + expect(body.id).toBeDefined(); + }); + + it("POST rejects invalid body", async () => { + const { POST } = await import("../src/pages/api/dm/initiative"); + const request = mockRequest("https://dm.randify.pro/api/dm/initiative", "https://randify.pro", "POST", { name: "" }); + const response = await POST!({ request, locals: { user: mockUser }, url: new URL(request.url), ...({} as any) }); + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.error).toBe("Validation failed"); + }); + + it("POST rejects missing name", async () => { + const { POST } = await import("../src/pages/api/dm/initiative"); + const request = mockRequest("https://dm.randify.pro/api/dm/initiative", "https://randify.pro", "POST", { participants: [] }); + const response = await POST!({ request, locals: { user: mockUser }, url: new URL(request.url), ...({} as any) }); + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.error).toBe("Validation failed"); + }); + + it("POST rejects invalid participant", async () => { + const { POST } = await import("../src/pages/api/dm/initiative"); + const request = mockRequest("https://dm.randify.pro/api/dm/initiative", "https://randify.pro", "POST", { + name: "Combat", + participants: [{ id: "p1", name: "Goblin", hp: "not-a-number" }], + }); + const response = await POST!({ request, locals: { user: mockUser }, url: new URL(request.url), ...({} as any) }); + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.error).toBe("Validation failed"); + }); + + it("PUT updates a session", async () => { + const { PUT } = await import("../src/pages/api/dm/initiative"); + mockDb._setStore([ + { id: 1, userId: 1, name: "Old Session", participants: [], createdAt: new Date(), updatedAt: new Date() }, + ]); + const request = mockRequest("https://dm.randify.pro/api/dm/initiative?id=1", "https://randify.pro", "PUT", { + name: "Updated Session", + participants: [{ id: "p1", name: "Orc" }], + }); + const response = await PUT!({ request, locals: { user: mockUser }, url: new URL(request.url), ...({} as any) }); + expect(response.status).toBe(200); + const body = await response.json(); + expect(body.name).toBe("Updated Session"); + expect(body.participants).toHaveLength(1); + }); + + it("PUT returns 404 for non-existent session", async () => { + const { PUT } = await import("../src/pages/api/dm/initiative"); + mockDb._setStore([]); + const request = mockRequest("https://dm.randify.pro/api/dm/initiative?id=999", "https://randify.pro", "PUT", { name: "Updated" }); + const response = await PUT!({ request, locals: { user: mockUser }, url: new URL(request.url), ...({} as any) }); + expect(response.status).toBe(404); + const body = await response.json(); + expect(body.error).toBe("Not found"); + }); + + it("PUT returns 404 when accessing another user's session", async () => { + const { PUT } = await import("../src/pages/api/dm/initiative"); + mockDb._setStore([ + { id: 1, userId: 2, name: "Other Session", participants: [], createdAt: new Date(), updatedAt: new Date() }, + ]); + const request = mockRequest("https://dm.randify.pro/api/dm/initiative?id=1", "https://randify.pro", "PUT", { name: "Hacked" }); + const response = await PUT!({ request, locals: { user: mockUser }, url: new URL(request.url), ...({} as any) }); + expect(response.status).toBe(404); + const body = await response.json(); + expect(body.error).toBe("Not found"); + }); + + it("DELETE removes a session", async () => { + const { DELETE } = await import("../src/pages/api/dm/initiative"); + mockDb._setStore([ + { id: 1, userId: 1, name: "To delete", participants: [], createdAt: new Date(), updatedAt: new Date() }, + ]); + const request = mockRequest("https://dm.randify.pro/api/dm/initiative?id=1", "https://randify.pro", "DELETE"); + const response = await DELETE!({ request, locals: { user: mockUser }, url: new URL(request.url), ...({} as any) }); + expect(response.status).toBe(200); + const body = await response.json(); + expect(body.success).toBe(true); + }); + + it("DELETE returns 404 for non-existent session", async () => { + const { DELETE } = await import("../src/pages/api/dm/initiative"); + mockDb._setStore([]); + const request = mockRequest("https://dm.randify.pro/api/dm/initiative?id=999", "https://randify.pro", "DELETE"); + const response = await DELETE!({ request, locals: { user: mockUser }, url: new URL(request.url), ...({} as any) }); + expect(response.status).toBe(404); + const body = await response.json(); + expect(body.error).toBe("Not found"); + }); + + it("DELETE returns 404 when accessing another user's session", async () => { + const { DELETE } = await import("../src/pages/api/dm/initiative"); + mockDb._setStore([ + { id: 1, userId: 2, name: "Other Session", participants: [], createdAt: new Date(), updatedAt: new Date() }, + ]); + const request = mockRequest("https://dm.randify.pro/api/dm/initiative?id=1", "https://randify.pro", "DELETE"); + const response = await DELETE!({ request, locals: { user: mockUser }, url: new URL(request.url), ...({} as any) }); + expect(response.status).toBe(404); + const body = await response.json(); + expect(body.error).toBe("Not found"); + }); + + it("PUT returns 400 for missing id", async () => { + const { PUT } = await import("../src/pages/api/dm/initiative"); + const request = mockRequest("https://dm.randify.pro/api/dm/initiative", "https://randify.pro", "PUT", { name: "Updated" }); + const response = await PUT!({ request, locals: { user: mockUser }, url: new URL(request.url), ...({} as any) }); + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.error).toBe("Missing id query parameter"); + }); + + it("DELETE returns 400 for missing id", async () => { + const { DELETE } = await import("../src/pages/api/dm/initiative"); + const request = mockRequest("https://dm.randify.pro/api/dm/initiative", "https://randify.pro", "DELETE"); + const response = await DELETE!({ request, locals: { user: mockUser }, url: new URL(request.url), ...({} as any) }); + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.error).toBe("Missing id query parameter"); + }); + + it("PUT returns 400 for invalid id", async () => { + const { PUT } = await import("../src/pages/api/dm/initiative"); + const request = mockRequest("https://dm.randify.pro/api/dm/initiative?id=abc", "https://randify.pro", "PUT", { name: "Updated" }); + const response = await PUT!({ request, locals: { user: mockUser }, url: new URL(request.url), ...({} as any) }); + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.error).toBe("Invalid id"); + }); + + it("POST allows session without participants", async () => { + const { POST } = await import("../src/pages/api/dm/initiative"); + const request = mockRequest("https://dm.randify.pro/api/dm/initiative", "https://randify.pro", "POST", { name: "Empty Session" }); + const response = await POST!({ request, locals: { user: mockUser }, url: new URL(request.url), ...({} as any) }); + expect(response.status).toBe(201); + const body = await response.json(); + expect(body.name).toBe("Empty Session"); + expect(body.participants).toEqual([]); + }); +}); diff --git a/tests/notes-api.test.ts b/tests/notes-api.test.ts new file mode 100644 index 0000000..a197530 --- /dev/null +++ b/tests/notes-api.test.ts @@ -0,0 +1,343 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const mockUser = { + id: 1, + vkId: null, + yandexId: null, + email: null, + name: "Test User", + avatar: null, + tier: "free" as const, + boostyVerifiedAt: null, + createdAt: new Date("2024-01-01"), +}; + +const mockUser2 = { + id: 2, + vkId: null, + yandexId: null, + email: null, + name: "Other User", + avatar: null, + tier: "free" as const, + boostyVerifiedAt: null, + createdAt: new Date("2024-01-01"), +}; + +function mockRequest( + url: string, + origin: string, + method = "GET", + body?: unknown +): Request { + return { + url, + method, + headers: { + get(name: string) { + if (name.toLowerCase() === "origin") return origin; + return null; + }, + }, + json: async () => body, + } as unknown as Request; +} + +function createMockDb() { + let notesStore: Array<{ + id: number; + userId: number; + title: string; + content: string | null; + createdAt: Date; + updatedAt: Date; + }> = []; + let nextNoteId = 1; + + function evaluateCondition(item: typeof notesStore[0], condition: unknown): boolean { + if (!condition || typeof condition !== "object") return true; + const c = condition as Record; + if (c.type === "eq") { + const colName = (c.column as string).replace(/_([a-z])/g, (_, letter) => letter.toUpperCase()); + const itemValue = (item as Record)[colName]; + return itemValue === c.value; + } + if (c.type === "and") { + const conditions = c.conditions as unknown[]; + return conditions.every((sub) => evaluateCondition(item, sub)); + } + return true; + } + + return { + reset() { + notesStore = []; + nextNoteId = 1; + }, + select: vi.fn(() => ({ + from: vi.fn(() => ({ + where: vi.fn((condition: unknown) => ({ + orderBy: vi.fn(() => { + const filtered = notesStore.filter((item) => evaluateCondition(item, condition)); + return Promise.resolve(filtered.sort((a, b) => a.updatedAt.getTime() - b.updatedAt.getTime())); + }), + })), + orderBy: vi.fn(() => Promise.resolve(notesStore)), + })), + })), + insert: vi.fn(() => ({ + values: vi.fn((vals: { userId: number; title: string; content: string | null }) => ({ + returning: vi.fn(() => { + const note = { + id: nextNoteId++, + ...vals, + createdAt: new Date(), + updatedAt: new Date(), + }; + notesStore.push(note); + return Promise.resolve([note]); + }), + })), + })), + update: vi.fn(() => ({ + set: vi.fn((vals: Partial<{ title: string; content: string | null }>) => ({ + where: vi.fn((condition: unknown) => ({ + returning: vi.fn(() => { + const idx = notesStore.findIndex((item) => evaluateCondition(item, condition)); + if (idx !== -1) { + Object.assign(notesStore[idx], vals, { updatedAt: new Date() }); + return Promise.resolve([{ ...notesStore[idx] }]); + } + return Promise.resolve([]); + }), + })), + })), + })), + delete: vi.fn(() => ({ + where: vi.fn((condition: unknown) => ({ + returning: vi.fn(() => { + const idx = notesStore.findIndex((item) => evaluateCondition(item, condition)); + if (idx !== -1) { + const removed = notesStore.splice(idx, 1); + return Promise.resolve(removed); + } + return Promise.resolve([]); + }), + })), + })), + _store: notesStore, + _setStore(store: typeof notesStore) { + notesStore = store; + }, + }; +} + +let mockDb = createMockDb(); + +vi.mock("drizzle-orm", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + eq: (column: { name: string }, value: unknown) => ({ type: "eq", column: column.name, value }), + and: (...conditions: unknown[]) => ({ type: "and", conditions }), + }; +}); + +vi.mock("../src/db/client", () => ({ + db: mockDb, +})); + +describe("Notes API", () => { + beforeEach(() => { + mockDb.reset(); + vi.clearAllMocks(); + }); + + it("GET returns 401 when unauthenticated", async () => { + const { GET } = await import("../src/pages/api/dm/notes"); + const request = mockRequest("https://dm.randify.pro/api/dm/notes", "https://randify.pro"); + const response = await GET!({ request, locals: { user: null }, url: new URL(request.url), ...({} as any) }); + expect(response.status).toBe(401); + const body = await response.json(); + expect(body.error).toBe("Unauthorized"); + }); + + it("POST returns 401 when unauthenticated", async () => { + const { POST } = await import("../src/pages/api/dm/notes"); + const request = mockRequest("https://dm.randify.pro/api/dm/notes", "https://randify.pro", "POST", { title: "Test" }); + const response = await POST!({ request, locals: { user: null }, url: new URL(request.url), ...({} as any) }); + expect(response.status).toBe(401); + }); + + it("PUT returns 401 when unauthenticated", async () => { + const { PUT } = await import("../src/pages/api/dm/notes"); + const request = mockRequest("https://dm.randify.pro/api/dm/notes?id=1", "https://randify.pro", "PUT", { title: "Updated" }); + const response = await PUT!({ request, locals: { user: null }, url: new URL(request.url), ...({} as any) }); + expect(response.status).toBe(401); + }); + + it("DELETE returns 401 when unauthenticated", async () => { + const { DELETE } = await import("../src/pages/api/dm/notes"); + const request = mockRequest("https://dm.randify.pro/api/dm/notes?id=1", "https://randify.pro", "DELETE"); + const response = await DELETE!({ request, locals: { user: null }, url: new URL(request.url), ...({} as any) }); + expect(response.status).toBe(401); + }); + + it("OPTIONS returns 204 with CORS headers", async () => { + const { OPTIONS } = await import("../src/pages/api/dm/notes"); + const request = mockRequest("https://dm.randify.pro/api/dm/notes", "https://randify.pro", "OPTIONS"); + const response = await OPTIONS!({ request, locals: { user: null }, url: new URL(request.url), ...({} as any) }); + expect(response.status).toBe(204); + expect(response.headers.get("Access-Control-Allow-Origin")).toBe("https://randify.pro"); + }); + + it("GET returns user's notes", async () => { + const { GET } = await import("../src/pages/api/dm/notes"); + mockDb._setStore([ + { id: 1, userId: 1, title: "Note 1", content: "Content 1", createdAt: new Date(), updatedAt: new Date() }, + { id: 2, userId: 1, title: "Note 2", content: "Content 2", createdAt: new Date(), updatedAt: new Date() }, + ]); + const request = mockRequest("https://dm.randify.pro/api/dm/notes", "https://randify.pro"); + const response = await GET!({ request, locals: { user: mockUser }, url: new URL(request.url), ...({} as any) }); + expect(response.status).toBe(200); + const body = await response.json(); + expect(body).toHaveLength(2); + expect(body[0].title).toBe("Note 1"); + expect(body[1].title).toBe("Note 2"); + }); + + it("POST creates a new note", async () => { + const { POST } = await import("../src/pages/api/dm/notes"); + const request = mockRequest("https://dm.randify.pro/api/dm/notes", "https://randify.pro", "POST", { title: "New Note", content: "Body" }); + const response = await POST!({ request, locals: { user: mockUser }, url: new URL(request.url), ...({} as any) }); + expect(response.status).toBe(201); + const body = await response.json(); + expect(body.title).toBe("New Note"); + expect(body.content).toBe("Body"); + expect(body.userId).toBe(1); + expect(body.id).toBeDefined(); + }); + + it("POST rejects invalid body", async () => { + const { POST } = await import("../src/pages/api/dm/notes"); + const request = mockRequest("https://dm.randify.pro/api/dm/notes", "https://randify.pro", "POST", { title: "" }); + const response = await POST!({ request, locals: { user: mockUser }, url: new URL(request.url), ...({} as any) }); + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.error).toBe("Validation failed"); + }); + + it("POST rejects missing title", async () => { + const { POST } = await import("../src/pages/api/dm/notes"); + const request = mockRequest("https://dm.randify.pro/api/dm/notes", "https://randify.pro", "POST", { content: "Body only" }); + const response = await POST!({ request, locals: { user: mockUser }, url: new URL(request.url), ...({} as any) }); + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.error).toBe("Validation failed"); + }); + + it("PUT updates a note", async () => { + const { PUT } = await import("../src/pages/api/dm/notes"); + mockDb._setStore([ + { id: 1, userId: 1, title: "Old", content: "Old content", createdAt: new Date(), updatedAt: new Date() }, + ]); + const request = mockRequest("https://dm.randify.pro/api/dm/notes?id=1", "https://randify.pro", "PUT", { title: "Updated", content: "New content" }); + const response = await PUT!({ request, locals: { user: mockUser }, url: new URL(request.url), ...({} as any) }); + expect(response.status).toBe(200); + const body = await response.json(); + expect(body.title).toBe("Updated"); + }); + + it("PUT returns 404 for non-existent note", async () => { + const { PUT } = await import("../src/pages/api/dm/notes"); + mockDb._setStore([]); + const request = mockRequest("https://dm.randify.pro/api/dm/notes?id=999", "https://randify.pro", "PUT", { title: "Updated" }); + const response = await PUT!({ request, locals: { user: mockUser }, url: new URL(request.url), ...({} as any) }); + expect(response.status).toBe(404); + const body = await response.json(); + expect(body.error).toBe("Not found"); + }); + + it("PUT returns 404 when accessing another user's note", async () => { + const { PUT } = await import("../src/pages/api/dm/notes"); + mockDb._setStore([ + { id: 1, userId: 2, title: "Other", content: "Other content", createdAt: new Date(), updatedAt: new Date() }, + ]); + const request = mockRequest("https://dm.randify.pro/api/dm/notes?id=1", "https://randify.pro", "PUT", { title: "Hacked" }); + const response = await PUT!({ request, locals: { user: mockUser }, url: new URL(request.url), ...({} as any) }); + expect(response.status).toBe(404); + const body = await response.json(); + expect(body.error).toBe("Not found"); + }); + + it("DELETE removes a note", async () => { + const { DELETE } = await import("../src/pages/api/dm/notes"); + mockDb._setStore([ + { id: 1, userId: 1, title: "To delete", content: "Content", createdAt: new Date(), updatedAt: new Date() }, + ]); + const request = mockRequest("https://dm.randify.pro/api/dm/notes?id=1", "https://randify.pro", "DELETE"); + const response = await DELETE!({ request, locals: { user: mockUser }, url: new URL(request.url), ...({} as any) }); + expect(response.status).toBe(200); + const body = await response.json(); + expect(body.success).toBe(true); + }); + + it("DELETE returns 404 for non-existent note", async () => { + const { DELETE } = await import("../src/pages/api/dm/notes"); + mockDb._setStore([]); + const request = mockRequest("https://dm.randify.pro/api/dm/notes?id=999", "https://randify.pro", "DELETE"); + const response = await DELETE!({ request, locals: { user: mockUser }, url: new URL(request.url), ...({} as any) }); + expect(response.status).toBe(404); + const body = await response.json(); + expect(body.error).toBe("Not found"); + }); + + it("DELETE returns 404 when accessing another user's note", async () => { + const { DELETE } = await import("../src/pages/api/dm/notes"); + mockDb._setStore([ + { id: 1, userId: 2, title: "Other", content: "Other content", createdAt: new Date(), updatedAt: new Date() }, + ]); + const request = mockRequest("https://dm.randify.pro/api/dm/notes?id=1", "https://randify.pro", "DELETE"); + const response = await DELETE!({ request, locals: { user: mockUser }, url: new URL(request.url), ...({} as any) }); + expect(response.status).toBe(404); + const body = await response.json(); + expect(body.error).toBe("Not found"); + }); + + it("PUT returns 400 for missing id", async () => { + const { PUT } = await import("../src/pages/api/dm/notes"); + const request = mockRequest("https://dm.randify.pro/api/dm/notes", "https://randify.pro", "PUT", { title: "Updated" }); + const response = await PUT!({ request, locals: { user: mockUser }, url: new URL(request.url), ...({} as any) }); + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.error).toBe("Missing id query parameter"); + }); + + it("DELETE returns 400 for missing id", async () => { + const { DELETE } = await import("../src/pages/api/dm/notes"); + const request = mockRequest("https://dm.randify.pro/api/dm/notes", "https://randify.pro", "DELETE"); + const response = await DELETE!({ request, locals: { user: mockUser }, url: new URL(request.url), ...({} as any) }); + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.error).toBe("Missing id query parameter"); + }); + + it("PUT returns 400 for invalid id", async () => { + const { PUT } = await import("../src/pages/api/dm/notes"); + const request = mockRequest("https://dm.randify.pro/api/dm/notes?id=abc", "https://randify.pro", "PUT", { title: "Updated" }); + const response = await PUT!({ request, locals: { user: mockUser }, url: new URL(request.url), ...({} as any) }); + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.error).toBe("Invalid id"); + }); + + it("POST allows note without content", async () => { + const { POST } = await import("../src/pages/api/dm/notes"); + const request = mockRequest("https://dm.randify.pro/api/dm/notes", "https://randify.pro", "POST", { title: "Title Only" }); + const response = await POST!({ request, locals: { user: mockUser }, url: new URL(request.url), ...({} as any) }); + expect(response.status).toBe(201); + const body = await response.json(); + expect(body.title).toBe("Title Only"); + expect(body.content).toBeNull(); + }); +});