diff --git a/.build.hash b/.build.hash index d3348bf..51c0d17 100644 --- a/.build.hash +++ b/.build.hash @@ -1 +1 @@ -c8af44caf3dec8c5f306fef35c4925be044f0374-22db19df6f582b90837f001b7bf6ead59acca441-e9eccd451b778829eb2f2c9752c670b707e1268b +24c4644c626acf48ddca3964105cd9bfa267d82a-372bcf506971f56c4911b429b9f5de5bc37ed008-e9eccd451b778829eb2f2c9752c670b707e1268b diff --git a/.env b/.env deleted file mode 100644 index 1b8e344..0000000 --- a/.env +++ /dev/null @@ -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 \ No newline at end of file diff --git a/.gitignore b/.gitignore index b21ab22..c06cc91 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,4 @@ uploads/.env .env .env.* scan-env.sh +.aptiva-test-user.json diff --git a/.last-lock b/.last-lock index 48c332e..99340a5 100644 --- a/.last-lock +++ b/.last-lock @@ -1 +1 @@ -1a7fe9191922c4f8389027ed53b6a4909740a48b +98f674eca26e366aee0b41f250978982060105f0 diff --git a/.lock.hash b/.lock.hash index 48c332e..99340a5 100644 --- a/.lock.hash +++ b/.lock.hash @@ -1 +1 @@ -1a7fe9191922c4f8389027ed53b6a4909740a48b +98f674eca26e366aee0b41f250978982060105f0 diff --git a/backend/tests/COMPONENTS.md b/backend/tests/COMPONENTS.md new file mode 100644 index 0000000..866f55a --- /dev/null +++ b/backend/tests/COMPONENTS.md @@ -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. diff --git a/backend/tests/auth_signup_signin.mjs b/backend/tests/auth_signup_signin.mjs new file mode 100644 index 0000000..161671e --- /dev/null +++ b/backend/tests/auth_signup_signin.mjs @@ -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); +}); diff --git a/backend/tests/components_runner.mjs b/backend/tests/components_runner.mjs new file mode 100644 index 0000000..2517e29 --- /dev/null +++ b/backend/tests/components_runner.mjs @@ -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'), + ]); + + diff --git a/backend/tests/support_limits.mjs b/backend/tests/support_limits.mjs new file mode 100644 index 0000000..5d75bd9 --- /dev/null +++ b/backend/tests/support_limits.mjs @@ -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); +}); diff --git a/package-lock.json b/package-lock.json index cd0ecc2..c5bf38e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index ecf680d..2b15cf4 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/playwright-report/index.html b/playwright-report/index.html new file mode 100644 index 0000000..ded81cc --- /dev/null +++ b/playwright-report/index.html @@ -0,0 +1,76 @@ + + + + + + + + + Playwright Test Report + + + + +
+ + + \ No newline at end of file diff --git a/playwright.config.js b/playwright.config.js deleted file mode 100644 index 79af517..0000000 --- a/playwright.config.js +++ /dev/null @@ -1,6 +0,0 @@ -const { defineConfig } = require('@playwright/test'); -module.exports = defineConfig({ - testDir: 'tests', - projects:[ {name:'chromium', use:{browserName:'chromium'}} ], - timeout: 30000, -}); diff --git a/playwright.config.mjs b/playwright.config.mjs new file mode 100644 index 0000000..41dc5a5 --- /dev/null +++ b/playwright.config.mjs @@ -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' }]], + }); \ No newline at end of file diff --git a/src/App.js b/src/App.js index b86eba6..ff52db3 100644 --- a/src/App.js +++ b/src/App.js @@ -286,6 +286,7 @@ const confirmLogout = async () => { 'aiClickDate', 'aiRecommendations', 'premiumOnboardingState', + 'premiumOnboardingPointer', 'financialProfile', 'selectedScenario', ]); diff --git a/src/components/CareerExplorer.js b/src/components/CareerExplorer.js index dfde47f..6ae7fb7 100644 --- a/src/components/CareerExplorer.js +++ b/src/components/CareerExplorer.js @@ -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() {

Explore Careers - use these tools to find your best fit

