Testing and minor changes to CareerExplorer, CollegeProfileForm fixes.

This commit is contained in:
Josh 2025-09-10 12:15:36 +00:00
parent e943f1c427
commit 375e9bacdc
82 changed files with 5137 additions and 238 deletions

View File

@ -1 +1 @@
c8af44caf3dec8c5f306fef35c4925be044f0374-22db19df6f582b90837f001b7bf6ead59acca441-e9eccd451b778829eb2f2c9752c670b707e1268b
24c4644c626acf48ddca3964105cd9bfa267d82a-372bcf506971f56c4911b429b9f5de5bc37ed008-e9eccd451b778829eb2f2c9752c670b707e1268b

8
.env
View File

@ -1,8 +0,0 @@
CORS_ALLOWED_ORIGINS=https://dev1.aptivaai.com,http://34.16.120.118:3000,http://localhost:3000
SERVER1_PORT=5000
SERVER2_PORT=5001
SERVER3_PORT=5002
IMG_TAG=ed1fdbb-202508121553
ENV_NAME=dev
PROJECT=aptivaai-dev

1
.gitignore vendored
View File

@ -26,3 +26,4 @@ uploads/.env
.env
.env.*
scan-env.sh
.aptiva-test-user.json

View File

@ -1 +1 @@
1a7fe9191922c4f8389027ed53b6a4909740a48b
98f674eca26e366aee0b41f250978982060105f0

View File

@ -1 +1 @@
1a7fe9191922c4f8389027ed53b6a4909740a48b
98f674eca26e366aee0b41f250978982060105f0

View File

@ -0,0 +1,37 @@
# AptivaAI Test Components (Curated, Active Only)
> Source of truth for what we **do test**. Keep this file tight and current.
> Add a component/feature here **before** adding tests.
## ✅ Active Components
### A. Auth & Profile (server1)
- **Feature A1**: SignUp → SignIn (cookie) → User Profile (JIT-PII: no `id`)
- Test: `backend/tests/auth_signup_signin.mjs`
### B. Support & Messaging (server3)
- **Feature B1**: `/api/support` auth, dedupe, rate limits, negatives (invalid category, short message)
- Test: `backend/tests/support_limits.mjs`
### C. Subscription & Paywall (server3)
- **Feature C1**: `/api/premium/subscription/status` returns `{ is_premium:false, is_pro_premium:false }` for new user; unauth → 401
- Test: `backend/tests/subscription_status.mjs`
---
## 🟨 Pending Confirmation (do **not** test until moved above)
- Premium Onboarding draft save/load (server3)
- Career data & caching (server2) salary & O*NET warm-cache
- Loan Repayment & ROI (free)
- Milestones & AI suggestions (server3)
- Financial Projection service (frontend utils server3)
- College Mode (premium)
- Reminders & Twilio (server3)
- AI chat risk analysis consumption (server3)
- Nginx/Secrets/CSP checks
- DB connectivity (MySQL SSL) & SQLite reads
- Caching & file safety
- Logging (rid present, no tokens/PII)
> Move items up only after you confirm theyre current and in scope.

View File

@ -0,0 +1,126 @@
// Run:
// node backend/tests/auth_signup_signin.mjs
// BASE=https://staging.aptivaai.com ALLOW_NON_DEV=1 node backend/tests/auth_signup_signin.mjs
//
// Behavior:
// - Creates a brand-new user each run (unique email/username)
// - Cookie-based auth only (captures Set-Cookie from register/signin)
// - Verifies /api/signin returns { message }, /api/user-profile returns 200 JSON w/ NO id leakage
// - Verifies /api/user-profile?fields=… respects allowlist
// - Verifies /api/logout clears cookie and subsequent /api/user-profile is unauthorized
// - Defaults to dev; requires ALLOW_NON_DEV=1 to run on non-dev BASE
import assert from 'node:assert/strict';
const BASE = process.env.BASE || 'https://dev1.aptivaai.com';
if (BASE !== 'https://dev1.aptivaai.com' && process.env.ALLOW_NON_DEV !== '1') {
console.error(`Refusing to run against non-dev BASE='${BASE}'. Set ALLOW_NON_DEV=1 to override.`);
process.exit(2);
}
const j = (o) => JSON.stringify(o);
const rand = () => Math.random().toString(36).slice(2, 10);
const email = `jcoakley@aptivaai.com`;
const username = `qa_${rand()}`;
const password = `Aa1!${rand()}Z`;
let cookie = ''; // session cookie (auth)
function captureSetCookie(headers) {
// In ESM fetch, headers.get('set-cookie') returns the first Set-Cookie (enough for session)
const sc = headers.get('set-cookie');
if (sc) cookie = sc.split(';')[0];
}
async function req(path, { method = 'GET', headers = {}, body } = {}) {
const h = {
'Content-Type': 'application/json',
...(cookie ? { Cookie: cookie } : {}),
...headers,
};
const res = await fetch(`${BASE}${path}`, {
method,
headers: h,
body: body ? j(body) : undefined,
});
const text = await res.text();
let json = null;
try { json = JSON.parse(text); } catch {}
return { res, text, json };
}
(async () => {
// 1) Register (201)
{
const { res, json } = await req('/api/register', {
method: 'POST',
body: {
username,
password,
firstname: 'QA',
lastname: 'Bot',
email,
zipcode: '30024',
state: 'GA',
area: 'Atlanta',
career_situation: 'planning',
},
});
assert.equal(res.status, 201, `register should 201, got ${res.status}`);
captureSetCookie(res.headers);
assert.ok(cookie, 'session cookie must be set after register');
}
// 2) Sign in (200) — cookie refreshed, { message } in body
{
const { res, json } = await req('/api/signin', {
method: 'POST',
body: { username, password },
});
assert.equal(res.status, 200, `signin should 200, got ${res.status}`);
assert.ok(json && typeof json.message === 'string', 'signin returns { message }');
captureSetCookie(res.headers);
assert.ok(cookie, 'session cookie must be present after signin');
}
// 3) Profile (200, JSON, no id leakage)
{
const { res, json, text } = await req('/api/user-profile');
assert.equal(res.status, 200, `profile fetch should 200, got ${res.status}, body=${text.slice(0,120)}`);
assert.ok(json && typeof json === 'object', 'profile returns JSON object');
if ('id' in json || 'user_id' in json) throw new Error('profile must NOT include id/user_id');
}
// 4) Field-filtered profile (allowlist)
{
const fields = 'firstname,lastname,career_situation';
const { res, json, text } = await req(`/api/user-profile?fields=${encodeURIComponent(fields)}`);
assert.equal(res.status, 200, `filtered profile should 200, got ${res.status}, body=${text.slice(0,120)}`);
const keys = Object.keys(json || {});
for (const k of keys) {
if (!['firstname','lastname','career_situation','sms_opt_in','phone_e164','email'].includes(k)) {
throw new Error(`unexpected field '${k}' in filtered profile`);
}
}
}
// 5) Username existence
{
const { res, json } = await req(`/api/check-username/${encodeURIComponent(username)}`);
assert.equal(res.status, 200, 'check-username should 200');
assert.equal(json?.exists, true, 'new username should exist');
}
// 6) Logout then profile blocked
{
const out = await req('/api/logout', { method: 'POST' });
assert.equal(out.res.status, 200, `logout should 200, got ${out.res.status}`);
cookie = ''; // simulate cleared cookie
const { res } = await req('/api/user-profile');
if (res.status === 200) throw new Error('profile should NOT be accessible after logout');
}
console.log('✓ AUTH regression suite passed');
})().catch((e) => {
console.error('✖ AUTH regression failed:', e?.message || e);
process.exit(1);
});

View File

@ -0,0 +1,24 @@
await component('Auth & Profile', [
() => feature('SignUp → SignIn → Profile (cookie, no id leakage)',
'backend/tests/auth_signup_signin.mjs'),
]);
// ─────────────────────────────────────────────────────────────
// Component: Support & Messaging (server3)
// Feature: /api/support auth, dedupe, rate limits, negatives
// ─────────────────────────────────────────────────────────────
await component('Support & Messaging', [
() => feature('Support: auth/dup/rate-limit/negatives',
'backend/tests/support_limits.mjs', { BURST: process.env.BURST || '20' }),
]);
// ─────────────────────────────────────────────────────────────
// Component: Subscription & Paywall (server3)
// Feature: status flags (no PII, default false/false)
// ─────────────────────────────────────────────────────────────
await component('Subscription & Paywall', [
() => feature('Subscription status flags', 'backend/tests/subscription_status.mjs'),
]);

View File

@ -0,0 +1,174 @@
// Run:
// node backend/tests/support_limits.mjs
// BASE=https://staging.aptivaai.com ALLOW_NON_DEV=1 node backend/tests/support_limits.mjs
//
// Behavior:
// - Cookie-based auth only (new user each run, using jcoakley@aptivaai.com by default)
// - Unauth /api/support → 401
// - First + immediate duplicate → each may be 2xx/202 (ok), 429 (rate-limited), or 503 (no SENDGRID)
// Dedupe happens before SENDGRID check, so if first is 503, duplicate often 202 (unless rate-limited).
// We accept {200,201,202,204,429,503} for each, and require that at least one is not 429.
//
import assert from 'node:assert/strict';
const BASE = process.env.BASE || 'https://dev1.aptivaai.com';
if (BASE !== 'https://dev1.aptivaai.com' && process.env.ALLOW_NON_DEV !== '1') {
console.error(`Refusing to run against non-dev BASE='${BASE}'. Set ALLOW_NON_DEV=1 to override.`);
process.exit(2);
}
const j = (o) => JSON.stringify(o);
const rand = () => Math.random().toString(36).slice(2, 10);
const email = process.env.QA_EMAIL || 'jcoakley@aptivaai.com';
const username = `qa_${rand()}`;
const password = `Aa1!${rand()}Z`;
let cookie = ''; // session cookie (auth)
function captureSetCookie(headers) {
const sc = headers.get('set-cookie');
if (sc) cookie = sc.split(';')[0];
}
async function req(path, { method = 'GET', headers = {}, body } = {}) {
const h = {
'Content-Type': 'application/json',
...(cookie ? { Cookie: cookie } : {}),
...headers,
};
const res = await fetch(`${BASE}${path}`, {
method,
headers: h,
body: body ? j(body) : undefined,
});
const text = await res.text();
let json = null;
try { json = JSON.parse(text); } catch {}
return { res, text, json };
}
async function reqNoAuth(path, { method = 'GET', headers = {}, body } = {}) {
const h = { 'Content-Type': 'application/json', ...headers };
const res = await fetch(`${BASE}${path}`, {
method, headers: h, body: body ? j(body) : undefined,
});
const text = await res.text();
let json = null;
try { json = JSON.parse(text); } catch {}
return { res, text, json };
}
(async () => {
// Register
{
const { res, json } = await req('/api/register', {
method: 'POST',
body: {
username,
password,
firstname: 'QA',
lastname: 'Bot',
email,
zipcode: '30024',
state: 'GA',
area: 'Atlanta',
career_situation: 'planning',
},
});
assert.equal(res.status, 201, `register should 201, got ${res.status}`);
captureSetCookie(res.headers);
assert.ok(cookie, 'session cookie must be set after register');
}
// Sign in (refresh cookie)
{
const { res, json } = await req('/api/signin', {
method: 'POST',
body: { username, password },
});
assert.equal(res.status, 200, `signin should 200, got ${res.status}`);
assert.ok(json && typeof json.message === 'string', 'signin returns { message }');
captureSetCookie(res.headers);
}
// Unauthenticated request should 401
{
const { res } = await reqNoAuth('/api/support', {
method: 'POST',
body: { subject: 'unauth test', category: 'general', message: 'unauth test message' },
});
assert.equal(res.status, 401, `unauth /api/support should 401, got ${res.status}`);
}
// First + duplicate: allow {200,201,202,204,429,503}; require at least one NOT 429
const dedupePayload = {
subject: `QA support ${Date.now()}`,
category: 'technical',
message: `QA support test ${Date.now()}`
};
const first = await req('/api/support', { method: 'POST', body: dedupePayload });
const dup = await req('/api/support', { method: 'POST', body: dedupePayload });
const valid = (s) => [200,201,202,204,429,503].includes(s);
if (!valid(first.res.status)) {
throw new Error(`/api/support first unexpected ${first.res.status}, body=${first.text.slice(0,120)}`);
}
if (!valid(dup.res.status)) {
throw new Error(`/api/support duplicate unexpected ${dup.res.status}, body=${dup.text.slice(0,120)}`);
}
const anyNot429 = [first.res.status, dup.res.status].some((s) => s !== 429);
if (!anyNot429) {
throw new Error(`/api/support first+duplicate were both 429 (statuses: ${first.res.status}, ${dup.res.status})`);
}
console.log('✓ SUPPORT: unauth→401, first+dup→(allowed with ≥1 non-429) — starting burst…');
// Burst to trigger rate limit (unique messages to avoid dedupe masking)
const N = Number(process.env.BURST || 20);
const tasks = Array.from({ length: N }, (_, i) =>
req('/api/support', {
method: 'POST',
body: {
subject: `burst ${i}`,
category: 'technical',
message: `burst ${i} ${Date.now()} ${rand()}`
},
})
);
const results = await Promise.all(tasks);
const codes = results.map(r => r.res.status);
const allowed = new Set([200,201,202,204,429,503]);
const rlCount = codes.filter(c => c === 429).length;
if (!codes.every(c => allowed.has(c))) {
throw new Error(`unexpected status in burst: ${codes.join(',')}`);
}
if (rlCount < 1) {
throw new Error(`expected at least one 429 during burst; got codes=${codes.join(',')}`);
}
// Negative cases: invalid category and too-short message
{
const badCat = await req('/api/support', {
method: 'POST',
body: { subject: 'x', category: 'nope', message: 'valid message content' }
});
if (badCat.res.status !== 400 && badCat.res.status !== 429) {
// Allow 429 if limiter tripped; otherwise require 400 for invalid category
throw new Error(`/api/support invalid category expected 400 or 429, got ${badCat.res.status}`);
}
}
{
const tooShort = await req('/api/support', {
method: 'POST',
body: { subject: 'x', category: 'general', message: 'hi' } // < 5 chars
});
if (tooShort.res.status !== 400 && tooShort.res.status !== 429) {
throw new Error(`/api/support short message expected 400 or 429, got ${tooShort.res.status}`);
}
}
console.log('✓ SUPPORT: unauth→401, first+dup→(allowed with ≥1 non-429), burst→(allowed 2xx/429/503 with ≥1 429), negatives→400/429');
})().catch((e) => {
console.error('✖ SUPPORT regression failed:', e?.message || e);
process.exit(1);
});

64
package-lock.json generated
View File

