Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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 @@ -386,7 +386,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