{ - setLoading(true); - setPendingCareerForModal(careerObj); - }} + onCareerSelected={handleCareerFromSearch} /> diff --git a/src/components/CollegeProfileForm.js b/src/components/CollegeProfileForm.js index 03d4095..18da6a7 100644 --- a/src/components/CollegeProfileForm.js +++ b/src/components/CollegeProfileForm.js @@ -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 (
@@ -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 /> - {schoolSug.map((s,i)=>( -
@@ -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 /> - - {progSug.map((p,i)=>( - + + {progSug.map((p,i)=>( + {/* 4 │ Program‑type */}
- - {types.map((t,i)=>)} - + {list.map((t,i)=>)} + ); + })()}
{/* 5 │ Academic calendar */} diff --git a/src/components/PremiumOnboarding/CollegeOnboarding.js b/src/components/PremiumOnboarding/CollegeOnboarding.js index b1fdecc..a673d28 100644 --- a/src/components/PremiumOnboarding/CollegeOnboarding.js +++ b/src/components/PremiumOnboarding/CollegeOnboarding.js @@ -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…" /> @@ -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…" /> diff --git a/test-results/.last-run.json b/test-results/.last-run.json new file mode 100644 index 0000000..53f2505 --- /dev/null +++ b/test-results/.last-run.json @@ -0,0 +1,6 @@ +{ + "status": "failed", + "failedTests": [ + "d94173b0fe5d7002a306-47f9b330456659f0f977" + ] +} \ No newline at end of file diff --git a/test-results/44-onboarding-e2e--p0-Onbo-00a6c--lands-on-career-roadmap-id-retry1/error-context.md b/test-results/44-onboarding-e2e--p0-Onbo-00a6c--lands-on-career-roadmap-id-retry1/error-context.md new file mode 100644 index 0000000..bc73311 --- /dev/null +++ b/test-results/44-onboarding-e2e--p0-Onbo-00a6c--lands-on-career-roadmap-id-retry1/error-context.md @@ -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 +``` \ No newline at end of file diff --git a/test-results/44-onboarding-e2e--p0-Onbo-00a6c--lands-on-career-roadmap-id-retry1/test-failed-1.png b/test-results/44-onboarding-e2e--p0-Onbo-00a6c--lands-on-career-roadmap-id-retry1/test-failed-1.png new file mode 100644 index 0000000..302c688 Binary files /dev/null and b/test-results/44-onboarding-e2e--p0-Onbo-00a6c--lands-on-career-roadmap-id-retry1/test-failed-1.png differ diff --git a/test-results/44-onboarding-e2e--p0-Onbo-00a6c--lands-on-career-roadmap-id-retry1/trace.zip b/test-results/44-onboarding-e2e--p0-Onbo-00a6c--lands-on-career-roadmap-id-retry1/trace.zip new file mode 100644 index 0000000..4971870 Binary files /dev/null and b/test-results/44-onboarding-e2e--p0-Onbo-00a6c--lands-on-career-roadmap-id-retry1/trace.zip differ diff --git a/test-results/44-onboarding-e2e--p0-Onbo-00a6c--lands-on-career-roadmap-id-retry1/video.webm b/test-results/44-onboarding-e2e--p0-Onbo-00a6c--lands-on-career-roadmap-id-retry1/video.webm new file mode 100644 index 0000000..80a0d46 Binary files /dev/null and b/test-results/44-onboarding-e2e--p0-Onbo-00a6c--lands-on-career-roadmap-id-retry1/video.webm differ diff --git a/test-results/44-onboarding-e2e--p0-Onbo-00a6c--lands-on-career-roadmap-id/error-context.md b/test-results/44-onboarding-e2e--p0-Onbo-00a6c--lands-on-career-roadmap-id/error-context.md new file mode 100644 index 0000000..b01d5d3 --- /dev/null +++ b/test-results/44-onboarding-e2e--p0-Onbo-00a6c--lands-on-career-roadmap-id/error-context.md @@ -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 +``` \ No newline at end of file diff --git a/test-results/44-onboarding-e2e--p0-Onbo-00a6c--lands-on-career-roadmap-id/test-failed-1.png b/test-results/44-onboarding-e2e--p0-Onbo-00a6c--lands-on-career-roadmap-id/test-failed-1.png new file mode 100644 index 0000000..e976e33 Binary files /dev/null and b/test-results/44-onboarding-e2e--p0-Onbo-00a6c--lands-on-career-roadmap-id/test-failed-1.png differ diff --git a/test-results/44-onboarding-e2e--p0-Onbo-00a6c--lands-on-career-roadmap-id/video.webm b/test-results/44-onboarding-e2e--p0-Onbo-00a6c--lands-on-career-roadmap-id/video.webm new file mode 100644 index 0000000..188f947 Binary files /dev/null and b/test-results/44-onboarding-e2e--p0-Onbo-00a6c--lands-on-career-roadmap-id/video.webm differ diff --git a/tests/e2e/01-signup.spec.mjs b/tests/e2e/01-signup.spec.mjs new file mode 100644 index 0000000..cef9512 --- /dev/null +++ b/tests/e2e/01-signup.spec.mjs @@ -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([]); + }); +}); diff --git a/tests/e2e/02-signin-landing.spec.mjs b/tests/e2e/02-signin-landing.spec.mjs new file mode 100644 index 0000000..f2b7985 --- /dev/null +++ b/tests/e2e/02-signin-landing.spec.mjs @@ -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([]); + }); +}); diff --git a/tests/e2e/03-interest-inventory.spec.mjs b/tests/e2e/03-interest-inventory.spec.mjs new file mode 100644 index 0000000..23b600d --- /dev/null +++ b/tests/e2e/03-interest-inventory.spec.mjs @@ -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 }); + }); +}); diff --git a/tests/e2e/04-career-explorer.core.spec.mjs b/tests/e2e/04-career-explorer.core.spec.mjs new file mode 100644 index 0000000..722c518 --- /dev/null +++ b/tests/e2e/04-career-explorer.core.spec.mjs @@ -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 (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(); + }); +}); diff --git a/tests/e2e/06-career-explorer.search.spec.mjs b/tests/e2e/06-career-explorer.search.spec.mjs new file mode 100644 index 0000000..33fc15f --- /dev/null +++ b/tests/e2e/06-career-explorer.search.spec.mjs @@ -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(''); + }); +}); diff --git a/tests/e2e/07-education-handoff.spec.mjs b/tests/e2e/07-education-handoff.spec.mjs new file mode 100644 index 0000000..a1cd4fc --- /dev/null +++ b/tests/e2e/07-education-handoff.spec.mjs @@ -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(); + }); +}); diff --git a/tests/e2e/08-logout.guard.spec.mjs b/tests/e2e/08-logout.guard.spec.mjs new file mode 100644 index 0000000..edb0381 --- /dev/null +++ b/tests/e2e/08-logout.guard.spec.mjs @@ -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 }); + }); +}); diff --git a/tests/e2e/09-comparison-duplicate-remove.spec.mjs b/tests/e2e/09-comparison-duplicate-remove.spec.mjs new file mode 100644 index 0000000..81e8c56 --- /dev/null +++ b/tests/e2e/09-comparison-duplicate-remove.spec.mjs @@ -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(); + } + }); +}); diff --git a/tests/e2e/10-priorities-modal.spec.mjs b/tests/e2e/10-priorities-modal.spec.mjs new file mode 100644 index 0000000..6fdbf2e --- /dev/null +++ b/tests/e2e/10-priorities-modal.spec.mjs @@ -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(() => {}); + } + }); +}); diff --git a/tests/e2e/11-forgot-password-ui.spec.mjs b/tests/e2e/11-forgot-password-ui.spec.mjs new file mode 100644 index 0000000..94d3643 --- /dev/null +++ b/tests/e2e/11-forgot-password-ui.spec.mjs @@ -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 }); + } + }); +}); diff --git a/tests/e2e/12-reset-password.spec.mjs b/tests/e2e/12-reset-password.spec.mjs new file mode 100644 index 0000000..6c8aba6 --- /dev/null +++ b/tests/e2e/12-reset-password.spec.mjs @@ -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(); + }); +}); diff --git a/tests/e2e/12a-reset-password-redirect.spec.mjs b/tests/e2e/12a-reset-password-redirect.spec.mjs new file mode 100644 index 0000000..dff4b39 --- /dev/null +++ b/tests/e2e/12a-reset-password-redirect.spec.mjs @@ -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 +}); diff --git a/tests/e2e/13-premium-route-guard.spec.mjs b/tests/e2e/13-premium-route-guard.spec.mjs new file mode 100644 index 0000000..d2a8ffe --- /dev/null +++ b/tests/e2e/13-premium-route-guard.spec.mjs @@ -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 }); + }); +}); diff --git a/tests/e2e/14-session-expired-handler.spec.mjs b/tests/e2e/14-session-expired-handler.spec.mjs new file mode 100644 index 0000000..a86a2c8 --- /dev/null +++ b/tests/e2e/14-session-expired-handler.spec.mjs @@ -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 }); + }); +}); diff --git a/tests/e2e/15-support-modal.spec.mjs b/tests/e2e/15-support-modal.spec.mjs new file mode 100644 index 0000000..f3c1453 --- /dev/null +++ b/tests/e2e/15-support-modal.spec.mjs @@ -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 }); + }); +}); diff --git a/tests/e2e/16-paywall-cta.spec.mjs b/tests/e2e/16-paywall-cta.spec.mjs new file mode 100644 index 0000000..c4f77bc --- /dev/null +++ b/tests/e2e/16-paywall-cta.spec.mjs @@ -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 }); + }); +}); diff --git a/tests/e2e/17-support-submit.spec.mjs b/tests/e2e/17-support-submit.spec.mjs new file mode 100644 index 0000000..ff47059 --- /dev/null +++ b/tests/e2e/17-support-submit.spec.mjs @@ -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(); + }); +}); \ No newline at end of file diff --git a/tests/e2e/18-nav-menus.spec.mjs b/tests/e2e/18-nav-menus.spec.mjs new file mode 100644 index 0000000..817028a --- /dev/null +++ b/tests/e2e/18-nav-menus.spec.mjs @@ -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 }); + }); +}); diff --git a/tests/e2e/19-profile-gating.spec.mjs b/tests/e2e/19-profile-gating.spec.mjs new file mode 100644 index 0000000..4c68ee6 --- /dev/null +++ b/tests/e2e/19-profile-gating.spec.mjs @@ -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 }); + }); +}); diff --git a/tests/e2e/20-support-rate-limit.spec.mjs b/tests/e2e/20-support-rate-limit.spec.mjs new file mode 100644 index 0000000..47d9de3 --- /dev/null +++ b/tests/e2e/20-support-rate-limit.spec.mjs @@ -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(); + }); +}); diff --git a/tests/e2e/21-support-auth-dedupe.spec.mjs b/tests/e2e/21-support-auth-dedupe.spec.mjs new file mode 100644 index 0000000..4e2f91d --- /dev/null +++ b/tests/e2e/21-support-auth-dedupe.spec.mjs @@ -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); + }); +}); diff --git a/tests/e2e/22-educational-programs.spec.mjs b/tests/e2e/22-educational-programs.spec.mjs new file mode 100644 index 0000000..9fb682d --- /dev/null +++ b/tests/e2e/22-educational-programs.spec.mjs @@ -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 }); + }); +}); \ No newline at end of file diff --git a/tests/e2e/23-educational-programs-select-school.spec.mjs b/tests/e2e/23-educational-programs-select-school.spec.mjs new file mode 100644 index 0000000..9d67484 --- /dev/null +++ b/tests/e2e/23-educational-programs-select-school.spec.mjs @@ -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 }); + }); +}); diff --git a/tests/e2e/24-chat-support-drawer.spec.mjs b/tests/e2e/24-chat-support-drawer.spec.mjs new file mode 100644 index 0000000..25bbe6e --- /dev/null +++ b/tests/e2e/24-chat-support-drawer.spec.mjs @@ -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 }); + }); +}); diff --git a/tests/e2e/25-chat-retire-tab-bounce.spec.mjs b/tests/e2e/25-chat-retire-tab-bounce.spec.mjs new file mode 100644 index 0000000..b86dbf8 --- /dev/null +++ b/tests/e2e/25-chat-retire-tab-bounce.spec.mjs @@ -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(); + }); +}); diff --git a/tests/e2e/26-chat-retire-tab-visibility.spec.mjs b/tests/e2e/26-chat-retire-tab-visibility.spec.mjs new file mode 100644 index 0000000..d04bc68 --- /dev/null +++ b/tests/e2e/26-chat-retire-tab-visibility.spec.mjs @@ -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(); + } + }); +}); diff --git a/tests/e2e/27-chat-support-stream-chunking.spec.mjs b/tests/e2e/27-chat-support-stream-chunking.spec.mjs new file mode 100644 index 0000000..1e47378 --- /dev/null +++ b/tests/e2e/27-chat-support-stream-chunking.spec.mjs @@ -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.'); + }); +}); diff --git a/tests/e2e/28-chat-support-throttle.spec.mjs b/tests/e2e/28-chat-support-throttle.spec.mjs new file mode 100644 index 0000000..8333ecb --- /dev/null +++ b/tests/e2e/28-chat-support-throttle.spec.mjs @@ -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 }); + }); +}); diff --git a/tests/e2e/29-career-explorer-filters.spec.mjs b/tests/e2e/29-career-explorer-filters.spec.mjs new file mode 100644 index 0000000..50a2e02 --- /dev/null +++ b/tests/e2e/29-career-explorer-filters.spec.mjs @@ -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); + }); +}); diff --git a/tests/e2e/30-profile-account-jitpii.spec.mjs b/tests/e2e/30-profile-account-jitpii.spec.mjs new file mode 100644 index 0000000..5604536 --- /dev/null +++ b/tests/e2e/30-profile-account-jitpii.spec.mjs @@ -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(); + }); +}); diff --git a/tests/e2e/31-logout-clears-caches.spec.mjs b/tests/e2e/31-logout-clears-caches.spec.mjs new file mode 100644 index 0000000..0f0f587 --- /dev/null +++ b/tests/e2e/31-logout-clears-caches.spec.mjs @@ -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(); + }); +}); diff --git a/tests/e2e/32-chat-support-thread-persist.spec.mjs b/tests/e2e/32-chat-support-thread-persist.spec.mjs new file mode 100644 index 0000000..3d013f1 --- /dev/null +++ b/tests/e2e/32-chat-support-thread-persist.spec.mjs @@ -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 }); + }); +}); diff --git a/tests/e2e/33-billing-portal-flow.spec.mjs b/tests/e2e/33-billing-portal-flow.spec.mjs new file mode 100644 index 0000000..d2b1fb6 --- /dev/null +++ b/tests/e2e/33-billing-portal-flow.spec.mjs @@ -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); + }); +}); diff --git a/tests/e2e/34-profile-areas-load.spec.mjs b/tests/e2e/34-profile-areas-load.spec.mjs new file mode 100644 index 0000000..9885059 --- /dev/null +++ b/tests/e2e/34-profile-areas-load.spec.mjs @@ -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')); + }); +}); diff --git a/tests/e2e/35-careersearech-arrowdown-enter.spec.mjs b/tests/e2e/35-careersearech-arrowdown-enter.spec.mjs new file mode 100644 index 0000000..5da862b --- /dev/null +++ b/tests/e2e/35-careersearech-arrowdown-enter.spec.mjs @@ -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'); + }); +}); diff --git a/tests/e2e/36-profile-change-password-toggle.spec.mjs b/tests/e2e/36-profile-change-password-toggle.spec.mjs new file mode 100644 index 0000000..ada917f --- /dev/null +++ b/tests/e2e/36-profile-change-password-toggle.spec.mjs @@ -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 }); + }); +}); diff --git a/tests/e2e/37-resume-optimizer-guard.spec.mjs b/tests/e2e/37-resume-optimizer-guard.spec.mjs new file mode 100644 index 0000000..d75e4df --- /dev/null +++ b/tests/e2e/37-resume-optimizer-guard.spec.mjs @@ -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 }); + }); +}); diff --git a/tests/e2e/38-educational-programs-sorting.spec.mjs b/tests/e2e/38-educational-programs-sorting.spec.mjs new file mode 100644 index 0000000..ab5d322 --- /dev/null +++ b/tests/e2e/38-educational-programs-sorting.spec.mjs @@ -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); + } + }); +}); diff --git a/tests/e2e/39-inventory-prefill-from-profile.spec.mjs b/tests/e2e/39-inventory-prefill-from-profile.spec.mjs new file mode 100644 index 0000000..169992e --- /dev/null +++ b/tests/e2e/39-inventory-prefill-from-profile.spec.mjs @@ -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 }); + }); +}); diff --git a/tests/e2e/40-educational-programs-distance-filter.spec.mjs b/tests/e2e/40-educational-programs-distance-filter.spec.mjs new file mode 100644 index 0000000..697edb0 --- /dev/null +++ b/tests/e2e/40-educational-programs-distance-filter.spec.mjs @@ -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(); + }); +}); diff --git a/tests/e2e/41-profile-change-password-submit.spec.mjs b/tests/e2e/41-profile-change-password-submit.spec.mjs new file mode 100644 index 0000000..37f6e3e --- /dev/null +++ b/tests/e2e/41-profile-change-password-submit.spec.mjs @@ -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(); + }); +}); diff --git a/tests/e2e/42-billing-refresh-status.spec.mjs b/tests/e2e/42-billing-refresh-status.spec.mjs new file mode 100644 index 0000000..5c76a09 --- /dev/null +++ b/tests/e2e/42-billing-refresh-status.spec.mjs @@ -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(); + }); +}); diff --git a/tests/e2e/43-billing-paywall.spec.mjs b/tests/e2e/43-billing-paywall.spec.mjs new file mode 100644 index 0000000..11a92f6 --- /dev/null +++ b/tests/e2e/43-billing-paywall.spec.mjs @@ -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$/); + } + }); +}); diff --git a/tests/e2e/44-onboarding-e2e.spec.mjs b/tests/e2e/44-onboarding-e2e.spec.mjs new file mode 100644 index 0000000..ddc7be6 --- /dev/null +++ b/tests/e2e/44-onboarding-e2e.spec.mjs @@ -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 }); + }); +}); diff --git a/tests/e2e/46-profile-editors.spec.mjs b/tests/e2e/46-profile-editors.spec.mjs new file mode 100644 index 0000000..c3f59e0 --- /dev/null +++ b/tests/e2e/46-profile-editors.spec.mjs @@ -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(() => {}); +}); +}); diff --git a/tests/e2e/47-career-roadmap-and-coach.spec.mjs b/tests/e2e/47-career-roadmap-and-coach.spec.mjs new file mode 100644 index 0000000..3e74503 --- /dev/null +++ b/tests/e2e/47-career-roadmap-and-coach.spec.mjs @@ -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 }); + }); +}); diff --git a/tests/e2e/48-milestones-crud.spec.mjs b/tests/e2e/48-milestones-crud.spec.mjs new file mode 100644 index 0000000..fdce765 --- /dev/null +++ b/tests/e2e/48-milestones-crud.spec.mjs @@ -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(); + }); +}); diff --git a/tests/e2e/49-coach-quick-actions.spec.mjs b/tests/e2e/49-coach-quick-actions.spec.mjs new file mode 100644 index 0000000..e3de25e --- /dev/null +++ b/tests/e2e/49-coach-quick-actions.spec.mjs @@ -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); + }); +}); diff --git a/tests/e2e/50-premium-routes-unlock.spec.mjs b/tests/e2e/50-premium-routes-unlock.spec.mjs new file mode 100644 index 0000000..b1f8b05 --- /dev/null +++ b/tests/e2e/50-premium-routes-unlock.spec.mjs @@ -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); + }); +}); diff --git a/tests/e2e/51-premium-refresh-to-free-locks.spec.mjs b/tests/e2e/51-premium-refresh-to-free-locks.spec.mjs new file mode 100644 index 0000000..30f0157 --- /dev/null +++ b/tests/e2e/51-premium-refresh-to-free-locks.spec.mjs @@ -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); + }); +}); diff --git a/tests/e2e/98-security-basics.spec.mjs b/tests/e2e/98-security-basics.spec.mjs new file mode 100644 index 0000000..5428dc7 --- /dev/null +++ b/tests/e2e/98-security-basics.spec.mjs @@ -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']); + }); +}); diff --git a/tests/e2e/99-perf-budgets.spec.mjs b/tests/e2e/99-perf-budgets.spec.mjs new file mode 100644 index 0000000..56895e2 --- /dev/null +++ b/tests/e2e/99-perf-budgets.spec.mjs @@ -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); + }); +}); diff --git a/tests/e2e/fixtures.mjs b/tests/e2e/fixtures.mjs new file mode 100644 index 0000000..65b6652 --- /dev/null +++ b/tests/e2e/fixtures.mjs @@ -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 }; +} diff --git a/tests/smoke.sh b/tests/smoke.sh old mode 100644 new mode 100755 index 9392dc8..377ec86 --- a/tests/smoke.sh +++ b/tests/smoke.sh @@ -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" diff --git a/tests/utils/testUser.js b/tests/utils/testUser.js new file mode 100644 index 0000000..8087c5f --- /dev/null +++ b/tests/utils/testUser.js @@ -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')); +}