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
102 changes: 102 additions & 0 deletions __tests__/fit-score-action.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,108 @@ beforeEach(() => {
});

describe('generateFitScore', () => {
it('refunds an empty report instead of saving a score without source binding', async () => {
mocks.getCurrentUserId.mockResolvedValue('user-1');
mocks.generateText.mockResolvedValue({
text: JSON.stringify({ ...fitScoreJson, dimensions: [] }),
});
const { generateFitScore } = await import('@/lib/actions/fit-score-action');
await expect(
generateFitScore('Resume', 'Job description', 'job-1', {
endpointUrl: 'https://ai.example/v1',
apiKey: 'key',
model: 'model',
})
).rejects.toThrow('unexpected response');
expect(mocks.creditTokens).toHaveBeenCalledExactlyOnceWith('user-1', 1, 'refund', 'ai_failure');
expect(mocks.dbExecute).not.toHaveBeenCalled();
});

it.each(['Skills Match', 'Core Skills'])(
'binds zero-match reports to their source with a %s dimension',
async (name) => {
mocks.getCurrentUserId.mockResolvedValue(null);
mocks.generateText.mockResolvedValue({
text: JSON.stringify({
...fitScoreJson,
dimensions: fitScoreJson.dimensions.map((dimension, index) =>
index === 1 ? { ...dimension, name } : dimension
),
requirement_matches: [],
}),
});
const { generateFitScore } = await import('@/lib/actions/fit-score-action');
const { fitScoreForResume } = await import('@/lib/requirement-evidence');
const source = 'Original resume source';
const result = await generateFitScore(source, 'Job description', 'job-1', {
endpointUrl: 'https://ai.example/v1',
apiKey: 'key',
model: 'model',
});
expect(result.dimensions.some((dimension) => dimension.requirement_source === source)).toBe(
true
);
expect(fitScoreForResume(result, source)).toBe(result);
expect(fitScoreForResume(result, 'Another resume')).toBeNull();
expect(mocks.dbExecute).not.toHaveBeenCalled();
}
);

it('stores only source-checked requirement evidence and strips embedded generated matches', async () => {
mocks.getCurrentUserId.mockResolvedValue('user-1');
mocks.dbExecute.mockResolvedValue({ rows: [] });
mocks.generateText.mockResolvedValue({
text: JSON.stringify({
...fitScoreJson,
dimensions: fitScoreJson.dimensions.map((dimension) => ({
...dimension,
requirement_matches: [
{
requirement: 'Invented',
status: 'evidence_found',
evidence: [{ excerpt: 'Fabricated' }],
},
],
requirement_source: 'Injected source',
})),
requirement_matches: [
{
requirement: 'Build React interfaces.',
status: 'evidence_found',
evidence: [{ excerpt: 'Built React interfaces.' }],
},
{
requirement: 'Operate Kubernetes clusters.',
status: 'evidence_found',
evidence: [{ excerpt: 'Operate Kubernetes clusters.' }],
},
],
}),
});
const { generateFitScore } = await import('@/lib/actions/fit-score-action');
const source = '# Alex\n## Experience\n### Studio\nBuilt React interfaces.';
const result = await generateFitScore(
source,
'Build React interfaces. Operate Kubernetes clusters.',
'job-1',
{ endpointUrl: 'https://ai.example/v1', apiKey: 'key', model: 'model' }
);
const skills = result.dimensions.find((dimension) => dimension.name === 'Skills Match')!;
expect(skills.requirement_source).toBe(source);
expect(skills.requirement_matches).toEqual([
{
requirement: 'Build React interfaces.',
status: 'evidence_found',
evidence: [{ excerpt: 'Built React interfaces.', context: 'Alex / Experience / Studio' }],
},
{ requirement: 'Operate Kubernetes clusters.', status: 'not_evidenced', evidence: [] },
]);
expect(result.dimensions[0].requirement_matches).toBeUndefined();
expect(JSON.stringify(result)).not.toContain('Fabricated');
const insertCall = mocks.dbExecute.mock.calls.at(-1)?.[0] as { args: unknown[] };
expect(JSON.parse(insertCall.args[4] as string)).toEqual(result.dimensions);
});

it('adds Knowledgebase semantic similarity as visible fit-score evidence', async () => {
mocks.getCurrentUserId.mockResolvedValue('user-1');
mocks.dbExecute
Expand Down
100 changes: 100 additions & 0 deletions __tests__/refine-resume-action.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';

const mocks = vi.hoisted(() => ({
generate: vi.fn(),
user: vi.fn(),
debit: vi.fn(),
credit: vi.fn(),
execute: vi.fn(),
}));
vi.mock('ai', () => ({ generateObject: mocks.generate }));
vi.mock('@/lib/ai-cloudflare', () => ({
getAIModel: () => ({}),
getAIModelRetryOptions: () => ({}),
}));
vi.mock('@/lib/auth-utils', () => ({ getCurrentUserId: mocks.user }));
vi.mock('@/lib/actions/token-actions', () => ({
debitToken: mocks.debit,
creditTokens: mocks.credit,
}));
vi.mock('@/lib/db', () => ({ db: { execute: mocks.execute } }));
import { refineResumeSectionForClient } from '@/lib/actions/refine-resume-action';

const master =
'# Alex\n\n## Experience\n### Studio\n- Built APIs.\n- Built React interfaces.\n\n## Projects\n### Reader\n- Built a reading queue.\n\n## Education\nBSc, Example College.\n';
const input = {
masterSource: master,
currentSource: master.replace(
'## Experience',
'## Summary\nSoftware engineer building APIs and React interfaces for users.\n\n## Experience'
),
jdText: 'Build React interfaces.',
instruction: 'Lead with frontend work',
target: { kind: 'bullets' as const, groupId: 'g1' },
aiConfig: { endpointUrl: '', apiKey: '', model: '' },
};
beforeEach(() => {
vi.resetAllMocks();
mocks.user.mockResolvedValue(null);
mocks.debit.mockResolvedValue({ success: true });
mocks.generate.mockResolvedValue({ object: { ordered_ids: ['g1b2', 'g1b1'] } });
});
describe('refinement action boundary', () => {
it('uses the same bounded job context as tailoring for long descriptions', async () => {
const jdText = 'A'.repeat(15_001);
const result = await refineResumeSectionForClient({ ...input, jdText });
expect(result.success).toBe(true);
const request = mocks.generate.mock.calls[0][0];
expect(JSON.parse(request.prompt).job_description).toBe(jdText.slice(0, 15_000));
expect(mocks.debit).not.toHaveBeenCalled();
});

it('returns a source-preserving guest proposal without cloud writes or billing', async () => {
const result = await refineResumeSectionForClient(input);
expect(result).toMatchObject({
success: true,
data: {
tailored: input.currentSource.replace(
'- Built APIs.\n- Built React interfaces.',
'- Built React interfaces.\n- Built APIs.'
),
},
});
expect(mocks.execute).not.toHaveBeenCalled();
expect(mocks.debit).not.toHaveBeenCalled();
});
it('validates bounds and current section before billing', async () => {
mocks.user.mockResolvedValue('user');
expect(
await refineResumeSectionForClient({ ...input, currentSource: 'x'.repeat(30_001) })
).toMatchObject({ success: false, retryable: false });
expect(
await refineResumeSectionForClient({
...input,
target: { kind: 'bullets', groupId: 'foreign' },
})
).toMatchObject({ success: false, retryable: false });
expect(mocks.debit).not.toHaveBeenCalled();
expect(mocks.generate).not.toHaveBeenCalled();
});
it('refunds invalid permutations once and leaves original draft untouched', async () => {
mocks.user.mockResolvedValue('user');
mocks.generate.mockResolvedValue({ object: { ordered_ids: ['g1b1', 'foreign'] } });
expect(await refineResumeSectionForClient(input)).toMatchObject({
success: false,
error: expect.stringContaining('draft is unchanged'),
});
expect(mocks.debit).toHaveBeenCalledExactlyOnceWith('tailor', 'refine');
expect(mocks.credit).toHaveBeenCalledExactlyOnceWith('user', 1, 'refund', 'ai_failure');
});
it('does not generate or refund when credits are unavailable', async () => {
mocks.user.mockResolvedValue('user');
mocks.debit.mockResolvedValue({ success: false, error: 'insufficient_tokens' });
expect(await refineResumeSectionForClient(input)).toMatchObject({
success: false,
error: 'No tokens remaining. Purchase more to continue.',
});
expect(mocks.generate).not.toHaveBeenCalled();
expect(mocks.credit).not.toHaveBeenCalled();
});
});
Loading
Loading