test(release): strengthen production verification
This commit is contained in:
@@ -1,3 +1,42 @@
|
|||||||
|
# Active implementation checklist — 2026-08-24
|
||||||
|
|
||||||
|
## Current
|
||||||
|
|
||||||
|
- [ ] Deploy the verified commits through the protected `main` workflow.
|
||||||
|
|
||||||
|
## Next
|
||||||
|
|
||||||
|
- [ ] Verify the live health/version endpoints and production UI after deployment.
|
||||||
|
|
||||||
|
## Completed
|
||||||
|
|
||||||
|
- [x] Revoke all sessions and trusted devices after administrator role mutations.
|
||||||
|
- [x] Isolate saved views, profile headlines and company caches by authenticated account.
|
||||||
|
- [x] Prevent stale resource requests from overwriting newer account/filter results.
|
||||||
|
- [x] Stream and cap remote job-import and CV summarizer inputs before parsing.
|
||||||
|
- [x] Apply workflow-readiness filters before pagination on the server.
|
||||||
|
- [x] Repair the CV builder unsaved-change navigation regression.
|
||||||
|
- [x] Restore the correspondence inbox to primary navigation and fix connected-account route metadata.
|
||||||
|
- [x] Bring recruiter status suggestions and durable strategy snapshots into the live application workspace.
|
||||||
|
- [x] Replace local-only notification toggles with a persisted per-user email reminder preference enforced by the worker.
|
||||||
|
- [x] Rebuild the CV editor as a responsive document studio with professional toolbar, autosave retry state, undo/redo and mobile edit/preview panes.
|
||||||
|
- [x] Add normalized colour, typography, spacing, page, date, language, skills and one/two-column layout controls shared by preview and PDF rendering.
|
||||||
|
- [x] Add real A4/Letter page stacks, overflow detection, fit/zoom/page navigation and long-content pagination safeguards.
|
||||||
|
- [x] Expand the structured master profile with GitHub/custom links and English/Norwegian labels/date rendering.
|
||||||
|
- [x] Upgrade CV management with a guided create flow, rendered template previews, useful job metadata, rename, duplicate, download and confirmed delete actions.
|
||||||
|
- [x] Add eight distinct data-driven templates, including ATS-safe, compact/two-column and photo-capable designs without duplicating renderer implementations.
|
||||||
|
- [x] Add explicit-review AI writing suggestions and one-click job-specific CV duplication/association.
|
||||||
|
- [x] Enforce server-side ownership for CV/job associations and revoke sessions after administrator role changes.
|
||||||
|
- [x] Make the standalone TypeScript check pass and remove obsolete React Router test flags.
|
||||||
|
- [x] Pass all 61 frontend suites (246 tests), standalone TypeScript, optimized production build, Python syntax, Compose config and diff checks.
|
||||||
|
- [x] Install .NET SDK 9.0.317 and configure the repository Git author from its established commit history.
|
||||||
|
- [x] Pass all 692 backend tests, 24 AI-service tests, 9 Playwright flows, dependency audits and all three production Docker image builds.
|
||||||
|
- [x] Reduce the frontend Docker build context from roughly 662 MB to about 10 KB by excluding generated artifacts.
|
||||||
|
- [x] Commit the release in three logical, fully verified batches.
|
||||||
|
- [x] Update CV architecture and verification notes with the rebuilt workflow and remaining environment gates.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
Continue working on JobTracker using the completed audit under `docs/audits/`.
|
Continue working on JobTracker using the completed audit under `docs/audits/`.
|
||||||
|
|
||||||
This is now an implementation task. Read all audit reports, evidence, repository instructions, architecture documentation, and the current git status before changing code.
|
This is now an implementation task. Read all audit reports, evidence, repository instructions, architecture documentation, and the current git status before changing code.
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
node_modules
|
node_modules
|
||||||
build
|
build
|
||||||
|
.next
|
||||||
|
out
|
||||||
|
coverage
|
||||||
|
test-results
|
||||||
|
playwright-report
|
||||||
.git
|
.git
|
||||||
.vscode
|
.vscode
|
||||||
npm-debug.log
|
npm-debug.log
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ test.beforeEach(async ({ page }) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("discovery remains usable across widths and opens the reviewed import", async ({ page }) => {
|
test("discovery remains usable across widths and opens the reviewed import", async ({ page }) => {
|
||||||
|
test.setTimeout(60_000);
|
||||||
await page.route("**/api/job-discovery/search**", async (route) => {
|
await page.route("**/api/job-discovery/search**", async (route) => {
|
||||||
await route.fulfill({ json: [
|
await route.fulfill({ json: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ describe("LandingPage forwards deep-link intent to /login", () => {
|
|||||||
mockedApi.get.mockRejectedValueOnce(new Error("401")); // /auth/me: not signed in
|
mockedApi.get.mockRejectedValueOnce(new Error("401")); // /auth/me: not signed in
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<MemoryRouter initialEntries={[{ pathname: "/", state: { from: "/jobs/42" } }]} future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
|
<MemoryRouter initialEntries={[{ pathname: "/", state: { from: "/jobs/42" } }]}>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<LandingPage />} />
|
<Route path="/" element={<LandingPage />} />
|
||||||
<Route path="/login" element={<LoginStub />} />
|
<Route path="/login" element={<LoginStub />} />
|
||||||
|
|||||||
@@ -232,6 +232,7 @@ test("hydrates list filters, sort and page from a shareable URL", async () => {
|
|||||||
companyId: 1,
|
companyId: 1,
|
||||||
location: "Oslo",
|
location: "Oslo",
|
||||||
needsFollowUp: true,
|
needsFollowUp: true,
|
||||||
|
readiness: "interview",
|
||||||
includeDeleted: true,
|
includeDeleted: true,
|
||||||
sortBy: "company",
|
sortBy: "company",
|
||||||
sortDir: "asc",
|
sortDir: "asc",
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ function renderPage() {
|
|||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
<ConfirmProvider>
|
<ConfirmProvider>
|
||||||
<I18nProvider>
|
<I18nProvider>
|
||||||
<MemoryRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
|
<MemoryRouter>
|
||||||
<CorrespondenceInboxPage />
|
<CorrespondenceInboxPage />
|
||||||
</MemoryRouter>
|
</MemoryRouter>
|
||||||
</I18nProvider>
|
</I18nProvider>
|
||||||
@@ -480,7 +480,7 @@ describe('CorrespondenceInboxPage', () => {
|
|||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
<ConfirmProvider>
|
<ConfirmProvider>
|
||||||
<I18nProvider>
|
<I18nProvider>
|
||||||
<MemoryRouter initialEntries={['/correspondence/review']} future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
|
<MemoryRouter initialEntries={['/correspondence/review']}>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/correspondence/review" element={<Navigate to="/correspondence?view=review" replace />} />
|
<Route path="/correspondence/review" element={<Navigate to="/correspondence?view=review" replace />} />
|
||||||
<Route path="/correspondence" element={<CorrespondenceInboxPage />} />
|
<Route path="/correspondence" element={<CorrespondenceInboxPage />} />
|
||||||
|
|||||||
@@ -163,7 +163,7 @@ function renderLoop(initialPath: string) {
|
|||||||
<I18nProvider>
|
<I18nProvider>
|
||||||
<ConfirmProvider>
|
<ConfirmProvider>
|
||||||
<PromptProvider>
|
<PromptProvider>
|
||||||
<MemoryRouter initialEntries={[initialPath]} future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
|
<MemoryRouter initialEntries={[initialPath]}>
|
||||||
<LocationIndicator />
|
<LocationIndicator />
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/dashboard" element={<DashboardView />} />
|
<Route path="/dashboard" element={<DashboardView />} />
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ function renderLoop(initialPath = '/jobs') {
|
|||||||
<I18nProvider>
|
<I18nProvider>
|
||||||
<ConfirmProvider>
|
<ConfirmProvider>
|
||||||
<PromptProvider>
|
<PromptProvider>
|
||||||
<MemoryRouter initialEntries={[initialPath]} future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
|
<MemoryRouter initialEntries={[initialPath]}>
|
||||||
<LocationIndicator />
|
<LocationIndicator />
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/jobs" element={<JobTable refreshToken={0} pageSize={15} onPageSizeChange={() => {}} columns={{ status: true, dateApplied: true, daysSince: true, jobUrl: false }} onColumnsChange={() => {}} mode="jobs" />} />
|
<Route path="/jobs" element={<JobTable refreshToken={0} pageSize={15} onPageSizeChange={() => {}} columns={{ status: true, dateApplied: true, daysSince: true, jobUrl: false }} onColumnsChange={() => {}} mode="jobs" />} />
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ function renderPage() {
|
|||||||
return render(
|
return render(
|
||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
<I18nProvider>
|
<I18nProvider>
|
||||||
<MemoryRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
|
<MemoryRouter>
|
||||||
<GmailReviewPage />
|
<GmailReviewPage />
|
||||||
</MemoryRouter>
|
</MemoryRouter>
|
||||||
</I18nProvider>
|
</I18nProvider>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ afterEach(() => jest.clearAllMocks());
|
|||||||
|
|
||||||
test("shows honest source metadata and offers the existing reviewed save flow", async () => {
|
test("shows honest source metadata and offers the existing reviewed save flow", async () => {
|
||||||
mockedApi.get.mockResolvedValue({ data: [{ id: "abc", title: "Backend Developer", company: "Acme", location: "OSLO", modifiedAt: "2026-08-01T10:00:00Z", deadline: "2026-08-15T23:59:59Z", url: "https://arbeidsplassen.nav.no/stillinger/stilling/abc", source: "nav", sourceName: "NAV Arbeidsplassen", acquisitionType: "searched", retrievedAt: "2026-08-10T10:00:00Z", countryCode: "NO" }] } as any);
|
mockedApi.get.mockResolvedValue({ data: [{ id: "abc", title: "Backend Developer", company: "Acme", location: "OSLO", modifiedAt: "2026-08-01T10:00:00Z", deadline: "2026-08-15T23:59:59Z", url: "https://arbeidsplassen.nav.no/stillinger/stilling/abc", source: "nav", sourceName: "NAV Arbeidsplassen", acquisitionType: "searched", retrievedAt: "2026-08-10T10:00:00Z", countryCode: "NO" }] } as any);
|
||||||
render(<MemoryRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}><JobDiscoveryPage /></MemoryRouter>);
|
render(<MemoryRouter><JobDiscoveryPage /></MemoryRouter>);
|
||||||
|
|
||||||
fireEvent.change(screen.getByLabelText("Role or company"), { target: { value: "backend" } });
|
fireEvent.change(screen.getByLabelText("Role or company"), { target: { value: "backend" } });
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Search NAV" }));
|
fireEvent.click(screen.getByRole("button", { name: "Search NAV" }));
|
||||||
@@ -27,7 +27,7 @@ test("shows honest source metadata and offers the existing reviewed save flow",
|
|||||||
|
|
||||||
test("distinguishes the initial guidance from a completed empty search", async () => {
|
test("distinguishes the initial guidance from a completed empty search", async () => {
|
||||||
mockedApi.get.mockResolvedValue({ data: [] } as any);
|
mockedApi.get.mockResolvedValue({ data: [] } as any);
|
||||||
render(<MemoryRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}><JobDiscoveryPage /></MemoryRouter>);
|
render(<MemoryRouter><JobDiscoveryPage /></MemoryRouter>);
|
||||||
|
|
||||||
expect(screen.getByText("Search recent active vacancies by role, company, or municipality.")).toBeInTheDocument();
|
expect(screen.getByText("Search recent active vacancies by role, company, or municipality.")).toBeInTheDocument();
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Search NAV" }));
|
fireEvent.click(screen.getByRole("button", { name: "Search NAV" }));
|
||||||
@@ -38,7 +38,7 @@ test("distinguishes the initial guidance from a completed empty search", async (
|
|||||||
|
|
||||||
test("retries the last submitted search after an error", async () => {
|
test("retries the last submitted search after an error", async () => {
|
||||||
mockedApi.get.mockRejectedValueOnce(new Error("offline")).mockResolvedValueOnce({ data: [] } as any);
|
mockedApi.get.mockRejectedValueOnce(new Error("offline")).mockResolvedValueOnce({ data: [] } as any);
|
||||||
render(<MemoryRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}><JobDiscoveryPage /></MemoryRouter>);
|
render(<MemoryRouter><JobDiscoveryPage /></MemoryRouter>);
|
||||||
|
|
||||||
fireEvent.change(screen.getByLabelText("Role or company"), { target: { value: " utvikler " } });
|
fireEvent.change(screen.getByLabelText("Role or company"), { target: { value: " utvikler " } });
|
||||||
fireEvent.change(screen.getByLabelText("Municipality"), { target: { value: " Oslo " } });
|
fireEvent.change(screen.getByLabelText("Municipality"), { target: { value: " Oslo " } });
|
||||||
@@ -54,7 +54,7 @@ test("sorts returned vacancies by title", async () => {
|
|||||||
{ id: "2", title: "Zoologist", url: "https://arbeidsplassen.nav.no/2", source: "nav", countryCode: "NO" },
|
{ id: "2", title: "Zoologist", url: "https://arbeidsplassen.nav.no/2", source: "nav", countryCode: "NO" },
|
||||||
{ id: "1", title: "Analyst", url: "https://arbeidsplassen.nav.no/1", source: "nav", countryCode: "NO" },
|
{ id: "1", title: "Analyst", url: "https://arbeidsplassen.nav.no/1", source: "nav", countryCode: "NO" },
|
||||||
] } as any);
|
] } as any);
|
||||||
render(<MemoryRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}><JobDiscoveryPage /></MemoryRouter>);
|
render(<MemoryRouter><JobDiscoveryPage /></MemoryRouter>);
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Search NAV" }));
|
fireEvent.click(screen.getByRole("button", { name: "Search NAV" }));
|
||||||
await screen.findByText("2 vacancies found");
|
await screen.findByText("2 vacancies found");
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ beforeEach(() => {
|
|||||||
|
|
||||||
test("shows exactly Free and Pro without invented commercial terms", async () => {
|
test("shows exactly Free and Pro without invented commercial terms", async () => {
|
||||||
render(
|
render(
|
||||||
<MemoryRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
|
<MemoryRouter>
|
||||||
<LandingPage />
|
<LandingPage />
|
||||||
</MemoryRouter>,
|
</MemoryRouter>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ let consoleErrorSpy: jest.SpyInstance;
|
|||||||
|
|
||||||
function renderLoginPage(initialMode: "login" | "register" = "login", from?: string) {
|
function renderLoginPage(initialMode: "login" | "register" = "login", from?: string) {
|
||||||
return render(
|
return render(
|
||||||
<MemoryRouter initialEntries={[{ pathname: "/login", state: from ? { from } : undefined }]} future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
|
<MemoryRouter initialEntries={[{ pathname: "/login", state: from ? { from } : undefined }]}>
|
||||||
<I18nProvider>
|
<I18nProvider>
|
||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
<LoginPage initialMode={initialMode} />
|
<LoginPage initialMode={initialMode} />
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ test("legacy Microsoft recovery requires the emailed proof and a fresh matching
|
|||||||
window.history.pushState({}, "", "/microsoft-legacy-relink?userId=user-1&tenantId=tenant-1&objectId=object-1&token=recovery-token");
|
window.history.pushState({}, "", "/microsoft-legacy-relink?userId=user-1&tenantId=tenant-1&objectId=object-1&token=recovery-token");
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<MemoryRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
|
<MemoryRouter>
|
||||||
<I18nProvider><MicrosoftLegacyRelinkPage /></I18nProvider>
|
<I18nProvider><MicrosoftLegacyRelinkPage /></I18nProvider>
|
||||||
</MemoryRouter>,
|
</MemoryRouter>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,5 +4,5 @@ import path from "node:path";
|
|||||||
test("canonical-host health reaches the backend instead of the SPA fallback", () => {
|
test("canonical-host health reaches the backend instead of the SPA fallback", () => {
|
||||||
const config = fs.readFileSync(path.join(process.cwd(), "nginx.conf.template"), "utf8");
|
const config = fs.readFileSync(path.join(process.cwd(), "nginx.conf.template"), "utf8");
|
||||||
|
|
||||||
expect(config).toMatch(/location = \/health\s*{[^}]*proxy_pass http:\/\/backend-web:8080\/health;/s);
|
expect(config).toMatch(/location = \/health\s*{[\s\S]*?proxy_pass http:\/\/backend-web:8080\/health;/);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ jest.mock("./api", () => ({
|
|||||||
const mockedApi = api as jest.Mocked<typeof api>;
|
const mockedApi = api as jest.Mocked<typeof api>;
|
||||||
|
|
||||||
function renderChecklist() {
|
function renderChecklist() {
|
||||||
return render(<MemoryRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}><I18nProvider><OnboardingChecklist hasJobs={false} /></I18nProvider></MemoryRouter>);
|
return render(<MemoryRouter><I18nProvider><OnboardingChecklist hasJobs={false} /></I18nProvider></MemoryRouter>);
|
||||||
}
|
}
|
||||||
|
|
||||||
test("uses structured career data and connected inbox status for onboarding progress", async () => {
|
test("uses structured career data and connected inbox status for onboarding progress", async () => {
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ function renderVerifyEmailPage(search: string, emailChange = false) {
|
|||||||
const path = emailChange ? '/confirm-email-change' : '/verify-email';
|
const path = emailChange ? '/confirm-email-change' : '/verify-email';
|
||||||
window.history.pushState({}, '', `${path}${search}`);
|
window.history.pushState({}, '', `${path}${search}`);
|
||||||
return render(
|
return render(
|
||||||
<MemoryRouter initialEntries={[`${path}${search}`]} future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
|
<MemoryRouter initialEntries={[`${path}${search}`]}>
|
||||||
<I18nProvider>
|
<I18nProvider>
|
||||||
<VerifyEmailPage emailChange={emailChange} />
|
<VerifyEmailPage emailChange={emailChange} />
|
||||||
</I18nProvider>
|
</I18nProvider>
|
||||||
|
|||||||
Reference in New Issue
Block a user