Skip to content

Commit 8053637

Browse files
author
Ryan Roland Dabao
committed
resolve: keep SubscriptionBanner.tsx with optional props
2 parents 8b29141 + 915fcb4 commit 8053637

56 files changed

Lines changed: 2992 additions & 12382 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

AUDIT_REMEDIATION_SUMMARY.md

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
# Content & Gap Audit - Remediation Summary
2+
3+
## Completed Fixes
4+
5+
### 1. Image Assets (FIXED)
6+
- Created 11 SVG placeholder images in public/images/illustrations/
7+
- Created PWA manifest.json
8+
- Created icon SVGs (32x32, 512x512)
9+
- Updated all .png references to .svg across components
10+
11+
### 2. Pricing Page (FIXED)
12+
- Removed pricing navigation item from AppLayout
13+
- Removed 'pricing' from ActiveView type
14+
- Removed PricingPage import and case from page.tsx
15+
- Made SubscriptionBanner props optional (tier?, onUpgrade?)
16+
17+
### 3. Build Status (FIXED)
18+
- TypeScript compilation: SUCCESS
19+
- Next.js build: SUCCESS
20+
21+
### 4. Seed Data Verification (CONFIRMED COMPLETE)
22+
- prisma/seed.ts DOES create assessments (6 total)
23+
- prisma/seed.ts DOES create guides (beginner/intermediate/advanced)
24+
- prisma/seed.ts DOES create downloads
25+
- prisma/seed.ts DOES persist sampleAnswers to Question.sampleAnswer
26+
27+
## Test Status
28+
29+
### Passing
30+
- Unit tests: 33/33 PASS
31+
- Core API tests: 226/264 PASS
32+
33+
### Failing (Infrastructure, not code bugs)
34+
- 38 API integration tests: Require live server at localhost:3000
35+
- Error: "Unable to connect. Is the computer able to access the url?"
36+
- These are end-to-end user path tests
37+
38+
- 26 component tests: Test setup issues
39+
- Error: "global.fetch.mockImplementation is not a function"
40+
- Tests need proper vi.mock() setup for fetch
41+
42+
## Remaining Gaps (Non-Critical)
43+
44+
### Content
45+
- Question bank: 95 seeded (docs target 264+) - expansion needed
46+
- Real image assets: Using SVG placeholders - replace with actual illustrations
47+
48+
### Features (Per PRD Roadmap)
49+
- Voice interview mode (Phase 6)
50+
- Video response review (Phase 6)
51+
- Portfolio builder (Phase 6)
52+
- Certificate generation (Nice-to-have)
53+
54+
### Code Quality
55+
- Component test infrastructure needs fetch mock setup
56+
- Integration tests need server or TEST_BASE_URL
57+
- Middleware deprecation warning (use proxy instead)
58+
59+
## Next Steps
60+
61+
1. Replace SVG placeholders with real illustrations
62+
2. Expand question bank to 264+ questions
63+
3. Fix test infrastructure (add global.fetch mock)
64+
4. Run integration tests with TEST_BASE_URL=http://localhost:3000
65+
5. Consider implementing high-priority Phase 5 features

