feat(dm-dashboard): Wave 3 — Boosty verification, notes/initiative API routes

This commit is contained in:
emil
2026-05-15 22:00:19 +03:00
parent 0bbaa6e3d8
commit 5aec915034
12 changed files with 1555 additions and 13 deletions
+299
View File
@@ -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();
});
});
+362
View File
@@ -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([]);
});
});
+343
View File
@@ -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();
});
});