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
2 changes: 1 addition & 1 deletion docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -405,7 +405,7 @@ GET /v1/credentials/verify/:onChainId
GET /v1/employer/search?skills=finance,defi&location=kenya
```

**Authentication:** Requires employer API key
**Authentication:** Requires an authenticated employer JWT. The per-plan page limit (`starter: 10`, `pro: 50`, `enterprise: 100`) is derived from the employer's persisted plan, not from a request header.

**Response:**

Expand Down
195 changes: 122 additions & 73 deletions integrations/unit/employer.controller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,61 +27,61 @@ function createResponse() {
return response as Response
}

// `getEmployerPlan` resolves the plan by querying the authenticated employer's
// persisted row (id 'emp-1'). Every other id resolves through the per-test candidate mock.
function mockEmployerPlan(plan: string | null) {
;(prisma.user.findUnique as any).mockImplementation(async ({ where }: any) => {
if (where.id === 'emp-1') {
return plan ? { id: 'emp-1', plan } : null
}

return null
})
}

const candidateFixture = {
id: 'cand-1',
email: 'alice.learner+seed@orivex.dev',
username: 'Alice Learner',
createdAt: new Date('2026-01-01T00:00:00Z'),
completions: [
{
score: 90,
completedAt: new Date('2026-02-01T00:00:00Z'),
module: { id: 'm1', title: 'Stellar Fundamentals', category: 'blockchain', difficulty: 'beginner' },
},
],
credentials: [
{
id: 'cred-1',
onChainId: 'chain-cred-1',
issuedAt: new Date('2026-02-02T00:00:00Z'),
module: { id: 'm1', title: 'Stellar Fundamentals', category: 'blockchain', difficulty: 'beginner' },
},
],
}

describe('EmployerController', () => {
beforeEach(() => {
vi.clearAllMocks()
delete process.env.PRIVATE_CANDIDATE_IDS
})

it('searchTalent returns candidates matching filters and excludes private profiles', async () => {
mockEmployerPlan('pro')
process.env.PRIVATE_CANDIDATE_IDS = 'cand-2'
;(prisma.user.findMany as any).mockResolvedValue([
{
id: 'cand-1',
email: 'alice.learner+seed@orivex.dev',
name: 'Alice Learner',
createdAt: new Date('2026-01-01T00:00:00Z'),
completions: [
{
score: 90,
completedAt: new Date('2026-02-01T00:00:00Z'),
module: {
id: 'm1',
title: 'Stellar Fundamentals',
category: 'blockchain',
difficulty: 'beginner',
},
},
],
credentials: [
{
id: 'cred-1',
onChainId: 'chain-cred-1',
issuedAt: new Date('2026-02-02T00:00:00Z'),
module: {
id: 'm1',
title: 'Stellar Fundamentals',
category: 'blockchain',
difficulty: 'beginner',
},
},
],
},
candidateFixture,
{
id: 'cand-2',
email: 'bob.learner+seed@orivex.dev',
name: 'Bob Learner',
username: 'Bob Learner',
createdAt: new Date('2026-01-01T00:00:00Z'),
completions: [
{
score: 88,
completedAt: new Date('2026-02-01T00:00:00Z'),
module: {
id: 'm2',
title: 'Wallet Security & Key Management',
category: 'security',
difficulty: 'intermediate',
},
module: { id: 'm2', title: 'Wallet Security & Key Management', category: 'security', difficulty: 'intermediate' },
},
],
credentials: [],
Expand All @@ -90,12 +90,7 @@ describe('EmployerController', () => {

const req = {
user: { id: 'emp-1', email: 'employer@orivex.dev', role: 'employer' },
headers: { 'x-employer-plan': 'pro' },
query: {
skills: 'blockchain',
location: 'lagos',
credentials: 'verified',
},
query: { skills: 'blockchain', location: 'lagos', credentials: 'verified' },
} as unknown as Request
const res = createResponse()

Expand All @@ -111,32 +106,57 @@ describe('EmployerController', () => {
verifiedCredentialCount: 1,
}),
],
plan: 'pro',
}),
)
})

it('searchTalent caps the page limit from the persisted enterprise plan', async () => {
mockEmployerPlan('enterprise')
;(prisma.user.findMany as any).mockResolvedValue([candidateFixture])

const req = {
user: { id: 'emp-1', role: 'employer' },
query: { limit: '100' },
} as unknown as Request
const res = createResponse()

await searchTalent(req, res)

expect(res.status).not.toHaveBeenCalled()
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({
pagination: expect.objectContaining({ limit: 100 }),
}),
)
})

it('searchTalent ignores a spoofed enterprise header when the persisted plan is starter', async () => {
mockEmployerPlan('starter')
;(prisma.user.findMany as any).mockResolvedValue([candidateFixture])

const req = {
user: { id: 'emp-1', role: 'employer' },
headers: { 'x-employer-plan': 'enterprise' },
query: { limit: '100' },
} as unknown as Request
const res = createResponse()

await searchTalent(req, res)

expect(res.status).toHaveBeenCalledWith(400)
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({
message: 'Current plan allows up to 10 results per page',
currentPlan: 'starter',
requestedLimit: 100,
maxLimit: 10,
}),
)
})

it('getCandidateProfile returns profile with verified credentials', async () => {
;(prisma.user.findUnique as any).mockResolvedValue({
id: 'cand-1',
email: 'alice.learner+seed@orivex.dev',
name: 'Alice Learner',
createdAt: new Date('2026-01-01T00:00:00Z'),
completions: [
{
score: 91,
completedAt: new Date('2026-02-01T00:00:00Z'),
module: { id: 'm1', title: 'Stellar Fundamentals', category: 'blockchain', difficulty: 'beginner' },
},
],
credentials: [
{
id: 'cred-1',
onChainId: 'onchain-abc',
issuedAt: new Date('2026-02-03T00:00:00Z'),
module: { id: 'm1', title: 'Stellar Fundamentals', category: 'blockchain', difficulty: 'beginner' },
},
],
})
;(prisma.user.findUnique as any).mockResolvedValue(candidateFixture)

const req = {
user: { id: 'emp-1', email: 'employer@orivex.dev', role: 'employer' },
Expand Down Expand Up @@ -169,10 +189,11 @@ describe('EmployerController', () => {
expect(res.json).toHaveBeenCalledWith({ message: 'Candidate profile is private' })
})

it('contactCandidate requires pro plan', async () => {
it('contactCandidate returns 402 for a starter employer from persistence', async () => {
mockEmployerPlan('starter')

const req = {
user: { id: 'emp-1', email: 'employer@orivex.dev', role: 'employer' },
headers: { 'x-employer-plan': 'starter' },
body: {
candidateId: 'cand-1',
subject: 'Role opportunity',
Expand All @@ -188,15 +209,43 @@ describe('EmployerController', () => {
expect.objectContaining({
message: 'Employer plan upgrade required',
requiredPlan: 'pro',
currentPlan: 'starter',
}),
)
})

it('contactCandidate records outreach attempts', async () => {
;(prisma.user.findUnique as any).mockResolvedValue({
id: 'cand-1',
email: 'alice.learner+seed@orivex.dev',
name: 'Alice Learner',
it('contactCandidate ignores a spoofed pro header when the persisted plan is starter', async () => {
mockEmployerPlan('starter')

const req = {
user: { id: 'emp-1', email: 'employer@orivex.dev', role: 'employer' },
headers: { 'x-employer-plan': 'pro' },
body: {
candidateId: 'cand-1',
subject: 'Role opportunity',
message: 'We would like to invite you to interview for a backend role.',
},
} as unknown as Request
const res = createResponse()

await contactCandidate(req, res)

expect(res.status).toHaveBeenCalledWith(402)
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({ message: 'Employer plan upgrade required' }),
)
})

it('contactCandidate records outreach attempts for a pro employer', async () => {
;(prisma.user.findUnique as any).mockImplementation(async ({ where }: any) => {
if (where.id === 'emp-1') {
return { id: 'emp-1', plan: 'pro' }
}
if (where.id === 'cand-1') {
return { id: 'cand-1', email: 'alice.learner+seed@orivex.dev', username: 'Alice Learner' }
}

return null
})
;(prisma.webhookEndpoint.upsert as any).mockResolvedValue({ id: 'system-employer-outreach-log' })
;(prisma.webhookDelivery.create as any).mockResolvedValue({
Expand All @@ -206,7 +255,6 @@ describe('EmployerController', () => {

const req = {
user: { id: 'emp-1', email: 'employer@orivex.dev', role: 'employer' },
headers: { 'x-employer-plan': 'pro' },
body: {
candidateId: 'cand-1',
subject: 'Role opportunity',
Expand All @@ -223,6 +271,7 @@ describe('EmployerController', () => {
expect.objectContaining({
data: expect.objectContaining({
eventType: 'employer.contact_attempt',
payload: expect.stringContaining('"employerPlan":"pro"'),
}),
}),
)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "users" ADD COLUMN "plan" TEXT NOT NULL DEFAULT 'starter';
1 change: 1 addition & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ model User {
username String @unique
password String
role Role @default(LEARNER)
plan String @default("starter") // starter, pro, enterprise
walletAddress String? @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
Expand Down
14 changes: 8 additions & 6 deletions src/controllers/employer.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,12 @@ const PLAN_MAX_SEARCH_LIMIT: Record<string, number> = {
enterprise: 100,
}

function getEmployerPlan(req: Request) {
const fromHeader = req.headers['x-employer-plan']
const planValue = Array.isArray(fromHeader) ? fromHeader[0] : fromHeader
const normalized = String(planValue ?? 'starter').toLowerCase()
async function getEmployerPlan(req: Request) {
const user = await prisma.user.findUnique({
where: { id: req.user?.id },
select: { plan: true },
})
const normalized = String(user?.plan ?? 'starter').toLowerCase()

return PLAN_RANK[normalized] ? normalized : 'starter'
}
Expand Down Expand Up @@ -159,7 +161,7 @@ export const searchTalent = async (req: Request, res: Response) => {
}

const { page, limit, skills, location, credentials, search } = parsed.data
const employerPlan = getEmployerPlan(req)
const employerPlan = await getEmployerPlan(req)
const maxLimit = PLAN_MAX_SEARCH_LIMIT[employerPlan] ?? PLAN_MAX_SEARCH_LIMIT.starter
if (limit > maxLimit) {
return res.status(400).json({
Expand Down Expand Up @@ -330,7 +332,7 @@ export const contactCandidate = async (req: Request, res: Response) => {
return res.status(403).json({ message: 'Employer account required' })
}

const employerPlan = getEmployerPlan(req)
const employerPlan = await getEmployerPlan(req)
if (PLAN_RANK[employerPlan] < PLAN_RANK.pro) {
return res.status(402).json({
message: 'Employer plan upgrade required',
Expand Down
Loading
Loading