test(release): strengthen production verification
CI and Deploy / test (pull_request) Successful in 5m35s
CI and Deploy / test (push) Successful in 5m17s
CI and Deploy / deploy (pull_request) Has been skipped
CI and Deploy / deploy (push) Failing after 8s

This commit is contained in:
cesnimda
2026-08-24 20:21:37 +02:00
parent 367b70681a
commit 44dba5d08d
16 changed files with 62 additions and 16 deletions
+39
View File
@@ -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.
+5
View File
@@ -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
+1
View File
@@ -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>
+4 -4
View File
@@ -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");
+1 -1
View File
@@ -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>,
); );
+1 -1
View File
@@ -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>,
); );
+1 -1
View File
@@ -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>