dev1/tests/e2e/48-milestones-crud.spec.mjs
2025-09-18 13:26:16 +00:00

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 });
});
});