384 lines
17 KiB
JavaScript
384 lines
17 KiB
JavaScript
// @ts-nocheck
|
|
import { test, expect } from '@playwright/test';
|
|
import { loadTestUser } from '../utils/testUser.js';
|
|
|
|
const j = (o) => JSON.stringify(o);
|
|
|
|
/** Safe helpers for @ts-check */
|
|
/** @param {string} url */
|
|
const lastNum = (url) => {
|
|
const m = url.match(/(\d+)$/);
|
|
return m ? Number(m[1]) : 0;
|
|
};
|
|
/** @param {string} url @param {string} key */
|
|
const getNumParam = (url, key) => {
|
|
try {
|
|
const u = new URL(url);
|
|
const v = u.searchParams.get(key);
|
|
return Number(v || '0');
|
|
} catch {
|
|
return 0;
|
|
}
|
|
};
|
|
|
|
test.describe('@p1 Milestones — CRUD + Drawer (48)', () => {
|
|
test.setTimeout(22000);
|
|
|
|
test('Create, edit, toggle task in drawer, and delete milestone', async ({ page, request }) => {
|
|
const u = loadTestUser();
|
|
|
|
// ── Premium gate
|
|
await page.route(
|
|
/\/api\/user-profile\?fields=.*(firstname|is_premium|is_pro_premium|area|state).*/i,
|
|
r => r.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: j({
|
|
firstname: 'Tester',
|
|
is_premium: 1,
|
|
is_pro_premium: 0,
|
|
area: 'Atlanta-Sandy Springs-Roswell, GA',
|
|
state: 'GA'
|
|
})
|
|
})
|
|
);
|
|
|
|
// ── Sign in (no storage-state bypass)
|
|
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: 15000 });
|
|
|
|
// ── Seed scenario
|
|
const scen = await request.post('/api/premium/career-profile', {
|
|
data: { career_name: 'Software Developers', status: 'planned', start_date: '2025-01-01' }
|
|
});
|
|
const { career_profile_id } = await scen.json();
|
|
|
|
// ── Minimal background routes for CareerRoadmap
|
|
await page.route(new RegExp(`/api/premium/career-profile/${career_profile_id}$`, 'i'), r =>
|
|
r.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: j({
|
|
id: career_profile_id,
|
|
career_name: 'Software Developers',
|
|
scenario_title: 'Software Developers',
|
|
soc_code: '15-1252.00',
|
|
start_date: '2025-01-01',
|
|
college_enrollment_status: 'not_enrolled'
|
|
})
|
|
})
|
|
);
|
|
|
|
await page.route(/\/api\/premium\/financial-profile$/i, r => r.fulfill({
|
|
status: 200, contentType: 'application/json',
|
|
body: j({
|
|
current_salary: 90000,
|
|
additional_income: 0,
|
|
monthly_expenses: 2500,
|
|
monthly_debt_payments: 300,
|
|
retirement_savings: 10000,
|
|
emergency_fund: 3000,
|
|
retirement_contribution: 300,
|
|
emergency_contribution: 200,
|
|
extra_cash_emergency_pct: 50,
|
|
extra_cash_retirement_pct: 50
|
|
})
|
|
}));
|
|
|
|
await page.route(new RegExp(`/api/premium/college-profile\\?careerProfileId=${career_profile_id}$`, 'i'), r =>
|
|
r.fulfill({
|
|
status: 200, contentType: 'application/json',
|
|
body: j({
|
|
college_enrollment_status: 'not_enrolled',
|
|
existing_college_debt: 0,
|
|
interest_rate: 5,
|
|
loan_term: 10,
|
|
loan_deferral_until_graduation: 0,
|
|
academic_calendar: 'monthly',
|
|
annual_financial_aid: 0,
|
|
tuition: 0,
|
|
extra_payment: 0,
|
|
expected_salary: 95000
|
|
})
|
|
})
|
|
);
|
|
|
|
await page.route(/\/api\/salary\?*/i, r => r.fulfill({
|
|
status: 200, contentType: 'application/json',
|
|
body: j({
|
|
regional: { regional_PCT10: 60000, regional_MEDIAN: 100000, regional_PCT90: 160000 },
|
|
national: { national_PCT10: 55000, national_MEDIAN: 98000, national_PCT90: 155000 }
|
|
})
|
|
}));
|
|
|
|
await page.route(/\/api\/projections\/15-1252\?state=.*/i, r => r.fulfill({
|
|
status: 200, contentType: 'application/json',
|
|
body: j({
|
|
state: { area: 'Georgia', baseYear: 2022, projectedYear: 2032, base: 45000, projection: 52000, change: 7000, annualOpenings: 3800, occupationName: 'Software Developers' },
|
|
national: { area: 'United States', baseYear: 2022, projectedYear: 2032, base: 1630000, projection: 1810000, change: 180000, annualOpenings: 153000, occupationName: 'Software Developers' }
|
|
})
|
|
}));
|
|
|
|
// ── In-memory milestone state for routes
|
|
let createdMilestoneId = 99901;
|
|
const existingMilestoneId = 88801;
|
|
const existingTaskId = 50001;
|
|
|
|
/** @type {{id:number,title:string,description:string,date:string,progress:number,status:string}[]} */
|
|
let milestoneList = [
|
|
{ id: existingMilestoneId, title: 'Promotion Planning', description: 'Prepare for next level', date: '2026-04-01', progress: 0, status: 'planned' }
|
|
];
|
|
/** @type {Record<number, Array<any>>} */
|
|
let tasksByMilestone = {
|
|
[existingMilestoneId]: [
|
|
{ id: existingTaskId, title: 'Draft self-review', description: 'Write draft', due_date: '2026-03-15', status: 'not_started' }
|
|
],
|
|
[createdMilestoneId]: []
|
|
};
|
|
/** @type {Record<number, Array<any>>} */
|
|
let impactsByMilestone = {
|
|
[existingMilestoneId]: [],
|
|
[createdMilestoneId]: []
|
|
};
|
|
|
|
// GET milestones
|
|
await page.route(new RegExp(`/api/premium/milestones\\?careerProfileId=${career_profile_id}$`, 'i'), r =>
|
|
r.fulfill({ status: 200, contentType: 'application/json', body: j({ milestones: milestoneList }) })
|
|
);
|
|
|
|
// GET impacts/tasks
|
|
await page.route(/\/api\/premium\/milestone-impacts\?milestone_id=\d+$/i, r => {
|
|
const mid = Number(new URL(r.request().url()).searchParams.get('milestone_id'));
|
|
r.fulfill({ status: 200, contentType: 'application/json', body: j({ impacts: impactsByMilestone[mid] || [] }) });
|
|
});
|
|
await page.route(/\/api\/premium\/tasks\?milestone_id=\d+$/i, r => {
|
|
const mid = Number(new URL(r.request().url()).searchParams.get('milestone_id'));
|
|
r.fulfill({ status: 200, contentType: 'application/json', body: j({ tasks: tasksByMilestone[mid] || [] }) });
|
|
});
|
|
|
|
// CREATE milestone
|
|
await page.route(/\/api\/premium\/milestone$/i, async r => {
|
|
if (r.request().method() !== 'POST') return r.fallback();
|
|
const body = await r.request().postDataJSON();
|
|
milestoneList = milestoneList.concat([{
|
|
id: createdMilestoneId,
|
|
title: body.title,
|
|
description: body.description || '',
|
|
date: body.date || '2026-05-01',
|
|
progress: body.progress || 0,
|
|
status: body.status || 'planned'
|
|
}]);
|
|
return r.fulfill({ status: 200, contentType: 'application/json', body: j({ id: createdMilestoneId }) });
|
|
});
|
|
|
|
// CREATE impact / task
|
|
await page.route(/\/api\/premium\/milestone-impacts$/i, async r => {
|
|
if (r.request().method() !== 'POST') return r.fallback();
|
|
const body = await r.request().postDataJSON();
|
|
const mid = Number(body.milestone_id);
|
|
const newId = Math.floor(Math.random() * 100000) + 70000;
|
|
impactsByMilestone[mid] = (impactsByMilestone[mid] || []).concat([{ id: newId, ...body }]);
|
|
return r.fulfill({ status: 200, contentType: 'application/json', body: j({ id: newId }) });
|
|
});
|
|
await page.route(/\/api\/premium\/tasks$/i, async r => {
|
|
if (r.request().method() !== 'POST') return r.fallback();
|
|
const body = await r.request().postDataJSON();
|
|
const mid = Number(body.milestone_id);
|
|
const newId = Math.floor(Math.random() * 100000) + 80000;
|
|
tasksByMilestone[mid] = (tasksByMilestone[mid] || []).concat([{ id: newId, ...body }]);
|
|
return r.fulfill({ status: 200, contentType: 'application/json', body: j({ id: newId }) });
|
|
});
|
|
|
|
// UPDATE milestone
|
|
await page.route(/\/api\/premium\/milestones\/\d+$/i, async r => {
|
|
if (r.request().method() !== 'PUT') return r.fallback();
|
|
const id = lastNum(r.request().url());
|
|
const body = await r.request().postDataJSON();
|
|
milestoneList = milestoneList.map(m => (m.id === id ? { ...m, ...body, id } : m));
|
|
return r.fulfill({ status: 200, contentType: 'application/json', body: j({ id }) });
|
|
});
|
|
|
|
// UPDATE/DELETE impacts
|
|
await page.route(/\/api\/premium\/milestone-impacts\/\d+$/i, async r => {
|
|
const id = lastNum(r.request().url());
|
|
if (r.request().method() === 'PUT') {
|
|
const body = await r.request().postDataJSON();
|
|
const mid = getNumParam(r.request().url(), 'milestone_id');
|
|
impactsByMilestone[mid] = (impactsByMilestone[mid] || []).map(i => (i.id === id ? { ...i, ...body, id } : i));
|
|
return r.fulfill({ status: 200, body: '{}' });
|
|
}
|
|
if (r.request().method() === 'DELETE') {
|
|
for (const mid of Object.keys(impactsByMilestone)) {
|
|
impactsByMilestone[mid] = impactsByMilestone[mid].filter(i => i.id !== id);
|
|
}
|
|
return r.fulfill({ status: 200, body: '{}' });
|
|
}
|
|
return r.fallback();
|
|
});
|
|
|
|
// UPDATE/DELETE tasks
|
|
await page.route(/\/api\/premium\/tasks\/\d+$/i, async r => {
|
|
const id = lastNum(r.request().url());
|
|
if (r.request().method() === 'PUT') {
|
|
const body = await r.request().postDataJSON();
|
|
for (const mid of Object.keys(tasksByMilestone)) {
|
|
tasksByMilestone[mid] = tasksByMilestone[mid].map(t => (t.id === id ? { ...t, ...body, id } : t));
|
|
}
|
|
return r.fulfill({ status: 200, body: '{}' });
|
|
}
|
|
if (r.request().method() === 'DELETE') {
|
|
for (const mid of Object.keys(tasksByMilestone)) {
|
|
tasksByMilestone[mid] = tasksByMilestone[mid].filter(t => t.id !== id);
|
|
}
|
|
return r.fulfill({ status: 200, body: '{}' });
|
|
}
|
|
return r.fallback();
|
|
});
|
|
|
|
// DELETE milestone
|
|
await page.route(/\/api\/premium\/milestones\/\d+$/i, async r => {
|
|
if (r.request().method() !== 'DELETE') return r.fallback();
|
|
const id = lastNum(r.request().url());
|
|
milestoneList = milestoneList.filter(m => m.id !== id);
|
|
delete impactsByMilestone[id];
|
|
delete tasksByMilestone[id];
|
|
return r.fulfill({ status: 200, body: '{}' });
|
|
});
|
|
|
|
// ── Navigate
|
|
await page.goto(`/career-roadmap/${career_profile_id}`, { waitUntil: 'domcontentloaded' });
|
|
|
|
// Milestones section
|
|
const msHeader = page.getByRole('heading', { name: /Milestones/i }).first();
|
|
await msHeader.scrollIntoViewIfNeeded();
|
|
await expect(msHeader).toBeVisible({ timeout: 6000 });
|
|
|
|
// ===== CREATE =====
|
|
await page.getByRole('button', { name: /^\+ Add Milestone$/ }).click();
|
|
const modalHeader = page.getByRole('heading', { name: /^Milestones$/i }).first();
|
|
await expect(modalHeader).toBeVisible({ timeout: 6000 });
|
|
const modalRoot = modalHeader.locator('xpath=ancestor::div[contains(@class,"max-w-3xl")]').first();
|
|
|
|
// Open <summary>Add new milestone</summary> via DOM (robust)
|
|
const rootHandle = await modalRoot.elementHandle();
|
|
if (rootHandle) {
|
|
await rootHandle.evaluate((root) => {
|
|
const summaries = Array.from(root.querySelectorAll('summary'));
|
|
const target = summaries.find(s => (s.textContent || '').trim().toLowerCase().startsWith('add new milestone'));
|
|
if (target) (target).click();
|
|
});
|
|
}
|
|
const newSection = modalRoot.locator('summary:has-text("Add new milestone") + div').first();
|
|
await expect(newSection.locator('label:has-text("Title")')).toBeVisible({ timeout: 8000 });
|
|
|
|
// Fill create fields
|
|
await newSection.locator('label:has-text("Title")').locator('..').locator('input').first()
|
|
.fill('Launch Portfolio Website');
|
|
|
|
const createDate = newSection.locator('label:has-text("Date")').locator('..').locator('input').first();
|
|
const createDateType = (await createDate.getAttribute('type')) || '';
|
|
if (createDateType.toLowerCase() === 'date') {
|
|
await createDate.fill('2026-05-01');
|
|
} else {
|
|
await createDate.fill('');
|
|
await createDate.type('05/01/2026', { delay: 10 });
|
|
}
|
|
|
|
await newSection.locator('label:has-text("Description")').locator('..').locator('textarea').first()
|
|
.fill('Ship v1 and announce.');
|
|
|
|
// Impact (salary)
|
|
await newSection.getByRole('button', { name: /^\+ Add impact$/ }).click();
|
|
await newSection.locator('label:has-text("Type")').locator('..').locator('select').last().selectOption('salary');
|
|
await newSection.locator('label:has-text("Amount")').locator('..').locator('input[type="number"]').last().fill('12000');
|
|
await newSection.locator('label:has-text("Start")').locator('..').locator('input[type="date"]').last().fill('2026-05-01');
|
|
|
|
// One task
|
|
await newSection.getByRole('button', { name: /^\+ Add task$/ }).click();
|
|
await newSection.locator('label:has-text("Title")').last().locator('..').locator('input').fill('Buy domain');
|
|
await newSection.locator('label:has-text("Description")').last().locator('..').locator('input').fill('Pick .com');
|
|
await newSection.locator('label:has-text("Due Date")').last().locator('..').locator('input[type="date"]').fill('2026-04-15');
|
|
await newSection.locator('label:has-text("Status")').last().locator('..').locator('select').selectOption('not_started');
|
|
|
|
// Save
|
|
await modalRoot.getByRole('button', { name: /^Save milestone$/i }).click();
|
|
|
|
// Ensure listed in panel and visible — expand the exact <details> that owns the row
|
|
const createdRowText = page.getByText('Launch Portfolio Website').first();
|
|
const createdDetails = createdRowText.locator('xpath=ancestor::details[1]');
|
|
await createdDetails.locator('summary').first().click().catch(() => {});
|
|
await expect(createdRowText).toBeVisible({ timeout: 8000 });
|
|
|
|
// ===== EDIT =====
|
|
// Select row (visible under May 2026) and click pencil to edit
|
|
const row = page.getByText('Launch Portfolio Website').first().locator('xpath=ancestor::li[1]');
|
|
await expect(row).toBeVisible({ timeout: 4000 });
|
|
await row.getByRole('button', { name: 'Edit milestone' }).click();
|
|
|
|
const editModal = page.getByRole('heading', { name: /^Milestones$/i })
|
|
.first().locator('xpath=ancestor::div[contains(@class,"max-w-3xl")]').first();
|
|
|
|
// Title & Date at top of expanded block
|
|
await editModal.locator('label:has-text("Title")').locator('..').locator('input').first()
|
|
.fill('Launch Portfolio Website v2');
|
|
|
|
const editDate = editModal.locator('label:has-text("Date")').locator('..').locator('input').first();
|
|
const editType = (await editDate.getAttribute('type')) || '';
|
|
if (editType.toLowerCase() === 'date') {
|
|
await editDate.fill('2026-06-01');
|
|
} else {
|
|
await editDate.fill('');
|
|
await editDate.type('06/01/2026', { delay: 10 });
|
|
}
|
|
|
|
// Add another impact (Monthly)
|
|
await editModal.getByRole('button', { name: /\+ Add impact/ }).first().click();
|
|
const typeSelects = editModal.locator('label:has-text("Type")').locator('..').locator('select');
|
|
await typeSelects.last().selectOption('MONTHLY');
|
|
await editModal.locator('label:has-text("Amount")').locator('..').locator('input[type="number"]').last().fill('150');
|
|
await editModal.locator('label:has-text("Start")').locator('..').locator('input[type="date"]').last().fill('2026-06-01');
|
|
await editModal.locator('label:has-text("End")').locator('..').locator('input[type="date"]').last().fill('2026-12-01');
|
|
|
|
// Add another task
|
|
await editModal.getByRole('button', { name: /\+ Add task/ }).first().click();
|
|
await editModal.locator('label:has-text("Title")').locator('..').locator('input').last()
|
|
.fill('Publish announcement');
|
|
|
|
// Save edits
|
|
await editModal.getByRole('button', { name: /^Save$/ }).click();
|
|
|
|
// Bring Milestones back into view (modal is closed now)
|
|
const msHeader2 = page.getByRole('heading', { name: /Milestones/i }).first();
|
|
await msHeader2.scrollIntoViewIfNeeded();
|
|
await expect(msHeader2).toBeVisible({ timeout: 4000 });
|
|
|
|
// Expand the exact <details> that contains the edited row
|
|
const editedRowText = page.getByText('Launch Portfolio Website v2').first();
|
|
const editedDetails = editedRowText.locator('xpath=ancestor::details[1]');
|
|
await editedDetails.locator('summary').first().click().catch(() => {});
|
|
const rowV2Panel = editedRowText.locator('xpath=ancestor::li[1]');
|
|
await expect(rowV2Panel).toBeVisible({ timeout: 6000 });
|
|
// ===== DRAWER: open (no task assertions here — drawer-only smoke) =====
|
|
await rowV2Panel.click(); // opens drawer
|
|
const drawer = page.locator('.fixed.inset-y-0.right-0').first();
|
|
await expect(drawer).toBeVisible({ timeout: 6000 });
|
|
// Close drawer (back chevron = first button in header)
|
|
await drawer.locator('button').first().click();
|
|
// ===== DELETE =====
|
|
await rowV2Panel.getByRole('button', { name: 'Edit milestone' }).click();
|
|
|
|
const delModal = page.getByRole('heading', { name: /^Milestones$/i }).first()
|
|
.locator('xpath=ancestor::div[contains(@class,"max-w-3xl")]').first();
|
|
|
|
// Confirm delete
|
|
page.once('dialog', d => d.accept().catch(() => {}));
|
|
await delModal.getByRole('button', { name: /^Delete milestone$/ }).click();
|
|
|
|
await expect(page.getByText('Launch Portfolio Website v2')).toHaveCount(0, { timeout: 8000 });
|
|
});
|
|
});
|