@ -70,6 +70,7 @@
"@babel/parser": "^7.28.0",
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
"@eslint/js": "^9.32.0",
"@playwright/test": "^1.55.0",
"@tailwindcss/forms": "^0.5.10",
"@tailwindcss/typography": "^0.5.16",
"autoprefixer": "^10.4.21",
@ -3328,6 +3329,22 @@
"node": ">=14"
}
},
"node_modules/@playwright/test": {
"version": "1.55.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.55.0.tgz",
"integrity": "sha512-04IXzPwHrW69XusN/SIdDdKZBzMfOT9UNT/YiJit/xpy2VuAoB8NHc8Aplb96zsWDddLnbkPL3TsmrS04ZU2xQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.55.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@pmmmwh/react-refresh-webpack-plugin": {
"version": "0.5.17",
"resolved": "https://registry.npmjs.org/@pmmmwh/react-refresh-webpack-plugin/-/react-refresh-webpack-plugin-0.5.17.tgz",
@ -15475,6 +15492,53 @@
"node": ">=4"
}
},
"node_modules/playwright": {
"version": "1.55.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.55.0.tgz",
"integrity": "sha512-sdCWStblvV1YU909Xqx0DhOjPZE4/5lJsIS84IfN9dAZfcl/CIZ5O8l3o0j7hPMjDvqoTF8ZUcc+i/GL5erstA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.55.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.55.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.55.0.tgz",
"integrity": "sha512-GvZs4vU3U5ro2nZpeiwyb0zuFaqb9sUiAJuyrWpcGouD8y9/HLgGbNRjIph7zU9D3hnPaisMl9zG9CgFi/biIg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/pluralize": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz",

View File

@ -104,6 +104,7 @@
"@babel/parser": "^7.28.0",
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
"@eslint/js": "^9.32.0",
"@playwright/test": "^1.55.0",
"@tailwindcss/forms": "^0.5.10",
"@tailwindcss/typography": "^0.5.16",
"autoprefixer": "^10.4.21",

File diff suppressed because one or more lines are too long

View File

@ -1,6 +0,0 @@
const { defineConfig } = require('@playwright/test');
module.exports = defineConfig({
testDir: 'tests',
projects:[ {name:'chromium', use:{browserName:'chromium'}} ],
timeout: 30000,
});

15
playwright.config.mjs Normal file
View File

@ -0,0 +1,15 @@
import { defineConfig } from '@playwright/test';
export default defineConfig({
// Limit Playwright to E2E specs only
testDir: '/home/jcoakley/aptiva-dev1-app/tests/e2e',
testMatch: /.*\.spec\.(?:mjs|js|ts)$/,
use: {
baseURL: process.env.PW_BASE_URL || 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
retries: 1,
reporter: [['list'], ['html', { open: 'never' }]],
});

View File

@ -286,6 +286,7 @@ const confirmLogout = async () => {
'aiClickDate',
'aiRecommendations',
'premiumOnboardingState',
'premiumOnboardingPointer',
'financialProfile',
'selectedScenario',
]);

View File

@ -104,6 +104,7 @@ function CareerExplorer() {
const [loading, setLoading] = useState(false);
const [progress, setProgress] = useState(0);
const [showInterestMeaningModal, setShowInterestMeaningModal] = useState(false);
const [isSuggesting, setIsSuggesting] = useState(false);
const fitRatingMap = { Best: 5, Great: 4, Good: 3 };
const jobZoneLabels = {
@ -162,7 +163,7 @@ function CareerExplorer() {
if (!answers) {
setCareerSuggestions([]);
localStorage.removeItem('careerSuggestionsCache');
setLoading(true); setProgress(0); setLoading(false);
setLoading(true); setProgress(0); setLoading(false); setIsSuggesting(true);
return;
}
@ -326,7 +327,6 @@ function CareerExplorer() {
setSelectedCareer(career);
setCareerDetails(null);
setLoading(true);
try {
let cipCode = null;
@ -393,14 +393,12 @@ function CareerExplorer() {
console.error('[handleCareerClick] fatal:', fatal);
setCareerDetails({ error: `We're sorry, but detailed info for "${career.title}" isn't available right now.` });
} finally {
setLoading(false);
}
}, [areaTitle, userState]);
// ---------- add-from-search ----------
const handleCareerFromSearch = useCallback((obj) => {
const adapted = { code: obj.soc_code, title: obj.title, cipCode: obj.cip_code, fromManualSearch: true };
setLoading(true);
setPendingCareerForModal(adapted);
}, []);
@ -578,10 +576,7 @@ function CareerExplorer() {
<h2 className="text-2xl font-semibold mb-2">Explore Careers - use these tools to find your best fit</h2>
<CareerSearch
disabled={showModal}
onCareerSelected={(careerObj) => {
setLoading(true);
setPendingCareerForModal(careerObj);
}}
onCareerSelected={handleCareerFromSearch}
/>
</div>

View File

@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useState, useRef } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import apiFetch from '../auth/apiFetch.js';
import moment from 'moment/moment.js';
@ -48,21 +48,24 @@ const toMySqlDate = iso => {
return iso.replace('T', ' ').slice(0, 19);
};
export default function CollegeProfileForm() {
const { careerId, id } = useParams(); // id optional
const nav = useNavigate();
const [cipRows, setCipRows] = useState([]);
const [schoolSug, setSchoolSug] = useState([]);
const [progSug, setProgSug] = useState([]);
const [types, setTypes] = useState([]);
const [ipeds, setIpeds] = useState([]);
const [schoolValid, setSchoolValid] = useState(true);
const [programValid, setProgramValid] = useState(true);
const [autoGradDate, setAutoGradDate] = useState('');
const [graduationTouched, setGraduationTouched] = useState(false);
const [programLengthTouched, setProgramLengthTouched] = useState(false);
const schoolData = cipRows;
const [selectedUnitId, setSelectedUnitId] = useState(null);
const schoolPrevRef = useRef('');
const programPrevRef = useRef('');
const lastSchoolText = useRef('');
const [form, setForm] = useState({
career_profile_id : careerId,
@ -81,6 +84,10 @@ export default function CollegeProfileForm() {
const [autoTuition, setAutoTuition] = useState(0);
const firstOfNextMonth = (d) => {
return moment(d).startOf('month').add(1, 'month').format('YYYY-MM-DD');
};
// ---------- handlers (inside component) ----------
const handleFieldChange = (e) => {
const { name, value, type, checked } = e.target;
@ -98,6 +105,11 @@ const handleFieldChange = (e) => {
) {
draft[name] = value === '' ? '' : parseFloat(value);
if (name === 'program_length') setProgramLengthTouched(true);
if (name === 'program_type') {
draft[name] = value;
draft.credit_hours_required = '';
setProgramLengthTouched(false);
}
} else {
draft[name] = value;
}
@ -105,59 +117,95 @@ const handleFieldChange = (e) => {
});
};
const onSchoolInput = (e) => {
handleFieldChange(e);
const v = e.target.value.toLowerCase();
const suggestions = cipRows
.filter((r) => r.INSTNM.toLowerCase().includes(v))
.map((r) => r.INSTNM);
setSchoolSug([...new Set(suggestions)].slice(0, 10));
};
const onProgramInput = (e) => {
handleFieldChange(e);
if (!form.selected_school) return;
const v = e.target.value.toLowerCase();
const sug = cipRows
.filter(
(r) =>
r.INSTNM.toLowerCase() === form.selected_school.toLowerCase() &&
r.CIPDESC.toLowerCase().includes(v)
)
.map((r) => r.CIPDESC);
setProgSug([...new Set(sug)].slice(0, 10));
};
// Prefill school suggestions when form loads or school changes
useEffect(() => {
const v = (form.selected_school || '').toLowerCase().trim();
if (!v || !cipRows.length) {
setSchoolSug([]);
return;
}
const suggestions = cipRows
.filter(r => (r.INSTNM || '').toLowerCase().includes(v))
.map(r => r.INSTNM);
setSchoolSug([...new Set(suggestions)].slice(0, 10));
}, [form.selected_school, cipRows]);
// Prefill program suggestions when form loads or program/school changes
useEffect(() => {
const sch = (form.selected_school || '').toLowerCase().trim();
const q = (form.selected_program || '').toLowerCase().trim();
if (!sch || !q || !cipRows.length) {
const onSchoolInput = async (e) => {
handleFieldChange(e);
const value = e.target.value || '';
// If school text changed at all, clear program suggestions and program/type selections
if (value !== lastSchoolText.current) {
setProgSug([]);
return;
setTypes([]);
programPrevRef.current = '';
setForm(prev => ({
...prev,
selected_program: value ? prev.selected_program : '', // clear if user erased school
program_type: ''
}));
lastSchoolText.current = value;
}
const sug = cipRows
.filter(r =>
(r.INSTNM || '').toLowerCase() === sch &&
(r.CIPDESC || '').toLowerCase().includes(q)
)
.map(r => r.CIPDESC);
setProgSug([...new Set(sug)].slice(0, 10));
}, [form.selected_school, form.selected_program, cipRows]);
if (!value.trim()) { setSchoolSug([]); schoolPrevRef.current = ''; return; }
const it = e?.nativeEvent?.inputType; // 'insertReplacementText' on datalist pick
const rep = it === 'insertReplacementText';
const big = Math.abs(value.length - (schoolPrevRef.current || '').length) > 1;
try {
const resp = await authFetch(`/api/schools/suggest?query=${encodeURIComponent(value)}&limit=10`);
const arr = resp.ok ? await resp.json() : [];
const opts = Array.isArray(arr) ? arr : [];
setSchoolSug(opts); // [{ name, unitId }]
const exact = opts.find(o => (o.name || '').toLowerCase() === value.toLowerCase());
if (exact && (rep || big)) {
setSelectedUnitId(exact.unitId ?? null);
setForm(prev => ({
...prev,
selected_school : exact.name,
selected_program: '',
program_type : ''
}));
setProgSug([]); setTypes([]);
}
} catch { setSchoolSug([]); }
schoolPrevRef.current = value;
};
const onProgramInput = async (e) => {
handleFieldChange(e);
const school = (form.selected_school || '').trim();
const value = e.target.value || '';
if (!school || !value) { setProgSug([]); programPrevRef.current = value; return; }
const it = e?.nativeEvent?.inputType; // 'insertReplacementText' when choosing from datalist
const rep = it === 'insertReplacementText';
const big = Math.abs(value.length - (programPrevRef.current || '').length) > 1;
try {
const resp = await authFetch(`/api/programs/suggest?school=${encodeURIComponent(school)}&query=${encodeURIComponent(value)}&limit=10`);
const arr = resp.ok ? await resp.json() : [];
const opts = Array.isArray(arr) ? arr : []; // [{ program }]
setProgSug(opts);
// Early commit if exact match was selected from the list (prevents double-pick annoyance)
const exact = opts.find(p => (p.program || '').toLowerCase() === value.toLowerCase());
if (exact && (rep || big)) {
setForm(prev => ({ ...prev, selected_program: exact.program }));
setTypes([]); // will refetch types below via effect/blur
}
} catch {
setProgSug([]);
}
programPrevRef.current = value;
};
// Prefill program suggestions once school+program exist (e.g., after API load)
useEffect(() => {
const school = (form.selected_school || '').trim();
const q = (form.selected_program || '').trim();
if (!school || !q) { setProgSug([]); return; }
(async () => {
try {
const resp = await authFetch(`/api/programs/suggest?school=${encodeURIComponent(school)}&query=${encodeURIComponent(q)}&limit=10`);
const arr = resp.ok ? await resp.json() : [];
setProgSug(Array.isArray(arr) ? arr : []);
} catch { setProgSug([]); }
})();
}, [form.selected_school, form.selected_program]);
// When selected_school changes (after commit/blur), reset program suggestions/types
useEffect(() => {
setProgSug([]);
setTypes([]);
setSelectedUnitId(null);
programPrevRef.current = '';
}, [form.selected_school]);
useEffect(() => {
if (id && id !== 'new') {
@ -174,27 +222,60 @@ useEffect(() => {
is_online : !!raw.is_online,
loan_deferral_until_graduation : !!raw.loan_deferral_until_graduation,
};
setForm(normalized);
if (normalized.tuition !== undefined && normalized.tuition !== null) {
setManualTuition(String(normalized.tuition));
}
setForm(normalized);
// Show saved tuition immediately; estimator will overwrite when deps change
if (normalized.tuition != null) {
const n = Number(normalized.tuition);
setAutoTuition(Number.isFinite(n) ? n : 0);
}
if (normalized.unit_id) setSelectedUnitId(normalized.unit_id);
// If profile came with school+program, load types so Degree Type select is populated
if ((normalized.selected_school || '') && (normalized.selected_program || '')) {
try {
const resp = await authFetch(
`/api/programs/types?school=${encodeURIComponent(normalized.selected_school)}&program=${encodeURIComponent(normalized.selected_program)}`
);
const data = resp.ok ? await resp.json() : null;
setTypes(Array.isArray(data?.types) ? data.types : []);
} catch {}
}
}
})();
}
}, [careerId, id]);
// 2) keep manualTuition aligned if form.tuition is updated elsewhere
useEffect(() => {
if (form.tuition !== undefined && form.tuition !== null) {
if (manualTuition.trim() === '') {
setManualTuition(String(form.tuition));
}
}
}, [form.tuition]);
async function handleSave(){
try{
const body = normalisePayload({ ...form, tuition: chosenTuition, career_profile_id: careerId });
// Compute chosen tuition exactly like Onboarding (manual override wins; blank => auto)
const chosenTuition =
(manualTuition.trim() === '')
? autoTuition
: (Number.isFinite(parseFloat(manualTuition)) ? parseFloat(manualTuition) : autoTuition);
// Confirm user actually picked from list (one alert, on Save only)
const school = (form.selected_school || '').trim().toLowerCase();
const prog = (form.selected_program || '').trim().toLowerCase();
// validate against current server suggestions (not local files)
const exactSchool = school && schoolSug.find(o =>
(o.name || '').toLowerCase() === school
);
if (school && !exactSchool) {
setSchoolValid(false);
alert('Please pick a school from the list.');
return;
}
const exactProgram = prog && progSug.find(p =>
(p.program || '').toLowerCase() === prog
);
if (prog && !exactProgram) {
setProgramValid(false);
alert('Please pick a program from the list.');
return;
}
const body = normalisePayload({ ...form, tuition: chosenTuition, career_profile_id: careerId, unit_id: selectedUnitId ?? null });
const res = await authFetch('/api/premium/college-profile',{
method:'POST',
headers:{'Content-Type':'application/json'},
@ -208,96 +289,79 @@ useEffect(() => {
}catch(err){ console.error(err); alert(err.message);}
}
/* LOAD iPEDS ----------------------------- */
useEffect(() => {
const sch = (form.selected_school || '').trim();
const prog = (form.selected_program || '').trim();
if (!sch || !prog) { setTypes([]); return; }
(async () => {
try {
const resp = await authFetch(`/api/programs/types?school=${encodeURIComponent(sch)}&program=${encodeURIComponent(prog)}`);
const data = resp.ok ? await resp.json() : null;
const arr = Array.isArray(data?.types) ? data.types : [];
setTypes(arr);
} catch { setTypes([]); }
})();
}, [form.selected_school, form.selected_program]);
// Resolve UNITID from typed/loaded school name (profile doesn't store unit_id)
useEffect(() => {
const name = (form.selected_school || '').trim();
if (!name || selectedUnitId) return;
let cancelled = false;
(async () => {
try {
// try a wider net so exact always shows up
const resp = await authFetch(`/api/schools/suggest?query=${encodeURIComponent(name)}&limit=50`);
if (!resp.ok) return;
const arr = await resp.json();
const exact = Array.isArray(arr)
? arr.find(o => (o.name || '').toLowerCase() === name.toLowerCase())
: null;
if (!cancelled && exact?.unitId) setSelectedUnitId(exact.unitId);
} catch {}
})();
return () => { cancelled = true; };
}, [form.selected_school, selectedUnitId]);
// Auto-calc Yearly Tuition via server (parity with Onboarding)
useEffect(() => {
fetch('/ic2023_ay.csv', { credentials: 'omit' })
.then(r => r.text())
.then(text => {
const rows = text.split('\n').map(l => l.split(','));
const headers = rows[0];
const parsed = rows.slice(1).map(r =>
Object.fromEntries(r.map((v,i)=>[headers[i], v]))
);
setIpeds(parsed); // you already declared setIpeds
})
.catch(err => console.error('iPEDS load failed', err));
}, []);
useEffect(() => { fetch('/cip_institution_mapping_new.json', { credentials: 'omit' })
.then(r=>r.text()).then(t => setCipRows(
t.split('\n').map(l=>{try{return JSON.parse(l)}catch{ return null }})
.filter(Boolean)
));
fetch('/ic2023_ay.csv')
.then(r=>r.text()).then(csv=>{/* identical to CollegeOnboarding */});
},[]);
useEffect(()=>{
if(!form.selected_school || !form.selected_program) { setTypes([]); return; }
const t = cipRows.filter(r =>
r.INSTNM.toLowerCase()===form.selected_school.toLowerCase() &&
r.CIPDESC===form.selected_program)
.map(r=>r.CREDDESC);
setTypes([...new Set(t)]);
},[form.selected_school, form.selected_program, cipRows]);
useEffect(() => {
if (!ipeds.length) return;
if (!form.selected_school ||
!form.program_type ||
!form.credit_hours_per_year) return;
/* 1 ─ locate UNITID */
const sch = cipRows.find(
r => r.INSTNM.toLowerCase() === form.selected_school.toLowerCase()
);
if (!sch) return;
const unitId = sch.UNITID;
const row = ipeds.find(r => r.UNITID === unitId);
if (!row) return;
/* 2 ─ decide instate / district buckets */
const grad = [
"Master's Degree","Doctoral Degree",
"Graduate/Professional Certificate","First Professional Degree"
].includes(form.program_type);
const pick = (codeInDist, codeInState, codeOut) => {
if (form.is_in_district) return row[codeInDist];
else if (form.is_in_state) return row[codeInState];
else return row[codeOut];
};
const partTime = grad
? pick('HRCHG5','HRCHG6','HRCHG7')
: pick('HRCHG1','HRCHG2','HRCHG3');
const fullTime = grad
? pick('TUITION5','TUITION6','TUITION7')
: pick('TUITION1','TUITION2','TUITION3');
const chpy = parseFloat(form.credit_hours_per_year) || 0;
const est = chpy && chpy < 24
? parseFloat(partTime || 0) * chpy
: parseFloat(fullTime || 0);
setAutoTuition(Math.round(est));
(async () => {
const chpy = Number(form.credit_hours_per_year);
if (!selectedUnitId ||
!form.program_type ||
!Number.isFinite(chpy) ||
chpy <= 0) {
// keep previous autoTuition if user has manual override; otherwise show 0
if (manualTuition.trim() === '') setAutoTuition(0);
return;
}
try {
const qs = new URLSearchParams({
unitId: String(selectedUnitId),
programType: form.program_type,
inState: (form.is_in_state ? 1 : 0).toString(),
inDistrict: (form.is_in_district ? 1 : 0).toString(),
creditHoursPerYear: String(chpy),
}).toString();
const resp = await authFetch(`/api/tuition/estimate?${qs}`);
const data = resp.ok ? await resp.json() : {};
const est = Number.isFinite(data?.estimate) ? data.estimate : 0;
if (manualTuition.trim() === '') setAutoTuition(est); // don't clobber manual override
} catch {
if (manualTuition.trim() === '') setAutoTuition(0);
}
})();
}, [
ipeds,
cipRows,
form.selected_school,
selectedUnitId,
form.program_type,
form.credit_hours_per_year,
form.is_in_state,
form.is_in_district
form.is_in_district,
manualTuition // include so clearing manual → auto resumes immediately
]);
const handleManualTuitionChange = e => setManualTuition(e.target.value);
const chosenTuition = (() => {
if (manualTuition.trim() === '') return autoTuition;
const n = parseFloat(manualTuition);
return Number.isFinite(n) ? n : autoTuition;
})();
/*
Autocalculate PROGRAM LENGTH when the user hasnt typed in
@ -306,7 +370,7 @@ const chosenTuition = (() => {
useEffect(() => {
if (programLengthTouched) return; // user override
// if a program_length already exists (e.g., from API), don't overwrite it
if (form.program_length !== '' && form.program_length != null) return; // user override
// user override
const chpy = parseFloat(form.credit_hours_per_year);
if (!chpy || chpy <= 0) return;
@ -341,32 +405,40 @@ const chpy = parseFloat(form.credit_hours_per_year);
programLengthTouched
]);
useEffect(() => {
useEffect(() => {
if (graduationTouched) return;
const years = parseFloat(form.program_length);
if (!years || years <= 0) return;
const start = form.enrollment_date
? moment(form.enrollment_date)
: moment();
// Mirror Onboardings start date selection
let start = null;
if (form.college_enrollment_status === 'prospective_student') {
if (!form.enrollment_date) return; // need users chosen start
start = moment(form.enrollment_date);
} else if (form.college_enrollment_status === 'currently_enrolled') {
start = moment().startOf('month').add(1, 'month'); // first of next month
} else {
// not in-school flows → do nothing
return;
}
const monthsToAdd = Math.round(years * 12);
const est = moment(start).add(monthsToAdd, 'months');
const iso = firstOfNextMonth(est);
const iso = start.add(years, 'years')
.startOf('month')
.format('YYYY-MM-DD');
setAutoGradDate(iso);
setForm(prev => ({ ...prev, expected_graduation: iso }));
}, [
form.program_length,
form.credit_hours_required,
form.credit_hours_per_year,
form.hours_completed,
form.credit_hours_per_year,
form.enrollment_date,
graduationTouched
]);
const handleManualTuitionChange = e => setManualTuition(e.target.value);
return (
<div className="max-w-md mx-auto p-6 space-y-4">
@ -401,22 +473,27 @@ return (
name="selected_school"
value={form.selected_school}
onChange={onSchoolInput}
onBlur={() => {
const ok = cipRows.some(
r => r.INSTNM.toLowerCase() === form.selected_school.toLowerCase()
);
setSchoolValid(ok);
if (!ok) alert('Please pick a school from the list.');
onBlur={() => {
const trimmed = (form.selected_school || '').trim();
const exact = schoolSug.find(o => (o.name || '').toLowerCase() === trimmed.toLowerCase());
// Auto-commit exact typed value so downstream lookups work
if (exact) {
if (form.selected_school !== exact.name) {
setForm(prev => ({ ...prev, selected_school: exact.name }));
}
if (!selectedUnitId) setSelectedUnitId(exact.unitId ?? null);
}
// Valid if empty (still choosing) OR exact chosen
setSchoolValid(trimmed === '' || !!exact);
}}
list="school-suggestions"
className={`w-full border rounded p-2 ${
(form.selected_school || '').trim() !== '' && !schoolValid ? 'border-red-500' : ''}`}
placeholder="Start typing and choose…"
className={`w-full border rounded p-2 ${schoolValid ? '' : 'border-red-500'}`}
required
/>
<datalist id="school-suggestions">
{schoolSug.map((s,i)=>(
<option key={i} value={s} />
))}
{schoolSug.map((s,i)=>(<option key={`${s.unitId ?? i}:${s.name}`} value={s.name} />))}
</datalist>
</div>
@ -429,33 +506,40 @@ return (
name="selected_program"
value={form.selected_program}
onChange={onProgramInput}
onBlur={() => {
const ok =
form.selected_school && // need a school first
cipRows.some(
r =>
r.INSTNM.toLowerCase() === form.selected_school.toLowerCase() &&
r.CIPDESC.toLowerCase() === form.selected_program.toLowerCase()
);
setProgramValid(ok);
if (!ok) alert('Please pick a program from the list.');
}}
list="program-suggestions"
onBlur={() => {
const prog = (form.selected_program || '').trim().toLowerCase();
const exact = progSug.find(p => (p.program || '').toLowerCase() === prog);
// If user typed an exact program, ensure canonical casing is committed
if (exact && form.selected_program !== exact.program) {
setForm(prev => ({ ...prev, selected_program: exact.program }));
}
setProgramValid(prog === '' || !!exact);
}}
list="program-suggestions"
placeholder="Start typing and choose…"
className={`w-full border rounded p-2 ${programValid ? '' : 'border-red-500'}`}
className={`w-full border rounded p-2 ${
(form.selected_program || '').trim() !== '' && !programValid ? 'border-red-500' : '' }`}
required
/>
<datalist id="program-suggestions">
{progSug.map((p,i)=>(
<option key={i} value={p} />
))}
</datalist>
<datalist id="program-suggestions">
{progSug.map((p,i)=>(
<option key={`${i}:${p.program}`} value={p.program} />
))}
</datalist>
</div>
{/* 4 │ Programtype */}
<div className="space-y-1">
<label className="block font-medium">Degree Type *</label>
<select
{(() => {
const list =
types && types.length
? (types.includes(form.program_type)
? types
: [form.program_type, ...types.filter(t => t !== form.program_type)].filter(Boolean))
: (form.program_type ? [form.program_type] : []);
return (
<select
name="program_type"
value={form.program_type}
onChange={handleFieldChange}
@ -463,8 +547,9 @@ return (
required
>
<option value="">Select Program Type</option>
{types.map((t,i)=><option key={i} value={t}>{t}</option>)}
</select>
{list.map((t,i)=><option key={`${t}-${i}`} value={t}>{t}</option>)}
</select>);
})()}
</div>
{/* 5 │ Academic calendar */}

View File

@ -12,8 +12,8 @@ function CollegeOnboarding({ nextStep, prevStep, data, setData }) {
const schoolPrevRef = useRef('');
const [programSuggestions, setProgramSuggestions] = useState([]);
const [availableProgramTypes, setAvailableProgramTypes] = useState([]);
const [schoolValid, setSchoolValid] = useState(false);
const [programValid, setProgramValid] = useState(false);
const [schoolValid, setSchoolValid] = useState(true);
const [programValid, setProgramValid] = useState(true);
const [enrollmentDate, setEnrollmentDate] = useState(
data.enrollment_date || ''
);
@ -465,6 +465,26 @@ useEffect(() => {
// final handleSubmit => we store chosen tuition + program_length, then move on
const handleSubmit = () => {
// enforce “picked from list” at submit time (no blur popups)
const schoolText = (selected_school || '').trim();
const programText = (selected_program || '').trim();
const exactSchool = schoolSuggestions.find(o =>
(o.name || '').toLowerCase() === schoolText.toLowerCase()
);
const exactProgram = programSuggestions.find(p =>
(p.program || '').toLowerCase() === programText.toLowerCase()
);
if (schoolText && !selectedUnitId && !exactSchool) {
setSchoolValid(false);
alert('Please pick a school from the list.');
return;
}
if (programText && !exactProgram && availableProgramTypes.length === 0) {
setProgramValid(false);
alert('Please pick a program from the list.');
return;
}
const chosenTuition = manualTuition.trim() === ''
? autoTuition
: parseFloat(manualTuition);
@ -577,15 +597,19 @@ const ready =
name="selected_school"
value={selected_school}
onChange={handleSchoolChange}
onBlur={() => {
const exact = schoolSuggestions.find(o => (o.name || '').toLowerCase() === (selected_school || '').toLowerCase());
if (exact) handleSchoolSelect(exact); // ensure UNITID is set
const ok = !!exact || !!selected_school;
setSchoolValid(ok);
if (!ok) alert("Please pick a school from the list.");
}}
onBlur={() => {
const trimmed = (selected_school || '').trim();
const exact = schoolSuggestions.find(o =>
(o.name || '').toLowerCase() === trimmed.toLowerCase()
);
// If exact text was typed, auto-commit so UNITID is set (covers nested/double-select cases)
if (exact && !selectedUnitId) handleSchoolSelect(exact);
// Valid while empty (still choosing) or when exact is chosen
setSchoolValid(trimmed === '' || !!exact);
}}
list="school-suggestions"
className={`w-full border rounded p-2 ${schoolValid ? '' : 'border-red-500'}`}
className={`w-full border rounded p-2 ${ (selected_school || '').trim() !== '' && !schoolValid ? 'border-red-500' : ''}`}
placeholder="Start typing and choose…"
/>
<datalist id="school-suggestions">
@ -605,13 +629,15 @@ const ready =
name="selected_program"
value={selected_program}
onChange={handleProgramChange}
onBlur={() => {
const ok = !!programSuggestions.find(p => (p.program || '').toLowerCase() === (selected_program || '').toLowerCase());
setProgramValid(ok);
if (!ok) alert("Please pick a program from the list.");
onBlur={() => {
const trimmed = (selected_program || '').trim();
const exact = programSuggestions.find(p =>
(p.program || '').toLowerCase() === trimmed.toLowerCase()
);
setProgramValid(trimmed === '' || !!exact);
}}
list="program-suggestions"
className={`w-full border rounded p-2 ${programValid ? '' : 'border-red-500'}`}
className={`w-full border rounded p-2 ${(selected_program || '').trim() !== '' && !programValid ? 'border-red-500' : ''}`}
placeholder="Start typing and choose…"
/>
<datalist id="program-suggestions">

View File

@ -0,0 +1,6 @@
{
"status": "failed",
"failedTests": [
"d94173b0fe5d7002a306-47f9b330456659f0f977"
]
}

View File

@ -0,0 +1,50 @@
# Page snapshot
```yaml
- generic [active] [ref=e1]:
- generic [ref=e2]:
- navigation "Navigation Bar" [ref=e3]:
- generic [ref=e4]:
- link "Home" [ref=e5] [cursor=pointer]:
- /url: /
- img [ref=e6] [cursor=pointer]
- link "Explore" [ref=e7] [cursor=pointer]:
- /url: /explore/repos
- link "Help" [ref=e8] [cursor=pointer]:
- /url: https://docs.gitea.com
- generic [ref=e9]:
- link "Register" [ref=e10] [cursor=pointer]:
- /url: /user/sign_up
- img [ref=e11] [cursor=pointer]
- text: Register
- link "Sign In" [ref=e13] [cursor=pointer]:
- /url: /user/login?redirect_to=%2fsignin
- img [ref=e14] [cursor=pointer]
- text: Sign In
- main "Page Not Found" [ref=e16]:
- generic [ref=e17]:
- img "404" [ref=e18]
- paragraph [ref=e19]:
- text: The page you are trying to reach either
- strong [ref=e20]: does not exist
- text: or
- strong [ref=e21]: you are not authorized
- text: to view it.
- group "Footer" [ref=e22]:
- contentinfo "About Software" [ref=e23]:
- link "Powered by Gitea" [ref=e24] [cursor=pointer]:
- /url: https://about.gitea.com
- text: "Version: 1.22.6 Page:"
- strong [ref=e25]: 2ms
- text: "Template:"
- strong [ref=e26]: 1ms
- group "Links" [ref=e27]:
- menu [ref=e28] [cursor=pointer]:
- generic [ref=e29] [cursor=pointer]:
- img [ref=e30] [cursor=pointer]
- text: English
- link "Licenses" [ref=e32] [cursor=pointer]:
- /url: /assets/licenses.txt
- link "API" [ref=e33] [cursor=pointer]:
- /url: /api/swagger
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

View File

@ -0,0 +1,50 @@
# Page snapshot
```yaml
- generic [active] [ref=e1]:
- generic [ref=e2]:
- navigation "Navigation Bar" [ref=e3]:
- generic [ref=e4]:
- link "Home" [ref=e5] [cursor=pointer]:
- /url: /
- img [ref=e6] [cursor=pointer]
- link "Explore" [ref=e7] [cursor=pointer]:
- /url: /explore/repos
- link "Help" [ref=e8] [cursor=pointer]:
- /url: https://docs.gitea.com
- generic [ref=e9]:
- link "Register" [ref=e10] [cursor=pointer]:
- /url: /user/sign_up
- img [ref=e11] [cursor=pointer]
- text: Register
- link "Sign In" [ref=e13] [cursor=pointer]:
- /url: /user/login?redirect_to=%2fsignin
- img [ref=e14] [cursor=pointer]
- text: Sign In
- main "Page Not Found" [ref=e16]:
- generic [ref=e17]:
- img "404" [ref=e18]
- paragraph [ref=e19]:
- text: The page you are trying to reach either
- strong [ref=e20]: does not exist
- text: or
- strong [ref=e21]: you are not authorized
- text: to view it.
- group "Footer" [ref=e22]:
- contentinfo "About Software" [ref=e23]:
- link "Powered by Gitea" [ref=e24] [cursor=pointer]:
- /url: https://about.gitea.com
- text: "Version: 1.22.6 Page:"
- strong [ref=e25]: 8ms
- text: "Template:"
- strong [ref=e26]: 6ms
- group "Links" [ref=e27]:
- menu [ref=e28] [cursor=pointer]:
- generic [ref=e29] [cursor=pointer]:
- img [ref=e30] [cursor=pointer]
- text: English
- link "Licenses" [ref=e32] [cursor=pointer]:
- /url: /assets/licenses.txt
- link "API" [ref=e33] [cursor=pointer]:
- /url: /api/swagger
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

View File

@ -0,0 +1,109 @@
// @ts-check
import { test, expect } from '@playwright/test';
import { saveTestUser } from '../utils/testUser.js';
function uniq() {
const t = new Date().toISOString().replace(/[-:.TZ]/g, '').slice(0, 14);
return `u${t}${Math.floor(Math.random() * 1e4)}`;
}
test.describe('@p0 SignUp → Journey select → Route', () => {
test.setTimeout(15000); // allow for slower first load + areas fetch
test('create a new user via UI and persist creds for later specs', async ({ page }) => {
const u = uniq();
const user = {
username: `test_${u}`,
password: `P@ssw0rd!${u.slice(-4)}`,
firstname: 'Test',
lastname: 'User',
email: `jcoakley@aptivaai.com`,
phone: '+16787696633',
zipcode: '30301',
stateVal: 'GA', // Georgia (has areas)
journeyTitle: 'Planning Your Career', // safe non-premium
journeyRoute: '/planning',
};
// Start clean
await page.context().clearCookies();
await page.goto('/signup', { waitUntil: 'networkidle' });
// Make sure the Sign Up form is visible
await expect(page.getByRole('heading', { name: /Sign Up/i })).toBeVisible();
// Fill form
await page.getByPlaceholder('Username').fill(user.username);
await page.getByPlaceholder('Password', { exact: true }).fill(user.password);
await page.getByPlaceholder('Retype Password', { exact: true }).fill(user.password);
await page.getByPlaceholder('First Name').fill(user.firstname);
await page.getByPlaceholder('Last Name').fill(user.lastname);
await page.getByPlaceholder('Email', { exact: true }).fill(user.email);
await page.getByPlaceholder('Retype Email', { exact: true }).fill(user.email);
await page.getByPlaceholder('+15555555555').fill(user.phone);
await page.getByPlaceholder('Zip Code').fill(user.zipcode);
// Select State (the dropdown that has “Select State” as its placeholder option)
const stateSelect = page.locator('select').filter({
has: page.locator('option', { hasText: 'Select State' }),
});
await expect(stateSelect).toBeVisible();
await stateSelect.selectOption(user.stateVal);
// Areas: MUST select one (validateFields requires area)
const areaSelect = page.locator('select#area');
await expect(areaSelect).toBeVisible();
// wait for the debounced / aborted fetch chain to complete for this state
const stateParam = encodeURIComponent(user.stateVal);
await page.waitForResponse(
r => r.url().includes(`/api/areas?state=${stateParam}`) && r.request().method() === 'GET',
{ timeout: 20000 }
);
// the select is disabled while loading; wait until it's enabled and populated
await expect(areaSelect).toBeEnabled({ timeout: 10000 });
await expect(async () => {
const count = await areaSelect.locator('option').count();
expect(count).toBeGreaterThan(1); // placeholder + at least one real option
}).toPass({ timeout: 10000 });
// choose first non-empty option
let choseArea = false;
const options = areaSelect.locator('option');
const n = await options.count();
for (let i = 0; i < n; i++) {
const val = await options.nth(i).getAttribute('value');
if (val) { await areaSelect.selectOption(val); choseArea = true; break; }
}
expect(choseArea).toBeTruthy(); // fail fast if areas didnt load
// Next → shows situation cards
await page.getByRole('button', { name: /^Next$/ }).click();
// Pick journey card (Planning Your Career)
await page.getByRole('heading', { name: /Where are you in your career journey/i }).waitFor();
// Click the journey card by its visible text (cards are not role=button)
const journeyCard = page.getByText(user.journeyTitle, { exact: true });
await expect(journeyCard).toBeVisible();
await journeyCard.click();
// Confirm modal → Confirm
await expect(page.getByRole('button', { name: /^Confirm$/ })).toBeVisible({ timeout: 5000 });
await page.getByRole('button', { name: /^Confirm$/ }).click();
// Expect navigation to journey route (e.g., /planning)
await page.waitForURL(`**${user.journeyRoute}**`, { timeout: 10000 });
// Persist credentials for later specs
saveTestUser({ ...user, choseArea });
// Sanity: cookie session (if register logs-in server-side)
const cookies = await page.context().cookies();
expect(cookies.some((c) => /jwt|session/i.test(c.name))).toBeTruthy();
// No console errors
const errors = [];
page.on('console', (m) => {
if (m.type() === 'error') errors.push(m.text());
});
expect(errors).toEqual([]);
});
});

View File

@ -0,0 +1,38 @@
// tests/e2e/02-signin-landing.spec.mjs
// @ts-check
import { test, expect } from '@playwright/test';
import { loadTestUser } from '../utils/testUser.js';
test.describe('@p0 SignIn → Landing', () => {
test.setTimeout(20000);
test('signs in with persisted user and reaches SignInLanding', async ({ page }) => {
const user = loadTestUser();
await page.context().clearCookies();
await page.goto('/signin', { waitUntil: 'networkidle' });
await expect(page.getByRole('heading', { name: /Sign In/i })).toBeVisible();
await page.getByPlaceholder('Username', { exact: true }).fill(user.username);
await page.getByPlaceholder('Password', { exact: true }).fill(user.password);
await page.getByRole('button', { name: /^Sign In$/ }).click();
await page.waitForURL('**/signin-landing**', { timeout: 15000 });
await expect(
page.getByRole('heading', { name: new RegExp(`Welcome to AptivaAI\\s+${user.firstname}!`) })
).toBeVisible();
await expect(page.getByRole('link', { name: /Go to Exploring/i })).toBeVisible();
await expect(page.getByRole('link', { name: /Go to Preparing/i })).toBeVisible();
await expect(page.getByRole('link', { name: /Go to Enhancing/i })).toBeVisible();
await expect(page.getByRole('link', { name: /Go to Retirement/i })).toBeVisible();
const cookies = await page.context().cookies();
expect(cookies.some(c => /jwt|session/i.test(c.name))).toBeTruthy();
const consoleErrors = [];
page.on('console', m => { if (m.type() === 'error') consoleErrors.push(m.text()); });
expect(consoleErrors).toEqual([]);
});
});

View File

@ -0,0 +1,58 @@
// @ts-check
import { test, expect } from '@playwright/test';
import { loadTestUser } from '../utils/testUser.js';
test.describe('@p0 Interest Inventory → Career Explorer', () => {
test.setTimeout(20000);
test('answer (randomize on dev) → submit → land on Explorer', async ({ page }) => {
const user = loadTestUser();
// Sign in (fresh context each test)
await page.context().clearCookies();
await page.goto('/signin', { waitUntil: 'networkidle' });
await expect(page.getByRole('heading', { name: /Sign In/i })).toBeVisible();
await page.getByPlaceholder('Username', { exact: true }).fill(user.username);
await page.getByPlaceholder('Password', { exact: true }).fill(user.password);
await page.getByRole('button', { name: /^Sign In$/ }).click();
await page.waitForURL('**/signin-landing**', { timeout: 15000 });
// Go to Interest Inventory
await page.goto('/interest-inventory', { waitUntil: 'networkidle' });
await expect(page.getByRole('heading', { name: /Interest Inventory/i })).toBeVisible();
// Wait for questions to render (page 1 of 10, 6 selects)
await expect(page.getByText(/Page\s+1\s+of\s+10/i)).toBeVisible();
await expect(page.locator('select')).toHaveCount(6, { timeout: 10000 });
// Dev-only helper: Randomize answers
const randomizeBtn = page.getByRole('button', { name: /Randomize Answers/i });
if (await randomizeBtn.isVisible()) {
await randomizeBtn.click();
await expect(page.getByText(/60\s*\/\s*60\s*answered/i)).toBeVisible();
} else {
// Fallback: fill current page with "Neutral" (3), then next; repeat (rare on prod)
for (let p = 0; p < 10; p++) {
const selects = page.locator('select');
const n = await selects.count();
for (let i = 0; i < n; i++) await selects.nth(i).selectOption('3');
if (p < 9) await page.getByRole('button', { name: /^Next$/ }).click();
}
}
// Move to last page if needed (randomize doesn't jump pages)
for (let i = 0; i < 9; i++) {
const next = page.getByRole('button', { name: /^Next$/ });
if (await next.isVisible()) await next.click();
}
// Submit
await page.getByRole('button', { name: /^Submit$/ }).click();
// Land on Career Explorer
await page.waitForURL('**/career-explorer**', { timeout: 20000 });
await expect(
page.getByRole('heading', { name: /Explore Careers - use these tools/i })
).toBeVisible({ timeout: 20000 });
});
});

View File

@ -0,0 +1,305 @@
// tests/e2e/04-career-explorer.core.spec.mjs
// @ts-check
import { test, expect } from '@playwright/test';
import { loadTestUser } from '../utils/testUser.js';
test.describe('@p0 Career Explorer core', () => {
// Enough headroom for cold suggestion builds, but still bounded.
test.setTimeout(40000);
test('suggestions visible (or reload) → open modal → add to comparison (+event bridge)', async ({ page }) => {
const user = loadTestUser();
const TIME = {
overlayAppear: 2000, // overlay should show quickly if it appears
cache: 60000, // wait for cache to populate after reload
tile: 8000, // find a tile quickly once cache exists
confirm: 7000, // modal/button appearances
tableRow: 20000, // time for table update after Save
};
// Accept alerts: inventory prompt and possible "already in comparison" duplicate
let sawInventoryAlert = false;
let sawDuplicateAlert = false;
page.on('dialog', async d => {
const msg = d.message();
if (/Interest Inventory/i.test(msg)) sawInventoryAlert = true;
if (/already in comparison/i.test(msg)) sawDuplicateAlert = true;
await d.accept();
});
// Helper: close any blocking overlay (priorities / meaning) by saving neutral defaults.
async function closeAnyOverlay() {
const overlay = page.locator('div.fixed.inset-0');
if (!(await overlay.isVisible({ timeout: 500 }).catch(() => false))) return;
const dialog = overlay.locator('div[role="dialog"], div.bg-white').first();
// Select first non-empty option in each <select> (if any)
const selects = dialog.locator('select');
const sc = await selects.count().catch(() => 0);
for (let i = 0; i < sc; i++) {
const opts = selects.nth(i).locator('option');
const n = await opts.count().catch(() => 0);
for (let j = 0; j < n; j++) {
const v = await opts.nth(j).getAttribute('value');
if (v) { await selects.nth(i).selectOption(v); break; }
}
}
// If theres a textbox, enter neutral “3”
const tb = dialog.locator('input, textarea, [role="textbox"]').first();
if (await tb.isVisible().catch(() => false)) await tb.fill('3');
// Prefer Save/Continue; else Cancel/Close; else Escape
const save = dialog.getByRole('button', { name: /(Save|Continue|Done|OK)/i });
const cancel = dialog.getByRole('button', { name: /(Cancel|Close)/i });
if (await save.isVisible({ timeout: 500 }).catch(() => false)) await save.click();
else if (await cancel.isVisible({ timeout: 500 }).catch(() => false)) await cancel.click();
else { await page.keyboard.press('Escape'); }
await overlay.waitFor({ state: 'hidden', timeout: 5000 }).catch(() => {});
}
// Helper: wait until careerSuggestionsCache has > 0 items
async function waitForSuggestionsCache() {
await expect
.poll(async () => {
return await page.evaluate(() => {
try {
const s = localStorage.getItem('careerSuggestionsCache');
const arr = s ? JSON.parse(s) : [];
return Array.isArray(arr) ? arr.length : 0;
} catch { return 0; }
});
}, { timeout: TIME.cache, message: 'careerSuggestionsCache not populated' })
.toBeGreaterThan(0);
}
// Helper: full reload → wait for cache (no percentage polling)
async function reloadSuggestionsAndWait() {
const reloadBtn = page.getByRole('button', { name: /Reload Career Suggestions/i });
await expect(reloadBtn).toBeVisible();
await closeAnyOverlay(); // ensure nothing intercepts
await reloadBtn.click();
// If an overlay appears, let it mount (dont require 100%)
const overlayText = page.getByText(/Loading Career Suggestions/i).first();
await overlayText.isVisible({ timeout: TIME.overlayAppear }).catch(() => {});
// Real readiness check: cache populated
await waitForSuggestionsCache();
}
// Sign in
await page.context().clearCookies();
await page.goto('/signin', { waitUntil: 'networkidle' });
await page.getByPlaceholder('Username', { exact: true }).fill(user.username);
await page.getByPlaceholder('Password', { exact: true }).fill(user.password);
await page.getByRole('button', { name: /^Sign In$/ }).click();
await page.waitForURL('**/signin-landing**', { timeout: 15000 });
// Go to Career Explorer
await page.goto('/career-explorer', { waitUntil: 'networkidle' });
await expect(
page.getByRole('heading', { name: /Explore Careers - use these tools/i })
).toBeVisible();
// If a priorities/meaning gate is up, close it first
await closeAnyOverlay();
// Ensure suggestions exist: try a reload, or complete inventory if server prompts for it.
const firstTile = page.locator('div.grid button').first();
if (!(await firstTile.isVisible({ timeout: 1500 }).catch(() => false))) {
await reloadSuggestionsAndWait();
// If server demanded Interest Inventory, complete it fast (dev has Randomize), then retry once.
if (sawInventoryAlert) {
await page.goto('/interest-inventory', { waitUntil: 'networkidle' });
await expect(page.getByRole('heading', { name: /Interest Inventory/i })).toBeVisible();
const randomizeBtn = page.getByRole('button', { name: /Randomize Answers/i });
if (await randomizeBtn.isVisible().catch(() => false)) {
await randomizeBtn.click();
await expect(page.getByText(/60\s*\/\s*60\s*answered/i)).toBeVisible();
} else {
// Fallback: fill each page with Neutral (3)
for (let p = 0; p < 10; p++) {
const selects = page.locator('select');
const n = await selects.count();
for (let i = 0; i < n; i++) await selects.nth(i).selectOption('3');
if (p < 9) await page.getByRole('button', { name: /^Next$/ }).click();
}
}
await page.getByRole('button', { name: /^Submit$/ }).click();
await page.waitForURL('**/career-explorer**', { timeout: 20000 });
await closeAnyOverlay();
await reloadSuggestionsAndWait();
}
}
// Click a suggestion. Capture the title we clicked so we can assert by text later.
let clickedTitle = null;
// Prefer clicking by exact title from cache.
const cachedFirstTitle = await page.evaluate(() => {
try {
const s = localStorage.getItem('careerSuggestionsCache');
const arr = s ? JSON.parse(s) : [];
return Array.isArray(arr) && arr.length ? String(arr[0].title || '') : null;
} catch { return null; }
});
if (cachedFirstTitle) {
const esc = cachedFirstTitle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const tileByTitle = page.getByRole('button', { name: new RegExp(`^${esc}$`) });
await expect(tileByTitle).toBeVisible({ timeout: TIME.tile });
clickedTitle = cachedFirstTitle;
await tileByTitle.click();
} else {
await expect(firstTile).toBeVisible({ timeout: TIME.tile });
clickedTitle = (await firstTile.textContent())?.replace('⚠️', '').trim() || null;
await firstTile.click();
}
// Wait for CareerModal, capture the definitive modal title if present (more reliable than tile text)
const modalH2 = page.getByRole('heading', { level: 2 });
if (await modalH2.isVisible({ timeout: TIME.confirm }).catch(() => false)) {
const t = await modalH2.first().textContent().catch(() => null);
if (t) clickedTitle = t.trim();
}
// Add to Comparison
await expect(page.getByRole('button', { name: /Add to Comparison/i }))
.toBeVisible({ timeout: TIME.confirm });
await page.getByRole('button', { name: /Add to Comparison/i }).click();
// Ratings modal: MUST click Save for the row to appear
{
const overlay = page.locator('div.fixed.inset-0');
if (await overlay.isVisible({ timeout: 2000 }).catch(() => false)) {
const dlg = overlay.locator('div[role="dialog"], div.bg-white').first();
// Fill any selects (choose '3' if available)
const selects = dlg.locator('select');
const sc = await selects.count().catch(() => 0);
for (let i = 0; i < sc; i++) {
const sel = selects.nth(i);
if (await sel.isVisible().catch(() => false)) {
const has3 = await sel.locator('option[value="3"]').count().catch(() => 0);
if (has3) await sel.selectOption('3');
else {
const opts = sel.locator('option');
const n = await opts.count().catch(() => 0);
for (let j = 0; j < n; j++) {
const v = await opts.nth(j).getAttribute('value');
if (v) { await sel.selectOption(v); break; }
}
}
}
}
// Fill any text/number inputs (set to '3' if empty)
const inputs = dlg.locator('input[type="number"], input[type="text"], [role="textbox"]');
const ic = await inputs.count().catch(() => 0);
for (let i = 0; i < ic; i++) {
const inp = inputs.nth(i);
if (await inp.isVisible().catch(() => false)) {
const val = (await inp.inputValue().catch(() => '')) || '';
if (!val) await inp.fill('3');
}
}
// Click Save (cover common label variants), else press Enter
const saveBtn = dlg.getByRole('button', { name: /^(Save|Save Ratings|Confirm|Done)$/i });
if (await saveBtn.isVisible({ timeout: 1000 }).catch(() => false)) {
await saveBtn.click();
} else {
await page.keyboard.press('Enter');
}
await overlay.waitFor({ state: 'hidden', timeout: 5000 }).catch(() => {});
}
}
// Assert success by presence of the clicked title in the table (or duplicate alert fallback).
const table = page.locator('table');
const rowLocator = table.locator('tbody tr');
await table.waitFor({ state: 'attached', timeout: 5000 }).catch(() => {});
await expect(async () => {
// If duplicate alert fired, consider success
if (sawDuplicateAlert) return true;
// Title-based presence (most reliable)
if (clickedTitle) {
const esc = clickedTitle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const cellWithTitle = table.getByText(new RegExp(esc, 'i'));
if (await cellWithTitle.isVisible().catch(() => false)) return true;
}
// Fallback: any increase in row count
const count = await rowLocator.count().catch(() => 0);
return count > 0;
}).toPass({ timeout: TIME.tableRow });
// ─────────────────────────────────────────────────────────────
// Event bridge checks (no duplicate add to avoid extra rows)
// ─────────────────────────────────────────────────────────────
// Pull a SOC from suggestions cache for events to target
const evSoc = await page.evaluate(() => {
try {
const arr = JSON.parse(localStorage.getItem('careerSuggestionsCache') || '[]');
return Array.isArray(arr) && arr.length ? (arr[0].code || arr[0].soc_code) : null;
} catch { return null; }
});
if (evSoc) {
// 1) open-career should open CareerModal
await page.evaluate((soc) => {
window.dispatchEvent(new CustomEvent('open-career', { detail: { socCode: soc } }));
}, evSoc);
await expect(page.getByRole('button', { name: /Add to Comparison/i }))
.toBeVisible({ timeout: TIME.confirm });
// Close modal to keep state clean
const closeBtnEv = page.getByRole('button', { name: /^Close$/i });
if (await closeBtnEv.isVisible().catch(() => false)) await closeBtnEv.click();
else await page.keyboard.press('Escape');
// 2) add-career should either add (if new) or trigger duplicate alert (if already present)
let sawDupAlertEv = false;
page.once('dialog', async d => {
if (/already in comparison/i.test(d.message())) sawDupAlertEv = true;
await d.accept();
});
const beforeRowsEv = await rowLocator.count().catch(() => 0);
await page.evaluate(({ soc, title }) => {
window.dispatchEvent(new CustomEvent('add-career', {
detail: { socCode: soc, careerName: title || '(name unavailable)' }
}));
}, { soc: evSoc, title: clickedTitle });
// If a ratings modal appears, save neutral defaults
const overlayEv = page.locator('div.fixed.inset-0');
if (await overlayEv.isVisible({ timeout: 1500 }).catch(() => false)) {
const dlg = overlayEv.locator('div[role="dialog"], div.bg-white').first();
const selects = dlg.locator('select');
const sc = await selects.count().catch(() => 0);
for (let i = 0; i < sc; i++) {
const sel = selects.nth(i);
const has3 = await sel.locator('option[value="3"]').count().catch(() => 0);
if (has3) await sel.selectOption('3');
}
const tb = dlg.locator('input, textarea, [role="textbox"]').first();
if (await tb.isVisible().catch(() => false)) await tb.fill('3');
const saveEv = dlg.getByRole('button', { name: /^(Save|Save Ratings|Confirm|Done|OK)$/i });
if (await saveEv.isVisible({ timeout: 800 }).catch(() => false)) await saveEv.click();
await overlayEv.waitFor({ state: 'hidden', timeout: 4000 }).catch(() => {});
}
await expect(async () => {
const afterEv = await rowLocator.count().catch(() => 0);
// Pass if either duplicate alert fired OR row count increased
return sawDupAlertEv || afterEv > beforeRowsEv;
}).toPass({ timeout: 8000 });
}
});
});

View File

@ -0,0 +1,151 @@
import { test, expect } from '@playwright/test';
import { loadTestUser } from '../utils/testUser.js';
test.describe('@p0 Career Explorer — Reload Suggestions', () => {
test.setTimeout(40000);
test('clears cache → Reload → cache & tiles repopulated (submit_answers fired)', async ({ page }) => {
const user = loadTestUser();
const TIME = {
overlayAppear: 2000, // overlay should mount quickly
cache: 30000, // allow cold path to populate cache
tile: 8000, // find a tile soon after cache
};
// Track server calls (prove reload hits submit_answers)
let sawSubmitAnswers = false;
page.on('response', (r) => {
if (r.request().method() === 'POST' && r.url().includes('/api/onet/submit_answers')) {
sawSubmitAnswers = true;
}
});
// Helper: close any blocking overlay (priorities/meaning) by saving neutral defaults.
async function closeAnyOverlay() {
const overlay = page.locator('div.fixed.inset-0');
if (!(await overlay.isVisible({ timeout: 500 }).catch(() => false))) return;
const dialog = overlay.locator('div[role="dialog"], div.bg-white').first();
// Select first non-empty option in each <select> (if any)
const selects = dialog.locator('select');
const sc = await selects.count().catch(() => 0);
for (let i = 0; i < sc; i++) {
const sel = selects.nth(i);
const opts = sel.locator('option');
const n = await opts.count().catch(() => 0);
for (let j = 0; j < n; j++) {
const v = await opts.nth(j).getAttribute('value');
if (v) { await sel.selectOption(v); break; }
}
}
// Fill any text/number inputs with neutral '3'
const inputs = dialog.locator('input[type="number"], input[type="text"], [role="textbox"]');
const ic = await inputs.count().catch(() => 0);
for (let i = 0; i < ic; i++) {
const inp = inputs.nth(i);
if (await inp.isVisible().catch(() => false)) {
const val = (await inp.inputValue().catch(() => '')) || '';
if (!val) await inp.fill('3');
}
}
// Save/Continue; else Cancel/Close; else Escape
const save = dialog.getByRole('button', { name: /(Save|Continue|Done|OK)/i });
const cancel = dialog.getByRole('button', { name: /(Cancel|Close)/i });
if (await save.isVisible({ timeout: 500 }).catch(() => false)) await save.click();
else if (await cancel.isVisible({ timeout: 500 }).catch(() => false)) await cancel.click();
else await page.keyboard.press('Escape');
await overlay.waitFor({ state: 'hidden', timeout: 5000 }).catch(() => {});
}
// Helper: wait for cache > 0 (source of truth for suggestions readiness)
async function waitForSuggestionsCache() {
await expect
.poll(async () => {
return await page.evaluate(() => {
try {
const s = localStorage.getItem('careerSuggestionsCache');
const arr = s ? JSON.parse(s) : [];
return Array.isArray(arr) ? arr.length : 0;
} catch { return 0; }
});
}, { timeout: TIME.cache, message: 'careerSuggestionsCache not populated' })
.toBeGreaterThan(0);
}
// Helper: complete Interest Inventory quickly if server demands it
async function completeInventoryIfNeeded() {
// If a dialog alert fires (Playwright 'dialog' event), we cannot peek msg here reliably,
// so we proactively check the inventory route if the reload yields no cache.
await page.goto('/interest-inventory', { waitUntil: 'networkidle' });
await expect(page.getByRole('heading', { name: /Interest Inventory/i })).toBeVisible();
const randomizeBtn = page.getByRole('button', { name: /Randomize Answers/i });
if (await randomizeBtn.isVisible().catch(() => false)) {
await randomizeBtn.click();
await expect(page.getByText(/60\s*\/\s*60\s*answered/i)).toBeVisible();
} else {
for (let p = 0; p < 10; p++) {
const selects = page.locator('select');
const n = await selects.count();
for (let i = 0; i < n; i++) await selects.nth(i).selectOption('3');
if (p < 9) await page.getByRole('button', { name: /^Next$/ }).click();
}
}
await page.getByRole('button', { name: /^Submit$/ }).click();
await page.waitForURL('**/career-explorer**', { timeout: 20000 });
await closeAnyOverlay();
}
// Sign in
await page.context().clearCookies();
await page.goto('/signin', { waitUntil: 'networkidle' });
await page.getByPlaceholder('Username', { exact: true }).fill(user.username);
await page.getByPlaceholder('Password', { exact: true }).fill(user.password);
await page.getByRole('button', { name: /^Sign In$/ }).click();
await page.waitForURL('**/signin-landing**', { timeout: 15000 });
// Go to Career Explorer
await page.goto('/career-explorer', { waitUntil: 'networkidle' });
await expect(page.getByRole('heading', { name: /Explore Careers - use these tools/i })).toBeVisible();
await closeAnyOverlay();
// 1) Clear cache and reload page → grid should have no tiles
await page.evaluate(() => localStorage.removeItem('careerSuggestionsCache'));
await page.reload({ waitUntil: 'networkidle' });
await closeAnyOverlay();
const tile = page.locator('div.grid button').first();
// Short check: no tile should be visible yet
const tileVisiblePre = await tile.isVisible({ timeout: 1000 }).catch(() => false);
expect(tileVisiblePre).toBeFalsy();
// 2) Click Reload Career Suggestions
const reloadBtn = page.getByRole('button', { name: /Reload Career Suggestions/i });
await expect(reloadBtn).toBeVisible();
await reloadBtn.click();
// If overlay mounts, let it mount; we don't require 100% display
const overlayText = page.getByText(/Loading Career Suggestions/i).first();
await overlayText.isVisible({ timeout: TIME.overlayAppear }).catch(() => {});
// 3) Wait for suggestions cache
try {
await waitForSuggestionsCache();
} catch {
// If cache didn't populate, complete inventory once and retry reload
await completeInventoryIfNeeded();
await expect(reloadBtn).toBeVisible();
await reloadBtn.click();
await overlayText.isVisible({ timeout: TIME.overlayAppear }).catch(() => {});
await waitForSuggestionsCache();
}
// 4) Tiles should now be present
await expect(tile).toBeVisible({ timeout: TIME.tile });
// 5) Assert submit_answers POST fired during reload
expect(sawSubmitAnswers).toBeTruthy();
});
});

View File

@ -0,0 +1,111 @@
import { test, expect } from '@playwright/test';
import { loadTestUser } from '../utils/testUser.js';
test.describe('@p1 Career Explorer — CareerSearch datalist', () => {
test.setTimeout(30000);
test('datalist commit opens modal; Change resets input', async ({ page }) => {
const user = loadTestUser();
// Helpers
async function closeAnyOverlay() {
const overlay = page.locator('div.fixed.inset-0');
if (!(await overlay.isVisible({ timeout: 500 }).catch(() => false))) return;
const dlg = overlay.locator('div[role="dialog"], div.bg-white').first();
// If modal asks for ratings, pick neutral values and Save/Continue so it goes away.
const selects = dlg.locator('select');
const sc = await selects.count().catch(() => 0);
for (let i = 0; i < sc; i++) {
const sel = selects.nth(i);
if (await sel.isVisible().catch(() => false)) {
const has3 = await sel.locator('option[value="3"]').count().catch(() => 0);
if (has3) await sel.selectOption('3');
else {
const opts = sel.locator('option');
const n = await opts.count().catch(() => 0);
for (let j = 0; j < n; j++) {
const v = await opts.nth(j).getAttribute('value');
if (v) { await sel.selectOption(v); break; }
}
}
}
}
const tb = dlg.locator('input, textarea, [role="textbox"]').first();
if (await tb.isVisible().catch(() => false)) await tb.fill('3');
const save = dlg.getByRole('button', { name: /(Save|Continue|Done|OK)/i });
const cancel = dlg.getByRole('button', { name: /(Cancel|Close)/i });
if (await save.isVisible({ timeout: 500 }).catch(() => false)) await save.click();
else if (await cancel.isVisible({ timeout: 500 }).catch(() => false)) await cancel.click();
else await page.keyboard.press('Escape');
await overlay.waitFor({ state: 'hidden', timeout: 5000 }).catch(() => {});
}
// Sign in
await page.context().clearCookies();
await page.goto('/signin', { waitUntil: 'networkidle' });
await page.getByPlaceholder('Username', { exact: true }).fill(user.username);
await page.getByPlaceholder('Password', { exact: true }).fill(user.password);
await page.getByRole('button', { name: /^Sign In$/ }).click();
await page.waitForURL('**/signin-landing**', { timeout: 15000 });
// Go to Career Explorer
await page.goto('/career-explorer', { waitUntil: 'networkidle' });
await expect(page.getByRole('heading', { name: /Explore Careers - use these tools/i })).toBeVisible();
await closeAnyOverlay();
const input = page.getByPlaceholder('Start typing a career...');
// If previously selected, the input is disabled; click Change to reset.
if (await input.isDisabled()) {
const changeLink = page.getByRole('button', { name: /^Change$/i }).or(page.getByText(/^Change$/));
await expect(changeLink).toBeVisible({ timeout: 5000 });
await changeLink.click();
await expect(input).toBeEnabled({ timeout: 2000 });
await expect(input).toHaveValue('');
}
// Type a partial and wait for datalist options to populate
await input.fill('block');
const options = page.locator('datalist#career-titles option');
await expect
.poll(async () => await options.count(), { timeout: 7000, message: 'no datalist options' })
.toBeGreaterThan(0);
// Take the first suggestion's exact value (e.g., "Blockchain Engineers")
const firstValue = await options.first().evaluate(el => el.value);
// Commit by setting exact value + blur (component commits on exact + blur)
await input.fill(firstValue);
await input.blur();
// Loading overlay may show; wait for it to appear (optional) then hide
const loading = page.getByText(/Loading Career/i).first(); // matches “Loading Career Suggestions…”
await loading.isVisible({ timeout: 2000 }).catch(() => {});
await loading.waitFor({ state: 'hidden', timeout: 60000 }).catch(() => {}); // guard slow cold path
// CareerModal should open (Add to Comparison button present)
await expect(page.getByRole('button', { name: /Add to Comparison/i })).toBeVisible({ timeout: 15000 });
// Close the modal (dont add in this test)
const closeBtn = page.getByRole('button', { name: /^Close$/i });
if (await closeBtn.isVisible().catch(() => false)) {
await closeBtn.click();
} else {
await page.keyboard.press('Escape');
}
// Input becomes disabled after selection; click Change to reset
await expect(input).toBeDisabled();
const changeLink = page.getByRole('button', { name: /^Change$/i }).or(page.getByText(/^Change$/));
await expect(changeLink).toBeVisible({ timeout: 5000 });
await changeLink.click();
// Now the input should be enabled and cleared
await expect(input).toBeEnabled();
await expect(input).toHaveValue('');
});
});

View File

@ -0,0 +1,178 @@
// tests/e2e/07-education-handoff.spec.mjs
// @ts-check
import { test, expect } from '@playwright/test';
import { loadTestUser } from '../utils/testUser.js';
test.describe('@p1 Education/Skills handoff', () => {
test.setTimeout(20000);
test('add to comparison → Plan your Education/Skills → navigates with selectedCareer stored', async ({ page }) => {
const user = loadTestUser();
const TIME = {
overlayAppear: 2000,
cache: 60000,
tile: 8000,
confirm: 7000,
route: 20000,
};
// Helpers
async function closeAnyOverlay() {
const overlay = page.locator('div.fixed.inset-0');
if (!(await overlay.isVisible({ timeout: 500 }).catch(() => false))) return;
const dlg = overlay.locator('div[role="dialog"], div.bg-white').first();
// Fill selects with neutral '3' (or first non-empty)
const selects = dlg.locator('select');
const sc = await selects.count().catch(() => 0);
for (let i = 0; i < sc; i++) {
const sel = selects.nth(i);
const has3 = await sel.locator('option[value="3"]').count().catch(() => 0);
if (has3) await sel.selectOption('3');
else {
const opts = sel.locator('option');
const n = await opts.count().catch(() => 0);
for (let j = 0; j < n; j++) {
const v = await opts.nth(j).getAttribute('value');
if (v) { await sel.selectOption(v); break; }
}
}
}
// Fill textbox if present
const tb = dlg.locator('input, textarea, [role="textbox"]').first();
if (await tb.isVisible().catch(() => false)) await tb.fill('3');
const save = dlg.getByRole('button', { name: /(Save|Continue|Done|OK)/i });
const cancel = dlg.getByRole('button', { name: /(Cancel|Close)/i });
if (await save.isVisible({ timeout: 500 }).catch(() => false)) await save.click();
else if (await cancel.isVisible({ timeout: 500 }).catch(() => false)) await cancel.click();
else await page.keyboard.press('Escape');
await overlay.waitFor({ state: 'hidden', timeout: 5000 }).catch(() => {});
}
async function waitForSuggestionsCache() {
await expect
.poll(async () => {
return await page.evaluate(() => {
try {
const s = localStorage.getItem('careerSuggestionsCache');
const arr = s ? JSON.parse(s) : [];
return Array.isArray(arr) ? arr.length : 0;
} catch { return 0; }
});
}, { timeout: TIME.cache, message: 'careerSuggestionsCache not populated' })
.toBeGreaterThan(0);
}
async function ensureSuggestions() {
const firstTile = page.locator('div.grid button').first();
if (await firstTile.isVisible({ timeout: 1500 }).catch(() => false)) return;
const reloadBtn = page.getByRole('button', { name: /Reload Career Suggestions/i });
await expect(reloadBtn).toBeVisible();
await closeAnyOverlay();
await reloadBtn.click();
// Let overlay mount if it appears (we don't require 100%)
const overlayText = page.getByText(/Loading Career Suggestions/i).first();
await overlayText.isVisible({ timeout: TIME.overlayAppear }).catch(() => {});
await waitForSuggestionsCache();
}
// Sign in
await page.context().clearCookies();
await page.goto('/signin', { waitUntil: 'networkidle' });
await page.getByPlaceholder('Username', { exact: true }).fill(user.username);
await page.getByPlaceholder('Password', { exact: true }).fill(user.password);
await page.getByRole('button', { name: /^Sign In$/ }).click();
await page.waitForURL('**/signin-landing**', { timeout: 15000 });
// Explorer
await page.goto('/career-explorer', { waitUntil: 'networkidle' });
await expect(page.getByRole('heading', { name: /Explore Careers - use these tools/i })).toBeVisible();
await closeAnyOverlay();
// Make sure we have suggestions
await ensureSuggestions();
// Open a suggestion tile → CareerModal
const firstTile = page.locator('div.grid button').first();
await expect(firstTile).toBeVisible({ timeout: TIME.tile });
let clickedTitle = (await firstTile.textContent())?.replace('⚠️', '').trim() || null;
await firstTile.click();
// Prefer modal H2 for authoritative title
const modalH2 = page.getByRole('heading', { level: 2 });
if (await modalH2.isVisible({ timeout: TIME.confirm }).catch(() => false)) {
const t = await modalH2.first().textContent().catch(() => null);
if (t) clickedTitle = t.trim();
}
// Add to Comparison
await expect(page.getByRole('button', { name: /Add to Comparison/i })).toBeVisible({ timeout: TIME.confirm });
await page.getByRole('button', { name: /Add to Comparison/i }).click();
// Ratings modal → Save (must persist the row)
{
const overlay = page.locator('div.fixed.inset-0');
if (await overlay.isVisible({ timeout: 2000 }).catch(() => false)) {
const dlg = overlay.locator('div[role="dialog"], div.bg-white').first();
// Fill selects to '3' if present
const selects = dlg.locator('select');
const sc = await selects.count().catch(() => 0);
for (let i = 0; i < sc; i++) {
const sel = selects.nth(i);
const has3 = await sel.locator('option[value="3"]').count().catch(() => 0);
if (has3) await sel.selectOption('3');
}
// Fill textbox if present
const tb = dlg.locator('input, textarea, [role="textbox"]').first();
if (await tb.isVisible().catch(() => false)) await tb.fill('3');
const saveBtn = dlg.getByRole('button', { name: /^(Save|Save Ratings|Confirm|Done)$/i });
if (await saveBtn.isVisible({ timeout: 1000 }).catch(() => false)) {
await saveBtn.click();
} else {
await page.keyboard.press('Enter');
}
await overlay.waitFor({ state: 'hidden', timeout: 5000 }).catch(() => {});
}
}
// Find the row for clickedTitle (fallback: first row)
const table = page.locator('table');
await table.waitFor({ state: 'attached', timeout: 5000 }).catch(() => {});
let targetRow;
if (clickedTitle) {
const cell = table.getByText(new RegExp(clickedTitle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i')).first();
if (await cell.isVisible().catch(() => false)) {
targetRow = cell.locator('xpath=ancestor::tr').first();
}
}
if (!targetRow) {
targetRow = table.locator('tbody tr').first();
}
// Click “Plan your Education/Skills” with confirm
page.once('dialog', d => d.accept());
await targetRow.getByRole('button', { name: /Plan your Education\/Skills/i }).click();
// Route change
await page.waitForURL('**/educational-programs**', { timeout: TIME.route });
// Assert selectedCareer is stored with expected shape
const selected = await page.evaluate(() => {
try {
return JSON.parse(localStorage.getItem('selectedCareer') || 'null');
} catch { return null; }
});
expect(selected).toBeTruthy();
expect(typeof selected.soc_code).toBe('string');
expect(Array.isArray(selected.cip_code)).toBeTruthy();
});
});

View File

@ -0,0 +1,28 @@
// @ts-check
import { test, expect } from '@playwright/test';
import { loadTestUser } from '../utils/testUser.js';
test.describe('@p0 Logout + guard', () => {
test.setTimeout(10000);
test('logout clears session; protected routes redirect to /signin', async ({ page }) => {
const user = loadTestUser();
// Sign in
await page.context().clearCookies();
await page.goto('/signin', { waitUntil: 'networkidle' });
await page.getByPlaceholder('Username', { exact: true }).fill(user.username);
await page.getByPlaceholder('Password', { exact: true }).fill(user.password);
await page.getByRole('button', { name: /^Sign In$/ }).click();
await page.waitForURL('**/signin-landing**', { timeout: 15000 });
// Click Logout in top nav (link or button)
const logout = page.getByRole('link', { name: /Logout/i }).or(page.getByRole('button', { name: /Logout/i }));
await expect(logout).toBeVisible({ timeout: 5000 });
await logout.click();
// Hitting a protected route should bounce to /signin
await page.goto('/career-explorer', { waitUntil: 'networkidle' });
await expect(page).toHaveURL(/\/signin(\?|$)/, { timeout: 10000 });
});
});

View File

@ -0,0 +1,122 @@
// @ts-check
import { test, expect } from '@playwright/test';
import { loadTestUser } from '../utils/testUser.js';
test.describe('@p0 Comparison: add → duplicate blocked → remove → persists', () => {
test.setTimeout(20000);
test('add one, block duplicate, remove and persist', async ({ page }) => {
const user = loadTestUser();
// Helpers
async function closeAnyOverlay() {
const overlay = page.locator('div.fixed.inset-0');
if (!(await overlay.isVisible({ timeout: 500 }).catch(() => false))) return;
const dlg = overlay.locator('div[role="dialog"], div.bg-white').first();
// Fill selects (neutral '3') if present
const selects = dlg.locator('select');
const sc = await selects.count().catch(() => 0);
for (let i = 0; i < sc; i++) {
const sel = selects.nth(i);
const has3 = await sel.locator('option[value="3"]').count().catch(() => 0);
if (has3) await sel.selectOption('3');
}
// Fill textbox if present
const tb = dlg.locator('input, textarea, [role="textbox"]').first();
if (await tb.isVisible().catch(() => false)) await tb.fill('3');
const save = dlg.getByRole('button', { name: /^(Save|Save Ratings|Confirm|Done|OK)$/i });
if (await save.isVisible({ timeout: 500 }).catch(() => false)) await save.click();
else await page.keyboard.press('Enter');
await overlay.waitFor({ state: 'hidden', timeout: 5000 }).catch(() => {});
}
async function ensureSuggestions() {
const firstTile = page.locator('div.grid button').first();
if (await firstTile.isVisible({ timeout: 1500 }).catch(() => false)) return;
const reloadBtn = page.getByRole('button', { name: /Reload Career Suggestions/i });
await expect(reloadBtn).toBeVisible();
await reloadBtn.click();
// Wait for cache
await expect
.poll(async () => {
return await page.evaluate(() => {
try {
const s = localStorage.getItem('careerSuggestionsCache');
const arr = s ? JSON.parse(s) : [];
return Array.isArray(arr) ? arr.length : 0;
} catch { return 0; }
});
}, { timeout: 60000, message: 'careerSuggestionsCache not populated' })
.toBeGreaterThan(0);
await expect(firstTile).toBeVisible({ timeout: 10000 });
}
// Sign in
await page.context().clearCookies();
await page.goto('/signin', { waitUntil: 'networkidle' });
await page.getByPlaceholder('Username', { exact: true }).fill(user.username);
await page.getByPlaceholder('Password', { exact: true }).fill(user.password);
await page.getByRole('button', { name: /^Sign In$/ }).click();
await page.waitForURL('**/signin-landing**', { timeout: 15000 });
// Explorer
await page.goto('/career-explorer', { waitUntil: 'networkidle' });
await expect(page.getByRole('heading', { name: /Explore Careers - use these tools/i })).toBeVisible();
await closeAnyOverlay();
// Ensure at least one tile is present
await ensureSuggestions();
// Open first suggestion -> modal
const firstTile = page.locator('div.grid button').first();
await expect(firstTile).toBeVisible({ timeout: 8000 });
const tileTitle = (await firstTile.textContent())?.replace('⚠️', '').trim() || null;
await firstTile.click();
// Add to Comparison (then ratings modal Save)
await expect(page.getByRole('button', { name: /Add to Comparison/i })).toBeVisible({ timeout: 15000 });
await page.getByRole('button', { name: /Add to Comparison/i }).click();
await closeAnyOverlay();
// Table row must exist for that title (fallback: any row exists)
const table = page.locator('table');
await table.waitFor({ state: 'attached', timeout: 8000 }).catch(() => {});
let row = null;
if (tileTitle) {
const cell = table.getByText(new RegExp(tileTitle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i')).first();
if (await cell.isVisible().catch(() => false)) row = cell.locator('xpath=ancestor::tr').first();
}
if (!row) row = table.locator('tbody tr').first();
await expect(row).toBeVisible({ timeout: 8000 });
// Try to add the same career again → expect duplicate alert
let sawDuplicate = false;
page.once('dialog', async d => {
if (/already in comparison/i.test(d.message())) sawDuplicate = true;
await d.accept();
});
// Open the same modal again (either by clicking the same tile or by search commit)
if (tileTitle) await firstTile.click();
else await page.locator('div.grid button').first().click();
await expect(page.getByRole('button', { name: /Add to Comparison/i })).toBeVisible({ timeout: 15000 });
await page.getByRole('button', { name: /Add to Comparison/i }).click();
await closeAnyOverlay();
expect(sawDuplicate).toBeTruthy();
// Remove the row
await row.getByRole('button', { name: /^Remove$/ }).click();
// Row should disappear
await expect(row).toBeHidden({ timeout: 8000 });
// Reload page → row should still be gone (persisted)
await page.reload({ waitUntil: 'networkidle' });
await table.waitFor({ state: 'attached', timeout: 8000 }).catch(() => {});
if (tileTitle) {
const cellAgain = table.getByText(new RegExp(tileTitle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i'));
expect(await cellAgain.isVisible().catch(() => false)).toBeFalsy();
}
});
});

View File

@ -0,0 +1,107 @@
// @ts-check
import { test, expect } from '@playwright/test';
import { loadTestUser } from '../utils/testUser.js';
test.describe('@p0 Priorities modal — save & persist (current UI)', () => {
test.setTimeout(20000);
test('open → choose answers → Save Answers → reload → same answers present', async ({ page }) => {
const user = loadTestUser();
// --- helpers -------------------------------------------------------------
async function openExplorer() {
await page.context().clearCookies();
await page.goto('/signin', { waitUntil: 'networkidle' });
await page.getByPlaceholder('Username', { exact: true }).fill(user.username);
await page.getByPlaceholder('Password', { exact: true }).fill(user.password);
await page.getByRole('button', { name: /^Sign In$/ }).click();
await page.waitForURL('**/signin-landing**', { timeout: 15000 });
await page.goto('/career-explorer', { waitUntil: 'networkidle' });
await expect(page.getByRole('heading', { name: /Explore Careers - use these tools/i })).toBeVisible();
}
async function openPrioritiesModal() {
const overlay = page.locator('div.fixed.inset-0');
// If its already up (gate), just use it
if (await overlay.isVisible({ timeout: 500 }).catch(() => false)) return overlay;
// Otherwise click "Edit priorities"
const edit = page.getByRole('button', { name: /Edit priorities/i }).or(page.getByText(/Edit priorities/i));
await expect(edit).toBeVisible({ timeout: 5000 });
await edit.click();
await expect(overlay).toBeVisible({ timeout: 3000 });
return overlay;
}
// choose valid option in each select (prefer "Very important", then "Yes, very important", etc.)
async function chooseAnswers(overlay) {
const dlg = overlay.locator('div[role="dialog"], div.bg-white').first();
const selects = dlg.locator('select');
const n = await selects.count();
const chosen = [];
for (let i = 0; i < n; i++) {
const sel = selects.nth(i);
await expect(sel).toBeVisible();
const opts = sel.locator('option');
const m = await opts.count();
const cand = [];
for (let j = 0; j < m; j++) {
const val = (await opts.nth(j).getAttribute('value')) || '';
if (!val) continue; // skip "Select an answer"
const txt = ((await opts.nth(j).textContent()) || '').trim();
cand.push({ val, txt });
}
const pickOrder = [/^Very important$/i, /^Yes, very important$/i, /^Somewhat important$/i, /^Not as important$/i];
let pick = cand.find(c => pickOrder[0].test(c.txt))
|| cand.find(c => pickOrder[1].test(c.txt))
|| cand.find(c => pickOrder[2].test(c.txt))
|| cand.find(c => pickOrder[3].test(c.txt))
|| cand[0];
await sel.selectOption(pick.val); // select by VALUE (robust)
chosen.push(pick.txt); // remember chosen text
}
return chosen;
}
async function readSelected(overlay) {
const dlg = overlay.locator('div[role="dialog"], div.bg-white').first();
const selects = dlg.locator('select');
const n = await selects.count();
const out = [];
for (let i = 0; i < n; i++) {
const txt = await selects.nth(i).locator('option:checked').first().textContent();
out.push((txt || '').trim());
}
return out;
}
// --- flow ----------------------------------------------------------------
await openExplorer();
// 1) Open modal (gate or via "Edit priorities")
let overlay = await openPrioritiesModal();
// 2) Make selections and save
const chosen = await chooseAnswers(overlay);
await expect(overlay.getByRole('button', { name: /^Save Answers$/i })).toBeEnabled({ timeout: 5000 });
await overlay.getByRole('button', { name: /^Save Answers$/i }).click();
await overlay.waitFor({ state: 'hidden', timeout: 5000 }).catch(() => {});
// 3) Reload page and re-open modal
await page.reload({ waitUntil: 'networkidle' });
overlay = await openPrioritiesModal();
// 4) Verify persistence (same number and same labels)
const persisted = await readSelected(overlay);
expect(persisted.length).toBe(chosen.length);
for (let i = 0; i < persisted.length; i++) {
expect(persisted[i]).toBe(chosen[i]);
}
// Optional: close (re-using Save Answers keeps state)
const saveAgain = overlay.getByRole('button', { name: /^Save Answers$/i });
if (await saveAgain.isVisible().catch(() => false)) {
await saveAgain.click();
await overlay.waitFor({ state: 'hidden', timeout: 3000 }).catch(() => {});
}
});
});

View File

@ -0,0 +1,61 @@
// @ts-check
import { test, expect } from '@playwright/test';
import { loadTestUser } from '../utils/testUser.js';
test.describe('@p0 Forgot Password — exact UI flow', () => {
test.setTimeout(20000);
test('SignIn → Forgot → invalid email blocked → valid submit shows success copy', async ({ page }) => {
const user = loadTestUser();
const email = (user.email || 'test@example.com').toLowerCase();
// Go to Sign In
await page.context().clearCookies();
await page.goto('/signin', { waitUntil: 'networkidle' });
await expect(page.getByRole('heading', { name: /^Sign In$/i })).toBeVisible();
// Click “Forgot your password?”
const forgotLink = page.getByRole('link', { name: /^Forgot your password\?$/i })
.or(page.getByText(/^Forgot your password\?$/i));
await expect(forgotLink).toBeVisible({ timeout: 5000 });
await forgotLink.click();
// Forgot page renders
await expect(page).toHaveURL(/\/forgot-password(\?|$)/, { timeout: 5000 });
await expect(page.getByRole('heading', { name: /^Forgot your password\?$/i })).toBeVisible();
// Email input present
const emailInput = page.getByRole('textbox', { name: /^Email$/i })
.or(page.locator('input[type="email"]'))
.first();
await expect(emailInput).toBeVisible();
// Invalid email → inline error “Enter a valid email.”
await emailInput.fill('not-an-email');
const sendBtn = page.getByRole('button', { name: /^Send reset link$/i });
await expect(sendBtn).toBeVisible();
await sendBtn.click();
const invalidByHtml5 = await emailInput.evaluate(el =>
el instanceof HTMLInputElement ? el.validity && !el.validity.valid : false
);
// Either native validity blocked submit or inline error appeared
const inlineErrVisible = await page.getByText(/^Enter a valid email\.$/i)
.isVisible().catch(() => false);
expect(invalidByHtml5 || inlineErrVisible).toBeTruthy();
// Valid email → success screen “Check your email” with the address
await emailInput.fill(email);
await sendBtn.click();
await expect(page.getByRole('heading', { name: /^Check your email$/i })).toBeVisible({ timeout: 7000 });
await expect(page.getByText(new RegExp(email.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i'))).toBeVisible();
// Optional: “Back to Sign In” works
const backBtn = page.getByRole('button', { name: /^Back to Sign In$/i })
.or(page.getByRole('link', { name: /^Back to Sign In$/i }));
if (await backBtn.isVisible().catch(() => false)) {
await backBtn.click();
await expect(page).toHaveURL(/\/signin(\?|$)/, { timeout: 5000 });
}
});
});

View File

@ -0,0 +1,85 @@
// @ts-check
import { test, expect } from '@playwright/test';
test.describe('@p0 Reset Password — exact UI flow', () => {
test.setTimeout(20000);
test('mismatch passwords → inline error, no request', async ({ page }) => {
// Route with a token param (the component checks presence only)
await page.goto('/reset-password/abcd1234', { waitUntil: 'networkidle' });
const newPw = page.getByLabel(/^New password$/i).or(page.locator('input[type="password"]').first());
const confirm = page.getByLabel(/^Confirm password$/i).or(page.locator('input[type="password"]').nth(1));
const submit = page.getByRole('button', { name: /^Update password$/i });
await expect(newPw).toBeVisible();
await newPw.fill('Str0ng!Passw0rd');
await confirm.fill('Different!Pass');
// Ensure no network call is made when mismatch
let sawConfirmCall = false;
page.on('request', (req) => {
if (req.method() === 'POST' && /\/api\/auth\/password-reset\/confirm$/.test(req.url())) {
sawConfirmCall = true;
}
});
await submit.click();
await expect(page.getByText(/^Passwords do not match\.$/i)).toBeVisible();
expect(sawConfirmCall).toBeFalsy();
});
test('success path → “Password updated” → Go to Sign In navigates', async ({ page }) => {
await page.goto('/reset-password/successToken123', { waitUntil: 'networkidle' });
// Intercept confirm endpoint with 200 OK
await page.route('**/api/auth/password-reset/confirm', async (route) => {
const req = route.request();
expect(req.method()).toBe('POST');
const body = JSON.parse(req.postData() || '{}');
// basic shape check
expect(body).toHaveProperty('token');
expect(body).toHaveProperty('password');
await route.fulfill({ status: 200, contentType: 'application/json', body: '{}' });
});
const newPw = page.getByLabel(/^New password$/i).or(page.locator('input[type="password"]').first());
const confirm = page.getByLabel(/^Confirm password$/i).or(page.locator('input[type="password"]').nth(1));
const submit = page.getByRole('button', { name: /^Update password$/i });
await newPw.fill('Str0ng!Passw0rd$');
await confirm.fill('Str0ng!Passw0rd$');
await submit.click();
await expect(page.getByRole('heading', { name: /^Password updated$/i })).toBeVisible({ timeout: 10000 });
const goSignin = page.getByRole('button', { name: /^Go to Sign In$/i });
await expect(goSignin).toBeVisible();
await goSignin.click();
await expect(page).toHaveURL(/\/signin(\?|$)/, { timeout: 10000 });
});
test('rate limited (429) → shows friendly error and stays on form', async ({ page }) => {
await page.goto('/reset-password/ratelimitToken', { waitUntil: 'networkidle' });
// Intercept with 429 (Too Many Requests)
await page.route('**/api/auth/password-reset/confirm', async (route) => {
await route.fulfill({ status: 429, contentType: 'application/json', body: '{}' });
});
const newPw = page.getByLabel(/^New password$/i).or(page.locator('input[type="password"]').first());
const confirm = page.getByLabel(/^Confirm password$/i).or(page.locator('input[type="password"]').nth(1));
const submit = page.getByRole('button', { name: /^Update password$/i });
await newPw.fill('Str0ng!Passw0rd$');
await confirm.fill('Str0ng!Passw0rd$');
await submit.click();
await expect(
page.getByText(/Too many attempts\. Please wait ~30 seconds and try again\./i)
).toBeVisible({ timeout: 5000 });
// Still on form (not the success screen)
await expect(page.getByRole('heading', { name: /^Set a new password$/i })).toBeVisible();
});
});

View File

@ -0,0 +1,8 @@
// tests/e2e/12a-reset-password-redirect.spec.mjs
import { test, expect } from '@playwright/test';
test('@p0 Reset Password — no-token path redirects to /signin', async ({ page }) => {
await page.context().clearCookies();
await page.goto('/reset-password', { waitUntil: 'networkidle' });
await expect(page).toHaveURL(/\/signin(\?|$)/, { timeout: 5000 }); // matches your wildcard redirect
});

View File

@ -0,0 +1,31 @@
// @ts-check
import { test, expect } from '@playwright/test';
import { loadTestUser } from '../utils/testUser.js';
test.describe('@p0 PremiumRoute guard', () => {
test.setTimeout(20000);
test('non-premium user is redirected to /paywall from premium routes', async ({ page }) => {
const user = loadTestUser();
// Sign in
await page.context().clearCookies();
await page.goto('/signin', { waitUntil: 'networkidle' });
await page.getByPlaceholder('Username', { exact: true }).fill(user.username);
await page.getByPlaceholder('Password', { exact: true }).fill(user.password);
await page.getByRole('button', { name: /^Sign In$/ }).click();
await page.waitForURL('**/signin-landing**', { timeout: 15000 });
// 1) /enhancing should bounce to /paywall
await page.goto('/enhancing', { waitUntil: 'networkidle' });
await expect(page).toHaveURL(/\/paywall(\?|$)/, { timeout: 8000 });
// 2) /retirement should bounce to /paywall
await page.goto('/retirement', { waitUntil: 'networkidle' });
await expect(page).toHaveURL(/\/paywall(\?|$)/, { timeout: 8000 });
// 3) /career-roadmap (premium) should bounce to /paywall
await page.goto('/career-roadmap', { waitUntil: 'networkidle' });
await expect(page).toHaveURL(/\/paywall(\?|$)/, { timeout: 8000 });
});
});

View File

@ -0,0 +1,21 @@
// @ts-check
import { test, expect } from '@playwright/test';
test.describe('@p0 SessionExpiredHandler', () => {
test.setTimeout(20000);
test('unauth → protected route → /signin?session=expired + banner', async ({ page }) => {
await page.context().clearCookies();
// Hit a protected route with no session
await page.goto('/career-explorer', { waitUntil: 'networkidle' });
// App code: navigate('/signin?session=expired', { replace: true })
await expect(page).toHaveURL(/\/signin\?session=expired$/i, { timeout: 8000 });
// SignIn banner: "Your session has expired. Please sign in again."
await expect(
page.getByText(/Your session has expired\. Please sign in again\./i)
).toBeVisible({ timeout: 5000 });
});
});

View File

@ -0,0 +1,48 @@
// @ts-check
import { test, expect } from '@playwright/test';
import { loadTestUser } from '../utils/testUser.js';
test.describe('@p0 Support modal — open/close', () => {
test.setTimeout(20000);
test('header Support opens modal and can be closed', async ({ page }) => {
const user = loadTestUser();
// Sign in
await page.context().clearCookies();
await page.goto('/signin', { waitUntil: 'networkidle' });
await page.getByPlaceholder('Username', { exact: true }).fill(user.username);
await page.getByPlaceholder('Password', { exact: true }).fill(user.password);
await page.getByRole('button', { name: /^Sign In$/ }).click();
await page.waitForURL('**/signin-landing**', { timeout: 15000 });
// Click Support in header
const supportBtn = page.getByRole('button', { name: /^Support$/i }).or(page.getByText(/^Support$/i));
await expect(supportBtn).toBeVisible({ timeout: 5000 });
await supportBtn.click();
// Modal/overlay appears (be tolerant re: structure)
// Modal/overlay appears (be tolerant re: structure)
const overlay = page.locator('div.fixed.inset-0').first();
await expect(overlay).toBeVisible({ timeout: 5000 });
// Try multiple closing strategies in order
const dlg = overlay.locator('div[role="dialog"], div.bg-white').first();
const closeByText = dlg.getByRole('button', { name: /(Close|Done|OK|Cancel|Dismiss)/i }).first();
const closeByAria = dlg.locator('[aria-label="Close"], [aria-label="close"]').first();
const headerSupport = page.getByRole('button', { name: /^Support$/i }).or(page.getByText(/^Support$/i));
if (await closeByText.isVisible({ timeout: 500 }).catch(() => false)) {
await closeByText.click();
} else if (await closeByAria.isVisible({ timeout: 200 }).catch(() => false)) {
await closeByAria.click();
} else {
// Fallbacks: ESC, then toggle Support again
await page.keyboard.press('Escape');
if (await overlay.isVisible({ timeout: 800 }).catch(() => false)) {
if (await headerSupport.isVisible().catch(() => false)) await headerSupport.click();
}
}
await expect(overlay).toBeHidden({ timeout: 8000 });
});
});

View File

@ -0,0 +1,29 @@
// @ts-check
import { test, expect } from '@playwright/test';
import { loadTestUser } from '../utils/testUser.js';
test.describe('@p0 Paywall CTA', () => {
test.setTimeout(20000);
test('Upgrade to Premium visible on non-premium pages and navigates to /paywall', async ({ page }) => {
const user = loadTestUser();
// Sign in
await page.context().clearCookies();
await page.goto('/signin', { waitUntil: 'networkidle' });
await page.getByPlaceholder('Username', { exact: true }).fill(user.username);
await page.getByPlaceholder('Password', { exact: true }).fill(user.password);
await page.getByRole('button', { name: /^Sign In$/ }).click();
await page.waitForURL('**/signin-landing**', { timeout: 15000 });
// On /signin-landing (non-premium path) CTA should be visible for non-premium users
const cta = page.getByRole('button', { name: /^Upgrade to Premium$/i }).or(
page.getByText(/^Upgrade to Premium$/i)
);
await expect(cta).toBeVisible({ timeout: 5000 });
// Click navigates to /paywall
await cta.click();
await expect(page).toHaveURL(/\/paywall(\?|$)/, { timeout: 8000 });
});
});

View File

@ -0,0 +1,74 @@
// @ts-check
import { test, expect } from '@playwright/test';
import { loadTestUser } from '../utils/testUser.js';
test.describe('@p0 Support — submit ticket', () => {
test.setTimeout(20000);
test('open → fill form → submit → success (network or UI)', async ({ page }) => {
const user = loadTestUser();
const stamp = new Date().toISOString().replace(/[-:TZ.]/g, '');
// Sign in
await page.context().clearCookies();
await page.goto('/signin', { waitUntil: 'networkidle' });
await page.getByPlaceholder('Username', { exact: true }).fill(user.username);
await page.getByPlaceholder('Password', { exact: true }).fill(user.password);
await page.getByRole('button', { name: /^Sign In$/ }).click();
await page.waitForURL('**/signin-landing**', { timeout: 15000 });
// Open Support
const supportBtn = page.getByRole('button', { name: /^Support$/i }).or(page.getByText(/^Support$/i));
await supportBtn.click();
const overlay = page.locator('div.fixed.inset-0').first();
await expect(overlay).toBeVisible({ timeout: 5000 });
const dlg = overlay.locator('div[role="dialog"], div.bg-white').first();
// Fill Subject/Message (tolerant selectors)
const subj = dlg.getByLabel(/Subject/i)
.or(dlg.getByPlaceholder(/Subject/i))
.or(dlg.locator('input[type="text"]'))
.first();
const msg = dlg.getByLabel(/(Message|Describe|How can we help)/i)
.or(dlg.locator('textarea'))
.first();
await subj.fill(`E2E support ${stamp}`);
await msg.fill(`Automated E2E support test at ${stamp}. Please ignore. User=${user.username}`);
// Submit button
const send = dlg.getByRole('button', { name: /(Send|Submit|Send message|Submit ticket|Send request)/i }).first();
await expect(send).toBeEnabled({ timeout: 5000 });
// Start the response wait **before** clicking
const respPromise = page.waitForResponse(r =>
r.request().method() === 'POST' && /\/api\/support$/.test(r.url()),
{ timeout: 15000 }
).catch(() => null);
await send.click();
// Consider success if we saw a POST with a reasonable status
const resp = await respPromise;
const okStatus = resp ? [200, 201, 202, 204, 409, 429].includes(resp.status()) : false;
// Also allow UI signals
const successText = page.getByText(/(thanks|we'll get back|message sent|received)/i).first();
const successShown = await successText.isVisible({ timeout: 3000 }).catch(() => false);
// Close the overlay (cover multiple close mechanisms)
const closeByText = dlg.getByRole('button', { name: /(Close|Done|OK|Cancel|Dismiss)/i }).first();
const closeByAria = dlg.locator('[aria-label="Close"], [aria-label="close"]').first();
if (await closeByText.isVisible({ timeout: 500 }).catch(() => false)) {
await closeByText.click();
} else if (await closeByAria.isVisible({ timeout: 200 }).catch(() => false)) {
await closeByAria.click();
} else {
await page.keyboard.press('Escape');
}
const overlayHidden = await overlay.isHidden({ timeout: 8000 }).catch(() => false);
expect(okStatus || successShown || overlayHidden).toBeTruthy();
});
});

View File

@ -0,0 +1,47 @@
// @ts-check
import { test, expect } from '@playwright/test';
import { loadTestUser } from '../utils/testUser.js';
test.describe('@p0 Header nav menus', () => {
test.setTimeout(20000);
test('Find Your Career menu → Career Explorer & Interest Inventory', async ({ page }) => {
const u = loadTestUser();
await page.context().clearCookies();
await page.goto('/signin', { waitUntil: 'networkidle' });
await page.getByPlaceholder('Username', { exact: true }).fill(u.username);
await page.getByPlaceholder('Password', { exact: true }).fill(u.password);
await page.getByRole('button', { name: /^Sign In$/ }).click();
await page.waitForURL('**/signin-landing**', { timeout: 15000 });
// Open "Find Your Career" dropdown (group-hover)
const findBtn = page.getByRole('button', { name: /^Find Your Career$/i });
await findBtn.hover();
// Click "Career Explorer"
await page.getByRole('link', { name: /^Career Explorer$/i }).click();
await expect(page.getByRole('heading', { name: /Explore Careers - use these tools/i }))
.toBeVisible({ timeout: 8000 });
// Back to header and open dropdown again
await findBtn.hover();
await page.getByRole('link', { name: /^Interest Inventory$/i }).click();
await expect(page.getByRole('heading', { name: /^Interest Inventory$/i }))
.toBeVisible({ timeout: 8000 });
});
test('Preparing menu → Educational Programs', async ({ page }) => {
const u = loadTestUser();
await page.context().clearCookies();
await page.goto('/signin', { waitUntil: 'networkidle' });
await page.getByPlaceholder('Username', { exact: true }).fill(u.username);
await page.getByPlaceholder('Password', { exact: true }).fill(u.password);
await page.getByRole('button', { name: /^Sign In$/ }).click();
await page.waitForURL('**/signin-landing**', { timeout: 15000 });
const prepBtn = page.getByRole('button', { name: /Preparing & UpSkilling for Your Career/i });
await prepBtn.hover();
await page.getByRole('link', { name: /^Educational Programs$/i }).click();
await expect(page).toHaveURL(/\/educational-programs(\?|$)/, { timeout: 8000 });
});
});

View File

@ -0,0 +1,35 @@
// @ts-check
import { test, expect } from '@playwright/test';
import { loadTestUser } from '../utils/testUser.js';
test.describe('@p0 Profile menu gating (non-premium)', () => {
test.setTimeout(20000);
test('Career/College Profiles show disabled labels when user is not premium', async ({ page }) => {
const u = loadTestUser();
await page.context().clearCookies();
await page.goto('/signin', { waitUntil: 'networkidle' });
await page.getByPlaceholder('Username', { exact: true }).fill(u.username);
await page.getByPlaceholder('Password', { exact: true }).fill(u.password);
await page.getByRole('button', { name: /^Sign In$/ }).click();
await page.waitForURL('**/signin-landing**', { timeout: 15000 });
// Hover Profile dropdown
const profileBtn = page.getByRole('button', { name: /^Profile$/i });
await profileBtn.hover();
// Disabled labels (spans) visible
await expect(page.getByText(/^Career Profiles \(Premium\)$/i)).toBeVisible();
await expect(page.getByText(/^College Profiles \(Premium\)$/i)).toBeVisible();
// And they are NOT links
const careerLink = page.getByRole('link', { name: /^Career Profiles$/i }).first();
const collegeLink = page.getByRole('link', { name: /^College Profiles$/i }).first();
expect(await careerLink.isVisible().catch(() => false)).toBeFalsy();
expect(await collegeLink.isVisible().catch(() => false)).toBeFalsy();
// "Account" link still works
await page.getByRole('link', { name: /^Account$/i }).click();
await expect(page).toHaveURL(/\/profile(\?|$)/, { timeout: 8000 });
});
});

View File

@ -0,0 +1,48 @@
// @ts-check
import { test, expect } from '@playwright/test';
import { loadTestUser } from '../utils/testUser.js';
test.describe('@p0 Support — burst rate limit', () => {
test.setTimeout(20000);
test('rapid submissions eventually return 429 Too Many Requests', async ({ page }) => {
const user = loadTestUser();
const stamp = new Date().toISOString().replace(/[-:TZ.]/g, '');
// 1) Sign in to get an auth cookie (support requires auth)
await page.context().clearCookies();
await page.goto('/signin', { waitUntil: 'networkidle' });
await page.getByPlaceholder('Username', { exact: true }).fill(user.username);
await page.getByPlaceholder('Password', { exact: true }).fill(user.password);
await page.getByRole('button', { name: /^Sign In$/ }).click();
await page.waitForURL('**/signin-landing**', { timeout: 15000 });
// 2) Fire a small burst of requests to /api/support
// (Assumption: burst limiter threshold < 10 in your config)
const tries = 12;
const statuses = [];
for (let i = 0; i < tries; i++) {
const resp = await page.request.post('/api/support', {
data: {
subject: `E2E rate limit test ${stamp} #${i}`,
message: `Automated burst ${i} at ${new Date().toISOString()} — please ignore.`,
},
}).catch(() => null);
const code = resp ? resp.status() : 0;
statuses.push(code);
// Small pacing to keep the server from batching writes too tightly
await page.waitForTimeout(100);
// Fast-exit if we already hit the limiter
if (code === 429) break;
}
// Log for report
console.log('support burst statuses:', statuses.join(', '));
// 3) Expect at least one 429 Too Many Requests in the burst
expect(statuses.some((s) => s === 429)).toBeTruthy();
});
});

View File

@ -0,0 +1,47 @@
// @ts-check
import { test, expect } from '@playwright/test';
import { loadTestUser } from '../utils/testUser.js';
test.describe('@p0 Support — auth + dedupe', () => {
test.setTimeout(20000);
test('unauthenticated request is rejected (401)', async ({ page }) => {
await page.context().clearCookies();
const resp = await page.request.post('/api/support', {
data: { subject: 'unauth test', message: 'should be 401' },
});
expect([401, 403]).toContain(resp.status());
});
test('duplicate submission within window is deduped (409 or similar)', async ({ page }) => {
const user = loadTestUser();
const stamp = new Date().toISOString().replace(/[-:TZ.]/g, '');
const subject = `E2E dedupe ${stamp}`;
const message = `Automated dedupe probe at ${stamp}.`;
// Sign in (to get auth cookie)
await page.context().clearCookies();
await page.goto('/signin', { waitUntil: 'networkidle' });
await page.getByPlaceholder('Username', { exact: true }).fill(user.username);
await page.getByPlaceholder('Password', { exact: true }).fill(user.password);
await page.getByRole('button', { name: /^Sign In$/ }).click();
await page.waitForURL('**/signin-landing**', { timeout: 15000 });
// First submission (usually 2xx, but allow 409/429 if dedupe/limit already active)
const r1 = await page.request.post('/api/support', { data: { subject, message } });
const s1 = r1.status();
expect([200, 201, 202, 204, 409, 429]).toContain(s1);
// Immediate duplicate (should trigger dedupe or be tolerated by server)
const r2 = await page.request.post('/api/support', { data: { subject, message } });
const s2 = r2.status();
// Accept dedupe/ratelimit or tolerated 2xx
expect([409, 429, 200, 201, 202, 204]).toContain(s2);
// At least one of the two should indicate dedupe/limit
expect([s1, s2].some(s => s === 409 || s === 429)).toBeTruthy();
// (Optional) small delay to avoid tripping burst limiter on shared environments
await page.waitForTimeout(100);
});
});

View File

@ -0,0 +1,158 @@
// @ts-check
import { test, expect } from '@playwright/test';
import { loadTestUser } from '../utils/testUser.js';
test.describe('@p0 Educational Programs — handoff & page render', () => {
test.setTimeout(20000);
test('handoff carries selectedCareer; page shows career title, KSA header and school cards; survives reload', async ({ page }) => {
const user = loadTestUser();
// ------- helpers -------
async function signIn() {
await page.context().clearCookies();
await page.goto('/signin', { waitUntil: 'networkidle' });
await page.getByPlaceholder('Username', { exact: true }).fill(user.username);
await page.getByPlaceholder('Password', { exact: true }).fill(user.password);
await page.getByRole('button', { name: /^Sign In$/ }).click();
await page.waitForURL('**/signin-landing**', { timeout: 15000 });
}
async function ensureSuggestions() {
await page.goto('/career-explorer', { waitUntil: 'networkidle' });
const tile = page.locator('div.grid button').first();
if (await tile.isVisible({ timeout: 1500 }).catch(() => false)) return;
// Cold path: click Reload and wait for cache
const reloadBtn = page.getByRole('button', { name: /Reload Career Suggestions/i });
await expect(reloadBtn).toBeVisible();
await reloadBtn.click();
// Optional overlay text, do not insist on 100%
const overlay = page.getByText(/Loading Career Suggestions/i).first();
await overlay.isVisible({ timeout: 2000 }).catch(() => {});
await expect
.poll(async () => {
return await page.evaluate(() => {
try {
const s = localStorage.getItem('careerSuggestionsCache');
const arr = s ? JSON.parse(s) : [];
return Array.isArray(arr) ? arr.length : 0;
} catch { return 0; }
});
}, { timeout: 60000, message: 'careerSuggestionsCache not populated' })
.toBeGreaterThan(0);
await expect(tile).toBeVisible({ timeout: 10000 });
}
// ------- flow -------
await signIn();
await ensureSuggestions();
// Open a suggestion → Add to Comparison
const firstTile = page.locator('div.grid button').first();
await expect(firstTile).toBeVisible({ timeout: 8000 });
const tileTitle = (await firstTile.textContent())?.replace('⚠️', '').trim() || null;
await firstTile.click();
await expect(page.getByRole('button', { name: /Add to Comparison/i })).toBeVisible({ timeout: 15000 });
await page.getByRole('button', { name: /Add to Comparison/i }).click();
// Ratings modal → Save (neutral)
{
const overlay = page.locator('div.fixed.inset-0');
if (await overlay.isVisible({ timeout: 2000 }).catch(() => false)) {
const dlg = overlay.locator('div[role="dialog"], div.bg-white').first();
const selects = dlg.locator('select');
const sc = await selects.count().catch(() => 0);
for (let i = 0; i < sc; i++) {
const sel = selects.nth(i);
const has3 = await sel.locator('option[value="3"]').count().catch(() => 0);
if (has3) await sel.selectOption('3');
}
const tb = dlg.locator('input, textarea, [role="textbox"]').first();
if (await tb.isVisible().catch(() => false)) await tb.fill('3');
const saveBtn = dlg.getByRole('button', { name: /^(Save|Save Ratings|Confirm|Done)$/i });
if (await saveBtn.isVisible({ timeout: 800 }).catch(() => false)) await saveBtn.click();
await overlay.waitFor({ state: 'hidden', timeout: 5000 }).catch(() => {});
}
}
// Row → Plan your Education/Skills
const table = page.locator('table');
await table.waitFor({ state: 'attached', timeout: 8000 }).catch(() => {});
let row = null;
if (tileTitle) {
const cell = table.getByText(new RegExp(tileTitle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i')).first();
if (await cell.isVisible().catch(() => false)) row = cell.locator('xpath=ancestor::tr').first();
}
if (!row) row = table.locator('tbody tr').first();
await expect(row).toBeVisible({ timeout: 8000 });
page.once('dialog', d => d.accept());
await row.getByRole('button', { name: /Plan your Education\/Skills/i }).click();
// Land on Educational Programs
await expect(page).toHaveURL(/\/educational-programs(\?|$)/, { timeout: 20000 });
// -------- Assertions (NO SOC/CIP in UI) --------
// A) localStorage handoff exists
const selected = await page.evaluate(() => {
try { return JSON.parse(localStorage.getItem('selectedCareer') || 'null'); }
catch { return null; }
});
expect(selected).toBeTruthy();
expect(typeof selected.title).toBe('string');
// B) Title shown in a unique place:
// Prefer the “Schools for: {title}” heading; fallback to “Currently selected: {title}”
function escRe(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }
if (selected.title) {
// get the Schools-for heading, then check it contains the title
const schoolsH2 = page.getByRole('heading', { level: 2 }).filter({ hasText: /^Schools for:/i }).first();
let ok = false;
if (await schoolsH2.isVisible({ timeout: 4000 }).catch(() => false)) {
const htxt = (await schoolsH2.textContent()) || '';
ok = new RegExp(`\\b${escRe(selected.title)}\\b`, 'i').test(htxt);
}
if (!ok) {
// fallback to the “Currently selected:” paragraph line
const line = page.locator('p.text-gray-700')
.filter({ hasText: /^Currently selected:/i })
.first();
if (await line.isVisible({ timeout: 3000 }).catch(() => false)) {
const txt = (await line.textContent()) || '';
ok = new RegExp(`\\b${escRe(selected.title)}\\b`, 'i').test(txt);
}
}
expect(ok).toBeTruthy();
}
// C) KSA header appears (tolerant): “Knowledge, Skills, and Abilities needed for: {careerTitle}”
const ksaHeader = page.getByRole('heading', { name: /Knowledge, Skills, and Abilities needed for:/i });
const hasKsaHeader = await ksaHeader.first().isVisible({ timeout: 8000 }).catch(() => false);
expect(hasKsaHeader).toBeTruthy();
// D) At least one school card is rendered with “Program:” and “Degree Type:” and a “Select School” button
const programText = page.getByText(/^Program:\s*/i).first();
const degreeText = page.getByText(/^Degree Type:\s*/i).first();
const selectBtn = page.getByRole('button', { name: /^Select School$/i }).first();
// Allow a little time for the IPEDS fetch + optional geocode
await expect(programText).toBeVisible({ timeout: 30000 });
await expect(degreeText).toBeVisible({ timeout: 30000 });
await expect(selectBtn).toBeVisible({ timeout: 30000 });
// E) Reload: the page should rehydrate from localStorage (title/schools still present)
await page.reload({ waitUntil: 'networkidle' });
// After reload, check that at least the “Schools for:” header (or the currently selected line) is present again
const schoolsForAfter = page.getByRole('heading', { name: /Schools for:/i }).first();
const currentSelAfter = page.getByText(/Currently selected:/i).first();
const headerOk = await schoolsForAfter.isVisible({ timeout: 8000 }).catch(() => false)
|| await currentSelAfter.isVisible({ timeout: 8000 }).catch(() => false);
expect(headerOk).toBeTruthy();
// And at least one “Program:” line is visible again
await expect(page.getByText(/^Program:\s*/i).first()).toBeVisible({ timeout: 15000 });
});
});

View File

@ -0,0 +1,111 @@
// @ts-check
import { test, expect } from '@playwright/test';
import { loadTestUser } from '../utils/testUser.js';
test.describe('@p0 Educational Programs — Select School → premium guard', () => {
test.setTimeout(20000);
test('plan education → programs page → Select School → confirm → redirected to /paywall (non-premium)', async ({ page }) => {
const user = loadTestUser();
// ------- helpers -------
async function signIn() {
await page.context().clearCookies();
await page.goto('/signin', { waitUntil: 'networkidle' });
await page.getByPlaceholder('Username', { exact: true }).fill(user.username);
await page.getByPlaceholder('Password', { exact: true }).fill(user.password);
await page.getByRole('button', { name: /^Sign In$/ }).click();
await page.waitForURL('**/signin-landing**', { timeout: 15000 });
}
async function ensureSuggestions() {
await page.goto('/career-explorer', { waitUntil: 'networkidle' });
const tile = page.locator('div.grid button').first();
if (await tile.isVisible({ timeout: 1500 }).catch(() => false)) return;
const reloadBtn = page.getByRole('button', { name: /Reload Career Suggestions/i });
await expect(reloadBtn).toBeVisible();
await reloadBtn.click();
const overlay = page.getByText(/Loading Career Suggestions/i).first();
await overlay.isVisible({ timeout: 2000 }).catch(() => {});
await expect
.poll(async () => {
return await page.evaluate(() => {
try {
const s = localStorage.getItem('careerSuggestionsCache');
const arr = s ? JSON.parse(s) : [];
return Array.isArray(arr) ? arr.length : 0;
} catch { return 0; }
});
}, { timeout: 60000, message: 'careerSuggestionsCache not populated' })
.toBeGreaterThan(0);
await expect(tile).toBeVisible({ timeout: 10000 });
}
async function addOneToComparison() {
const firstTile = page.locator('div.grid button').first();
await expect(firstTile).toBeVisible({ timeout: 8000 });
await firstTile.click();
await expect(page.getByRole('button', { name: /Add to Comparison/i }))
.toBeVisible({ timeout: 15000 });
await page.getByRole('button', { name: /Add to Comparison/i }).click();
// Ratings modal → Save (neutral)
const overlay = page.locator('div.fixed.inset-0');
if (await overlay.isVisible({ timeout: 2000 }).catch(() => false)) {
const dlg = overlay.locator('div[role="dialog"], div.bg-white').first();
const selects = dlg.locator('select');
const sc = await selects.count().catch(() => 0);
for (let i = 0; i < sc; i++) {
const sel = selects.nth(i);
const has3 = await sel.locator('option[value="3"]').count().catch(() => 0);
if (has3) await sel.selectOption('3');
}
const tb = dlg.locator('input, textarea, [role="textbox"]').first();
if (await tb.isVisible().catch(() => false)) await tb.fill('3');
const saveBtn = dlg.getByRole('button', { name: /^(Save|Save Ratings|Confirm|Done)$/i });
if (await saveBtn.isVisible({ timeout: 800 }).catch(() => false)) await saveBtn.click();
await overlay.waitFor({ state: 'hidden', timeout: 5000 }).catch(() => {});
}
}
// ------- flow -------
await signIn();
await ensureSuggestions();
await addOneToComparison();
// Open the row → Plan your Education/Skills
const table = page.locator('table');
await table.waitFor({ state: 'attached', timeout: 8000 }).catch(() => {});
const row = table.locator('tbody tr').first();
await expect(row).toBeVisible({ timeout: 8000 });
page.once('dialog', d => d.accept());
await row.getByRole('button', { name: /Plan your Education\/Skills/i }).click();
// Land on Educational Programs; wait for school cards to render
await expect(page).toHaveURL(/\/educational-programs(\?|$)/, { timeout: 20000 });
const programText = page.getByText(/^Program:\s*/i).first();
const selectBtn = page.getByRole('button', { name: /^Select School$/i }).first();
await expect(programText).toBeVisible({ timeout: 30000 });
await expect(selectBtn).toBeVisible({ timeout: 30000 });
// Draft calls must not block navigation: stub premium draft API to succeed
await page.route('**/api/premium/onboarding/draft', async (route) => {
// return minimal OK payload regardless of method
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ data: {} }),
});
});
// Click Select School → confirm → PremiumRoute guard should redirect to /paywall (non-premium)
page.once('dialog', d => d.accept());
await selectBtn.click();
await expect(page).toHaveURL(/\/paywall(\?|$)/, { timeout: 20000 });
});
});

View File

@ -0,0 +1,92 @@
// @ts-check
import { test, expect } from '@playwright/test';
import { loadTestUser } from '../utils/testUser.js';
test.describe('@p0 Chat drawer — Support stream', () => {
test.setTimeout(20000);
test('FAB opens Support → create/load thread → send prompt → assistant reply appears', async ({ page }) => {
const user = loadTestUser();
// ---- Stub chat API before the app mounts (ensureSupportThread runs on mount) ----
let createdId = 'thread-e2e';
// list existing -> none
await page.route('**/api/chat/threads', async (route) => {
if (route.request().method() === 'GET') {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ threads: [] }),
});
return;
}
// create new
if (route.request().method() === 'POST') {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ id: createdId }),
});
return;
}
await route.fallback();
});
// preload thread messages -> empty
await page.route(`**/api/chat/threads/${createdId}`, async (route) => {
if (route.request().method() === 'GET') {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ messages: [] }),
});
return;
}
await route.fallback();
});
// streaming endpoint -> return simple line chunks as text/event-stream
await page.route('**/api/chat/threads/*/stream', async (route) => {
const reply = 'E2E assistant reply.';
await route.fulfill({
status: 200,
headers: { 'Content-Type': 'text/event-stream' },
body: `${reply}\n`,
});
});
// ---- Sign in ----
await page.context().clearCookies();
await page.goto('/signin', { waitUntil: 'networkidle' });
await page.getByPlaceholder('Username', { exact: true }).fill(user.username);
await page.getByPlaceholder('Password', { exact: true }).fill(user.password);
await page.getByRole('button', { name: /^Sign In$/ }).click();
await page.waitForURL('**/signin-landing**', { timeout: 15000 });
// ---- Open chat via FAB (forces Support pane) ----
const fab = page.getByRole('button', { name: /^Open chat$/i });
await expect(fab).toBeVisible({ timeout: 5000 });
await fab.click();
// Drawer visible; Support tab selected
const supportTab = page.getByRole('button', { name: /^Aptiva\s*Support$/i });
await expect(supportTab).toBeVisible({ timeout: 5000 });
// Input and Send present
const input = page.getByPlaceholder('Ask me anything…');
const send = page.getByRole('button', { name: /^Send$/i });
await expect(input).toBeVisible();
await expect(send).toBeDisabled(); // empty prompt
// Send a message -> should render user bubble + streamed assistant reply
await input.fill('Hi from E2E');
await expect(send).toBeEnabled();
await send.click();
// User bubble
await expect(page.getByText(/^Hi from E2E$/)).toBeVisible({ timeout: 5000 });
// Assistant reply (from our stream stub)
await expect(page.getByText(/^E2E assistant reply\./)).toBeVisible({ timeout: 8000 });
});
});

View File

@ -0,0 +1,32 @@
// @ts-check
import { test, expect } from '@playwright/test';
import { loadTestUser } from '../utils/testUser.js';
test.describe('@p0 Chat drawer — Retirement tab gating (non-retire pages)', () => {
test.setTimeout(20000);
test('Retirement tab is not shown on non-retirement pages', async ({ page }) => {
const user = loadTestUser();
// Sign in and go to a non-retirement page (e.g., Career Explorer)
await page.context().clearCookies();
await page.goto('/signin', { waitUntil: 'networkidle' });
await page.getByPlaceholder('Username', { exact: true }).fill(user.username);
await page.getByPlaceholder('Password', { exact: true }).fill(user.password);
await page.getByRole('button', { name: /^Sign In$/i }).click();
await page.waitForURL('**/signin-landing**', { timeout: 15000 });
await page.goto('/career-explorer', { waitUntil: 'networkidle' });
// Open chat
const fab = page.getByRole('button', { name: /^Open chat$/i });
await fab.click();
// Support tab present…
await expect(page.getByRole('button', { name: /^Aptiva\s*Support$/i })).toBeVisible({ timeout: 5000 });
// …but Retirement tab should NOT be rendered on non-retirement pages
const retireTab = page.getByRole('button', { name: /^Retirement\s*Helper$/i });
expect(await retireTab.isVisible().catch(() => false)).toBeFalsy();
});
});

View File

@ -0,0 +1,46 @@
// @ts-check
import { test, expect } from '@playwright/test';
import { loadTestUser } from '../utils/testUser.js';
test.describe('@p0 Chat drawer — Retirement tab visibility (premium-aware)', () => {
test.setTimeout(20000);
test('Retirement tab visible only on /retirement for premium; otherwise Support-only on /paywall', async ({ page }) => {
const user = loadTestUser();
// Sign in
await page.context().clearCookies();
await page.goto('/signin', { waitUntil: 'networkidle' });
await page.getByPlaceholder('Username', { exact: true }).fill(user.username);
await page.getByPlaceholder('Password', { exact: true }).fill(user.password);
await page.getByRole('button', { name: /^Sign In$/ }).click();
await page.waitForURL('**/signin-landing**', { timeout: 15000 });
// Try to reach Retirement Planner (PremiumRoute)
await page.goto('/retirement', { waitUntil: 'networkidle' });
const url = page.url();
// Open chat via FAB
const fab = page.getByRole('button', { name: /^Open chat$/i });
await expect(fab).toBeVisible({ timeout: 5000 });
await fab.click();
const supportTab = page.getByRole('button', { name: /^Aptiva\s*Support$/i });
const retireTab = page.getByRole('button', { name: /^Retirement\s*Helper$/i });
await expect(supportTab).toBeVisible({ timeout: 5000 });
if (/\/paywall(\?|$)/.test(url)) {
// Non-premium path: Retirement tab should NOT be present
const visible = await retireTab.isVisible().catch(() => false);
expect(visible).toBeFalsy();
} else if (/\/retirement(\?|$)/.test(url)) {
// Premium path: Retirement tab should be visible
await expect(retireTab).toBeVisible({ timeout: 5000 });
} else {
// Unexpected route; treat as guard behavior: Support-only
const visible = await retireTab.isVisible().catch(() => false);
expect(visible).toBeFalsy();
}
});
});

View File

@ -0,0 +1,63 @@
// @ts-check
import { test, expect } from '@playwright/test';
import { loadTestUser } from '../utils/testUser.js';
test.describe('@p0 Chat drawer — stream chunking', () => {
test.setTimeout(20000);
test('assistant reply is assembled from multiple chunks/lines', async ({ page }) => {
const user = loadTestUser();
// Stub threads list/create -> empty -> create {id}
const threadId = 'thread-chunks';
await page.route('**/api/chat/threads', async route => {
if (route.request().method() === 'GET') {
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ threads: [] }) });
}
if (route.request().method() === 'POST') {
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ id: threadId }) });
}
return route.fallback();
});
await page.route(`**/api/chat/threads/${threadId}`, async route => {
if (route.request().method() === 'GET') {
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ messages: [] }) });
}
return route.fallback();
});
// Stub stream with multiple line fragments (simulate chunked SSE)
await page.route('**/api/chat/threads/*/stream', async route => {
const body = [
'This is line 1.',
'This is line 2.',
'Final line.',
].map(l => l + '\n').join('');
await route.fulfill({ status: 200, headers: { 'Content-Type': 'text/event-stream' }, body });
});
// Sign in
await page.context().clearCookies();
await page.goto('/signin', { waitUntil: 'networkidle' });
await page.getByPlaceholder('Username', { exact: true }).fill(user.username);
await page.getByPlaceholder('Password', { exact: true }).fill(user.password);
await page.getByRole('button', { name: /^Sign In$/ }).click();
await page.waitForURL('**/signin-landing**', { timeout: 15000 });
// Open chat
await page.getByRole('button', { name: /^Open chat$/i }).click();
// Send prompt
const input = page.getByPlaceholder('Ask me anything…');
const send = page.getByRole('button', { name: /^Send$/i });
await input.fill('test chunked stream');
await send.click();
// Expect all lines to appear merged in the **same** assistant message
const assistantBubble = page.locator('div.text-left.text-gray-800').last();
await expect(assistantBubble).toBeVisible({ timeout: 8000 });
await expect(assistantBubble).toContainText('This is line 1.');
await expect(assistantBubble).toContainText('This is line 2.');
await expect(assistantBubble).toContainText('Final line.');
});
});

View File

@ -0,0 +1,70 @@
// @ts-check
import { test, expect } from '@playwright/test';
import { loadTestUser } from '../utils/testUser.js';
test.describe('@p0 Chat drawer — stream throttling (429) + recovery', () => {
test.setTimeout(20000);
test('second prompt hits 429 → shows fallback; third succeeds', async ({ page }) => {
const user = loadTestUser();
// Stub threads (list/create + preload)
const threadId = 'thread-throttle';
await page.route('**/api/chat/threads', async route => {
if (route.request().method() === 'GET') {
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ threads: [] }) });
}
if (route.request().method() === 'POST') {
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ id: threadId }) });
}
return route.fallback();
});
await page.route(`**/api/chat/threads/${threadId}`, async route => {
if (route.request().method() === 'GET') {
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ messages: [] }) });
}
return route.fallback();
});
// Stream endpoint: 1st -> 200, 2nd -> 429, 3rd -> 200
let callCount = 0;
await page.route('**/api/chat/threads/*/stream', async route => {
callCount += 1;
if (callCount === 2) {
return route.fulfill({ status: 429, contentType: 'text/event-stream', body: '' });
}
const text = callCount === 1 ? 'First OK reply.' : 'Recovered OK reply.';
return route.fulfill({ status: 200, headers: { 'Content-Type': 'text/event-stream' }, body: text + '\n' });
});
// Sign in
await page.context().clearCookies();
await page.goto('/signin', { waitUntil: 'networkidle' });
await page.getByPlaceholder('Username', { exact: true }).fill(user.username);
await page.getByPlaceholder('Password', { exact: true }).fill(user.password);
await page.getByRole('button', { name: /^Sign In$/ }).click();
await page.waitForURL('**/signin-landing**', { timeout: 15000 });
// Open chat
await page.getByRole('button', { name: /^Open chat$/i }).click();
// 1) First send → success
const input = page.getByPlaceholder('Ask me anything…');
const send = page.getByRole('button', { name: /^Send$/i });
await input.fill('hello 1');
await send.click();
await expect(page.getByText(/^First OK reply\./)).toBeVisible({ timeout: 8000 });
// 2) Second send → 429 throttled → fallback error appears
await input.fill('hello 2');
await send.click();
await expect(
page.getByText(/Sorry — something went wrong\. Please try again later\./i)
).toBeVisible({ timeout: 8000 });
// 3) Third send → recovery success
await input.fill('hello 3');
await send.click();
await expect(page.getByText(/^Recovered OK reply\./)).toBeVisible({ timeout: 8000 });
});
});