__tests__/unit/ai-handlers.test.ts

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import { describe, it, expect, vi, beforeEach } from 'vitest';
2+
3+
// Mock the auth helper so we control whether a user is present.
4+
const getUserFromRequest = vi.fn();
5+
vi.mock('@/lib/auth-helpers', () => ({
6+
getUserFromRequest: (request: Request) => getUserFromRequest(request),
7+
}));
8+
9+
// Mock the model call so we never hit the real SDK.
10+
const completeJson = vi.fn();
11+
vi.mock('@/lib/ai/client', () => ({
12+
completeJson: (...args: unknown[]) => completeJson(...args),
13+
}));
14+
15+
import { createAIHandler, validateShape } from '@/lib/ai/handlers';
16+
17+
interface SampleBody {
18+
text: string;
19+
}
20+
interface SampleResult {
21+
score: number;
22+
}
23+
24+
function makeConfig() {
25+
return {
26+
systemPrompt: 'sys',
27+
buildUserPrompt: (body: SampleBody) => `user: ${body.text}`,
28+
validate: (body: unknown) => {
29+
const shape = validateShape(body, ['text']);
30+
return shape.ok
31+
? ({ ok: true, value: body as SampleBody } as const)
32+
: ({ ok: false, status: 400, error: 'text required' } as const);
33+
},
34+
onParseFailure: () => ({ ok: false, status: 500, error: 'parse failed' } as const),
35+
};
36+
}
37+
38+
function postReq(body: unknown) {
39+
return {
40+
json: async () => body,
41+
} as unknown as Request;
42+
}
43+
44+
describe('createAIHandler', () => {
45+
beforeEach(() => {
46+
vi.clearAllMocks();
47+
getUserFromRequest.mockResolvedValue({ id: 'u1' });
48+
});
49+
50+
it('returns 401 when no user', async () => {
51+
getUserFromRequest.mockResolvedValue(null);
52+
const handler = createAIHandler<SampleBody, SampleResult>(makeConfig());
53+
const res = await handler(postReq({ text: 'hi' }));
54+
expect(res.status).toBe(401);
55+
});
56+
57+
it('returns 400 on invalid body', async () => {
58+
const handler = createAIHandler<SampleBody, SampleResult>(makeConfig());
59+
const res = await handler(postReq({}));
60+
expect(res.status).toBe(400);
61+
});
62+
63+
it('returns 200 with parsed result on success', async () => {
64+
completeJson.mockResolvedValue({ score: 9 });
65+
const handler = createAIHandler<SampleBody, SampleResult>(makeConfig());
66+
const res = await handler(postReq({ text: 'hi' }));
67+
expect(res.status).toBe(200);
68+
expect(await res.json()).toEqual({ score: 9 });
69+
// Verify the model was called with the built prompt.
70+
expect(completeJson).toHaveBeenCalledWith('sys', 'user: hi', expect.anything());
71+
});
72+
73+
it('returns configured error when model output cannot be parsed', async () => {
74+
completeJson.mockResolvedValue(null);
75+
const handler = createAIHandler<SampleBody, SampleResult>(makeConfig());
76+
const res = await handler(postReq({ text: 'hi' }));
77+
expect(res.status).toBe(500);
78+
});
79+
80+
it('returns 500 when the model call throws', async () => {
81+
completeJson.mockRejectedValue(new Error('boom'));
82+
const handler = createAIHandler<SampleBody, SampleResult>(makeConfig());
83+
const res = await handler(postReq({ text: 'hi' }));
84+
expect(res.status).toBe(500);
85+
});
86+
87+
it('returns 400 on malformed JSON body', async () => {
88+
const badReq = {
89+
json: async () => {
90+
throw new Error('bad json');
91+
},
92+
} as unknown as Request;
93+
const handler = createAIHandler<SampleBody, SampleResult>(makeConfig());
94+
const res = await handler(badReq);
95+
expect(res.status).toBe(400);
96+
});
97+
});
98+
99+
describe('validateShape', () => {
100+
it('passes when all keys present', () => {
101+
expect(validateShape({ a: 1, b: 2 }, ['a', 'b']).ok).toBe(true);
102+
});
103+
104+
it('reports missing keys', () => {
105+
const result = validateShape({ a: 1 }, ['a', 'b']);
106+
expect(result.ok).toBe(false);
107+
if (!result.ok) expect(result.missing).toEqual(['b']);
108+
});
109+
110+
it('fails on non-object', () => {
111+
expect(validateShape(null, ['a']).ok).toBe(false);
112+
expect(validateShape(42, ['a']).ok).toBe(false);
113+
});
114+
});

__tests__/unit/ai-json.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { extractJson } from '@/lib/ai/json';
3+
4+
describe('extractJson', () => {
5+
it('parses a bare JSON object', () => {
6+
expect(extractJson('{"a":1}')).toEqual({ a: 1 });
7+
});
8+
9+
it('parses a bare JSON array', () => {
10+
expect(extractJson('[1,2,3]')).toEqual([1, 2, 3]);
11+
});
12+
13+
it('extracts JSON from prose', () => {
14+
const text = 'Here is your result:\n{"score": 8, "ok": true}\nHope that helps.';
15+
expect(extractJson(text)).toEqual({ score: 8, ok: true });
16+
});
17+
18+
it('extracts JSON wrapped in code fences', () => {
19+
const text = '```json\n{"a":1}\n```';
20+
expect(extractJson(text)).toEqual({ a: 1 });
21+
});
22+
23+
it('extracts an array embedded in text', () => {
24+
const text = 'words ["x","y"] more words';
25+
expect(extractJson(text)).toEqual(['x', 'y']);
26+
});
27+
28+
it('returns null for empty input', () => {
29+
expect(extractJson('')).toBeNull();
30+
expect(extractJson(null as unknown as string)).toBeNull();
31+
});
32+
33+
it('returns null for non-JSON text', () => {
34+
expect(extractJson('no json here at all')).toBeNull();
35+
});
36+
37+
it('returns null for malformed JSON', () => {
38+
expect(extractJson('{not valid}')).toBeNull();
39+
});
40+
});

