feat(dm-dashboard): Wave 3 — Boosty verification, notes/initiative API routes
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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<typeof import("drizzle-orm")>();
|
||||
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
|
||||
|
||||
@@ -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 `<DmSidebar />`.
|
||||
@@ -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.
|
||||
|
||||
Vendored
+1
@@ -8,6 +8,7 @@ interface User {
|
||||
name: string;
|
||||
avatar: string | null;
|
||||
tier: 'free' | 'pro';
|
||||
boostyVerifiedAt: Date | null;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, CacheEntry>();
|
||||
|
||||
/** 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<string, unknown>;
|
||||
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<void> {
|
||||
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<void> {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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<Response>((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();
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>;
|
||||
if (c.type === "eq") {
|
||||
const colName = (c.column as string).replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());
|
||||
const itemValue = (item as Record<string, unknown>)[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<typeof import("drizzle-orm")>();
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>;
|
||||
if (c.type === "eq") {
|
||||
const colName = (c.column as string).replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());
|
||||
const itemValue = (item as Record<string, unknown>)[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<typeof import("drizzle-orm")>();
|
||||
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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user