View File

@ -0,0 +1,109 @@
// @ts-check
import { test, expect } from '@playwright/test';
import { loadTestUser } from '../utils/testUser.js';
test.describe('@p0 Career Explorer — filters (job zone + fit)', () => {
test.setTimeout(15000);
test('filters change visible tiles without clearing cache or affecting comparison list', async ({ page }) => {
const user = loadTestUser();
// Sign in
await page.context().clearCookies();
await page.goto('/signin', { waitUntil: 'networkidle' });
await page.getByPlaceholder('Username', { exact: true }).fill(user.username);
await page.getByPlaceholder('Password', { exact: true }).fill(user.password);
await page.getByRole('button', { name: /^Sign In$/ }).click();
await page.waitForURL('**/signin-landing**', { timeout: 15000 });
// Explorer
await page.goto('/career-explorer', { waitUntil: 'networkidle' });
// Ensure at least one tile exists (reload if needed)
const tile = page.locator('div.grid button').first();
if (!(await tile.isVisible({ timeout: 1500 }).catch(() => false))) {
const reloadBtn = page.getByRole('button', { name: /Reload Career Suggestions/i });
await expect(reloadBtn).toBeVisible();
await reloadBtn.click();
const overlay = page.getByText(/Loading Career Suggestions/i).first();
await overlay.isVisible({ timeout: 2000 }).catch(() => {});
await expect
.poll(async () => {
return await page.evaluate(() => {
try {
const s = localStorage.getItem('careerSuggestionsCache');
const arr = s ? JSON.parse(s) : [];
return Array.isArray(arr) ? arr.length : 0;
} catch { return 0; }
});
}, { timeout: 15000, message: 'careerSuggestionsCache not populated' })
.toBeGreaterThan(0);
await expect(tile).toBeVisible({ timeout: 10000 });
}
// Snapshot cache length and comparison row count
const cacheLen = await page.evaluate(() => {
try {
const s = localStorage.getItem('careerSuggestionsCache');
const arr = s ? JSON.parse(s) : [];
return Array.isArray(arr) ? arr.length : 0;
} catch { return 0; }
});
const tableRows = page.locator('table tbody tr');
const beforeRows = await tableRows.count().catch(() => 0);
// Count tiles before filters
const tilesBefore = await page.locator('div.grid button').count();
// Selects
const zoneSelect = page.locator('select').filter({
has: page.locator('option', { hasText: 'All Preparation Levels' }),
}).first();
const fitSelect = page.locator('select').filter({
has: page.locator('option', { hasText: 'All Fit Levels' }),
}).first();
await expect(zoneSelect).toBeVisible();
await expect(fitSelect).toBeVisible();
// Apply restrictive filters using the actual visible labels
await zoneSelect.selectOption({ label: 'Extensive Preparation Needed' });
await fitSelect.selectOption({ label: 'Best - Very Strong Match' });
// Wait for tile count to settle and be <= before
await expect
.poll(async () => await page.locator('div.grid button').count(), { timeout: 8000 })
.toBeLessThanOrEqual(tilesBefore);
const tilesAfter = await page.locator('div.grid button').count();
// Reset filters to "All" and ensure tile count rebounds to >= filtered count (ideally back to initial)
// Reset filters to the “All …” labels
await zoneSelect.selectOption({ label: 'All Preparation Levels' });
await fitSelect.selectOption({ label: 'All Fit Levels' });
await expect
.poll(async () => await page.locator('div.grid button').count(), { timeout: 8000 })
.toBeGreaterThanOrEqual(tilesAfter);
const tilesReset = await page.locator('div.grid button').count();
// Cache NOT cleared
const cacheLenAfter = await page.evaluate(() => {
try {
const s = localStorage.getItem('careerSuggestionsCache');
const arr = s ? JSON.parse(s) : [];
return Array.isArray(arr) ? arr.length : 0;
} catch { return 0; }
});
expect(cacheLenAfter).toBe(cacheLen);
// Comparison list unaffected
const afterRows = await tableRows.count().catch(() => 0);
expect(afterRows).toBe(beforeRows);
// Basic sanity: we still have tiles visible after reset
expect(tilesReset).toBeGreaterThan(0);
});
});

