diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index c20fe0a..3c3492b 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -6,7 +6,9 @@ import { dbMiddleware } from './middleware/db.js' import { postingsRouter } from './routes/postings.js' import { usersRouter } from './routes/users.js' import { clubMembersRouter } from './routes/club-members.js' + import { recruitersRouter } from './routes/recruiters.js' +import { talentsRouter } from './routes/talents.js' const app = new Hono<{ Bindings: { DATABASE_URL: string; NEON_AUTH_BASE_URL: string; WEB_ORIGIN: string } @@ -47,5 +49,8 @@ app.route('/api/clubs/members', clubMembersRouter) // Recruiter onboarding and company profile management app.route('/api/recruiters', recruitersRouter) +// Recruiter talent search +app.route('/api/talents', talentsRouter) + export { app } export type AppType = typeof app diff --git a/apps/api/src/controllers/talents.controller.ts b/apps/api/src/controllers/talents.controller.ts new file mode 100644 index 0000000..5c87544 --- /dev/null +++ b/apps/api/src/controllers/talents.controller.ts @@ -0,0 +1,291 @@ +import { and, asc, desc, eq, ilike, inArray, or, sql } from 'drizzle-orm' +import type { Database } from '@repo/db' +import { projects, skills, studentProfiles, users } from '@repo/db' +import { type PaginationParams, pageToOffset } from '../utils/pagination.js' + +// ========================================== +// TYPES +// ========================================== + +export type TalentSortField = 'fullName' | 'gradYear' | 'updatedAt' +export type SortOrder = 'asc' | 'desc' +export type SkillMatchMode = 'all' | 'any' + +export interface TalentFilters { + /** Free-text search across profile info, skills, and project content */ + q?: string + /** Structured skill filter from the skills table */ + skills?: string[] + /** Whether student must match ALL skills (default) or ANY skill */ + skillMatchMode?: SkillMatchMode + /** Filter by openToWork status */ + openToWork?: boolean + /** Filter by DK24 verification status */ + dk24Status?: 'none' | 'pending' | 'verified' | 'rejected' | 'revoked' + /** Filter by graduation year */ + gradYear?: number + /** Sort field */ + sortBy?: TalentSortField + /** Sort direction */ + order?: SortOrder +} + +export interface TalentProject { + id: string + title: string + description: string | null + role: string | null + contributions: string | null + learnings: string | null + skillsUsed: string[] | null + liveUrl: string | null + githubUrl: string | null + displayOrder: number + createdAt: Date +} + +export interface TalentResult { + id: string + fullName: string + email: string + headline: string | null + bio: string | null + gradYear: number | null + openToWork: boolean + dk24Status: 'none' | 'pending' | 'verified' | 'rejected' | 'revoked' + resumeUrl: string | null + githubUrl: string | null + linkedinUrl: string | null + otherLinks: unknown + consentGivenAt: Date | null + updatedAt: Date + skills: string[] + projects: TalentProject[] +} + +// ========================================== +// QUERY BUILDER HELPERS +// ========================================== + +function sortColumn(field: TalentSortField) { + switch (field) { + case 'fullName': + return users.fullName + case 'gradYear': + return studentProfiles.gradYear + case 'updatedAt': + default: + return studentProfiles.updatedAt + } +} + +/** + * Builds the WHERE conditions for talent search. + */ +function buildWhereConditions(filters: TalentFilters) { + const conditions = [] + + // Free-text search (q) across name, headline, bio, skills, and project content + if (filters.q) { + const pattern = `%${filters.q}%` + conditions.push( + or( + ilike(users.fullName, pattern), + ilike(studentProfiles.headline, pattern), + ilike(studentProfiles.bio, pattern), + sql`EXISTS ( + SELECT 1 FROM ${skills} + WHERE ${skills.studentId} = ${studentProfiles.userId} + AND ${skills.skill} ILIKE ${pattern} + )`, + sql`EXISTS ( + SELECT 1 FROM ${projects} + WHERE ${projects.studentId} = ${studentProfiles.userId} + AND ( + ${projects.title} ILIKE ${pattern} + OR ${projects.description} ILIKE ${pattern} + OR ${projects.role} ILIKE ${pattern} + OR ${projects.contributions} ILIKE ${pattern} + OR array_to_string(${projects.skillsUsed}, ' ') ILIKE ${pattern} + ) + )`, + )!, + ) + } + + // Structured skills filter (skills table as single source of truth) + if (filters.skills && filters.skills.length > 0) { + const matchMode = filters.skillMatchMode ?? 'all' + + if (matchMode === 'any') { + const skillConditions = filters.skills.map((s) => ilike(skills.skill, s)) + conditions.push( + sql`EXISTS ( + SELECT 1 FROM ${skills} + WHERE ${skills.studentId} = ${studentProfiles.userId} + AND (${or(...skillConditions)}) + )`, + ) + } else { + // 'all' mode (default): student must possess every specified skill + for (const s of filters.skills) { + conditions.push( + sql`EXISTS ( + SELECT 1 FROM ${skills} + WHERE ${skills.studentId} = ${studentProfiles.userId} + AND ${skills.skill} ILIKE ${s} + )`, + ) + } + } + } + + // openToWork filter + if (filters.openToWork !== undefined) { + conditions.push(eq(studentProfiles.openToWork, filters.openToWork)) + } + + // dk24Status filter + if (filters.dk24Status) { + conditions.push(eq(studentProfiles.dk24Status, filters.dk24Status)) + } + + // gradYear filter + if (filters.gradYear !== undefined) { + conditions.push(eq(studentProfiles.gradYear, filters.gradYear)) + } + + return conditions.length > 0 ? and(...conditions) : undefined +} + +// ========================================== +// SERVICE FUNCTIONS +// ========================================== + +/** + * Returns a paginated list of candidate student profiles with aggregated skills + * and projects, filtered by skills, verification status, availability, and keyword search. + */ +export async function listTalents( + db: Database, + filters: TalentFilters, + pagination: PaginationParams, +): Promise<{ + rows: TalentResult[] + total: number +}> { + const where = buildWhereConditions(filters) + + const sortBy = filters.sortBy ?? 'updatedAt' + const order = filters.order ?? 'desc' + const col = sortColumn(sortBy) + const orderExpr = order === 'asc' ? asc(col) : desc(col) + // Secondary sort on users.id ensures deterministic pagination ordering + const secondaryOrder = asc(users.id) + + const offset = pageToOffset(pagination.page, pagination.limit) + + const [studentRows, countResult] = await Promise.all([ + db + .select({ + id: users.id, + fullName: users.fullName, + email: users.email, + headline: studentProfiles.headline, + bio: studentProfiles.bio, + gradYear: studentProfiles.gradYear, + openToWork: studentProfiles.openToWork, + dk24Status: studentProfiles.dk24Status, + resumeUrl: studentProfiles.resumeUrl, + githubUrl: studentProfiles.githubUrl, + linkedinUrl: studentProfiles.linkedinUrl, + otherLinks: studentProfiles.otherLinks, + consentGivenAt: studentProfiles.consentGivenAt, + updatedAt: studentProfiles.updatedAt, + }) + .from(users) + .innerJoin(studentProfiles, eq(users.id, studentProfiles.userId)) + .where(where) + .orderBy(orderExpr, secondaryOrder) + .limit(pagination.limit) + .offset(offset), + + db + .select({ count: sql`count(*)::int` }) + .from(users) + .innerJoin(studentProfiles, eq(users.id, studentProfiles.userId)) + .where(where), + ]) + + const total = countResult[0]?.count ?? 0 + + if (studentRows.length === 0) { + return { rows: [], total } + } + + const studentIds = studentRows.map((s) => s.id) + + // Two-stage batch hydration avoids 1-to-many Cartesian explosion during pagination + const [skillRows, projectRows] = await Promise.all([ + db + .select({ + studentId: skills.studentId, + skill: skills.skill, + }) + .from(skills) + .where(inArray(skills.studentId, studentIds)), + + db + .select({ + id: projects.id, + studentId: projects.studentId, + title: projects.title, + description: projects.description, + role: projects.role, + contributions: projects.contributions, + learnings: projects.learnings, + skillsUsed: projects.skillsUsed, + liveUrl: projects.liveUrl, + githubUrl: projects.githubUrl, + displayOrder: projects.displayOrder, + createdAt: projects.createdAt, + }) + .from(projects) + .where(inArray(projects.studentId, studentIds)) + .orderBy(asc(projects.displayOrder), desc(projects.createdAt)), + ]) + + const skillsByStudentId = new Map() + for (const s of skillRows) { + const list = skillsByStudentId.get(s.studentId) ?? [] + list.push(s.skill) + skillsByStudentId.set(s.studentId, list) + } + + const projectsByStudentId = new Map() + for (const p of projectRows) { + const list = projectsByStudentId.get(p.studentId) ?? [] + list.push({ + id: p.id, + title: p.title, + description: p.description, + role: p.role, + contributions: p.contributions, + learnings: p.learnings, + skillsUsed: p.skillsUsed, + liveUrl: p.liveUrl, + githubUrl: p.githubUrl, + displayOrder: p.displayOrder, + createdAt: p.createdAt, + }) + projectsByStudentId.set(p.studentId, list) + } + + const rows: TalentResult[] = studentRows.map((s) => ({ + ...s, + skills: skillsByStudentId.get(s.id) ?? [], + projects: projectsByStudentId.get(s.id) ?? [], + })) + + return { rows, total } +} diff --git a/apps/api/src/routes/talents.ts b/apps/api/src/routes/talents.ts new file mode 100644 index 0000000..0e9118f --- /dev/null +++ b/apps/api/src/routes/talents.ts @@ -0,0 +1,118 @@ +import { Hono } from 'hono' +import { zValidator } from '@hono/zod-validator' +import { z } from 'zod' +import type { DbVariables } from '../middleware/db.js' +import type { AuthVariables } from '../middleware/auth.js' +import { requireAuth } from '../middleware/auth.js' +import { requireRecruiter } from '../middleware/requireRecruiter.js' +import { listTalents } from '../controllers/talents.controller.js' +import { buildPaginationMeta, PAGINATION_DEFAULTS } from '../utils/pagination.js' + +// ========================================== +// QUERY PARAM SCHEMAS +// ========================================== + +const listTalentsSchema = z.object({ + // Pagination + page: z + .string() + .optional() + .refine((v) => v === undefined || /^[1-9]\d*$/.test(v), { + message: 'page must be an integer greater than or equal to 1', + }) + .transform((v) => (v ? parseInt(v, 10) : 1)), + limit: z + .string() + .optional() + .transform((v) => + Math.min(PAGINATION_DEFAULTS.maxLimit, Math.max(1, parseInt(v ?? '20', 10) || 20)), + ), + + // Free-text search + q: z.string().trim().optional(), + + // Array filter: ?skills=React&skills=Node or comma-separated ?skills=React,Node + skills: z + .union([z.string(), z.array(z.string())]) + .optional() + .transform((v) => { + if (!v) return undefined + const raw = Array.isArray(v) ? v : v.split(',') + const cleaned = raw.map((s) => s.trim()).filter(Boolean) + return cleaned.length > 0 ? cleaned : undefined + }), + + // Skill match mode: 'all' (default) vs 'any' + skillMatchMode: z.enum(['all', 'any']).optional().default('all'), + + // Availability filter + openToWork: z + .enum(['true', 'false']) + .optional() + .transform((v) => { + if (v === undefined) return undefined + return v === 'true' + }), + + // DK24 verification status filter + dk24Status: z.enum(['none', 'pending', 'verified', 'rejected', 'revoked']).optional(), + + // Graduation year filter + gradYear: z + .string() + .optional() + .transform((v) => (v ? parseInt(v, 10) || undefined : undefined)), + + // Sorting + sortBy: z.enum(['fullName', 'gradYear', 'updatedAt']).optional().default('updatedAt'), + order: z.enum(['asc', 'desc']).optional().default('desc'), +}) + +// ========================================== +// ROUTER +// ========================================== + +export const talentsRouter = new Hono<{ + Bindings: { DATABASE_URL: string; NEON_AUTH_BASE_URL: string } + Variables: DbVariables & AuthVariables +}>() + +talentsRouter.use('*', requireAuth) +talentsRouter.use('*', requireRecruiter) + +/** + * GET /api/talents + * Returns a paginated list of student profiles matching filter criteria. + * + * Query params: + * page, limit — pagination (default: 1, 20; max limit: 100) + * q — broad free-text search across profile info, skills, and projects + * skills — structured skill filter (e.g. ?skills=React&skills=Node) + * skillMatchMode — 'all' (default, AND) | 'any' (OR) + * openToWork — 'true' | 'false' + * dk24Status — 'none' | 'pending' | 'verified' | 'rejected' | 'revoked' + * gradYear — graduation year (e.g. 2025) + * sortBy — 'fullName' | 'gradYear' | 'updatedAt' (default: 'updatedAt') + * order — 'asc' | 'desc' (default: 'desc') + */ +talentsRouter.get('/', zValidator('query', listTalentsSchema), async (c) => { + const query = c.req.valid('query') + const db = c.var.db + + const pagination = { page: query.page, limit: query.limit } + const filters = { + q: query.q, + skills: query.skills, + skillMatchMode: query.skillMatchMode, + openToWork: query.openToWork, + dk24Status: query.dk24Status, + gradYear: query.gradYear, + sortBy: query.sortBy, + order: query.order, + } + + const { rows, total } = await listTalents(db, filters, pagination) + const meta = buildPaginationMeta(total, pagination) + + return c.json({ data: rows, meta }) +}) diff --git a/apps/api/src/tests/talents.test.ts b/apps/api/src/tests/talents.test.ts new file mode 100644 index 0000000..0c121df --- /dev/null +++ b/apps/api/src/tests/talents.test.ts @@ -0,0 +1,414 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest' +import { app } from '../app.js' +import type { TalentResult } from '../controllers/talents.controller.js' + +const DATABASE_URL = 'postgresql://user:pass@db.test/hireme' + +process.env.DATABASE_URL = DATABASE_URL + +/** Parse a Response body as JSON with a loose type for easy test assertions. */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +async function json(res: Response): Promise { + return res.json() +} + +// ========================================== +// AUTH & AUTHORIZATION MOCKS +// ========================================== + +const mockState = vi.hoisted(() => ({ + auth: { + outcome: 'allow' as 'allow' | 'unauthorized', + user: { + id: '11111111-1111-4111-8111-111111111111', + email: 'recruiter@example.com', + name: 'Jane Recruiter', + }, + }, + recruiter: { + outcome: 'allow' as 'allow' | 'forbidden', + recruiterId: 'recruiter-1', + }, +})) + +vi.mock('../middleware/auth.ts', () => ({ + requireAuth: vi.fn(async (c, next) => { + if (mockState.auth.outcome === 'unauthorized') { + return c.json({ error: 'Unauthorized' }, 401) + } + c.set('authUser', mockState.auth.user) + await next() + }), +})) + +vi.mock('../middleware/requireRecruiter.ts', () => ({ + requireRecruiter: vi.fn(async (c, next) => { + if (mockState.recruiter.outcome === 'forbidden') { + return c.json({ error: 'Recruiter profile required to manage postings' }, 403) + } + c.set('recruiterId', mockState.recruiter.recruiterId) + await next() + }), +})) + +// ========================================== +// SERVICE MOCK +// ========================================== + +vi.mock('../controllers/talents.controller.ts', () => ({ + listTalents: vi.fn(), +})) + +import { listTalents } from '../controllers/talents.controller.js' + +// ========================================== +// FIXTURES +// ========================================== + +function makeTalent(overrides: Partial = {}): TalentResult { + return { + id: '00000000-0001-0000-0000-000000000001', + fullName: 'Arjun Sharma', + email: 'arjun.sharma@college.edu', + headline: 'Full-Stack Developer | React & Node.js', + bio: 'Final year CS student passionate about building scalable web apps.', + gradYear: 2025, + openToWork: true, + dk24Status: 'verified', + resumeUrl: 'https://cdn.hire-me.college/resumes/arjun-sharma.pdf', + githubUrl: 'https://github.com/arjunsharma', + linkedinUrl: 'https://linkedin.com/in/arjunsharma', + otherLinks: null, + consentGivenAt: new Date('2024-09-01T00:00:00Z'), + updatedAt: new Date('2025-01-01T00:00:00Z'), + skills: ['TypeScript', 'React', 'Node.js', 'PostgreSQL', 'Docker'], + projects: [ + { + id: '00000000-0020-0000-0000-000000000001', + title: 'hire-me Platform', + description: 'Campus job-board with DK24 student verification...', + role: 'Full-Stack Lead', + contributions: 'Designed schema and built API', + learnings: 'Drizzle ORM, Neon Postgres', + skillsUsed: ['TypeScript', 'React', 'Hono', 'Drizzle ORM', 'PostgreSQL'], + liveUrl: null, + githubUrl: 'https://github.com/arjunsharma/hire-me', + displayOrder: 0, + createdAt: new Date('2025-01-01T00:00:00Z'), + }, + ], + ...overrides, + } +} + +// ========================================== +// HELPERS +// ========================================== + +function request(path: string) { + return app.request(path, {}, { DATABASE_URL }) +} + +// ========================================== +// TALENTS SEARCH ENDPOINT TESTS +// ========================================== + +describe('GET /api/talents', () => { + beforeEach(() => { + vi.clearAllMocks() + mockState.auth = { + outcome: 'allow', + user: { + id: '11111111-1111-4111-8111-111111111111', + email: 'recruiter@example.com', + name: 'Jane Recruiter', + }, + } + mockState.recruiter = { outcome: 'allow', recruiterId: 'recruiter-1' } + vi.mocked(listTalents).mockResolvedValue({ rows: [], total: 0 }) + }) + + it('returns 200 with data and pagination meta for default listing', async () => { + const talent = makeTalent() + vi.mocked(listTalents).mockResolvedValue({ rows: [talent], total: 1 }) + + const res = await request('/api/talents') + expect(res.status).toBe(200) + + const body = await json(res) + expect(body.data).toHaveLength(1) + expect(body.data[0].id).toBe(talent.id) + expect(body.data[0].fullName).toBe('Arjun Sharma') + expect(body.data[0].skills).toEqual(['TypeScript', 'React', 'Node.js', 'PostgreSQL', 'Docker']) + expect(body.data[0].projects).toHaveLength(1) + expect(body.data[0].projects[0].title).toBe('hire-me Platform') + expect(body.meta).toEqual({ + page: 1, + limit: 20, + total: 1, + totalPages: 1, + hasNext: false, + hasPrev: false, + }) + }) + + it('defaults pagination to page 1 and limit 20 when page is missing', async () => { + await request('/api/talents') + expect(listTalents).toHaveBeenCalledWith(expect.anything(), expect.anything(), { + page: 1, + limit: 20, + }) + }) + + it('accepts valid page=1 parameter', async () => { + const res = await request('/api/talents?page=1') + expect(res.status).toBe(200) + expect(listTalents).toHaveBeenCalledWith(expect.anything(), expect.anything(), { + page: 1, + limit: 20, + }) + }) + + it('accepts valid page=2 parameter', async () => { + const res = await request('/api/talents?page=2') + expect(res.status).toBe(200) + expect(listTalents).toHaveBeenCalledWith(expect.anything(), expect.anything(), { + page: 2, + limit: 20, + }) + }) + + it('passes custom page and limit to pagination params', async () => { + await request('/api/talents?page=3&limit=15') + expect(listTalents).toHaveBeenCalledWith(expect.anything(), expect.anything(), { + page: 3, + limit: 15, + }) + }) + + it('clamps max limit to 100', async () => { + await request('/api/talents?limit=500') + expect(listTalents).toHaveBeenCalledWith(expect.anything(), expect.anything(), { + page: 1, + limit: 100, + }) + }) + + it('passes free-text search (q) to the service', async () => { + await request('/api/talents?q=React') + expect(listTalents).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ q: 'React' }), + expect.anything(), + ) + }) + + it('passes single skill filter to the service as an array with default ALL matchMode', async () => { + await request('/api/talents?skills=React') + expect(listTalents).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + skills: ['React'], + skillMatchMode: 'all', + }), + expect.anything(), + ) + }) + + it('passes multiple skill filters with default ALL match mode', async () => { + await request('/api/talents?skills=Angular&skills=.NET') + expect(listTalents).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + skills: ['Angular', '.NET'], + skillMatchMode: 'all', + }), + expect.anything(), + ) + }) + + it('supports skillMatchMode=any for OR skill filtering', async () => { + await request('/api/talents?skills=Angular&skills=.NET&skillMatchMode=any') + expect(listTalents).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + skills: ['Angular', '.NET'], + skillMatchMode: 'any', + }), + expect.anything(), + ) + }) + + it('supports comma-separated skills in query parameter', async () => { + await request('/api/talents?skills=React,Node.js,PostgreSQL') + expect(listTalents).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + skills: ['React', 'Node.js', 'PostgreSQL'], + skillMatchMode: 'all', + }), + expect.anything(), + ) + }) + + it('passes openToWork=true filter to the service', async () => { + await request('/api/talents?openToWork=true') + expect(listTalents).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ openToWork: true }), + expect.anything(), + ) + }) + + it('passes openToWork=false filter to the service', async () => { + await request('/api/talents?openToWork=false') + expect(listTalents).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ openToWork: false }), + expect.anything(), + ) + }) + + it('passes dk24Status filter to the service', async () => { + await request('/api/talents?dk24Status=verified') + expect(listTalents).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ dk24Status: 'verified' }), + expect.anything(), + ) + }) + + it('passes gradYear filter to the service as a number', async () => { + await request('/api/talents?gradYear=2025') + expect(listTalents).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ gradYear: 2025 }), + expect.anything(), + ) + }) + + it('passes sort and order options to the service', async () => { + await request('/api/talents?sortBy=fullName&order=asc') + expect(listTalents).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ sortBy: 'fullName', order: 'asc' }), + expect.anything(), + ) + }) + + it('passes combined filters simultaneously', async () => { + await request( + '/api/talents?q=Full-Stack&skills=TypeScript&skills=React&skillMatchMode=all&openToWork=true&dk24Status=verified&gradYear=2025&sortBy=gradYear&order=desc', + ) + expect(listTalents).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + q: 'Full-Stack', + skills: ['TypeScript', 'React'], + skillMatchMode: 'all', + openToWork: true, + dk24Status: 'verified', + gradYear: 2025, + sortBy: 'gradYear', + order: 'desc', + }), + expect.anything(), + ) + }) + + it('returns empty data array when no students match', async () => { + vi.mocked(listTalents).mockResolvedValue({ rows: [], total: 0 }) + + const res = await request('/api/talents?q=NonExistentDeveloper') + expect(res.status).toBe(200) + + const body = await json(res) + expect(body.data).toEqual([]) + expect(body.meta).toEqual({ + page: 1, + limit: 20, + total: 0, + totalPages: 1, + hasNext: false, + hasPrev: false, + }) + }) + + it('computes correct pagination meta for multiple pages', async () => { + vi.mocked(listTalents).mockResolvedValue({ rows: [makeTalent()], total: 45 }) + + const res = await request('/api/talents?page=2&limit=10') + expect(res.status).toBe(200) + + const body = await json(res) + expect(body.meta).toEqual({ + page: 2, + limit: 10, + total: 45, + totalPages: 5, + hasNext: true, + hasPrev: true, + }) + }) + + it('handles page beyond total pages gracefully', async () => { + vi.mocked(listTalents).mockResolvedValue({ rows: [], total: 10 }) + + const res = await request('/api/talents?page=5&limit=10') + expect(res.status).toBe(200) + + const body = await json(res) + expect(body.data).toEqual([]) + expect(body.meta).toEqual({ + page: 5, + limit: 10, + total: 10, + totalPages: 1, + hasNext: false, + hasPrev: true, + }) + }) + + it('rejects page=0 with 400 Bad Request', async () => { + const res = await request('/api/talents?page=0') + expect(res.status).toBe(400) + }) + + it('rejects page=abc with 400 Bad Request', async () => { + const res = await request('/api/talents?page=abc') + expect(res.status).toBe(400) + }) + + it('rejects invalid dk24Status with 400 Bad Request', async () => { + const res = await request('/api/talents?dk24Status=unknown_status') + expect(res.status).toBe(400) + }) + + it('rejects invalid openToWork value with 400 Bad Request', async () => { + const res = await request('/api/talents?openToWork=maybe') + expect(res.status).toBe(400) + }) + + it('rejects invalid skillMatchMode with 400 Bad Request', async () => { + const res = await request('/api/talents?skillMatchMode=exact') + expect(res.status).toBe(400) + }) + + it('returns 401 when unauthenticated (no or invalid token)', async () => { + mockState.auth.outcome = 'unauthorized' + const res = await request('/api/talents') + expect(res.status).toBe(401) + const body = await json(res) + expect(body.error).toBe('Unauthorized') + expect(listTalents).not.toHaveBeenCalled() + }) + + it('returns 403 when authenticated user has no active recruiter profile', async () => { + mockState.recruiter.outcome = 'forbidden' + const res = await request('/api/talents') + expect(res.status).toBe(403) + const body = await json(res) + expect(body.error).toBe('Recruiter profile required to manage postings') + expect(listTalents).not.toHaveBeenCalled() + }) +})