Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,7 @@ data/*.sql
data/*.json
data/*.log
!.gitkeep

# Test artifacts
playwright-report/
test-results/
42 changes: 42 additions & 0 deletions docs/migration/test-coverage.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,19 @@
- `/hub?scope=money&record=transfer-[id]`
- `/hub?scope=compliance&record=id-[uuid]`

## Playwright Smoke Tests (added STU-26)

Playwright smoke tests live in `e2e/smoke/` and use fixture-signed session cookies
for authenticated brower tests. Fixtures are discovered from the database via
`scripts/fixtures/discover.mjs` and validated with `scripts/fixtures/validate.mjs`.

- `e2e/smoke/login.spec.ts` — login page rendering, form fields, unauthenticated redirect
- `e2e/smoke/candidate-search.spec.ts` — admin + staff candidate search, cross-role guards
- `e2e/smoke/request-desk.spec.ts` — admin + staff + company request pages, cross-role guards
- `e2e/smoke/role-portals.spec.ts` — full portal smoke per role (admin/staff/candidate/company/inspector), app/hub shell, cross-role guards

Run with: `npx playwright test` (requires dev server at `PLAYWRIGHT_BASE_URL` and MySQL at `DATABASE_URL`).

## Not Yet Covered

- Form submissions and mutations.
Expand All @@ -99,6 +112,35 @@
- External dependencies such as Algolia, email, notifications, storage, payment/payroll exports, and third-party identity providers.
- Meilisearch integration tests for indexing, filtering, typo tolerance, and MySQL fallback behavior.

## Parity Checklist — Automated Test Gap Map

Each gap from "Not Yet Covered" is mapped to whether automated testing is needed
and the current implementation status.

| # | Gap | Needs Automation | Status | Owner | Notes |
|---|---|---|---|---|---|
| 1 | Form submissions and mutations | Yes | Not started | QA | `e2e/smoke/` covers read-only routes; mutation tests need DB seeding + cleanup |
| 2 | Credential submission E2E | Yes | Not started | QA | Requires test credentials in seed data; blocked until test accounts exist |
| 3 | Multi-account chooser flow | Yes | Not started | QA | Needs DB with multi-identity users |
| 4 | Capability decision assertions | Yes | Not started | QA | Unit-testable with `capabilities.ts`; can add Jest/Vitest suite |
| 5 | Shared `/app/*` capability routes | Yes | Not started | QA | Gated on module delivery |
| 6 | Suggestion mutation smoke | Yes | Not started | QA | Needs mutation test harness |
| 7 | Old-system parity assertions | Manual | Not started | Role owners | Business-rule parity is manual sign-off per migration gate |
| 8 | Cross-role auth denial (basic) | Done | Covered | QA | Playwright role-portal + candidate-search + request-desk |
| 9 | Cross-role auth denial (deep) | Yes | Partial | QA | Basic redirect guards covered; per-mutation auth not tested |
| 10 | Unavailable hub previews | Yes | Not started | QA | Can add to `role-portals.spec.ts` |
| 11 | Keyboard/command-palette tests | Yes | Not started | QA | Playwright keyboard API; needs Cmd+K palette stable |
| 12 | Visual regression checks | Yes | Blocked | UXDesigner | Gated on STU-21 (app shell delivery); use Playwright screenshot diffing |
| 13 | Performance budgets | Yes | Not started | QA | `test:smoke` enforces per-route 5s budget; needs Lighthouse CI or similar |
| 14 | File/media/document rendering | Manual | Not started | Role owners | Visual inspection needed |
| 15 | External dependencies | No | Not started | DevOps | Algolia/email/storage/payment tested in their own stacks |
| 16 | Meilisearch integration | Yes | Not started | QA | Needs Meilisearch instance; `search:index-candidates` exists |

### Legend
- **Needs Automation**: whether automated testing is appropriate
- **Status**: Done / Partial / Not started / Blocked / Manual
- **Owner**: who should deliver the test coverage

## Migration Gate

Before replacing any old portal slice, that slice should have:
Expand Down
170 changes: 170 additions & 0 deletions e2e/fixtures/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
import crypto from "node:crypto";
import { PrismaClient } from "@prisma/client";

const prisma = new PrismaClient();

function requireEnv(name: string): string {
const value = process.env[name];
if (!value) throw new Error(`${name} is required`);
return value;
}

export interface SessionUser {
role: string;
id: string;
name: string;
email: string;
issuedAt: number;
}

export function signSession(user: Omit<SessionUser, "issuedAt">): string {
const payload = Buffer.from(
JSON.stringify({ ...user, issuedAt: Date.now() }),
).toString("base64url");
const signature = crypto
.createHmac("sha256", requireEnv("AUTH_SECRET"))
.update(payload)
.digest("base64url");
return `${payload}.${signature}`;
}

export interface FixtureUser {
role: string;
id: string;
name: string;
email: string;
cookie: string;
}

async function firstOrThrow<T>(label: string, query: () => Promise<T | null>): Promise<T> {
const value = await query();
if (!value) throw new Error(`Missing fixture data: ${label}`);
return value;
}

let fixtureCache: Map<string, FixtureUser> | null = null;

export async function getFixtures(): Promise<Map<string, FixtureUser>> {
if (fixtureCache) return fixtureCache;

const [admin, staff, candidate, company, inspector] = await Promise.all([
firstOrThrow("admin", () =>
prisma.admin.findFirst({
where: { admin_status: 10 },
select: { admin_id: true, admin_name: true, admin_email: true },
}),
),
firstOrThrow("staff", () =>
prisma.staff.findFirst({
where: { deleted: 0 },
select: { staff_id: true, staff_name: true, staff_email: true },
}),
),
firstOrThrow("candidate", () =>
prisma.candidate.findFirst({
where: { deleted: 0 },
orderBy: { candidate_updated_at: "desc" },
select: { candidate_id: true, candidate_name: true, candidate_email: true },
}),
),
firstOrThrow("company contact", () =>
prisma.company_contact.findFirst({
where: { allow_access: true, contact_uuid: { not: null } },
select: {
contact_uuid: true,
contact: { select: { contact_name: true, contact_email: true } },
},
}),
),
firstOrThrow("inspector", () =>
prisma.inspector.findFirst({
where: { inspector_deleted: 0 },
select: { inspector_uuid: true, inspector_name: true, inspector_email: true },
}),
),
]);

fixtureCache = new Map([
[
"admin",
{
role: "admin",
id: String(admin.admin_id),
name: admin.admin_name,
email: admin.admin_email,
cookie: signSession({
role: "admin",
id: String(admin.admin_id),
name: admin.admin_name,
email: admin.admin_email,
}),
},
],
[
"staff",
{
role: "staff",
id: String(staff.staff_id),
name: staff.staff_name,
email: staff.staff_email,
cookie: signSession({
role: "staff",
id: String(staff.staff_id),
name: staff.staff_name,
email: staff.staff_email,
}),
},
],
[
"candidate",
{
role: "candidate",
id: String(candidate.candidate_id),
name: candidate.candidate_name,
email: candidate.candidate_email,
cookie: signSession({
role: "candidate",
id: String(candidate.candidate_id),
name: candidate.candidate_name,
email: candidate.candidate_email,
}),
},
],
[
"company",
{
role: "company",
id: company.contact_uuid,
name: company.contact.contact_name,
email: company.contact.contact_email,
cookie: signSession({
role: "company",
id: company.contact_uuid,
name: company.contact.contact_name,
email: company.contact.contact_email,
}),
},
],
[
"inspector",
{
role: "inspector",
id: inspector.inspector_uuid,
name: inspector.inspector_name,
email: inspector.inspector_email,
cookie: signSession({
role: "inspector",
id: inspector.inspector_uuid,
name: inspector.inspector_name,
email: inspector.inspector_email,
}),
},
],
]);

return fixtureCache;
}

export async function disconnectPrisma(): Promise<void> {
await prisma.$disconnect();
}
99 changes: 99 additions & 0 deletions e2e/smoke/candidate-search.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { test, expect } from "@playwright/test";
import { getFixtures, disconnectPrisma, signSession } from "../fixtures/auth";

test.afterAll(async () => {
await disconnectPrisma();
});

test.describe("Candidate search", () => {
test.describe.configure({ mode: "serial" });

let adminCookie: string;
let staffCookie: string;

test.beforeAll(async () => {
const fixtures = await getFixtures();
adminCookie = fixtures.get("admin")!.cookie;
staffCookie = fixtures.get("staff")!.cookie;
});

test("admin can access candidate search page", async ({ browser }) => {
const context = await browser.newContext();
await context.addCookies([
{ name: "studenthub_next_session", value: adminCookie, domain: "127.0.0.1", path: "/" },
]);
const page = await context.newPage();
await page.goto("/admin/candidates");
await expect(page).toHaveURL("/admin/candidates");
// Should render the candidate workspace
await expect(page.locator('text="Open candidate tabs"')).toBeVisible({ timeout: 15000 });
await context.close();
});

test("admin candidate search renders search input", async ({ browser }) => {
const context = await browser.newContext();
await context.addCookies([
{ name: "studenthub_next_session", value: adminCookie, domain: "127.0.0.1", path: "/" },
]);
const page = await context.newPage();
await page.goto("/admin/candidates");
// Search should be present (Command menu or search input)
await expect(page.locator('input[type="search"], input[placeholder*="Search"], input[placeholder*="search"]').or(page.locator('[cmdk-input]'))).toBeVisible({ timeout: 15000 });
await context.close();
});

test("admin can search candidates by query", async ({ browser }) => {
const context = await browser.newContext();
await context.addCookies([
{ name: "studenthub_next_session", value: adminCookie, domain: "127.0.0.1", path: "/" },
]);
const page = await context.newPage();
await page.goto("/admin/candidates?q=test");
await expect(page).toHaveURL(/\/admin\/candidates\?q=test/);
await expect(page.locator('text="Filtered view"')).toBeVisible({ timeout: 15000 });
await context.close();
});

test("staff can access candidate search page", async ({ browser }) => {
const context = await browser.newContext();
await context.addCookies([
{ name: "studenthub_next_session", value: staffCookie, domain: "127.0.0.1", path: "/" },
]);
const page = await context.newPage();
await page.goto("/staff/candidates");
await expect(page).toHaveURL("/staff/candidates");
await expect(page.locator('text="All production"')).toBeVisible({ timeout: 15000 });
await context.close();
});

test("staff can view assigned candidates", async ({ browser }) => {
const context = await browser.newContext();
await context.addCookies([
{ name: "studenthub_next_session", value: staffCookie, domain: "127.0.0.1", path: "/" },
]);
const page = await context.newPage();
await page.goto("/staff/candidates?view=assigned");
await expect(page).toHaveURL(/\/staff\/candidates\?view=assigned/);
await expect(page.locator('text="Assigned to me"')).toBeVisible({ timeout: 15000 });
await context.close();
});

test("staff cannot access admin candidates page (cross-role guard)", async ({ browser }) => {
const context = await browser.newContext();
await context.addCookies([
{ name: "studenthub_next_session", value: staffCookie, domain: "127.0.0.1", path: "/" },
]);
const page = await context.newPage();
await page.goto("/admin/candidates");
// Should be redirected away from admin
await expect(page).not.toHaveURL("/admin/candidates");
await context.close();
});

test("unauthenticated users are redirected from candidate pages", async ({ page }) => {
await page.goto("/admin/candidates");
await expect(page).toHaveURL(/\/login/);
await page.goto("/staff/candidates");
await expect(page).toHaveURL(/\/login/);
});
});
Loading
Loading