View File

@ -0,0 +1,62 @@
// @ts-check
import { test, expect } from '@playwright/test';
import { loadTestUser } from '../utils/testUser.js';
test.describe('@p0 Profile Account — JIT-PII + greeting (UI save)', () => {
test.setTimeout(15000);
test('UI save first name → GET ?fields=firstname allowlist only → landing greets with saved name', async ({ page }) => {
const user = loadTestUser();
// Sign in
await page.context().clearCookies();
await page.goto('/signin', { waitUntil: 'networkidle' });
await page.getByPlaceholder('Username', { exact: true }).fill(user.username);
await page.getByPlaceholder('Password', { exact: true }).fill(user.password);
await page.getByRole('button', { name: /^Sign In$/ }).click();
await page.waitForURL('**/signin-landing**', { timeout: 15000 });
// Go to Profile (UI form handles required fields & correct casing)
await page.goto('/profile', { waitUntil: 'networkidle' });
// Inputs
const firstInput = page.getByLabel(/^First Name:$/i).or(page.locator('input').nth(0));
const lastInput = page.getByLabel(/^Last Name:$/i);
const emailInput = page.getByLabel(/^Email:$/i);
const zipInput = page.getByLabel(/^ZIP Code:$/i);
const stateSel = page.getByLabel(/^State:$/i);
// Make sure the form is hydrated
await expect(firstInput).toBeVisible({ timeout: 10000 });
const oldFirst = await firstInput.inputValue();
// New firstname (unique)
const stamp = new Date().toISOString().slice(11,19).replace(/:/g, '');
const newFirst = `E2E${stamp}`;
// Keep everything else as-is; only change First Name
await firstInput.fill(newFirst);
// Submit
await page.getByRole('button', { name: /^Save Profile$/i }).click();
// Give the backend a moment, then assert via JIT-PII allowlist
const jit = await page.request.get('/api/user-profile?fields=firstname');
expect(jit.status()).toBe(200);
const body = await jit.json();
const keys = Object.keys(body || {});
const allowed = new Set(['firstname', 'is_premium', 'is_pro_premium']);
expect(keys.every(k => allowed.has(k))).toBeTruthy();
expect(String(body.firstname || '')).toBe(newFirst);
// Landing greeting reflects saved name
await page.goto('/signin-landing', { waitUntil: 'networkidle' });
const greetRe = new RegExp(`^\\s*Welcome to AptivaAI\\s+${newFirst.replace(/[.*+?^${}()|[\\]\\\\]/g,'\\\\$&')}!\\s*$`);
await expect(page.getByRole('heading', { name: greetRe })).toBeVisible({ timeout: 10000 });
// Cleanup: restore old firstname (so we dont pollute the account)
await page.goto('/profile', { waitUntil: 'networkidle' });
await firstInput.fill(oldFirst);
await page.getByRole('button', { name: /^Save Profile$/i }).click();
});
});