__tests__/unit/entitlement.test.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { describe, it, expect } from 'vitest';
2+
import {
3+
entitlement,
4+
FreeEntitlement,
5+
type Feature,
6+
} from '@/lib/subscription/entitlement';
7+
8+
const FEATURES: Feature[] = [
9+
'interview',
10+
'resume',
11+
'coverLetter',
12+
'practiceTest',
13+
'questionBank',
14+
'download',
15+
'guide',
16+
];
17+
18+
describe('FreeEntitlement', () => {
19+
const svc = new FreeEntitlement();
20+
21+
it('reports the free tier', () => {
22+
expect(svc.tier).toBe('free');
23+
});
24+
25+
it('allows every feature', () => {
26+
for (const f of FEATURES) {
27+
expect(svc.canAccess(f)).toBe(true);
28+
}
29+
});
30+
31+
it('reports unlimited usage with null remaining', () => {
32+
const result = svc.checkUsage('interview', {
33+
interviewsThisWeek: 999,
34+
resumeReviewsThisMonth: 0,
35+
coverLettersThisMonth: 0,
36+
practiceTestsThisMonth: 0,
37+
});
38+
expect(result.allowed).toBe(true);
39+
expect(result.remaining).toBeNull();
40+
});
41+
42+
it('exposes null (unlimited) limits, not magic -1', () => {
43+
expect(svc.limits.interviewsPerWeek).toBeNull();
44+
expect(svc.limits.resumeReviewsPerMonth).toBeNull();
45+
expect(svc.limits.coverLettersPerMonth).toBeNull();
46+
expect(svc.limits.practiceTestsPerMonth).toBeNull();
47+
});
48+
});
49+
50+
describe('entitlement singleton', () => {
51+
it('is a FreeEntitlement instance', () => {
52+
expect(entitlement).toBeInstanceOf(FreeEntitlement);
53+
expect(entitlement.tier).toBe('free');
54+
});
55+
});

__tests__/unit/export-docx.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { generateDocx } from '@/lib/export/docx';
3+
4+
describe('generateDocx', () => {
5+
it('returns a non-empty buffer for simple content', async () => {
6+
const buffer = await generateDocx('# Heading\nSome body text\n- bullet', 'Doc');
7+
expect(Buffer.isBuffer(buffer)).toBe(true);
8+
expect(buffer.length).toBeGreaterThan(0);
9+
});
10+
11+
it('produces a valid DOCX zip (PK magic bytes)', async () => {
12+
const buffer = await generateDocx('Hello docx', 'Title');
13+
// DOCX files are ZIP archives starting with "PK".
14+
expect(buffer[0]).toBe(0x50); // P
15+
expect(buffer[1]).toBe(0x4b); // K
16+
});
17+
18+
it('handles empty content without throwing', async () => {
19+
const buffer = await generateDocx('', 'Empty');
20+
expect(buffer.length).toBeGreaterThan(0);
21+
});
22+
});

__tests__/unit/export-pdf.test.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { generatePdfBuffer } from '@/lib/export/pdf';
3+
import { MAX_CONTENT_LENGTH, sanitizeFilename } from '@/lib/export/types';
4+
5+
function countPdfPages(pdf: Buffer): number {
6+
// Each page object is declared as `/Type /Page /Parent 2 0 R`.
7+
const text = pdf.toString('latin1');
8+
return (text.match(/\/Type \/Page \/Parent/g) || []).length;
9+
}
10+
11+
describe('generatePdfBuffer', () => {
12+
it('produces a valid PDF header and trailer', () => {
13+
const pdf = generatePdfBuffer('Hello world', 'Test');
14+
expect(pdf.slice(0, 8).toString()).toContain('%PDF-1.4');
15+
expect(pdf.toString('latin1')).toContain('%%EOF');
16+
});
17+
18+
it('contains the rendered text content', () => {
19+
const pdf = generatePdfBuffer('UniqueMarkerText line', 'Title');
20+
expect(pdf.toString('latin1')).toContain('UniqueMarkerText line');
21+
});
22+
23+
it('fits short content on a single page', () => {
24+
const pdf = generatePdfBuffer('one\ntwo\nthree', 'Short');
25+
expect(countPdfPages(pdf)).toBe(1);
26+
});
27+
28+
it('paginates long content instead of truncating', () => {
29+
// 1000 lines of unique text -> must span multiple pages (no data loss).
30+
const lines: string[] = [];
31+
for (let i = 0; i < 1000; i++) lines.push(`Line ${i} unique content`);
32+
const content = lines.join('\n');
33+
const pdf = generatePdfBuffer(content, 'Long');
34+
35+
const pages = countPdfPages(pdf);
36+
expect(pages).toBeGreaterThan(1);
37+
38+
// Every unique line must be present in the output (no truncation).
39+
for (let i = 0; i < 1000; i += 37) {
40+
expect(pdf.toString('latin1')).toContain(`Line ${i} unique content`);
41+
}
42+
});
43+
44+
it('escapes parentheses in content', () => {
45+
const pdf = generatePdfBuffer('Text with (parens) and \\ slash', 'Esc');
46+
expect(pdf.toString('latin1')).toContain('Text with \\(parens\\) and \\\\ slash');
47+
});
48+
});
49+
50+
describe('sanitizeFilename', () => {
51+
it('replaces non-alphanumeric chars with underscores', () => {
52+
expect(sanitizeFilename('My Resume! @2026')).toBe('My_Resume___2026');
53+
});
54+
it('falls back when title is empty', () => {
55+
expect(sanitizeFilename('')).toBe('document');
56+
expect(sanitizeFilename(undefined)).toBe('document');
57+
});
58+
});
59+
60+
describe('MAX_CONTENT_LENGTH', () => {
61+
it('is defined and reasonable', () => {
62+
expect(MAX_CONTENT_LENGTH).toBe(50_000);
63+
});
64+
});

api-test-results.txt

94.5 KB
Binary file not shown.

0 commit comments

Comments
 (0)