Testing and minor changes to CareerExplorer, CollegeProfileForm fixes.
This commit is contained in:
parent
e943f1c427
commit
375e9bacdc
@ -1 +1 @@
|
||||
c8af44caf3dec8c5f306fef35c4925be044f0374-22db19df6f582b90837f001b7bf6ead59acca441-e9eccd451b778829eb2f2c9752c670b707e1268b
|
||||
24c4644c626acf48ddca3964105cd9bfa267d82a-372bcf506971f56c4911b429b9f5de5bc37ed008-e9eccd451b778829eb2f2c9752c670b707e1268b
|
||||
|
8
.env
8
.env
@ -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
1
.gitignore
vendored
@ -26,3 +26,4 @@ uploads/.env
|
||||
.env
|
||||
.env.*
|
||||
scan-env.sh
|
||||
.aptiva-test-user.json
|
||||
|
@ -1 +1 @@
|
||||
1a7fe9191922c4f8389027ed53b6a4909740a48b
|
||||
98f674eca26e366aee0b41f250978982060105f0
|
||||
|
@ -1 +1 @@
|
||||
1a7fe9191922c4f8389027ed53b6a4909740a48b
|
||||
98f674eca26e366aee0b41f250978982060105f0
|
||||
|
37
backend/tests/COMPONENTS.md
Normal file
37
backend/tests/COMPONENTS.md
Normal 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 they’re current and in scope.
|
126
backend/tests/auth_signup_signin.mjs
Normal file
126
backend/tests/auth_signup_signin.mjs
Normal 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);
|
||||
});
|
24
backend/tests/components_runner.mjs
Normal file
24
backend/tests/components_runner.mjs
Normal 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'),
|
||||
]);
|
||||
|
||||
|
174
backend/tests/support_limits.mjs
Normal file
174
backend/tests/support_limits.mjs
Normal 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
64
package-lock.json
generated
@ -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",
|
||||
|
@ -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",
|
||||
|
76
playwright-report/index.html
Normal file
76
playwright-report/index.html
Normal file
File diff suppressed because one or more lines are too long
@ -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
15
playwright.config.mjs
Normal 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' }]],
|
||||
});
|
@ -286,6 +286,7 @@ const confirmLogout = async () => {
|
||||
'aiClickDate',
|
||||
'aiRecommendations',
|
||||
'premiumOnboardingState',
|
||||
'premiumOnboardingPointer',
|
||||
'financialProfile',
|
||||
'selectedScenario',
|
||||
]);
|
||||
|
@ -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>
|
||||
|
||||
|
@ -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 in‑state / 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;
|
||||
})();
|
||||
|
||||
/* ────────────────────────────────────────────────────────────
|
||||
Auto‑calculate PROGRAM LENGTH when the user hasn’t 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 Onboarding’s start date selection
|
||||
let start = null;
|
||||
if (form.college_enrollment_status === 'prospective_student') {
|
||||
if (!form.enrollment_date) return; // need user’s 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 │ Program‑type */}
|
||||
<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 */}
|
||||
|
@ -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">
|
||||
|
6
test-results/.last-run.json
Normal file
6
test-results/.last-run.json
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"status": "failed",
|
||||
"failedTests": [
|
||||
"d94173b0fe5d7002a306-47f9b330456659f0f977"
|
||||
]
|
||||
}
|
@ -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 |
Binary file not shown.
Binary file not shown.
@ -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 |
Binary file not shown.
109
tests/e2e/01-signup.spec.mjs
Normal file
109
tests/e2e/01-signup.spec.mjs
Normal 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 didn’t 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([]);
|
||||
});
|
||||
});
|
38
tests/e2e/02-signin-landing.spec.mjs
Normal file
38
tests/e2e/02-signin-landing.spec.mjs
Normal 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([]);
|
||||
});
|
||||
});
|
58
tests/e2e/03-interest-inventory.spec.mjs
Normal file
58
tests/e2e/03-interest-inventory.spec.mjs
Normal 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 });
|
||||
});
|
||||
});
|
305
tests/e2e/04-career-explorer.core.spec.mjs
Normal file
305
tests/e2e/04-career-explorer.core.spec.mjs
Normal 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 there’s 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 (don’t 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 });
|
||||
}
|
||||
});
|
||||
});
|
151
tests/e2e/05-career-explorer.reload.spec.mjs
Normal file
151
tests/e2e/05-career-explorer.reload.spec.mjs
Normal 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();
|
||||
});
|
||||
});
|
111
tests/e2e/06-career-explorer.search.spec.mjs
Normal file
111
tests/e2e/06-career-explorer.search.spec.mjs
Normal 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 (don’t 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('');
|
||||
});
|
||||
});
|
178
tests/e2e/07-education-handoff.spec.mjs
Normal file
178
tests/e2e/07-education-handoff.spec.mjs
Normal 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();
|
||||
});
|
||||
});
|
28
tests/e2e/08-logout.guard.spec.mjs
Normal file
28
tests/e2e/08-logout.guard.spec.mjs
Normal 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 });
|
||||
});
|
||||
});
|
122
tests/e2e/09-comparison-duplicate-remove.spec.mjs
Normal file
122
tests/e2e/09-comparison-duplicate-remove.spec.mjs
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
107
tests/e2e/10-priorities-modal.spec.mjs
Normal file
107
tests/e2e/10-priorities-modal.spec.mjs
Normal 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 it’s 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(() => {});
|
||||
}
|
||||
});
|
||||
});
|
61
tests/e2e/11-forgot-password-ui.spec.mjs
Normal file
61
tests/e2e/11-forgot-password-ui.spec.mjs
Normal 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 });
|
||||
}
|
||||
});
|
||||
});
|
85
tests/e2e/12-reset-password.spec.mjs
Normal file
85
tests/e2e/12-reset-password.spec.mjs
Normal 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();
|
||||
});
|
||||
});
|
8
tests/e2e/12a-reset-password-redirect.spec.mjs
Normal file
8
tests/e2e/12a-reset-password-redirect.spec.mjs
Normal 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
|
||||
});
|
31
tests/e2e/13-premium-route-guard.spec.mjs
Normal file
31
tests/e2e/13-premium-route-guard.spec.mjs
Normal 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 });
|
||||
});
|
||||
});
|
21
tests/e2e/14-session-expired-handler.spec.mjs
Normal file
21
tests/e2e/14-session-expired-handler.spec.mjs
Normal 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 });
|
||||
});
|
||||
});
|
48
tests/e2e/15-support-modal.spec.mjs
Normal file
48
tests/e2e/15-support-modal.spec.mjs
Normal 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 });
|
||||
});
|
||||
});
|
29
tests/e2e/16-paywall-cta.spec.mjs
Normal file
29
tests/e2e/16-paywall-cta.spec.mjs
Normal 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 });
|
||||
});
|
||||
});
|
74
tests/e2e/17-support-submit.spec.mjs
Normal file
74
tests/e2e/17-support-submit.spec.mjs
Normal 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();
|
||||
});
|
||||
});
|
47
tests/e2e/18-nav-menus.spec.mjs
Normal file
47
tests/e2e/18-nav-menus.spec.mjs
Normal 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 });
|
||||
});
|
||||
});
|
35
tests/e2e/19-profile-gating.spec.mjs
Normal file
35
tests/e2e/19-profile-gating.spec.mjs
Normal 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 });
|
||||
});
|
||||
});
|
48
tests/e2e/20-support-rate-limit.spec.mjs
Normal file
48
tests/e2e/20-support-rate-limit.spec.mjs
Normal 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();
|
||||
});
|
||||
});
|
47
tests/e2e/21-support-auth-dedupe.spec.mjs
Normal file
47
tests/e2e/21-support-auth-dedupe.spec.mjs
Normal 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);
|
||||
});
|
||||
});
|
158
tests/e2e/22-educational-programs.spec.mjs
Normal file
158
tests/e2e/22-educational-programs.spec.mjs
Normal 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 });
|
||||
});
|
||||
});
|
111
tests/e2e/23-educational-programs-select-school.spec.mjs
Normal file
111
tests/e2e/23-educational-programs-select-school.spec.mjs
Normal 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 });
|
||||
});
|
||||
});
|
92
tests/e2e/24-chat-support-drawer.spec.mjs
Normal file
92
tests/e2e/24-chat-support-drawer.spec.mjs
Normal 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 });
|
||||
});
|
||||
});
|
32
tests/e2e/25-chat-retire-tab-bounce.spec.mjs
Normal file
32
tests/e2e/25-chat-retire-tab-bounce.spec.mjs
Normal 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();
|
||||
});
|
||||
});
|
46
tests/e2e/26-chat-retire-tab-visibility.spec.mjs
Normal file
46
tests/e2e/26-chat-retire-tab-visibility.spec.mjs
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
63
tests/e2e/27-chat-support-stream-chunking.spec.mjs
Normal file
63
tests/e2e/27-chat-support-stream-chunking.spec.mjs
Normal 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.');
|
||||
});
|
||||
});
|
70
tests/e2e/28-chat-support-throttle.spec.mjs
Normal file
70
tests/e2e/28-chat-support-throttle.spec.mjs
Normal 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 });
|
||||
});
|
||||
});
|
109
tests/e2e/29-career-explorer-filters.spec.mjs
Normal file
109
tests/e2e/29-career-explorer-filters.spec.mjs
Normal 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);
|
||||
});
|
||||
});
|
62
tests/e2e/30-profile-account-jitpii.spec.mjs
Normal file
62
tests/e2e/30-profile-account-jitpii.spec.mjs
Normal 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 don’t pollute the account)
|
||||
await page.goto('/profile', { waitUntil: 'networkidle' });
|
||||
await firstInput.fill(oldFirst);
|
||||
await page.getByRole('button', { name: /^Save Profile$/i }).click();
|
||||
});
|
||||
});
|
58
tests/e2e/31-logout-clears-caches.spec.mjs
Normal file
58
tests/e2e/31-logout-clears-caches.spec.mjs
Normal 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();
|
||||
});
|
||||
});
|
69
tests/e2e/32-chat-support-thread-persist.spec.mjs
Normal file
69
tests/e2e/32-chat-support-thread-persist.spec.mjs
Normal 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; // shouldn’t 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 });
|
||||
});
|
||||
});
|
36
tests/e2e/33-billing-portal-flow.spec.mjs
Normal file
36
tests/e2e/33-billing-portal-flow.spec.mjs
Normal 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);
|
||||
});
|
||||
});
|
46
tests/e2e/34-profile-areas-load.spec.mjs
Normal file
46
tests/e2e/34-profile-areas-load.spec.mjs
Normal 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'));
|
||||
});
|
||||
});
|
44
tests/e2e/35-careersearech-arrowdown-enter.spec.mjs
Normal file
44
tests/e2e/35-careersearech-arrowdown-enter.spec.mjs
Normal 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');
|
||||
});
|
||||
});
|
33
tests/e2e/36-profile-change-password-toggle.spec.mjs
Normal file
33
tests/e2e/36-profile-change-password-toggle.spec.mjs
Normal 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 });
|
||||
});
|
||||
});
|
21
tests/e2e/37-resume-optimizer-guard.spec.mjs
Normal file
21
tests/e2e/37-resume-optimizer-guard.spec.mjs
Normal 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 });
|
||||
});
|
||||
});
|
88
tests/e2e/38-educational-programs-sorting.spec.mjs
Normal file
88
tests/e2e/38-educational-programs-sorting.spec.mjs
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
51
tests/e2e/39-inventory-prefill-from-profile.spec.mjs
Normal file
51
tests/e2e/39-inventory-prefill-from-profile.spec.mjs
Normal 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 });
|
||||
});
|
||||
});
|
140
tests/e2e/40-educational-programs-distance-filter.spec.mjs
Normal file
140
tests/e2e/40-educational-programs-distance-filter.spec.mjs
Normal 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 (~3–5 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
|
||||
// (don’t 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();
|
||||
});
|
||||
});
|
98
tests/e2e/41-profile-change-password-submit.spec.mjs
Normal file
98
tests/e2e/41-profile-change-password-submit.spec.mjs
Normal 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();
|
||||
});
|
||||
});
|
53
tests/e2e/42-billing-refresh-status.spec.mjs
Normal file
53
tests/e2e/42-billing-refresh-status.spec.mjs
Normal 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();
|
||||
});
|
||||
});
|
54
tests/e2e/43-billing-paywall.spec.mjs
Normal file
54
tests/e2e/43-billing-paywall.spec.mjs
Normal 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$/);
|
||||
}
|
||||
});
|
||||
});
|
122
tests/e2e/44-onboarding-e2e.spec.mjs
Normal file
122
tests/e2e/44-onboarding-e2e.spec.mjs
Normal 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 });
|
||||
});
|
||||
});
|
239
tests/e2e/46-profile-editors.spec.mjs
Normal file
239
tests/e2e/46-profile-editors.spec.mjs
Normal 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 we’re not showing the error text
|
||||
await expect(page.getByText(/Please pick a school from the list/i))
|
||||
.toHaveCount(0, { timeout: 2000 })
|
||||
.catch(() => {});
|
||||
});
|
||||
});
|
35
tests/e2e/47-career-roadmap-and-coach.spec.mjs
Normal file
35
tests/e2e/47-career-roadmap-and-coach.spec.mjs
Normal 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 });
|
||||
});
|
||||
});
|
68
tests/e2e/48-milestones-crud.spec.mjs
Normal file
68
tests/e2e/48-milestones-crud.spec.mjs
Normal 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();
|
||||
});
|
||||
});
|
37
tests/e2e/49-coach-quick-actions.spec.mjs
Normal file
37
tests/e2e/49-coach-quick-actions.spec.mjs
Normal 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);
|
||||
});
|
||||
});
|
37
tests/e2e/50-premium-routes-unlock.spec.mjs
Normal file
37
tests/e2e/50-premium-routes-unlock.spec.mjs
Normal 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);
|
||||
});
|
||||
});
|
34
tests/e2e/51-premium-refresh-to-free-locks.spec.mjs
Normal file
34
tests/e2e/51-premium-refresh-to-free-locks.spec.mjs
Normal 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);
|
||||
});
|
||||
});
|
121
tests/e2e/98-security-basics.spec.mjs
Normal file
121
tests/e2e/98-security-basics.spec.mjs
Normal 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']);
|
||||
});
|
||||
});
|
148
tests/e2e/99-perf-budgets.spec.mjs
Normal file
148
tests/e2e/99-perf-budgets.spec.mjs
Normal 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
26
tests/e2e/fixtures.mjs
Normal 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
37
tests/smoke.sh
Normal file → Executable 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
15
tests/utils/testUser.js
Normal 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'));
|
||||
}
|
Loading…
Reference in New Issue
Block a user