View File

@ -0,0 +1,58 @@
// @ts-check
import { test, expect } from '@playwright/test';
import { loadTestUser } from '../utils/testUser.js';
test.describe('@p0 Logout — clears client caches & redirects', () => {
test.setTimeout(10000);
test('logout nukes cached keys and lands on /signin', async ({ page }) => {
const u = loadTestUser();
// Sign in
await page.context().clearCookies();
await page.goto('/signin', { waitUntil: 'networkidle' });
await page.getByPlaceholder('Username', { exact: true }).fill(u.username);
await page.getByPlaceholder('Password', { exact: true }).fill(u.password);
await page.getByRole('button', { name: /^Sign In$/ }).click();
await page.waitForURL('**/signin-landing**', { timeout: 15000 });
// Seed the keys that App.clearMany() clears during logout
await page.evaluate(() => {
localStorage.setItem('id', '123');
localStorage.setItem('careerSuggestionsCache', '["demo"]');
localStorage.setItem('lastSelectedCareerProfileId', 'abc');
localStorage.setItem('selectedCareer', '{"title":"Test"}');
localStorage.setItem('aiClickCount', '1');
localStorage.setItem('aiClickDate', '2024-01-01');
localStorage.setItem('aiRecommendations', '[]');
localStorage.setItem('premiumOnboardingState', '{"step":1}');
localStorage.setItem('premiumOnboardingPointer', 'wizard://step/2');
localStorage.setItem('financialProfile', '{"ok":true}');
localStorage.setItem('selectedScenario', 'foo');
});
// Click Logout
const logout = page.getByRole('button', { name: /^Logout$/i }).or(page.getByText(/^Logout$/i));
await expect(logout).toBeVisible({ timeout: 5000 });
await logout.click();
// Redirected to /signin
await expect(page).toHaveURL(/\/signin(\?|$)/, { timeout: 8000 });
// All keys cleared
const cleared = await page.evaluate(() => ([
'id',
'careerSuggestionsCache',
'lastSelectedCareerProfileId',
'selectedCareer',
'aiClickCount',
'aiClickDate',
'aiRecommendations',
'premiumOnboardingState',
'premiumOnboardingPointer',
'financialProfile',
'selectedScenario',
].every(k => localStorage.getItem(k) === null)));
expect(cleared).toBeTruthy();
});
});

