Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 19 additions & 8 deletions apps/dashboard/app/api/guilds/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<NextResponse> {
return handleApiError(async () => {
Expand Down Expand Up @@ -38,33 +39,43 @@ export async function GET(): Promise<NextResponse> {
* 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<NextResponse> {
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;
});
}
Expand Down
2 changes: 1 addition & 1 deletion apps/dashboard/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
201 changes: 201 additions & 0 deletions apps/dashboard/test/guild-api.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
1 change: 1 addition & 0 deletions packages/integration-client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,4 @@ export {
detectSchemaVersion,
type RawActivityEvent,
} from "./activity-event-migration.js";
export * from "./schemas/guild.js";
27 changes: 27 additions & 0 deletions packages/integration-client/src/schemas/guild.ts
Original file line number Diff line number Diff line change
@@ -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<typeof guildSchema>;