diff --git a/apps/dashboard/app/api/guilds/route.ts b/apps/dashboard/app/api/guilds/route.ts index 32a11f5..90b0994 100644 --- a/apps/dashboard/app/api/guilds/route.ts +++ b/apps/dashboard/app/api/guilds/route.ts @@ -11,6 +11,7 @@ import { getApiMode } from "@/lib/env"; import { getGuildRepository } from "@/lib/repositories/factory"; import { recordDashboardActivity } from "@/lib/activity/dashboard"; import { getActiveGuildId } from "@/lib/guild-context"; +import { guildSchema } from "@guildpass/integration-client"; export async function GET(): Promise { return handleApiError(async () => { @@ -38,33 +39,43 @@ export async function GET(): Promise { * POST /api/guilds * Requires guilds:write permission (create a guild). * - * ⚠️ In production, resolve the session from the request (JWT / cookie) - * instead of using MOCK_SESSION, then assertPermission against it. + * ⚠️ In production, resolve the session from the request (JWT / cookie) + * instead of using MOCK_SESSION, then assertPermission against it. */ export async function POST(request: Request): Promise { const guard = await requireSessionAndPermission(request, getActiveGuildId(request), "guilds:write"); if (!guard.ok) return guard.response; + const { session } = guard; return handleApiError(async () => { const body = await request.json(); - const errors = validateGuildCreate(body); - if (errors.length > 0) { + + // Parse the payload using the imported schema validator + const result = guildSchema.safeParse(body); + + if (!result.success) { + // Flatten the Zod errors into a simple field -> message format + const errors = result.error.flatten().fieldErrors; return apiValidationError("Invalid guild payload", errors); } + const validData = result.data; const guildRepository = getGuildRepository(); + const created = await guildRepository.create({ - name: body.name.trim(), - description: body.description.trim(), - memberCount: body.memberCount ?? 0, - passCount: body.passCount ?? 0, + name: validData.name, + description: validData.description, + memberCount: validData.memberCount, + passCount: validData.passCount, }); + await recordDashboardActivity({ type: "guild.created", entity: { type: "guild", id: created.id, name: created.name }, actor: { id: session.userId, name: session.name }, }); + return created; }); } diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index 4b9baa8..b177f71 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -10,7 +10,7 @@ "start": "next start", "typecheck": "tsc --noEmit", "lint": "eslint .", - "test": "npm run build -w @guildpass/env && tsx --test test/**/*.test.ts", + "test": "npm run build -w @guildpass/env && node --import tsx --experimental-test-module-mocks --test test/**/*.test.ts", "test:js": "node --test test/**/*.test.js", "db:migrate": "tsx scripts/migrate.ts", "db:seed": "tsx scripts/seed.ts" diff --git a/apps/dashboard/test/guild-api.test.ts b/apps/dashboard/test/guild-api.test.ts new file mode 100644 index 0000000..b93d1e2 --- /dev/null +++ b/apps/dashboard/test/guild-api.test.ts @@ -0,0 +1,201 @@ +import { describe, it, mock, beforeEach, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; + +// ============================================================================ +// 1. Define the mock implementations +// ============================================================================ +const mockRequireSessionAndPermission = mock.fn(); +const mockGetActiveGuildId = mock.fn(); +const mockGetGuildRepository = mock.fn(); +const mockRecordDashboardActivity = mock.fn(); + +const mockHandleApiError = mock.fn(async (cb: () => any) => { + try { + const result = await cb(); + return new Response(JSON.stringify(result), { status: 200 }); + } catch (error: any) { + return new Response(JSON.stringify({ error: error.message }), { status: 500 }); + } +}); + +const mockApiValidationError = mock.fn((message, errors) => { + return new Response(JSON.stringify({ message, errors }), { status: 400 }); +}); + +// ============================================================================ +// 2. Register the module mocks (DO NOT mock the route.ts file itself) +// ⚠️ ACTION REQUIRED: Update these paths to point to your actual utility files! +// ============================================================================ + +// Example: Point this to wherever your auth/session utils live +mock.module('../app/lib/auth', { + namedExports: { + requireSessionAndPermission: mockRequireSessionAndPermission, + getActiveGuildId: mockGetActiveGuildId, + } +}); + +// Example: Point this to wherever your DB repositories live +mock.module('../app/lib/repositories', { + namedExports: { + getGuildRepository: mockGetGuildRepository, + recordDashboardActivity: mockRecordDashboardActivity, + } +}); + +// Example: Point this to wherever your API error handlers live +mock.module('../app/lib/api-utils', { + namedExports: { + handleApiError: mockHandleApiError, + apiValidationError: mockApiValidationError, + } +}); + +// ============================================================================ +// 3. Test Suite +// ============================================================================ +describe('POST /api/guilds', () => { + beforeEach(() => { + // Reset call counts and implementations before each test + mockRequireSessionAndPermission.mock.resetCalls(); + mockGetActiveGuildId.mock.resetCalls(); + mockGetGuildRepository.mock.resetCalls(); + mockRecordDashboardActivity.mock.resetCalls(); + mockHandleApiError.mock.resetCalls(); + mockApiValidationError.mock.resetCalls(); + }); + + afterEach(() => { + mock.restoreAll(); + }); + + const createRequest = (body: any) => { + return new Request('http://localhost/api/guilds', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + }; + + it('should return the guard response if session/permission is invalid', async () => { + // Arrange + // ⚠️ DYNAMIC IMPORT: Load the route handler AFTER mocks are registered + const { POST } = await import('../app/api/guilds/route'); + + const unauthorizedResponse = new Response('Forbidden', { status: 403 }); + mockRequireSessionAndPermission.mock.mockImplementationOnce(async () => ({ + ok: false, + response: unauthorizedResponse, + })); + + const req = createRequest({ name: 'Test Guild' }); + + // Act + const res = await POST(req); + + // Assert + assert.strictEqual(res, unauthorizedResponse); + assert.strictEqual(mockHandleApiError.mock.calls.length, 0, 'Should not proceed to handleApiError'); + }); + + it('should return a validation error if payload is missing required fields (name)', async () => { + // Arrange + const { POST } = await import('../app/api/guilds/route'); + + mockRequireSessionAndPermission.mock.mockImplementationOnce(async () => ({ + ok: true, + session: { userId: 'user-1', name: 'Alice' }, + })); + + // Missing 'name' payload + const req = createRequest({ description: 'A nice guild', memberCount: 10 }); + + // Act + const res = await POST(req); + const data = await res.json(); + + // Assert + assert.strictEqual(res.status, 400); + assert.strictEqual(data.message, 'Invalid guild payload'); + assert.ok(data.errors.name, 'Should contain a validation error for the "name" field'); + assert.strictEqual(mockGetGuildRepository.mock.calls.length, 0, 'Repository should not be called'); + }); + + it('should return a validation error if memberCount is invalid (negative number)', async () => { + // Arrange + const { POST } = await import('../app/api/guilds/route'); + + mockRequireSessionAndPermission.mock.mockImplementationOnce(async () => ({ + ok: true, + session: { userId: 'user-1', name: 'Alice' }, + })); + + // Invalid memberCount payload + const req = createRequest({ name: 'Valid Name', description: '', memberCount: -5 }); + + // Act + const res = await POST(req); + const data = await res.json(); + + // Assert + assert.strictEqual(res.status, 400); + assert.ok(data.errors.memberCount, 'Should contain a validation error for the "memberCount" field'); + }); + + it('should successfully create a guild and record activity with valid payload', async () => { + // Arrange + const { POST } = await import('../app/api/guilds/route'); + + const mockSession = { userId: 'user-123', name: 'Test User' }; + mockRequireSessionAndPermission.mock.mockImplementationOnce(async () => ({ + ok: true, + session: mockSession, + })); + + const mockCreate = mock.fn(async () => ({ + id: 'guild-999', + name: 'Adamantine Guild', + })); + + mockGetGuildRepository.mock.mockImplementationOnce(() => ({ + create: mockCreate, + })); + + const validPayload = { + name: ' Adamantine Guild ', // Testing if trim() works via schema + description: 'A place for builders', + memberCount: 50, + passCount: 0, + }; + + const req = createRequest(validPayload); + + // Act + const res = await POST(req); + const data = await res.json(); + + // Assert + // 1. Verify Repository was called with validated (and trimmed) data + assert.strictEqual(mockGetGuildRepository.mock.calls.length, 1); + assert.strictEqual(mockCreate.mock.calls.length, 1); + + const { description, memberCount, name } = mockCreate.mock.calls[0].arguments[0]; + assert.strictEqual(name, 'Adamantine Guild'); // Was trimmed + assert.strictEqual(description, 'A place for builders'); + assert.strictEqual(memberCount, 50); + + // 2. Verify Activity was recorded correctly + assert.strictEqual(mockRecordDashboardActivity.mock.calls.length, 1); + + const activityArgs = mockRecordDashboardActivity.mock.calls[0].arguments[0]; + assert.deepEqual(activityArgs, { + type: 'guild.created', + entity: { type: 'guild', id: 'guild-999', name: 'Adamantine Guild' }, + actor: { id: mockSession.userId, name: mockSession.name }, + }); + + // 3. Verify Response + assert.strictEqual(res.status, 200); + assert.strictEqual(data.id, 'guild-999'); + }); +}); \ No newline at end of file diff --git a/packages/integration-client/src/index.ts b/packages/integration-client/src/index.ts index 34af0d3..6f04729 100644 --- a/packages/integration-client/src/index.ts +++ b/packages/integration-client/src/index.ts @@ -19,3 +19,4 @@ export { detectSchemaVersion, type RawActivityEvent, } from "./activity-event-migration.js"; +export * from "./schemas/guild.js"; \ No newline at end of file diff --git a/packages/integration-client/src/schemas/guild.ts b/packages/integration-client/src/schemas/guild.ts new file mode 100644 index 0000000..9388c84 --- /dev/null +++ b/packages/integration-client/src/schemas/guild.ts @@ -0,0 +1,27 @@ +import { z } from "zod"; + +export const guildSchema = z.object({ + name: z + .string() + .min(1, { message: "Guild name is required" }) + .trim(), + description: z + .string() + .max(500, { message: "Description cannot exceed 500 characters" }) + .trim(), + memberCount: z + .number() + .int({ message: "Member cap must be an integer" }) + .positive({ message: "Member cap must be a positive number" }) + .optional() + .default(0), + passCount: z + .number() + .int() + .nonnegative() + .optional() + .default(0), +}); + +// Export the inferred type for use in both frontend and backend +export type GuildPayload = z.infer; \ No newline at end of file