View File

@ -0,0 +1,69 @@
// @ts-check
import { test, expect } from '@playwright/test';
import { loadTestUser } from '../utils/testUser.js';
test.describe('@p0 Chat drawer — existing thread preload', () => {
test.setTimeout(20000);
test('uses existing thread from /api/chat/threads and shows its messages (persists across reload)', async ({ page }) => {
const u = loadTestUser();
// ---- Stub: existing thread list + preload messages; block POST creation ----
const threadId = 'persist-thread-1';
let sawCreate = false;
await page.route('**/api/chat/threads', async (route) => {
if (route.request().method() === 'GET') {
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ threads: [{ id: threadId }] }),
});
}
if (route.request().method() === 'POST') {
sawCreate = true; // shouldnt happen when list is non-empty
return route.fulfill({ status: 409, contentType: 'application/json', body: '{}' });
}
return route.fallback();
});
await page.route(`**/api/chat/threads/${threadId}`, async (route) => {
if (route.request().method() === 'GET') {
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
messages: [
{ role: 'assistant', content: 'Welcome back. How can I help?' },
{ role: 'user', content: 'Earlier question…' },
],
}),
});
}
return route.fallback();
});
// ---- Sign in ----
await page.context().clearCookies();
await page.goto('/signin', { waitUntil: 'networkidle' });
await page.getByPlaceholder('Username', { exact: true }).fill(u.username);
await page.getByPlaceholder('Password', { exact: true }).fill(u.password);
await page.getByRole('button', { name: /^Sign In$/ }).click();
await page.waitForURL('**/signin-landing**', { timeout: 15000 });
// Open chat via FAB → should show preloaded messages
const fab = page.getByRole('button', { name: /^Open chat$/i });
await fab.click();
await expect(page.getByText(/^Welcome back\. How can I help\?/)).toBeVisible({ timeout: 8000 });
await expect(page.getByText(/^Earlier question…$/)).toBeVisible();
// No creation attempted
expect(sawCreate).toBeFalsy();
// Reload the page and open again → same preload visible
await page.reload({ waitUntil: 'networkidle' });
await fab.click();
await expect(page.getByText(/^Welcome back\. How can I help\?/)).toBeVisible({ timeout: 8000 });
});
});

View File

@ -0,0 +1,36 @@
// @ts-check
import { test, expect } from '@playwright/test';
import { loadTestUser } from '../utils/testUser.js';
test.describe('@p0 Billing portal — return_url flow', () => {
test.setTimeout(20000);
test('Manage subscription → redirects to return_url and cleans ?portal=done', async ({ page }) => {
const u = loadTestUser();
// Stub portal endpoint to return a same-origin URL we can follow safely
await page.route('**/api/premium/stripe/customer-portal**', async (route) => {
const url = `${new URL(route.request().url()).origin}/profile?portal=done`;
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ url }) });
});
// Sign in and open Profile
await page.context().clearCookies();
await page.goto('/signin', { waitUntil: 'networkidle' });
await page.getByPlaceholder('Username', { exact: true }).fill(u.username);
await page.getByPlaceholder('Password', { exact: true }).fill(u.password);
await page.getByRole('button', { name: /^Sign In$/ }).click();
await page.waitForURL('**/signin-landing**', { timeout: 15000 });
await page.goto('/profile', { waitUntil: 'networkidle' });
// Click "Manage subscription" → we should "navigate" to /profile?portal=done
await page.getByRole('button', { name: /^Manage subscription$/i }).click();
await expect(page).toHaveURL(/\/profile\?portal=done$/i, { timeout: 8000 });
// Your effect should refresh status and then clean the param
await expect
.poll(() => page.url(), { timeout: 8000 })
.not.toMatch(/portal=done/i);
});
});

View File

@ -0,0 +1,46 @@
// @ts-check
import { test, expect } from '@playwright/test';
import { loadTestUser } from '../utils/testUser.js';
test.describe('@p0 Profile — Areas load on State change', () => {
test.setTimeout(20000);
test('changing State fetches Areas and enables Area select', async ({ page }) => {
const u = loadTestUser();
// Stub /api/areas?state=GA to return 2 areas
await page.route('**/api/areas?state=GA', async (route) => {
await route.fulfill({
status: 200, contentType: 'application/json',
body: JSON.stringify({ areas: ['Atlanta-Sandy Springs-Roswell, GA', 'Augusta-Richmond County, GA-SC'] })
});
});
await page.context().clearCookies();
await page.goto('/signin', { waitUntil: 'networkidle' });
await page.getByPlaceholder('Username', { exact: true }).fill(u.username);
await page.getByPlaceholder('Password', { exact: true }).fill(u.password);
await page.getByRole('button', { name: /^Sign In$/ }).click();
await page.waitForURL('**/signin-landing**', { timeout: 15000 });
await page.goto('/profile', { waitUntil: 'networkidle' });
// Change State to GA (select identified by its placeholder option)
const stateSel = page.locator('select').filter({
has: page.locator('option', { hasText: 'Select a State' }),
}).first();
await expect(stateSel).toBeVisible({ timeout: 8000 });
await stateSel.selectOption('GA');
// Loading… may appear briefly
await page.getByText(/Loading areas\.\.\./i).isVisible({ timeout: 1000 }).catch(() => {});
// Area select should appear with our stubbed options (identified by its placeholder option)
const areaSel = page.locator('select').filter({
has: page.locator('option', { hasText: 'Select an Area' }),
}).first();
await expect(areaSel).toBeVisible({ timeout: 8000 });
// Pick the first non-placeholder option
const firstReal = areaSel.locator('option').nth(1);
await areaSel.selectOption(await firstReal.getAttribute('value'));
});
});

View File

@ -0,0 +1,44 @@
// @ts-check
import { test, expect } from '@playwright/test';
import { loadTestUser } from '../utils/testUser.js';
test.describe('@p1 CareerSearch — ArrowDown + Enter commit', () => {
test.setTimeout(20000);
test('type → ArrowDown → Enter commits first suggestion and opens modal', async ({ page }) => {
const u = loadTestUser();
// Stub search endpoint
await page.route('**/api/careers/search**', async (route) => {
const res = [
{ title: 'Curators', soc_code: '25-4012.00', cip_codes: ['50.0703','30.1401'], limited_data: false, ratings: {} },
{ title: 'Data Analyst', soc_code: '15-2051.00', cip_codes: ['11.0802'], limited_data: false, ratings: {} },
];
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(res) });
});
await page.context().clearCookies();
await page.goto('/signin', { waitUntil: 'networkidle' });
await page.getByPlaceholder('Username', { exact: true }).fill(u.username);
await page.getByPlaceholder('Password', { exact: true }).fill(u.password);
await page.getByRole('button', { name: /^Sign In$/ }).click();
await page.waitForURL('**/signin-landing**', { timeout: 15000 });
await page.goto('/career-explorer', { waitUntil: 'networkidle' });
const input = page.getByPlaceholder('Start typing a career...');
await expect(input).toBeVisible();
await input.fill('cu');
await input.press('ArrowDown');
await input.press('Enter');
// CareerModal opens → "Add to Comparison"
await expect(page.getByRole('button', { name: /Add to Comparison/i })).toBeVisible({ timeout: 8000 });
// Close modal to keep state clean
const closeBtn = page.getByRole('button', { name: /^Close$/i });
if (await closeBtn.isVisible().catch(() => false)) await closeBtn.click();
else await page.keyboard.press('Escape');
});
});

View File

@ -0,0 +1,33 @@
// @ts-check
import { test, expect } from '@playwright/test';
import { loadTestUser } from '../utils/testUser.js';
test.describe('@p0 Profile — Change password toggle', () => {
test.setTimeout(20000);
test('toggle shows and hides ChangePasswordForm', async ({ page }) => {
const u = loadTestUser();
await page.context().clearCookies();
await page.goto('/signin', { waitUntil: 'networkidle' });
await page.getByPlaceholder('Username', { exact: true }).fill(u.username);
await page.getByPlaceholder('Password', { exact: true }).fill(u.password);
await page.getByRole('button', { name: /^Sign In$/ }).click();
await page.waitForURL('**/signin-landing**', { timeout: 15000 });
await page.goto('/profile', { waitUntil: 'networkidle' });
// Open change password
const toggleBtn = page.getByRole('button', { name: /^Change password$/i });
await expect(toggleBtn).toBeVisible();
await toggleBtn.click();
// Form should appear (look for "Update password" button)
await expect(page.getByRole('button', { name: /Update password/i })).toBeVisible({ timeout: 5000 });
// Cancel hides it
const cancelBtn = page.getByRole('button', { name: /^Cancel password change$/i });
await cancelBtn.click();
await expect(page.getByRole('button', { name: /Update password/i })).toBeHidden({ timeout: 3000 });
});
});

View File

@ -0,0 +1,21 @@
// @ts-check
import { test, expect } from '@playwright/test';
import { loadTestUser } from '../utils/testUser.js';
test.describe('@p0 Resume Optimizer — paywall guard', () => {
test.setTimeout(20000);
test('non-premium user redirected to /paywall', async ({ page }) => {
const u = loadTestUser();
await page.context().clearCookies();
await page.goto('/signin', { waitUntil: 'networkidle' });
await page.getByPlaceholder('Username', { exact: true }).fill(u.username);
await page.getByPlaceholder('Password', { exact: true }).fill(u.password);
await page.getByRole('button', { name: /^Sign In$/ }).click();
await page.waitForURL('**/signin-landing**', { timeout: 15000 });
await page.goto('/resume-optimizer', { waitUntil: 'networkidle' });
await expect(page).toHaveURL(/\/paywall(\?|$)/, { timeout: 8000 });
});
});

View File

@ -0,0 +1,88 @@
// @ts-check
import { test, expect } from '@playwright/test';
import { loadTestUser } from '../utils/testUser.js';
test.describe('@p1 Educational Programs — sorting (tuition vs distance)', () => {
test.setTimeout(120000);
test('sort toggles change ordering; list remains populated', async ({ page }) => {
const u = loadTestUser();
// Sign in
await page.context().clearCookies();
await page.goto('/signin', { waitUntil: 'networkidle' });
await page.getByPlaceholder('Username', { exact: true }).fill(u.username);
await page.getByPlaceholder('Password', { exact: true }).fill(u.password);
await page.getByRole('button', { name: /^Sign In$/ }).click();
await page.waitForURL('**/signin-landing**', { timeout: 15000 });
// Seed selectedCareer for programs fetch (UI does not render SOC/CIP)
await page.evaluate(() => {
localStorage.setItem('selectedCareer', JSON.stringify({
title: 'Data Analyst',
soc_code: '15-2051.00',
cip_code: ['11.0802', '04.0201']
}));
});
await page.goto('/educational-programs', { waitUntil: 'networkidle' });
// Wait for at least one card to render (match the card structure used in the component)
const card = page.locator('div.rounded.border.p-3.text-sm').first();
await expect(card).toBeVisible({ timeout: 30000 });
// Helper to read numbers from card list
async function readTuitionList() {
return await page.locator('div.rounded.border.p-3.text-sm').evaluateAll(nodes =>
nodes.map(n => {
const line = [...n.querySelectorAll('p')]
.map(p => p.textContent || '')
.find(t => /^In-State Tuition:\s*\$/.test(t));
if (!line) return NaN;
const match = line.replace(/,/g, '').match(/In-State Tuition:\s*\$(\d+(?:\.\d+)?)/i);
return match ? parseFloat(match[1]) : NaN;
}).filter(x => Number.isFinite(x))
);
}
async function readDistanceList() {
return await page.locator('div.rounded.border.p-3.text-sm').evaluateAll(nodes =>
nodes.map(n => {
const line = [...n.querySelectorAll('p')]
.map(p => p.textContent || '')
.find(t => /^Distance:\s*/i.test(t));
if (!line) return NaN;
const match = line.match(/Distance:\s*(\d+(?:\.\d+)?)\s*mi/i);
return match ? parseFloat(match[1]) : NaN; // NaN if "N/A"
}).filter(x => Number.isFinite(x))
);
}
const isNonDecreasing = (arr) => arr.every((v, i) => i === 0 || arr[i - 1] <= v);
// --- Default sort = Tuition ---
const tuitionBefore = await readTuitionList();
if (tuitionBefore.length >= 2) {
expect(isNonDecreasing(tuitionBefore)).toBeTruthy();
} else {
// At least the list is populated with cards
const cardCount = await page.locator('div.rounded.border.p-3.text-sm').count();
expect(cardCount).toBeGreaterThan(0);
}
// Switch Sort to Distance (the select is inside its label)
const sortSelect = page.locator('label:has-text("Sort")').locator('select');
await expect(sortSelect).toBeVisible();
await sortSelect.selectOption('distance');
// Wait for re-render by observing that either distances appear or first card block text changes
const distances = await readDistanceList();
if (distances.length >= 2) {
expect(isNonDecreasing(distances)).toBeTruthy();
} else {
// If distances are N/A (no ZIP), at least ensure the list remains populated
const cardCount = await page.locator('div.rounded.border.p-3.text-sm').count();
expect(cardCount).toBeGreaterThan(0);
}
});
});

View File

@ -0,0 +1,51 @@
// @ts-check
import { test, expect } from '@playwright/test';
import { loadTestUser } from '../utils/testUser.js';
test.describe('@p0 Interest Inventory — restore from profile', () => {
test.setTimeout(20000);
test('after submit, returning shows 60/60 answered (prefilled)', async ({ page }) => {
const u = loadTestUser();
// Sign in
await page.context().clearCookies();
await page.goto('/signin', { waitUntil: 'networkidle' });
await page.getByPlaceholder('Username', { exact: true }).fill(u.username);
await page.getByPlaceholder('Password', { exact: true }).fill(u.password);
await page.getByRole('button', { name: /^Sign In$/ }).click();
await page.waitForURL('**/signin-landing**', { timeout: 15000 });
// Go to Inventory
await page.goto('/interest-inventory', { waitUntil: 'networkidle' });
await expect(page.getByRole('heading', { name: /Interest Inventory/i })).toBeVisible();
// If not completed, complete now (dev has Randomize)
const answered = page.getByText(/60\s*\/\s*60\s*answered/i);
if (!(await answered.isVisible({ timeout: 1000 }).catch(() => false))) {
const randomize = page.getByRole('button', { name: /Randomize Answers/i });
if (await randomize.isVisible().catch(() => false)) {
await randomize.click();
} else {
// fallback: fill current page
for (let p = 0; p < 10; p++) {
const selects = page.locator('select');
const n = await selects.count();
for (let i = 0; i < n; i++) await selects.nth(i).selectOption('3');
if (p < 9) await page.getByRole('button', { name: /^Next$/ }).click();
}
}
// advance to last page and submit
for (let i = 0; i < 9; i++) {
const next = page.getByRole('button', { name: /^Next$/ });
if (await next.isVisible().catch(() => false)) await next.click();
}
await page.getByRole('button', { name: /^Submit$/ }).click();
await page.waitForURL('**/career-explorer**', { timeout: 20000 });
}
// Return to inventory; it should show 60/60 answered (restored)
await page.goto('/interest-inventory', { waitUntil: 'networkidle' });
await expect(page.getByText(/60\s*\/\s*60\s*answered/i)).toBeVisible({ timeout: 10000 });
});
});

View File

@ -0,0 +1,140 @@
// @ts-check
import { test, expect } from '@playwright/test';
import { loadTestUser } from '../utils/testUser.js';
test.describe('@p1 Educational Programs — distance filter (with geocode + schools stubs)', () => {
test.setTimeout(20000);
test('Distance (max) narrows results when ZIP is present', async ({ page }) => {
const u = loadTestUser();
// ---- Stubs ----
// 1) Profile ZIP/state so distance can be computed (match any query suffix)
await page.route('**/api/user-profile**', async route => {
await route.fulfill({
status: 200, contentType: 'application/json',
body: JSON.stringify({ zipcode: '30301', state: 'GA', area: 'Atlanta, GA' })
});
});
// 2) Geocode ZIP → lat/lng (actual endpoint used by clientGeocodeZip)
await page.route('https://maps.googleapis.com/maps/api/geocode/json**', async route => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
status: 'OK',
results: [
{ geometry: { location: { lat: 33.7495, lng: -84.3883 } } }
]
})
});
});
// 3) Schools list for CIP codes (cover common paths)
const schools = [
// near Atlanta (~35 mi)
{
INSTNM: 'Atlanta Tech College',
CIPDESC: 'Data Analytics',
CREDDESC: 'Certificate',
'In_state cost': '5000',
'Out_state cost': '7000',
LATITUDE: '33.80',
LONGITUD: '-84.39',
Website: 'atl.tech.edu',
UNITID: '1001',
State: 'GA'
},
// far (Chicago, ~590 mi)
{
INSTNM: 'Chicago State',
CIPDESC: 'Data Analytics',
CREDDESC: 'Bachelor',
'In_state cost': '6000',
'Out_state cost': '8000',
LATITUDE: '41.8781',
LONGITUD: '-87.6298',
Website: 'chicago.state.edu',
UNITID: '2002',
State: 'IL'
}
];
const fulfillSchools = async (route) => {
await route.fulfill({
status: 200, contentType: 'application/json',
body: JSON.stringify(schools)
});
};
await page.route('**/api/**schools**', fulfillSchools);
await page.route('**/api/**program**', fulfillSchools);
// 4) Avoid premium KSA latency: return empty quickly
await page.route('**/api/premium/ksa/**', async route => {
await route.fulfill({
status: 200, contentType: 'application/json',
body: JSON.stringify({ data: { knowledge: [], skills: [], abilities: [] } })
});
});
// ---- Sign in ----
await page.context().clearCookies();
await page.goto('/signin', { waitUntil: 'networkidle' });
await page.getByPlaceholder('Username', { exact: true }).fill(u.username);
await page.getByPlaceholder('Password', { exact: true }).fill(u.password);
await page.getByRole('button', { name: /^Sign In$/ }).click();
await page.waitForURL('**/signin-landing**', { timeout: 15000 });
// Seed selectedCareer so Programs loads without Explorer handoff
await page.evaluate(() => {
localStorage.setItem('selectedCareer', JSON.stringify({
title: 'Data Analyst',
soc_code: '15-2051.00',
cip_code: ['11.0802', '04.0201']
}));
});
// ---- Go to Programs ----
await page.goto('/educational-programs', { waitUntil: 'networkidle' });
// Wait for cards to render
const cards = page.locator('div.rounded.border.p-3.text-sm');
await expect(cards.first()).toBeVisible({ timeout: 30000 });
// Confirm we have at least one card up front
const countBefore = await cards.count();
expect(countBefore).toBeGreaterThan(0);
// Set Distance (max) to a small number to keep only the ATL card
const distanceInput = page.getByLabel(/Distance \(max\)/i);
await expect(distanceInput).toBeVisible();
await distanceInput.fill('10');
// ensure onChange/onBlur handlers fire
await distanceInput.press('Enter').catch(() => {});
await distanceInput.blur().catch(() => {});
await page.waitForTimeout(200); // small debounce cushion
// Trigger change — many components require blur or Enter
await distanceInput.press('Enter').catch(() => {});
await distanceInput.blur().catch(() => {});
// After applying Distance(max)=10, the Chicago card should disappear
// (dont rely on count; assert by card names)
await expect
.poll(async () => {
const names = await page
.locator('div.rounded.border.p-3.text-sm strong')
.allInnerTexts()
.catch(() => []);
const anyVisible = names.length > 0;
const hasChicago = names.some(n => /chicago/i.test(n));
return anyVisible && !hasChicago;
}, { timeout: 20000 })
.toBeTruthy();
// And at least one visible card should be the ATL school
const namesAfter = await page
.locator('div.rounded.border.p-3.text-sm strong')
.allInnerTexts()
.catch(() => []);
expect(namesAfter.some(n => /atlanta/i.test(n))).toBeTruthy();
});
});

View File

@ -0,0 +1,98 @@
// @ts-check
import { test, expect } from '@playwright/test';
import { loadTestUser } from '../utils/testUser.js';
test.describe('@p0 Profile — ChangePasswordForm submit', () => {
test.setTimeout(20000);
test('success path closes the form; 429 path shows error and stays open', async ({ page }) => {
const u = loadTestUser();
// First POST => 200; second => 429 (no endpoint guessing needed)
let calls = 0;
await page.route('**/api/**password**', async (route) => {
calls += 1;
if (calls === 1) return route.fulfill({ status: 200, contentType: 'application/json', body: '{}' });
if (calls === 2) return route.fulfill({ status: 429, contentType: 'application/json', body: JSON.stringify({ error: 'Too many attempts' }) });
return route.fallback();
});
// Sign in (your existing flow)
await page.context().clearCookies();
await page.goto('/signin', { waitUntil: 'networkidle' });
await page.getByPlaceholder('Username', { exact: true }).fill(u.username);
await page.getByPlaceholder('Password', { exact: true }).fill(u.password);
await page.getByRole('button', { name: /^Sign In$/ }).click();
await page.waitForURL('**/signin-landing**', { timeout: 15000 });
await page.goto('/profile', { waitUntil: 'networkidle' });
// If already open, close to make deterministic
const cancelIfOpen = page.getByRole('button', { name: /^Cancel password change$/i });
if (await cancelIfOpen.isVisible().catch(() => false)) {
await cancelIfOpen.click();
await expect(cancelIfOpen).toBeHidden({ timeout: 5000 });
}
// Open (use exact text; fresh locator)
await page.getByRole('button', { name: /^Change Password$/i }).click();
// 3 password inputs: current / new / confirm
const pwInputs = page.locator('input[type="password"]');
await expect(pwInputs.first()).toBeVisible({ timeout: 5000 });
// 1) Success submit — form should close
await pwInputs.nth(0).fill('OldPass!1');
await pwInputs.nth(1).fill('NewPass!1');
await pwInputs.nth(2).fill('NewPass!1');
const submit = page.getByRole('button', { name: /^Update Password$/i });
await submit.click();
// Form closes => Update button disappears
await expect(submit).toBeHidden({ timeout: 7000 });
// Reopen deterministically (works whether trigger is a button or link,
// or if the section needs a refresh)
async function reopenChangePassword(page) {
// If somehow still open, close first
const update = page.getByRole('button', { name: /^Update Password$/i }).first();
if (await update.isVisible().catch(() => false)) {
const cancel = page.getByRole('button', { name: /^Cancel password change$/i }).first();
if (await cancel.isVisible().catch(() => false)) {
await cancel.click();
await expect(update).toBeHidden({ timeout: 7000 });
}
}
// Try common triggers
const triggers = [
page.getByRole('button', { name: /^Change Password$/i }).first(),
page.getByRole('link', { name: /^Change Password$/i }).first(),
page.locator('text=Change Password').first(),
];
for (const t of triggers) {
if (await t.isVisible().catch(() => false)) { await t.click(); return; }
}
// Fallback: reload the page section and try once more
await page.goto('/profile', { waitUntil: 'networkidle' });
for (const t of triggers) {
if (await t.isVisible().catch(() => false)) { await t.click(); return; }
}
throw new Error('Could not find Change Password trigger after success submit');
}
await reopenChangePassword(page);
await pwInputs.nth(0).fill('OldPass!1');
await pwInputs.nth(1).fill('NewPass!2');
await pwInputs.nth(2).fill('NewPass!2');
await page.getByRole('button', { name: /^Update Password$/i }).click();
// For 429: either see an error, OR the form remains open (Update/Cancel still visible)
const errText = page.getByText(/too many attempts|please wait|try again/i);
const sawError = await errText.isVisible({ timeout: 3000 }).catch(() => false);
const stillOpen =
(await page.getByRole('button', { name: /^Update Password$/i }).isVisible().catch(() => false)) ||
(await page.getByRole('button', { name: /^Cancel password change$/i }).isVisible().catch(() => false));
expect(sawError || stillOpen).toBeTruthy();
});
});

View File

@ -0,0 +1,53 @@
// @ts-check
import { test, expect } from '@playwright/test';
import { loadTestUser } from '../utils/testUser.js';
test.describe('@p0 Billing — Refresh status updates plan label', () => {
test.setTimeout(20000);
test('Refresh status re-reads and updates plan label', async ({ page }) => {
const u = loadTestUser();
// First response: Free; second response: Premium
let calls = 0;
await page.route('**/api/premium/subscription/status', async (route) => {
calls += 1;
if (calls === 1) {
return route.fulfill({
status: 200, contentType: 'application/json',
body: JSON.stringify({ is_premium: 0, is_pro_premium: 0 })
});
}
return route.fulfill({
status: 200, contentType: 'application/json',
body: JSON.stringify({ is_premium: 1, is_pro_premium: 0 })
});
});
// Sign in → Profile
await page.context().clearCookies();
await page.goto('/signin', { waitUntil: 'networkidle' });
await page.getByPlaceholder('Username', { exact: true }).fill(u.username);
await page.getByPlaceholder('Password', { exact: true }).fill(u.password);
await page.getByRole('button', { name: /^Sign In$/ }).click();
await page.waitForURL('**/signin-landing**', { timeout: 15000 });
await page.goto('/profile', { waitUntil: 'networkidle' });
// Initial plan label should mention Free
const planLine = page.getByText(/^Current plan:/i);
await expect(planLine).toBeVisible({ timeout: 5000 });
const initialText = (await planLine.textContent() || '').toLowerCase();
expect(initialText.includes('free')).toBeTruthy();
// Click Refresh status → label should update to Premium
await page.getByRole('button', { name: /^Refresh status$/i }).click();
await expect
.poll(async () => {
const txt = (await planLine.textContent() || '').toLowerCase();
return txt.includes('premium');
}, { timeout: 8000 })
.toBeTruthy();
});
});

View File

@ -0,0 +1,54 @@
// tests/e2e/43-billing-paywall.spec.mjs
import { test, expect } from '@playwright/test';
test.describe('Billing / Paywall', () => {
test('Logged-out: falls back to pricing, anchors render, Back works', async ({ page }) => {
// Ensure logged-out and create history so Back is deterministic
await page.goto('/signin'); // public page
await page.goto('/billing'); // paywall
await expect(page.getByText('Loading…')).toBeVisible();
await expect(page.getByRole('heading', { name: 'Upgrade to AptivaAI' })).toBeVisible();
await expect(page.getByRole('heading', { name: 'Premium' })).toBeVisible();
await expect(page.getByRole('heading', { name: 'Pro Premium' })).toBeVisible();
await expect(page.getByRole('button', { name: /\$4\.99/ })).toBeVisible();
await expect(page.getByRole('button', { name: /\$49/ })).toBeVisible();
await expect(page.getByRole('button', { name: /\$7\.99/ })).toBeVisible();
await expect(page.getByRole('button', { name: /\$79/ })).toBeVisible();
// Back button returns to previous page (signin)
await page.getByRole('button', { name: /Cancel \/ Go back/i }).click();
await expect(page).toHaveURL(/\/signin/);
});
test('Logged-out: clicking price posts correct checkout payload; no redirect on non-OK', async ({ page }) => {
// Stay logged-out so the checkout POST is expected to be non-OK (no redirect)
await page.goto('/billing');
const scenarios = [
{ re: /\$4\.99/, tier: 'premium', cycle: 'monthly' },
{ re: /\$49/, tier: 'premium', cycle: 'annual' },
{ re: /\$7\.99/, tier: 'pro', cycle: 'monthly' },
{ re: /\$79/, tier: 'pro', cycle: 'annual' },
];
for (const s of scenarios) {
const [req] = await Promise.all([
page.waitForRequest(r =>
r.url().endsWith('/api/premium/stripe/create-checkout-session') && r.method() === 'POST'
),
page.getByRole('button', { name: s.re }).click(),
]);
const body = JSON.parse(req.postData() || '{}');
expect(body.tier).toBe(s.tier);
expect(body.cycle).toBe(s.cycle);
expect(body.success_url).toMatch(/\/billing\?ck=success$/);
expect(body.cancel_url).toMatch(/\/billing\?ck=cancel$/);
// Should remain on /billing because response is non-OK when logged-out
await expect(page).toHaveURL(/\/billing$/);
}
});
});

View File

@ -0,0 +1,122 @@
// @ts-check
import { test, expect } from '@playwright/test';
import { loadTestUser } from '../utils/testUser.js';
test.describe('@p0 Onboarding E2E — Welcome→Career→Financial→College→Review→Roadmap', () => {
test.setTimeout(120_000);
test('Submit lands on /career-roadmap/:id', async ({ page }) => {
const u = loadTestUser();
// PremiumRoute gate: mark user premium BEFORE any navigation
await page.route(
/\/api\/user-profile\?fields=.*(firstname|is_premium|is_pro_premium).*/i,
async route => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ firstname: 'Tester', is_premium: 1, is_pro_premium: 0 })
});
}
);
// Sign in (single continuous session)
await page.context().clearCookies();
await page.goto('/signin', { waitUntil: 'domcontentloaded' });
await page.getByPlaceholder('Username', { exact: true }).fill(u.username);
await page.getByPlaceholder('Password', { exact: true }).fill(u.password);
await page.getByRole('button', { name: /^Sign In$/ }).click();
await page.waitForURL('**/signin-landing**', { timeout: 15_000 });
// Clean local pointers; seed career BEFORE starting (Career step reads LS)
await page.evaluate(() => {
localStorage.removeItem('premiumOnboardingPointer');
sessionStorage.removeItem('suppressOnboardingGuard');
localStorage.setItem('selectedCareer', JSON.stringify({
title: 'Data Analyst', soc_code: '15-2051.00'
}));
});
// Welcome → Career
await page.goto('/premium-onboarding', { waitUntil: 'domcontentloaded' });
await page.getByRole('button', { name: /^Get Started$/i }).click();
await expect(page.getByRole('heading', { name: /Career Details/i })).toBeVisible({ timeout: 15_000 });
// Career
const selects = page.locator('select');
await selects.nth(0).selectOption('yes'); // currently earning
await selects.nth(1).selectOption('planned'); // status
await selects.nth(2).selectOption('currently_enrolled'); // in-school (requires expected grad)
await page.locator('input[type="date"]').fill('2025-09-01');
const nextFromCareer = page.getByRole('button', { name: /^Financial →$/i });
await expect(nextFromCareer).toBeEnabled();
await nextFromCareer.click();
// Financial (selectors by name)
await expect(page.getByRole('heading', { name: /Financial Details/i })).toBeVisible({ timeout: 15_000 });
await page.locator('input[name="monthly_expenses"]').fill('1200');
await page.getByRole('button', { name: /Next: College →/i }).click();
// College
await expect(page.getByRole('heading', { name: /College Details/i })).toBeVisible({ timeout: 20_000 });
// Known-good pair from your dataset
const SCHOOL = 'Alabama A & M University';
const PROGRAM = 'Agriculture, General.';
// helper — some nested lists require two selects; simulate that
async function commitAutosuggest(input, text) {
await input.click();
await input.fill(text);
await page.keyboard.press('ArrowDown').catch(() => {});
await page.keyboard.press('Enter').catch(() => {});
await input.blur();
const v1 = (await input.inputValue()).trim();
// second pass if UI needs double-confirm
if (v1.toLowerCase() === text.toLowerCase()) {
await input.click();
await page.keyboard.press('ArrowDown').catch(() => {});
await page.keyboard.press('Enter').catch(() => {});
await input.blur();
}
}
// Fill + commit autosuggests
const schoolBox = page.locator('input[name="selected_school"]');
const programBox = page.locator('input[name="selected_program"]');
await commitAutosuggest(schoolBox, SCHOOL);
await commitAutosuggest(programBox, PROGRAM);
// Degree Type — use Bachelor's or first non-placeholder
const degreeSelect = page.getByLabel(/Degree Type/i);
let picked = false;
try {
await degreeSelect.selectOption({ label: "Bachelor's Degree" });
picked = true;
} catch {}
if (!picked) {
const secondOption = degreeSelect.locator('option').nth(1);
await secondOption.waitFor({ state: 'attached', timeout: 10_000 });
const val = await secondOption.getAttribute('value');
if (val) await degreeSelect.selectOption(val);
}
// Required when in-school
await page.getByLabel(/Expected Graduation Date/i).fill('2027-06-01');
// Continue to Review
const finishBtn = page.getByRole('button', { name: /Finish Onboarding/i });
await expect(finishBtn).toBeEnabled({ timeout: 10_000 });
await finishBtn.click();
// Review → Submit All
await expect(page.getByRole('heading', { name: /Review Your Info/i })).toBeVisible({ timeout: 20_000 });
await page.getByRole('button', { name: /^Submit All$/i }).click();
// Roadmap
await expect(page).toHaveURL(/\/career-roadmap\/[a-z0-9-]+$/i, { timeout: 30_000 });
await expect(page.getByText(/Where you are now and where you are going/i))
.toBeVisible({ timeout: 20_000 });
});
});

View File

@ -0,0 +1,239 @@
// @ts-check
import { test, expect } from '@playwright/test';
import { loadTestUser } from '../utils/testUser.js';
test.describe('@p1 Profile Editors — Career, Financial, and College', () => {
test.setTimeout(90_000);
test.beforeEach(async ({ page }) => {
const u = loadTestUser();
// Premium gate that App.js / PremiumRoute actually uses
await page.route(
/\/api\/user-profile\?fields=.*(firstname|is_premium|is_pro_premium).*/i,
async route => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ firstname: 'Tester', is_premium: 1, is_pro_premium: 0 })
});
}
);
// Real sign-in (single session per test)
await page.context().clearCookies();
await page.goto('/signin', { waitUntil: 'domcontentloaded' });
await page.getByPlaceholder('Username', { exact: true }).fill(u.username);
await page.getByPlaceholder('Password', { exact: true }).fill(u.password);
await page.getByRole('button', { name: /^Sign In$/ }).click();
await page.waitForURL('**/signin-landing**', { timeout: 15_000 });
});
// -------- Career profile (edit) --------
test('CareerProfileList → edit first profile title and save', async ({ page }) => {
// Ensure at least one
await page.request.post('/api/premium/career-profile', {
data: { career_name: 'QA Scenario', status: 'planned', start_date: '2025-09-01' }
});
await page.goto('/profile/careers', { waitUntil: 'domcontentloaded' });
await expect(page.getByRole('heading', { name: 'Career Profiles' })).toBeVisible();
const editLinks = page.locator('a', { hasText: /^edit$/i });
await editLinks.first().click();
await expect(page.getByRole('heading', { name: /Edit Career Profile|New Career Profile/i }))
.toBeVisible({ timeout: 15_000 });
const title = page.getByLabel(/Scenario Title/i);
const prev = await title.inputValue().catch(() => 'Scenario');
await title.fill((prev || 'Scenario') + ' — test');
await page.getByRole('button', { name: /^Save$/ }).click();
await expect(page).toHaveURL(/\/career-roadmap\/[a-z0-9-]+$/i, { timeout: 20_000 });
});
// -------- Financial profile (save) --------
test('FinancialProfileForm saves and returns', async ({ page }) => {
await page.goto('/profile/financial', { waitUntil: 'domcontentloaded' });
await expect(page.getByRole('heading', { name: /Financial Profile/i })).toBeVisible({ timeout: 10_000 });
await page.getByLabel(/Current.*Annual.*Salary/i).fill('55000');
await page.getByLabel(/Monthly.*Living.*Expenses/i).fill('1800');
await page.getByLabel(/Monthly.*Debt.*Payments/i).fill('200');
await page.getByLabel(/To.*Emergency.*\(%\)/i).fill('40'); // 60 to retirement auto-computed
await page.getByRole('button', { name: /^Save$/ }).click();
await expect(page).not.toHaveURL(/\/profile\/financial$/i, { timeout: 15_000 });
});
// Helper: commit autosuggest that sometimes needs two selects
async function commitAutosuggest(page, input, text) {
await input.click();
await input.fill(text);
await page.keyboard.press('ArrowDown').catch(() => {});
await page.keyboard.press('Enter').catch(() => {});
await input.blur();
const v1 = (await input.inputValue()).trim();
if (v1.toLowerCase() === text.toLowerCase()) {
// Some nested lists require a second confirmation
await input.click();
await page.keyboard.press('ArrowDown').catch(() => {});
await page.keyboard.press('Enter').catch(() => {});
await input.blur();
}
}
// ---- College helpers (put near the top of the file) ----
async function commitAutosuggest(page, input, text) {
// Type and try first commit
await input.click();
await input.fill(text);
await page.keyboard.press('ArrowDown').catch(() => {});
await page.keyboard.press('Enter').catch(() => {});
await input.blur();
// If typed value still equals exactly what we wrote, some lists need a second confirm
const v1 = (await input.inputValue()).trim();
if (v1.toLowerCase() === text.toLowerCase()) {
await input.click();
await page.keyboard.press('ArrowDown').catch(() => {});
await page.keyboard.press('Enter').catch(() => {});
await input.blur();
}
}
async function pickDegree(page) {
const select = page.getByLabel(/Degree Type/i);
try {
await select.selectOption({ label: "Bachelor's Degree" });
return;
} catch {}
const second = select.locator('option').nth(1); // skip placeholder
await second.waitFor({ state: 'attached', timeout: 10_000 });
const val = await second.getAttribute('value');
if (val) await select.selectOption(val);
}
// Known-good school/program pair from your dataset
const SCHOOL = 'Alabama A & M University';
const PROGRAM = 'Agriculture, General.';
// ---- CollegeProfileForm — create NEW plan (autosuggests must appear, then save) ----
test('CollegeProfileForm — create new plan (autosuggests + degree + save)', async ({ page, request }) => {
// Fail the test if any unexpected alert shows
page.on('dialog', d => {
throw new Error(`Unexpected dialog: "${d.message()}"`);
});
// Create a scenario to attach the college plan
const scen = await request.post('/api/premium/career-profile', {
data: { career_name: 'QA College Plan (new)', status: 'planned', start_date: '2025-09-01' }
});
const { career_profile_id } = await scen.json();
await page.goto(`/profile/college/${career_profile_id}/new`, { waitUntil: 'domcontentloaded' });
await expect(page.getByRole('heading', { name: /College Plan|Edit College Plan|New College/i }))
.toBeVisible({ timeout: 15_000 });
const SCHOOL = 'Alabama A & M University';
const PROGRAM = 'Agriculture, General.';
const schoolBox = page.getByLabel(/School Name/i);
const programBox = page.getByLabel(/Major.*Program.*Name/i);
// Type partial and assert autosuggest options exist (prove the dropdown is presented)
await schoolBox.fill('Alabama A &');
await expect.poll(async () => {
return await page.locator('#school-suggestions option').count();
}, { timeout: 5000 }).toBeGreaterThan(0);
// Commit school programmatically with double-confirm helper
await commitAutosuggest(page, schoolBox, SCHOOL);
// Program autosuggest must be present too
await programBox.fill('Agri');
await expect.poll(async () => {
return await page.locator('#school-suggestions option').count();
}, { timeout: 5000 }).toBeGreaterThan(0);
// Commit program
await commitAutosuggest(page, programBox, PROGRAM);
// Pick degree + set expected graduation
await pickDegree(page);
const grad = page.getByLabel(/Expected Graduation Date/i);
if (await grad.isVisible().catch(() => false)) await grad.fill('2027-06-01');
// Save
await page.getByRole('button', { name: /^Save$/i }).click();
// Should not remain stuck on /new (and no alerts were raised)
await expect(page).not.toHaveURL(/\/new$/i, { timeout: 10_000 });
});
// ---- CollegeProfileForm — EDIT existing plan (autosuggests + degree + save) ----
test('CollegeProfileForm — edit existing plan (autosuggests + degree + save)', async ({ page, request }) => {
// Fail the test if any unexpected alert shows
page.on('dialog', d => {
throw new Error(`Unexpected dialog: "${d.message()}"`);
});
// Create a scenario and seed a minimal college plan
const scen = await request.post('/api/premium/career-profile', {
data: { career_name: 'QA College Plan (edit)', status: 'planned', start_date: '2025-09-01' }
});
const { career_profile_id } = await scen.json();
const SCHOOL = 'Alabama A & M University';
const PROGRAM = 'Agriculture, General.';
await request.post('/api/premium/college-profile', {
data: {
career_profile_id,
selected_school: SCHOOL,
selected_program: PROGRAM,
program_type: "Bachelor's Degree",
expected_graduation: '2027-06-01',
academic_calendar: 'semester',
credit_hours_per_year: 30,
interest_rate: 5.5,
loan_term: 10
}
});
await page.goto(`/profile/college/${career_profile_id}/edit`, { waitUntil: 'domcontentloaded' });
await expect(page.getByRole('heading', { name: /College Plan|Edit College Plan/i }))
.toBeVisible({ timeout: 15_000 });
const schoolBox = page.getByLabel(/School Name/i);
const programBox = page.getByLabel(/Major.*Program.*Name/i);
// Assert autosuggest presents options on partial typing
await schoolBox.fill('Alabama A &');
await expect.poll(async () => {
return await page.locator('#school-suggestions option').count();
}, { timeout: 5000 }).toBeGreaterThan(0);
// Recommit the same school/program (exercise autosuggest again)
await commitAutosuggest(page, schoolBox, SCHOOL);
await programBox.fill('Agri');
await expect.poll(async () => {
return await page.locator('#school-suggestions option').count();
}, { timeout: 5000 }).toBeGreaterThan(0);
await commitAutosuggest(page, programBox, PROGRAM);
await pickDegree(page);
const grad = page.getByLabel(/Expected Graduation Date/i);
if (await grad.isVisible().catch(() => false)) await grad.fill('2028-05-01');
await page.getByRole('button', { name: /^Save$/i }).click();
// No error popup was allowed; we just assert were not showing the error text
await expect(page.getByText(/Please pick a school from the list/i))
.toHaveCount(0, { timeout: 2000 })
.catch(() => {});
});
});

View File

@ -0,0 +1,35 @@
// @ts-check
import { test, expect } from '@playwright/test';
import { loadTestUser } from '../utils/testUser.js';
test.describe('@p1 CareerRoadmap — panels + Coach basics', () => {
test.setTimeout(60000);
test('open roadmap for a known scenario; coach replies', async ({ page }) => {
const u = loadTestUser();
await page.route('**/api/premium/subscription/status', async route => {
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ is_premium: 1, is_pro_premium: 0 }) });
});
await page.context().clearCookies();
await page.goto('/signin', { waitUntil: 'networkidle' });
await page.getByPlaceholder('Username', { exact: true }).fill(u.username);
await page.getByPlaceholder('Password', { exact: true }).fill(u.password);
await page.getByRole('button', { name: /^Sign In$/ }).click();
await page.waitForURL('**/signin-landing**', { timeout: 15000 });
const scen = await page.request.post('/api/premium/career-profile', {
data: { career_name: 'QA Roadmap', status: 'planned', start_date: '2025-09-01' }
});
const { career_profile_id } = await scen.json();
await page.goto(`/career-roadmap/${career_profile_id}`, { waitUntil: 'networkidle' });
await expect(page.getByText(/Where you are now and where you are going/i)).toBeVisible({ timeout: 20000 });
await expect(page.getByRole('heading', { name: 'Career Coach' })).toBeVisible();
await page.getByPlaceholder('Ask your Career Coach…').fill('Give me one tip.');
await page.getByRole('button', { name: /^Send$/ }).click();
await expect(page.getByText(/Coach is typing…/i)).toBeVisible();
await expect(page.getByText(/Coach is typing…/i)).toHaveCount(0, { timeout: 25000 });
});
});

View File

@ -0,0 +1,68 @@
// @ts-check
import { test, expect } from '@playwright/test';
import { loadTestUser } from '../utils/testUser.js';
test.describe('@p1 Milestones — create (modal), edit, delete', () => {
test.setTimeout(60000);
test('ensure scenario, open Milestones, add + edit + delete', async ({ page }) => {
const u = loadTestUser();
await page.route('**/api/premium/subscription/status', async route => {
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ is_premium: 1, is_pro_premium: 0 }) });
});
await page.context().clearCookies();
await page.goto('/signin', { waitUntil: 'networkidle' });
await page.getByPlaceholder('Username', { exact: true }).fill(u.username);
await page.getByPlaceholder('Password', { exact: true }).fill(u.password);
await page.getByRole('button', { name: /^Sign In$/ }).click();
await page.waitForURL('**/signin-landing**', { timeout: 15000 });
const scen = await page.request.post('/api/premium/career-profile', {
data: { career_name: 'QA Milestones', status: 'planned', start_date: '2025-09-01' }
});
const { career_profile_id } = await scen.json();
await page.goto(`/career-roadmap/${career_profile_id}`, { waitUntil: 'networkidle' });
// Milestones section anchor
await expect(page.getByRole('heading', { name: /^Milestones$/ })).toBeVisible({ timeout: 20000 });
// Open MilestoneEditModal via "Add Details" (missing banner) or panel button
const addDetails = page.getByRole('button', { name: /Add Details/i });
if (await addDetails.isVisible().catch(() => false)) {
await addDetails.click();
} else {
// Fallback: open the modal through any visible “Milestones” action button
const anyBtn = page.getByRole('button', { name: /Add|Edit|Milestone/i }).first();
await anyBtn.click().catch(() => {});
}
// Modal header
await expect(page.getByRole('heading', { name: /^Milestones$/ })).toBeVisible({ timeout: 15000 });
// Add new milestone section
await page.getByText(/Add new milestone/i).click();
await page.getByLabel(/^Title$/i).first().fill('QA Milestone A');
await page.getByLabel(/^Date$/i).first().fill('2026-04-01');
await page.getByRole('button', { name: /\+ Add impact/i }).click();
await page.getByLabel(/^Type$/i).first().selectOption('ONE_TIME');
await page.getByLabel(/^Amount$/i).first().fill('123');
await page.getByLabel(/^Start$/i).first().fill('2026-03-01');
await page.getByRole('button', { name: /\+ Add task/i }).click();
await page.getByLabel(/^Title$/i).nth(1).fill('Prepare docs');
await page.getByLabel(/^Due Date$/i).first().fill('2026-03-15');
await page.getByRole('button', { name: /Save milestone/i }).click();
await expect(page.getByText(/QA Milestone A/i)).toBeVisible({ timeout: 20000 });
// Edit existing milestone: open accordion by its title
await page.getByRole('button', { name: /QA Milestone A/i }).click();
await page.getByLabel(/Description/i).fill('Short description');
await page.getByRole('button', { name: /^Save$/ }).click();
// Delete milestone
await page.getByRole('button', { name: /Delete milestone/i }).click();
// Close modal
await page.getByRole('button', { name: /^Close$/ }).click();
});
});

View File

@ -0,0 +1,37 @@
// @ts-check
import { test, expect } from '@playwright/test';
import { loadTestUser } from '../utils/testUser.js';
test.describe('@p2 Coach — quick actions', () => {
test.setTimeout(60000);
test('Networking Plan triggers and yields reply without leaking hidden prompts', async ({ page }) => {
const u = loadTestUser();
await page.route('**/api/premium/subscription/status', async route => {
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ is_premium: 1, is_pro_premium: 0 }) });
});
await page.context().clearCookies();
await page.goto('/signin', { waitUntil: 'networkidle' });
await page.getByPlaceholder('Username', { exact: true }).fill(u.username);
await page.getByPlaceholder('Password', { exact: true }).fill(u.password);
await page.getByRole('button', { name: /^Sign In$/ }).click();
await page.waitForURL('**/signin-landing**', { timeout: 15000 });
const scen = await page.request.post('/api/premium/career-profile', {
data: { career_name: 'QA Coach', status: 'planned', start_date: '2025-09-01' }
});
const { career_profile_id } = await scen.json();
await page.goto(`/career-roadmap/${career_profile_id}`, { waitUntil: 'networkidle' });
await expect(page.getByRole('heading', { name: 'Career Coach' })).toBeVisible({ timeout: 20000 });
await page.getByRole('button', { name: /Networking Plan/i }).click();
await expect(page.getByText(/Coach is typing/i)).toBeVisible();
await expect(page.getByText(/Coach is typing/i)).toHaveCount(0, { timeout: 30000 });
// Hidden system prompts must not leak
const leaks = await page.locator('text=/^# ⛔️|^MODE :|\"milestones\"/').count();
expect(leaks).toBe(0);
});
});

View File

@ -0,0 +1,37 @@
// @ts-check
import { test, expect } from '@playwright/test';
import { loadTestUser } from '../utils/testUser.js';
test.describe('@premium Premium routes unlock', () => {
test.setTimeout(40000);
test('with premium status, /enhancing /retirement /career-roadmap are accessible', async ({ page }) => {
const u = loadTestUser();
// Force premium status
await page.route('**/api/premium/subscription/status', async route => {
await route.fulfill({ status: 200, contentType: 'application/json',
body: JSON.stringify({ is_premium: 1, is_pro_premium: 0 }) });
});
// Sign in
await page.context().clearCookies();
await page.goto('/signin', { waitUntil: 'networkidle' });
await page.getByPlaceholder('Username', { exact: true }).fill(u.username);
await page.getByPlaceholder('Password', { exact: true }).fill(u.password);
await page.getByRole('button', { name: /^Sign In$/ }).click();
await page.waitForURL('**/signin-landing**', { timeout: 15000 });
// Enhancing
await page.goto('/enhancing', { waitUntil: 'networkidle' });
await expect(page).not.toHaveURL(/\/paywall/i);
// Retirement
await page.goto('/retirement', { waitUntil: 'networkidle' });
await expect(page).not.toHaveURL(/\/paywall/i);
// Career roadmap (no id param → page shell)
await page.goto('/career-roadmap', { waitUntil: 'networkidle' });
await expect(page).not.toHaveURL(/\/paywall/i);
});
});

View File

@ -0,0 +1,34 @@
// @ts-check
import { test, expect } from '@playwright/test';
import { loadTestUser } from '../utils/testUser.js';
test.describe('@premium Premium → refresh to free locks routes', () => {
test.setTimeout(50000);
test('refresh status to free redirects to /paywall on premium routes', async ({ page }) => {
const u = loadTestUser();
let calls = 0;
await page.route('**/api/premium/subscription/status', async route => {
calls += 1;
const body = calls === 1
? { is_premium: 1, is_pro_premium: 0 } // first load premium
: { is_premium: 0, is_pro_premium: 0 }; // after refresh → free
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) });
});
await page.context().clearCookies();
await page.goto('/signin', { waitUntil: 'networkidle' });
await page.getByPlaceholder('Username', { exact: true }).fill(u.username);
await page.getByPlaceholder('Password', { exact: true }).fill(u.password);
await page.getByRole('button', { name: /^Sign In$/ }).click();
await page.waitForURL('**/signin-landing**', { timeout: 15000 });
// Profile → Refresh status (flips to free)
await page.goto('/profile', { waitUntil: 'networkidle' });
await page.getByRole('button', { name: /^Refresh status$/i }).click();
// Try premium route → now should hit paywall
await page.goto('/enhancing', { waitUntil: 'networkidle' });
await expect(page).toHaveURL(/\/paywall/i);
});
});

View File

@ -0,0 +1,121 @@
// tests/e2e/98-security-basics.spec.mjs
// @ts-check
import { test, expect } from '@playwright/test';
import { loadTestUser } from '../utils/testUser.js';
test.describe('@p0 Security basics', () => {
test.setTimeout(20000);
test('wrong creds are generic; no cookie set; rate-limit headers present', async ({ page }) => {
const badUser = `nope_${Date.now()}`;
const badPass = 'Bad#12345!';
// A) Via raw API (check headers/body semantics)
const resp = await page.request.post('/api/signin', {
data: { username: badUser, password: badPass },
});
expect(resp.status()).toBe(401);
const body = await resp.json();
expect(body?.error || '').toMatch(/invalid username or password/i);
// no sensitive fields in body
expect(body).not.toHaveProperty('id');
expect(body).not.toHaveProperty('user');
expect(body).not.toHaveProperty('token');
// standard rate-limit headers (express-rate-limit standardHeaders:true)
const h = resp.headers();
expect(
'ratelimit-remaining' in h ||
'ratelimit-limit' in h ||
'ratelimit-reset' in h
).toBeTruthy();
// B) Via UI (no cookie; generic message)
await page.context().clearCookies();
await page.goto('/signin', { waitUntil: 'networkidle' });
await page.getByPlaceholder('Username', { exact: true }).fill(badUser);
await page.getByPlaceholder('Password', { exact: true }).fill(badPass);
await page.getByRole('button', { name: /^Sign In$/ }).click();
// Generic UI error visible
await expect(page.getByText(/invalid username or password/i)).toBeVisible();
// No auth cookie was set
const cookies = await page.context().cookies();
const anyAuth = cookies.some(c => c.httpOnly && /jwt|session|auth/i.test(c.name));
expect(anyAuth).toBeFalsy();
});
test('successful signin: body has no PII/token; secure, HttpOnly cookie; minimal JIT-PII profile', async ({ page }) => {
const user = loadTestUser();
await page.context().clearCookies();
await page.goto('/signin', { waitUntil: 'networkidle' });
// Capture the /api/signin response to inspect body
const signinResponsePromise = page.waitForResponse(r =>
r.request().method() === 'POST' && r.url().includes('/api/signin')
);
await page.getByPlaceholder('Username', { exact: true }).fill(user.username);
await page.getByPlaceholder('Password', { exact: true }).fill(user.password);
await page.getByRole('button', { name: /^Sign In$/ }).click();
const signinResp = await signinResponsePromise;
expect(signinResp.status()).toBe(200);
const signinBody = await signinResp.json();
// Body should be minimal (no id/email/token/user object)
expect(signinBody).not.toHaveProperty('id');
expect(signinBody).not.toHaveProperty('user');
expect(signinBody).not.toHaveProperty('token');
expect(signinBody?.message || '').toMatch(/login successful/i);
// Land on SignInLanding
await page.waitForURL('**/signin-landing**', { timeout: 15000 });
// Cookie flags: HttpOnly + Secure; SameSite Lax/Strict (or None+Secure)
const cookies = await page.context().cookies();
const authCookie = cookies.find(c =>
c.httpOnly && c.secure && /jwt|session|auth|aptiva/i.test(c.name)
);
expect(authCookie).toBeTruthy();
if (authCookie) {
// sameSite may be 'Lax' | 'Strict' | 'None'
expect(['Lax', 'Strict', 'None']).toContain(authCookie.sameSite);
if (authCookie.sameSite === 'None') {
expect(authCookie.secure).toBeTruthy();
}
}
// JIT-PII: minimal profile request should NOT leak id/email
const prof = await page.request.get('/api/user-profile?fields=firstname');
expect(prof.status()).toBe(200);
const profJson = await prof.json();
// allowlist keys that may legitimately be present
const allowed = new Set(['firstname', 'is_premium', 'is_pro_premium']);
for (const k of Object.keys(profJson)) {
expect(allowed.has(k)).toBeTruthy();
}
expect(profJson).not.toHaveProperty('id');
expect(profJson).not.toHaveProperty('email');
});
test('username availability endpoint returns only {exists} (no leakage)', async ({ page }) => {
const user = loadTestUser();
const unknown = `available_${Date.now()}`;
const knownResp = await page.request.get(`/api/check-username/${encodeURIComponent(user.username)}`);
expect(knownResp.status()).toBe(200);
const knownJson = await knownResp.json();
expect(knownJson).toHaveProperty('exists');
const unknownResp = await page.request.get(`/api/check-username/${encodeURIComponent(unknown)}`);
expect(unknownResp.status()).toBe(200);
const unknownJson = await unknownResp.json();
expect(unknownJson).toHaveProperty('exists');
// Both shapes should be minimal (only 'exists')
expect(Object.keys(knownJson)).toEqual(['exists']);
expect(Object.keys(unknownJson)).toEqual(['exists']);
});
});

View File

@ -0,0 +1,148 @@
// @ts-check
import { test, expect } from '@playwright/test';
import { loadTestUser } from '../utils/testUser.js';
const ENFORCE = process.env.APTIVA_ENFORCE_BUDGETS === '1';
// Budgets (ms) — adjust here when needed
const B = {
signup_areas: 15000, // state change -> areas select enabled & options>1 (dev may be slow)
reload_cold: 60000, // click Reload -> careerSuggestionsCache length > 0
modal_cold: 3000, // click tile -> Add to Comparison visible
};
function log(name, ms, budget) {
console.log(`${name}: ${Math.round(ms)} ms${budget ? ` (budget ≤ ${budget})` : ''}`);
if (ENFORCE && budget) expect(ms).toBeLessThanOrEqual(budget);
}
test.describe('@perf Focused Budgets', () => {
test.setTimeout(30000);
test('SignUp: Areas population time', async ({ page }) => {
await page.context().clearCookies();
await page.goto('/signup', { waitUntil: 'networkidle' });
// Select State (dropdown with "Select State" placeholder)
const stateSelect = page.locator('select').filter({
has: page.locator('option', { hasText: 'Select State' }),
});
await expect(stateSelect).toBeVisible();
const areaSelect = page.locator('select#area');
await expect(areaSelect).toBeVisible();
const STATE = 'GA'; // Georgia (populates areas)
const stateParam = encodeURIComponent(STATE);
const t0 = Date.now();
await stateSelect.selectOption(STATE);
// Wait for the API call and the UI to be interactable
await page.waitForResponse(
r => r.url().includes(`/api/areas?state=${stateParam}`) && r.request().method() === 'GET',
{ timeout: 20000 }
);
await expect(areaSelect).toBeEnabled({ timeout: 10000 });
await expect(async () => {
const count = await areaSelect.locator('option').count();
expect(count).toBeGreaterThan(1);
}).toPass({ timeout: 10000 });
log('signup_areas', Date.now() - t0, B.signup_areas);
});
test('Career Explorer: Reload Career Suggestions (cold)', async ({ page }) => {
const u = loadTestUser();
await page.context().clearCookies();
await page.goto('/signin', { waitUntil: 'networkidle' });
await page.getByPlaceholder('Username', { exact: true }).fill(u.username);
await page.getByPlaceholder('Password', { exact: true }).fill(u.password);
await page.getByRole('button', { name: /^Sign In$/ }).click();
await page.waitForURL('**/signin-landing**', { timeout: 15000 });
await page.goto('/career-explorer', { waitUntil: 'networkidle' });
// Force cold by clearing cache
await page.evaluate(() => localStorage.removeItem('careerSuggestionsCache'));
const reloadBtn = page.getByRole('button', { name: /Reload Career Suggestions/i });
await expect(reloadBtn).toBeVisible();
const t0 = Date.now();
await reloadBtn.click();
// Wait for overlay to appear (if it does), then for it to effectively finish and hide.
const overlayText = page.getByText(/Loading Career Suggestions/i).first();
const appeared = await overlayText.isVisible({ timeout: 2000 }).catch(() => false);
if (appeared) {
// Treat overlay hidden as 100%; otherwise poll the % and accept ≥95 as done.
await expect
.poll(async () => {
const stillVisible = await overlayText.isVisible().catch(() => false);
if (!stillVisible) return 100;
const t = (await overlayText.textContent()) || '';
const m = t.match(/(\d+)%/);
return m ? parseInt(m[1], 10) : 0;
}, { timeout: B.reload_cold, message: 'reload overlay did not complete' })
.toBeGreaterThanOrEqual(95);
await overlayText.waitFor({ state: 'hidden', timeout: B.reload_cold }).catch(() => {});
}
// Source of truth: cache populated after overlay completes
await expect
.poll(async () => {
return await page.evaluate(() => {
try {
const s = localStorage.getItem('careerSuggestionsCache');
const arr = s ? JSON.parse(s) : [];
return Array.isArray(arr) ? arr.length : 0;
} catch { return 0; }
});
}, { timeout: B.reload_cold, message: 'careerSuggestionsCache not populated' })
.toBeGreaterThan(0);
log('explorer_reload_cold', Date.now() - t0, B.reload_cold);
});
test('Career Explorer: CareerModal open (cold)', async ({ page }) => {
const u = loadTestUser();
await page.context().clearCookies();
await page.goto('/signin', { waitUntil: 'networkidle' });
await page.getByPlaceholder('Username', { exact: true }).fill(u.username);
await page.getByPlaceholder('Password', { exact: true }).fill(u.password);
await page.getByRole('button', { name: /^Sign In$/ }).click();
await page.waitForURL('**/signin-landing**', { timeout: 15000 });
await page.goto('/career-explorer', { waitUntil: 'networkidle' });
// Ensure at least one tile exists; if not, perform a quick reload
const tile = page.locator('div.grid button').first();
if (!(await tile.isVisible({ timeout: 1500 }).catch(() => false))) {
const reloadBtn = page.getByRole('button', { name: /Reload Career Suggestions/i });
await reloadBtn.click();
await expect
.poll(async () => {
return await page.evaluate(() => {
try {
const s = localStorage.getItem('careerSuggestionsCache');
const arr = s ? JSON.parse(s) : [];
return Array.isArray(arr) ? arr.length : 0;
} catch { return 0; }
});
}, { timeout: 60000 })
.toBeGreaterThan(0);
await expect(tile).toBeVisible({ timeout: 8000 });
}
const t0 = Date.now();
await tile.click();
await page.getByRole('button', { name: /Add to Comparison/i }).waitFor({ timeout: 15000 });
log('careermodal_open_cold', Date.now() - t0, B.modal_cold);
});
});

26
tests/e2e/fixtures.mjs Normal file
View File

@ -0,0 +1,26 @@
// Small helpers shared by E2E specs
export const j = (o) => JSON.stringify(o);
export const rand = () => Math.random().toString(36).slice(2, 10);
// Parse Set-Cookie into a Playwright cookie.
export function cookieFromSetCookie(setCookie, baseURL) {
// "name=value; Path=/; HttpOnly; Secure; SameSite=Lax"
const [pair, ...attrs] = setCookie.split(';').map(s => s.trim());
const [name, value] = pair.split('=');
const url = new URL(baseURL);
const domain = url.hostname; // e.g., dev1.aptivaai.com
let path = '/';
let secure = true, httpOnly = true, sameSite = 'Lax';
for (const a of attrs) {
const k = a.toLowerCase();
if (k.startsWith('path=')) path = a.slice(5) || '/';
else if (k === 'secure') secure = true;
else if (k === 'httponly') httpOnly = true;
else if (k.startsWith('samesite=')) {
const v = a.split('=')[1];
sameSite = (v && /^(Lax|Strict|None)$/i.test(v)) ? v : 'Lax';
}
}
return { name, value, domain, path, httpOnly, secure, sameSite };
}

37
tests/smoke.sh Normal file → Executable file
View File

@ -2,15 +2,40 @@
set -euo pipefail
BASE="${BASE:-https://dev1.aptivaai.com}"
GOOD_ORIGIN="${GOOD_ORIGIN:-https://dev1.aptivaai.com}"
GOOD_ORIGIN="${GOOD_ORIGIN:-$BASE}"
BAD_ORIGIN="${BAD_ORIGIN:-https://evil.example.com}"
pass(){ echo "$*"; }
fail(){ echo "$*"; exit 1; }
# curl JSON helper: capture status, validate JSON, show snippet on fail
json_check () {
local url="$1" label="$2"
local tmp
tmp="$(mktemp)"
local code
code="$(curl -sSL --max-redirs 5 -H 'Accept: application/json' -o "$tmp" -w '%{http_code}' "$url")" || { echo "⚠️ curl transport error for $label"; rm -f "$tmp"; fail "$label"; }
if [[ "$code" != "200" ]]; then
echo "⚠️ $label HTTP $code"
echo "--- $label body (first 400 bytes) ---"
head -c 400 "$tmp" | sed 's/[^[:print:]\t]/./g'
echo
rm -f "$tmp"; fail "$label"
fi
if ! jq -e . < "$tmp" >/dev/null 2>&1; then
echo "⚠️ $label returned non-JSON or invalid JSON"
echo "--- $label body (first 400 bytes) ---"
head -c 400 "$tmp" | sed 's/[^[:print:]\t]/./g'
echo
rm -f "$tmp"; fail "$label"
fi
rm -f "$tmp"
}
# --- Health checks (server1/2/3) ---
for p in /livez /readyz /healthz; do
curl -fsS "$BASE$ p" >/dev/null || fail "server2 $p"
curl -fsS "$BASE$p" >/dev/null || fail "server2 $p"
done
pass "server2 health endpoints up"
@ -30,9 +55,9 @@ code=$(curl -s -o /dev/null -w '%{http_code}' -H "Origin: $BAD_ORIGIN" "$BASE/ap
pass "CORS bad origin blocked"
# --- Public data flows (server2) ---
curl -fsS "$BASE/api/projections/15-1252?state=GA" | jq . > /dev/null || fail "projections"
curl -fsS "$BASE/api/salary?socCode=15-1252&area=Atlanta-Sandy Springs-Roswell, GA" | jq . > /dev/null || fail "salary"
curl -fsS "$BASE/api/tuition?cipCodes=1101,1103&state=GA" | jq . > /dev/null || fail "tuition"
pass "public data endpoints OK"
json_check "$BASE/api/projections/15-1252?state=GA" "projections"
json_check "$BASE/api/salary?socCode=15-1252&area=Atlanta-Sandy%20Springs-Roswell%2C%20GA" "salary"
json_check "$BASE/api/tuition?cipCodes=1101,1103&state=GA" "tuition"
pass "public data endpoints OK (JSON + 200 verified)"
echo "✓ SMOKE PASSED"

15
tests/utils/testUser.js Normal file
View File

@ -0,0 +1,15 @@
import fs from 'fs';
import path from 'path';
const FILE = process.env.APTIVA_TEST_USER_FILE ||
path.resolve(process.cwd(), '.aptiva-test-user.json');
export function saveTestUser(user) {
fs.writeFileSync(FILE, JSON.stringify(user, null, 2), 'utf8');
return FILE;
}
export function loadTestUser() {
if (!fs.existsSync(FILE)) throw new Error(`No test user file at ${FILE}`);
return JSON.parse(fs.readFileSync(FILE, 'utf8'));
}