945 lines
39 KiB
TypeScript
945 lines
39 KiB
TypeScript
import { test } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { existsSync, readFileSync } from "node:fs";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
/**
|
|
* D-057/G-103 source pins (moved from marketplace-shells /
|
|
* marketplace-search / environment-kinds): each test below either
|
|
* documents a regression lesson (E87) or guards cross-boundary wiring
|
|
* (apps/web ↔ packages/types ↔ apps/ai-service) invisible to typecheck
|
|
* and behavioral tests. Paths are anchored on import.meta.url so the
|
|
* suite passes from any CWD.
|
|
*/
|
|
|
|
const root = new URL("..", import.meta.url);
|
|
|
|
function web(path: string): string {
|
|
return fileURLToPath(new URL(path, root));
|
|
}
|
|
|
|
// -- E87 regression lesson (G-101) --------------------------------------------
|
|
|
|
test("E87 regression pin (G-101): generateStaticParams STAYS with ONE placeholder param", async () => {
|
|
const jobs = readFileSync(
|
|
web("app/(marketplace)/marketplace/jobs/[jobId]/page.tsx"),
|
|
"utf8",
|
|
);
|
|
const employers = readFileSync(
|
|
web("app/(marketplace)/marketplace/employers/[employerId]/page.tsx"),
|
|
"utf8",
|
|
);
|
|
for (const [name, src] of [
|
|
["jobs/[jobId]", jobs],
|
|
["employers/[employerId]", employers],
|
|
] as const) {
|
|
assert.match(src, /export function generateStaticParams/, `${name}: must keep generateStaticParams`);
|
|
assert.match(
|
|
src,
|
|
/\[{ jobId: '_' }\]|\[{ employerId: '_' }\]/,
|
|
`${name}: exactly one placeholder param`,
|
|
);
|
|
assert.ok(!src.includes("'use client'"), `${name}: server shell (client lives in the component)`);
|
|
}
|
|
});
|
|
|
|
// -- cross-boundary wire shapes (packages/types ↔ ai-service py contracts) ------
|
|
|
|
test("wire pin: PostingSummary mirrors marketplace/base.py field-for-field (dual-schema sync)", async () => {
|
|
const typesSrc = readFileSync(web("../../packages/types/marketplace.ts"), "utf8");
|
|
const fields = [
|
|
"id:", "source:", "external_id:", "employer_id:", "title:",
|
|
"description:", "required_competencies:", "skills:", "seniority:",
|
|
"category:", "location:", "remote:", "salary_min:", "salary_max:",
|
|
"status:", "tags:", "posted_at:",
|
|
];
|
|
const block = typesSrc.slice(
|
|
typesSrc.indexOf("export interface PostingSummary"),
|
|
typesSrc.indexOf("export type PostingDetail"),
|
|
);
|
|
for (const f of fields) {
|
|
assert.ok(block.includes(f), `PostingSummary missing base.py field ${f}`);
|
|
}
|
|
});
|
|
|
|
test("wire pin: SearchResult/SearchResponse mirror api/marketplace.py search contracts", async () => {
|
|
const typesSrc = readFileSync(web("../../packages/types/marketplace.ts"), "utf8");
|
|
const pySrc = readFileSync(
|
|
web("../ai-service/ai_service/api/marketplace.py"),
|
|
"utf8",
|
|
);
|
|
|
|
const contribBlock = typesSrc.slice(
|
|
typesSrc.indexOf("export interface SkillContribution"),
|
|
typesSrc.indexOf("export interface SearchResult"),
|
|
);
|
|
for (const field of ["skill:", "overlap:", "contribution:"]) {
|
|
assert.ok(contribBlock.includes(field), `SkillContribution missing ${field}`);
|
|
}
|
|
const resultBlock = typesSrc.slice(
|
|
typesSrc.indexOf("export interface SearchResult"),
|
|
typesSrc.indexOf("export interface SearchResponse"),
|
|
);
|
|
for (const field of [
|
|
"posting: PostingSummary;",
|
|
"match_score: number | null;",
|
|
"skill_breakdown: SkillContribution[];",
|
|
]) {
|
|
assert.ok(resultBlock.includes(field), `SearchResult missing ${field}`);
|
|
}
|
|
const responseBlock = typesSrc.slice(typesSrc.indexOf("export interface SearchResponse"));
|
|
for (const field of [
|
|
"results: SearchResult[];",
|
|
"query: string;",
|
|
"ranked: number;",
|
|
]) {
|
|
assert.ok(responseBlock.includes(field), `SearchResponse missing ${field}`);
|
|
}
|
|
|
|
for (const pyField of [
|
|
"class SkillBreakdownEntry",
|
|
"skill: str",
|
|
"overlap: int",
|
|
"contribution: float",
|
|
"class SearchEntry",
|
|
"posting: PostingResponse",
|
|
"match_score: float | None",
|
|
"skill_breakdown: list[SkillBreakdownEntry]",
|
|
"class SearchResponse",
|
|
"results: list[SearchEntry]",
|
|
"query: str",
|
|
"ranked: int",
|
|
]) {
|
|
assert.ok(pySrc.includes(pyField), `py search contract missing ${pyField}`);
|
|
}
|
|
});
|
|
|
|
test("wire pin: PostingFacets rides the dual-schema fetch-all (base.py PostingListResponse)", async () => {
|
|
const client = readFileSync(web("lib/engine-client.ts"), "utf8");
|
|
assert.ok(/facets\?: PostingFacets/.test(client), "PostingsFetchAll must carry facets");
|
|
assert.ok(/facets = body\.facets/.test(client), "listPostings must propagate facets");
|
|
const wire = readFileSync(web("../../packages/types/marketplace.ts"), "utf8");
|
|
assert.ok(/export interface PostingFacets/.test(wire), "PostingFacets type exists");
|
|
assert.ok(/facets: PostingFacets;/.test(wire), "PostingPage carries facets");
|
|
});
|
|
|
|
test("wire pin: ProvenanceField merged view + resume gate routes (A-304, dual-schema)", async () => {
|
|
const wire = readFileSync(web("../../packages/types/marketplace.ts"), "utf8");
|
|
assert.ok(/export interface ProvenanceField/.test(wire), "ProvenanceField type");
|
|
assert.ok(/merged\?: Record<string, ProvenanceField>/.test(wire), "EmployerProfile.merged");
|
|
const client = readFileSync(web("lib/engine-client.ts"), "utf8");
|
|
assert.ok(/export async function uploadResume/.test(client), "uploadResume");
|
|
assert.ok(/export async function confirmResume/.test(client), "confirmResume");
|
|
assert.ok(/\/v1\/marketplace\/resume/.test(client), "resume route path");
|
|
});
|
|
|
|
test("wire pin: TaskVariant carries REQUIRED environment + test_command from packages/types (a-11)", async () => {
|
|
const src = readFileSync(web("../../packages/types/variants.ts"), "utf8");
|
|
assert.match(src, /environment:\s*'build' \| 'design' \| 'simulation';/);
|
|
assert.match(src, /test_command:\s*string;/);
|
|
const envLine = src.split("\n").find((l: string) => l.includes("environment: 'build'"));
|
|
assert.ok(envLine && !envLine.includes("?"), "environment must be required");
|
|
const cmdLine = src.split("\n").find((l: string) => l.trim().startsWith("test_command"));
|
|
assert.ok(cmdLine && !cmdLine.includes("?"), "test_command must be required");
|
|
});
|
|
|
|
// -- honest-score wire guard: match_score exists ONLY on the search wire --------
|
|
|
|
test("wire pin: no matchScore on marketplace wire shapes (G-106/G-107, honest absent score)", async () => {
|
|
const surfaces = [
|
|
"components/marketplace/board-client.tsx",
|
|
"components/marketplace/job-detail-client.tsx",
|
|
"components/marketplace/employer-profile-client.tsx",
|
|
"components/marketplace/job-filters.tsx",
|
|
];
|
|
for (const f of surfaces) {
|
|
const src = readFileSync(web(f), "utf8");
|
|
assert.ok(!/matchScore/.test(src), `${f} must not reference matchScore`);
|
|
}
|
|
const typesSrc = readFileSync(web("../../packages/types/marketplace.ts"), "utf8");
|
|
const wireBlock = typesSrc.slice(typesSrc.indexOf("export type PostingStatus"));
|
|
const scoreFields = wireBlock
|
|
.split("\n")
|
|
.filter((l) => /^\s*\w+\??:/.test(l))
|
|
.filter((l) => /matchScore/i.test(l));
|
|
assert.equal(
|
|
scoreFields.length,
|
|
0,
|
|
`wire shapes must not carry matchScore fields: ${scoreFields}`,
|
|
);
|
|
assert.ok(
|
|
/match_score: number \| null;/.test(wireBlock),
|
|
"the search wire shape carries the computed match_score",
|
|
);
|
|
});
|
|
|
|
// -- SEO boundary: build-time metadata/sitemap limits under output:'export' ----
|
|
|
|
test("SEO pin (MH-5c/5d): server pages ship metadata + JSON-LD; NO build-time sitemap/robots (D-056)", async () => {
|
|
const board = readFileSync(web("app/(marketplace)/marketplace/page.tsx"), "utf8");
|
|
assert.ok(/export const metadata/.test(board), "board page metadata");
|
|
assert.ok(/ItemList/.test(board), "board ItemList JSON-LD");
|
|
// The board JSON-LD enumerates STRUCTURE only — never runtime postings
|
|
// (G-109: the export cannot see them honestly).
|
|
assert.ok(/numberOfItems: 0/.test(board), "structure-only ItemList");
|
|
assert.ok(
|
|
/'@type': 'Organization'/.test(board),
|
|
"server-rendered Organization JSON-LD on the board",
|
|
);
|
|
const pricing = readFileSync(web("app/(marketplace)/marketplace/pricing/page.tsx"), "utf8");
|
|
assert.ok(/export const metadata/.test(pricing), "pricing page metadata");
|
|
const catalog = readFileSync(web("app/(learner)/catalog/page.tsx"), "utf8");
|
|
assert.ok(/export const metadata/.test(catalog), "catalog page metadata");
|
|
// Runtime-set client JSON-LD (A-411 accepted limitation).
|
|
const detail = readFileSync(web("components/marketplace/job-detail-client.tsx"), "utf8");
|
|
assert.ok(/'@type': 'JobPosting'/.test(detail), "JobPosting JSON-LD after fetch");
|
|
// NO Next app/sitemap.ts (build-time enumeration cannot see runtime
|
|
// postings under output:'export' — D-056). ai-service serves both
|
|
// dynamically, so no app/robots.ts either.
|
|
assert.ok(!existsSync(web("app/sitemap.ts")), "no build-time app/sitemap.ts");
|
|
assert.ok(!existsSync(web("app/robots.ts")), "no build-time app/robots.ts");
|
|
});
|
|
|
|
// -- v0.9 billing (REQ-9-002, G-108 dual-schema) --------------------------------
|
|
|
|
test("wire pin: Package/Plan/CatalogResponse mirror billing/base.py field-for-field (dual-schema sync)", async () => {
|
|
const typesSrc = readFileSync(web("../../packages/types/billing.ts"), "utf8");
|
|
const pySrc = readFileSync(
|
|
web("../ai-service/ai_service/billing/base.py"),
|
|
"utf8",
|
|
);
|
|
const pkgBlock = typesSrc.slice(
|
|
typesSrc.indexOf("export interface Package"),
|
|
typesSrc.indexOf("export interface Plan"),
|
|
);
|
|
for (const f of ["id:", "name:", "price_cents:", "posts_limit:", "duration_days:", "features:"]) {
|
|
assert.ok(pkgBlock.includes(f), `Package missing base.py field ${f}`);
|
|
}
|
|
const planBlock = typesSrc.slice(
|
|
typesSrc.indexOf("export interface Plan"),
|
|
typesSrc.indexOf("export interface CatalogResponse"),
|
|
);
|
|
for (const f of ["id:", "name:", "price_cents:", "interval:", "features:"]) {
|
|
assert.ok(planBlock.includes(f), `Plan missing base.py field ${f}`);
|
|
}
|
|
// The py side keeps the same fields in its pydantic contracts.
|
|
for (const f of ["posts_limit", "duration_days", "price_cents", "interval"]) {
|
|
assert.ok(pySrc.includes(f), `billing/base.py missing wire field ${f}`);
|
|
}
|
|
// D-081: cents discipline is visible in the TS contract comment.
|
|
assert.ok(/integer cents/i.test(typesSrc), "D-081 cents documented in billing.ts");
|
|
});
|
|
|
|
test("pricing page: runtime catalog wiring (REQ-9-002/D-079 — no hardcoded tier constants)", async () => {
|
|
const pricing = readFileSync(web("app/(marketplace)/marketplace/pricing/page.tsx"), "utf8");
|
|
const client = readFileSync(web("components/marketplace/pricing-client.tsx"), "utf8");
|
|
// The page is a server shell rendering the client (A-406 pattern).
|
|
assert.ok(/<PricingClient \/>/.test(pricing), "page renders PricingClient");
|
|
assert.ok(/export const metadata/.test(pricing), "pricing page metadata retained (A-411)");
|
|
// MH-2c: the inline tier constants are GONE (data-driven copy).
|
|
assert.ok(!/const JOB_PLANS/.test(pricing), "no JOB_PLANS constant on the page");
|
|
assert.ok(!/const TALENT_PLANS/.test(pricing), "no TALENT_PLANS constant on the page");
|
|
assert.ok(!/const JOB_PLANS/.test(client), "no hardcoded JOB_PLANS in the client");
|
|
assert.ok(!/const TALENT_PLANS/.test(client), "no hardcoded TALENT_PLANS in the client");
|
|
// The client fetches the catalog with an AbortController + honest states.
|
|
assert.ok(/listBillingCatalog/.test(client), "client fetches the catalog");
|
|
assert.ok(/AbortController/.test(client), "AbortController discipline");
|
|
assert.ok(/Loading pricing/.test(client), "honest loading state");
|
|
assert.ok(/Failed to load pricing|error/.test(client), "honest error state");
|
|
// D-079: contact-path CTAs — no self-serve purchase path at pilot.
|
|
assert.ok(/Contact Sales/.test(client), "enterprise contact CTA");
|
|
// Honest empty state (MH-2 "honest states" third arm) + cents display
|
|
// (D-081): the empty branch + the cents→dollars conversion exist in
|
|
// the source — no render harness in-tree (D-059), so the branch is
|
|
// pinned by source (the E87 pin discipline).
|
|
assert.ok(/No pricing tiers are configured yet/.test(client), "honest empty state");
|
|
assert.ok(/formatPrice/.test(client), "cents formatting helper");
|
|
assert.ok(
|
|
/cents === ENTERPRISE_CENTS/.test(client),
|
|
"price_cents=0 renders as Custom, never as $0 (honest enterprise contact tier)",
|
|
);
|
|
assert.ok(
|
|
!/price_cents \/ 100|price_cents\* *100/.test(client),
|
|
"no raw cents arithmetic outside formatPrice",
|
|
);
|
|
});
|
|
|
|
// -- v0.10 employer accounts (REQ-10-003, G-108 dual-schema, MH-3a) ---------------
|
|
|
|
test("wire pin: EmployerAccount/EmployerClaim/token+me shapes mirror employers/base.py field-for-field (dual-schema sync)", async () => {
|
|
const typesSrc = readFileSync(web("../../packages/types/employer.ts"), "utf8");
|
|
const pySrc = readFileSync(
|
|
web("../ai-service/ai_service/employers/base.py"),
|
|
"utf8",
|
|
);
|
|
|
|
// EmployerAccount: every pydantic field asserted on BOTH sides.
|
|
const acctBlock = typesSrc.slice(
|
|
typesSrc.indexOf("export interface EmployerAccount"),
|
|
typesSrc.indexOf("export interface EmployerClaim {"),
|
|
);
|
|
for (const f of [
|
|
"id: string;",
|
|
"email: string;",
|
|
"employer_id: string | null;",
|
|
"created_at: string;",
|
|
"email_verified: false;",
|
|
]) {
|
|
assert.ok(acctBlock.includes(f), `EmployerAccount missing base.py field ${f}`);
|
|
}
|
|
// D-097: email_verified can never be true (Literal[False] both sides).
|
|
assert.ok(!/email_verified: boolean/.test(acctBlock), "email_verified is false, never boolean");
|
|
assert.ok(/email_verified: Literal\[False\]/.test(pySrc), "py side pins Literal[False]");
|
|
|
|
// EmployerClaim (+ the typed evidence dict).
|
|
const claimBlock = typesSrc.slice(
|
|
typesSrc.indexOf("export interface EmployerClaim {"),
|
|
typesSrc.indexOf("export interface RegisterEmployerRequest {"),
|
|
);
|
|
for (const f of [
|
|
"id: string;",
|
|
"account_id: string;",
|
|
"employer_id: string;",
|
|
"status: EmployerClaimStatus;",
|
|
"evidence:",
|
|
"created_at: string;",
|
|
"decided_by: string | null;",
|
|
"decided_at: string | null;",
|
|
]) {
|
|
assert.ok(claimBlock.includes(f), `EmployerClaim missing base.py field ${f}`);
|
|
}
|
|
// The advisory evidence dict shape (claim_evidence.py compute_claim_evidence).
|
|
const evidenceBlock = typesSrc.slice(
|
|
typesSrc.indexOf("export interface EmployerClaimEvidence"),
|
|
typesSrc.indexOf("export interface EmployerAccount"),
|
|
);
|
|
for (const f of [
|
|
"account_email_domain: string;",
|
|
"entity_website_domain: string;",
|
|
"match: boolean;",
|
|
]) {
|
|
assert.ok(evidenceBlock.includes(f), `EmployerClaimEvidence missing D-089 field ${f}`);
|
|
}
|
|
|
|
// Request shapes + TokenResponse + EmployerMe.
|
|
const reqBlock = typesSrc.slice(
|
|
typesSrc.indexOf("export interface RegisterEmployerRequest {"),
|
|
typesSrc.indexOf("export interface EmployerTokenResponse"),
|
|
);
|
|
for (const f of ["email: string;", "password: string;", "employer_id: string;"]) {
|
|
assert.ok(reqBlock.includes(f), `employer request shapes missing base.py field ${f}`);
|
|
}
|
|
const tokenBlock = typesSrc.slice(
|
|
typesSrc.indexOf("export interface EmployerTokenResponse"),
|
|
typesSrc.indexOf("export interface EmployerMe"),
|
|
);
|
|
for (const f of ["token: string;", "expires_at: string;"]) {
|
|
assert.ok(tokenBlock.includes(f), `TokenResponse missing base.py field ${f}`);
|
|
}
|
|
const meBlock = typesSrc.slice(
|
|
typesSrc.indexOf("export interface EmployerMe"),
|
|
typesSrc.indexOf("export type OwnPostingStatus"),
|
|
);
|
|
for (const f of [
|
|
"account: EmployerAccount;",
|
|
"claimed_employer_id: string | null;",
|
|
"claim: EmployerClaim | null;",
|
|
]) {
|
|
assert.ok(meBlock.includes(f), `EmployerMe missing base.py field ${f}`);
|
|
}
|
|
|
|
// The py side keeps the same fields in its pydantic contracts.
|
|
for (const f of [
|
|
"class EmployerAccount",
|
|
"class EmployerClaim",
|
|
"class RegisterEmployerRequest",
|
|
"class LoginEmployerRequest",
|
|
"class ClaimEmployerRequest",
|
|
"class TokenResponse",
|
|
"class EmployerMe",
|
|
"ClaimStatus",
|
|
"PASSWORD_MIN_LENGTH = 8",
|
|
]) {
|
|
assert.ok(pySrc.includes(f), `employers/base.py missing wire contract ${f}`);
|
|
}
|
|
// Claim status parity: the TS union mirrors the py Literal.
|
|
const tsStatuses = typesSrc.match(
|
|
/export type EmployerClaimStatus = '[a-z]+' \| '[a-z]+' \| '[a-z]+'/,
|
|
);
|
|
assert.ok(tsStatuses, "EmployerClaimStatus union exists");
|
|
assert.ok(
|
|
tsStatuses && tsStatuses[0].includes("'pending'") && tsStatuses[0].includes("'approved'") && tsStatuses[0].includes("'rejected'"),
|
|
"claim status union = pending|approved|rejected (py ClaimStatus)",
|
|
);
|
|
});
|
|
|
|
test("wire pin: OwnPostingSummary REUSES PostingSummary + own-postings response mirrors base.py (G-319/D-093)", async () => {
|
|
const typesSrc = readFileSync(web("../../packages/types/employer.ts"), "utf8");
|
|
const marketplaceSrc = readFileSync(web("../../packages/types/marketplace.ts"), "utf8");
|
|
const pySrc = readFileSync(
|
|
web("../ai-service/ai_service/employers/base.py"),
|
|
"utf8",
|
|
);
|
|
|
|
// G-319 REUSE: composition (extends PostingSummary), never a
|
|
// field-for-field duplicate of the PostingSummary fields.
|
|
const ownBlock = typesSrc.slice(
|
|
typesSrc.indexOf("export interface OwnPostingSummary"),
|
|
typesSrc.indexOf("export interface OwnPostingsFacets"),
|
|
);
|
|
assert.ok(
|
|
/export interface OwnPostingSummary extends PostingSummary \{/.test(ownBlock),
|
|
"OwnPostingSummary extends PostingSummary (reuse, never duplication)",
|
|
);
|
|
assert.ok(/applicant_count: number;/.test(ownBlock), "OwnPostingSummary carries applicant_count");
|
|
// The PostingSummary fields must NOT be re-listed inside the own block
|
|
// (a duplicate would drift — the REUSE contract).
|
|
for (const dup of ["external_id:", "required_competencies:", "salary_min:"]) {
|
|
assert.ok(!ownBlock.includes(dup), `OwnPostingSummary duplicates PostingSummary field ${dup}`);
|
|
}
|
|
// The py side subclasses marketplace.base.Posting (the same reuse).
|
|
assert.ok(/class OwnPostingSummary\(Posting\)/.test(pySrc), "py OwnPostingSummary subclasses Posting");
|
|
// PII boundary (G-319, binding): the contract carries the count only —
|
|
// no learner-identifier field name exists anywhere on the TS shape.
|
|
assert.ok(!/learner_id|applicant_name|candidate_id/.test(ownBlock), "PII boundary: counts only");
|
|
|
|
// OwnPostingsFacets: every lifecycle key on both sides.
|
|
const facetsBlock = typesSrc.slice(
|
|
typesSrc.indexOf("export interface OwnPostingsFacets"),
|
|
typesSrc.indexOf("export interface OwnPostingsResponse"),
|
|
);
|
|
for (const f of ["draft: number;", "published: number;", "closed: number;"]) {
|
|
assert.ok(facetsBlock.includes(f), `OwnPostingsFacets missing lifecycle key ${f}`);
|
|
}
|
|
assert.ok(/draft: int = 0/.test(pySrc) && /published: int = 0/.test(pySrc) && /closed: int = 0/.test(pySrc), "py OwnPostingsFacets carries the same three keys");
|
|
|
|
// OwnPostingsResponse: list + facets + honest total, both sides.
|
|
const respBlock = typesSrc.slice(typesSrc.indexOf("export interface OwnPostingsResponse"));
|
|
for (const f of [
|
|
"postings: OwnPostingSummary[];",
|
|
"facets: OwnPostingsFacets;",
|
|
"total: number;",
|
|
]) {
|
|
assert.ok(respBlock.includes(f), `OwnPostingsResponse missing base.py field ${f}`);
|
|
}
|
|
assert.ok(/postings: list\[OwnPostingSummary\]/.test(pySrc), "py OwnPostingsResponse carries the postings list");
|
|
|
|
// PostingSummary itself stays single-sourced in marketplace.ts (the
|
|
// wire pin at pins.test.ts:48 owns its field list).
|
|
assert.ok(
|
|
/export interface PostingSummary \{/.test(marketplaceSrc),
|
|
"PostingSummary remains defined once in marketplace.ts",
|
|
);
|
|
});
|
|
|
|
test("wire pin: employer types surface through the package index (the billing.ts precedent)", async () => {
|
|
const indexSrc = readFileSync(web("../../packages/types/index.ts"), "utf8");
|
|
assert.ok(
|
|
/export \* from '\.\/employer';/.test(indexSrc),
|
|
"index.ts re-exports employer.ts",
|
|
);
|
|
});
|
|
|
|
test("wire pin: EmployerPostingCreate/Edit/WriteResponse mirror employers/base.py field-for-field (P4 write contracts)", async () => {
|
|
const typesSrc = readFileSync(web("../../packages/types/employer.ts"), "utf8");
|
|
const pySrc = readFileSync(
|
|
web("../ai-service/ai_service/employers/base.py"),
|
|
"utf8",
|
|
);
|
|
|
|
// EmployerPostingCreate: every pydantic field asserted on BOTH sides.
|
|
const createBlock = typesSrc.slice(
|
|
typesSrc.indexOf("export interface EmployerPostingCreate"),
|
|
typesSrc.indexOf("export interface EmployerPostingEdit"),
|
|
);
|
|
for (const f of [
|
|
"title: string;",
|
|
"description?: string;",
|
|
"required_competencies?: string[];",
|
|
"skills?: string[];",
|
|
"seniority?: string | null;",
|
|
"category?: string | null;",
|
|
"location?: string;",
|
|
"remote?: boolean;",
|
|
"salary_min?: number | null;",
|
|
"salary_max?: number | null;",
|
|
]) {
|
|
assert.ok(createBlock.includes(f), `EmployerPostingCreate missing base.py field ${f}`);
|
|
}
|
|
// The employer_id field is deliberately ABSENT (the entity derives
|
|
// from the session's claim, never the body — Task 4-1-02).
|
|
assert.ok(
|
|
!/employer_id/.test(createBlock),
|
|
"EmployerPostingCreate never carries employer_id (claim-derived)",
|
|
);
|
|
// The py side keeps the same fields (base.py EmployerPostingCreate).
|
|
const pyCreateBlock = pySrc.slice(
|
|
pySrc.indexOf("class EmployerPostingCreate"),
|
|
pySrc.indexOf("class EmployerPostingEdit"),
|
|
);
|
|
for (const f of [
|
|
"title: str = Field(min_length=1)",
|
|
"description: str =",
|
|
"required_competencies: list[str]",
|
|
"skills: list[str]",
|
|
"seniority: str | None",
|
|
"category: str | None",
|
|
"location: str =",
|
|
"remote: bool =",
|
|
"salary_min: int | None",
|
|
"salary_max: int | None",
|
|
]) {
|
|
assert.ok(pyCreateBlock.includes(f), `base.py EmployerPostingCreate missing ${f}`);
|
|
}
|
|
|
|
// EmployerPostingEdit: the partial shape (title/description/skills).
|
|
const editBlock = typesSrc.slice(
|
|
typesSrc.indexOf("export interface EmployerPostingEdit"),
|
|
typesSrc.indexOf("export interface EmployerPostingWriteResponse"),
|
|
);
|
|
for (const f of ["title?: string;", "description?: string;", "skills?: string[];"]) {
|
|
assert.ok(editBlock.includes(f), `EmployerPostingEdit missing base.py field ${f}`);
|
|
}
|
|
const pyEditBlock = pySrc.slice(
|
|
pySrc.indexOf("class EmployerPostingEdit"),
|
|
pySrc.indexOf("class EmployerPostingWriteResponse"),
|
|
);
|
|
for (const f of [
|
|
"title: str | None = Field(default=None, min_length=1)",
|
|
"description: str | None = None",
|
|
"skills: list[str] | None = None",
|
|
]) {
|
|
assert.ok(pyEditBlock.includes(f), `base.py EmployerPostingEdit missing ${f}`);
|
|
}
|
|
|
|
// EmployerPostingWriteResponse: the write-verdict shape both sides.
|
|
const respBlock = typesSrc.slice(
|
|
typesSrc.indexOf("export interface EmployerPostingWriteResponse"),
|
|
);
|
|
for (const f of [
|
|
"id: string;",
|
|
"source: string;",
|
|
"external_id: string;",
|
|
"employer_id: string;",
|
|
"title: string;",
|
|
"status: string;",
|
|
]) {
|
|
assert.ok(respBlock.includes(f), `EmployerPostingWriteResponse missing base.py field ${f}`);
|
|
}
|
|
const pyRespBlock = pySrc.slice(pySrc.indexOf("class EmployerPostingWriteResponse"));
|
|
for (const f of ["id: str", "source: str", "external_id: str", "employer_id: str", "title: str", "status: str"]) {
|
|
assert.ok(pyRespBlock.includes(f), `base.py EmployerPostingWriteResponse missing ${f}`);
|
|
}
|
|
});
|
|
|
|
// -- v0.10 P5 billing + pricing CTAs (REQ-10-005, Task 5-2-01) ----------------------
|
|
|
|
test("wire pin: EmployerCheckout/Entitlement/MockComplete shapes mirror employers/base.py field-for-field (P5 billing contracts)", async () => {
|
|
const typesSrc = readFileSync(web("../../packages/types/employer.ts"), "utf8");
|
|
const pySrc = readFileSync(
|
|
web("../ai-service/ai_service/employers/base.py"),
|
|
"utf8",
|
|
);
|
|
|
|
// EmployerCheckoutCreate: item_id only (D-076's employer_id body
|
|
// field is GONE — the claimed entity is the only buyer).
|
|
const createBlock = typesSrc.slice(
|
|
typesSrc.indexOf("export interface EmployerCheckoutCreate"),
|
|
typesSrc.indexOf("export interface EmployerCheckoutResponse"),
|
|
);
|
|
assert.ok(
|
|
createBlock.includes("item_id: string;"),
|
|
"EmployerCheckoutCreate carries item_id",
|
|
);
|
|
assert.ok(
|
|
!/employer_id/.test(createBlock),
|
|
"EmployerCheckoutCreate never carries employer_id (claim-derived buyer)",
|
|
);
|
|
|
|
// EmployerCheckoutResponse: the v0.9 CheckoutResponse shape reused.
|
|
const respBlock = typesSrc.slice(
|
|
typesSrc.indexOf("export interface EmployerCheckoutResponse"),
|
|
typesSrc.indexOf("export interface EmployerPurchaseEntitlement"),
|
|
);
|
|
for (const f of [
|
|
"session_id: string;",
|
|
"provider_session_id: string;",
|
|
"url: string;",
|
|
"mode: string;",
|
|
]) {
|
|
assert.ok(respBlock.includes(f), `EmployerCheckoutResponse missing base.py field ${f}`);
|
|
}
|
|
|
|
// The purchase/subscription entitlement summaries — the EXACT keys
|
|
// billing/store.py get_entitlement emits (the v0.9 shape reused).
|
|
const purchaseBlock = typesSrc.slice(
|
|
typesSrc.indexOf("export interface EmployerPurchaseEntitlement"),
|
|
typesSrc.indexOf("export interface EmployerSubscriptionEntitlement"),
|
|
);
|
|
for (const f of [
|
|
"id: string;",
|
|
"package_id: string;",
|
|
"package_name: string;",
|
|
"posts_used: number;",
|
|
"posts_limit: number | null;",
|
|
"expires_at: string | null;",
|
|
]) {
|
|
assert.ok(purchaseBlock.includes(f), `EmployerPurchaseEntitlement missing store key ${f}`);
|
|
}
|
|
const subBlock = typesSrc.slice(
|
|
typesSrc.indexOf("export interface EmployerSubscriptionEntitlement"),
|
|
typesSrc.indexOf("export interface EmployerEntitlementResponse"),
|
|
);
|
|
for (const f of [
|
|
"id: string;",
|
|
"plan_id: string;",
|
|
"plan_name: string;",
|
|
"status: string;",
|
|
"current_period_end: string | null;",
|
|
"past_due_at: string | null;",
|
|
]) {
|
|
assert.ok(subBlock.includes(f), `EmployerSubscriptionEntitlement missing store key ${f}`);
|
|
}
|
|
|
|
// EmployerEntitlementResponse: honest nulls until a real grant.
|
|
const entBlock = typesSrc.slice(
|
|
typesSrc.indexOf("export interface EmployerEntitlementResponse"),
|
|
typesSrc.indexOf("export interface EmployerMockCompleteRequest"),
|
|
);
|
|
assert.ok(entBlock.includes("employer_id: string;"), "employer_id");
|
|
assert.ok(
|
|
entBlock.includes("purchase: EmployerPurchaseEntitlement | null;"),
|
|
"purchase is honestly nullable",
|
|
);
|
|
assert.ok(
|
|
entBlock.includes("subscription: EmployerSubscriptionEntitlement | null;"),
|
|
"subscription is honestly nullable",
|
|
);
|
|
|
|
// EmployerMockCompleteRequest/Response (D-082/G-301/G-302).
|
|
const reqBlock = typesSrc.slice(
|
|
typesSrc.indexOf("export interface EmployerMockCompleteRequest"),
|
|
typesSrc.indexOf("export interface EmployerMockCompleteResponse"),
|
|
);
|
|
assert.ok(
|
|
reqBlock.includes("provider_session_id: string;"),
|
|
"the untrusted ID reference (G-302)",
|
|
);
|
|
const mockBlock = typesSrc.slice(
|
|
typesSrc.indexOf("export interface EmployerMockCompleteResponse"),
|
|
);
|
|
for (const f of ["completed: boolean;", "payment_status: string;"]) {
|
|
assert.ok(mockBlock.includes(f), `EmployerMockCompleteResponse missing ${f}`);
|
|
}
|
|
|
|
// The py side keeps the same contracts (base.py P5 classes).
|
|
const pyP5Block = pySrc.slice(pySrc.indexOf("class EmployerCheckoutCreate"));
|
|
for (const f of [
|
|
"class EmployerCheckoutCreate",
|
|
"item_id: str = Field(min_length=1)",
|
|
"class EmployerCheckoutResponse",
|
|
"session_id: str",
|
|
"provider_session_id: str",
|
|
"url: str",
|
|
"mode: str",
|
|
"class EmployerEntitlementResponse",
|
|
"purchase: dict[str, Any] | None = None",
|
|
"subscription: dict[str, Any] | None = None",
|
|
"class EmployerMockCompleteRequest",
|
|
"class EmployerMockCompleteResponse",
|
|
"completed: bool",
|
|
"payment_status: str",
|
|
]) {
|
|
assert.ok(pyP5Block.includes(f), `base.py P5 billing contract missing ${f}`);
|
|
}
|
|
});
|
|
|
|
test("wire pin: admin overview shapes mirror employers_admin.py operator contracts (Task 5-2-02, D-094)", async () => {
|
|
const typesSrc = readFileSync(web("../../packages/types/employer.ts"), "utf8");
|
|
const pySrc = readFileSync(
|
|
web("../ai-service/ai_service/api/employers_admin.py"),
|
|
"utf8",
|
|
);
|
|
|
|
// AdminEmployerAccount: the accounts-list wire row, both sides.
|
|
const acctBlock = typesSrc.slice(
|
|
typesSrc.indexOf("export interface AdminEmployerAccount"),
|
|
typesSrc.indexOf("export interface AdminAccountsResponse"),
|
|
);
|
|
for (const f of [
|
|
"id: string;",
|
|
"email: string;",
|
|
"employer_id: string | null;",
|
|
"created_at: string;",
|
|
"email_verified: false;",
|
|
]) {
|
|
assert.ok(acctBlock.includes(f), `AdminEmployerAccount missing employers_admin.py field ${f}`);
|
|
}
|
|
// D-097: the unverified marker can never be true; D-087: the
|
|
// password hash is NEVER a wire field.
|
|
assert.ok(!/email_verified: boolean/.test(acctBlock), "email_verified is false, never boolean");
|
|
assert.ok(!/password_hash/.test(acctBlock), "password_hash never rides the wire (D-087)");
|
|
assert.ok(/email_verified: Literal\[False\]/.test(pySrc), "py side pins Literal[False]");
|
|
|
|
// AdminAccountsResponse / AdminClaimsResponse / ClaimDecisionResponse.
|
|
const accountsRespBlock = typesSrc.slice(
|
|
typesSrc.indexOf("export interface AdminAccountsResponse"),
|
|
typesSrc.indexOf("export interface AdminClaimsResponse"),
|
|
);
|
|
assert.ok(
|
|
accountsRespBlock.includes("accounts: AdminEmployerAccount[];"),
|
|
"AdminAccountsResponse.accounts",
|
|
);
|
|
assert.ok(accountsRespBlock.includes("count: number;"), "AdminAccountsResponse.count");
|
|
const claimsRespBlock = typesSrc.slice(
|
|
typesSrc.indexOf("export interface AdminClaimsResponse"),
|
|
typesSrc.indexOf("export interface ClaimDecisionResponse"),
|
|
);
|
|
assert.ok(
|
|
claimsRespBlock.includes("claims: EmployerClaim[];"),
|
|
"the queue reuses the EmployerClaim wire shape",
|
|
);
|
|
assert.ok(claimsRespBlock.includes("count: number;"), "AdminClaimsResponse.count");
|
|
const decisionBlock = typesSrc.slice(
|
|
typesSrc.indexOf("export interface ClaimDecisionResponse"),
|
|
typesSrc.indexOf("export interface AdminAuditEvent"),
|
|
);
|
|
assert.ok(decisionBlock.includes("claim: EmployerClaim;"), "ClaimDecisionResponse.claim");
|
|
|
|
// AdminAuditEvent / AdminAuditResponse — the BillingEventRow shape.
|
|
const auditBlock = typesSrc.slice(
|
|
typesSrc.indexOf("export interface AdminAuditEvent"),
|
|
typesSrc.indexOf("export interface AdminAuditResponse"),
|
|
);
|
|
for (const f of [
|
|
"id: number;",
|
|
"account_id: string | null;",
|
|
"employer_id: string | null;",
|
|
"kind: string;",
|
|
"name: string;",
|
|
"detail: Record<string, unknown>;",
|
|
"operator: string | null;",
|
|
"created_at: string | null;",
|
|
]) {
|
|
assert.ok(auditBlock.includes(f), `AdminAuditEvent missing employers_admin.py field ${f}`);
|
|
}
|
|
const auditRespBlock = typesSrc.slice(
|
|
typesSrc.indexOf("export interface AdminAuditResponse"),
|
|
);
|
|
assert.ok(auditRespBlock.includes("events: AdminAuditEvent[];"), "AdminAuditResponse.events");
|
|
assert.ok(auditRespBlock.includes("count: number;"), "AdminAuditResponse.count");
|
|
|
|
// The py side keeps the same contracts (employers_admin.py).
|
|
for (const f of [
|
|
"class AdminEmployerAccount",
|
|
"class AdminAccountsResponse",
|
|
"class AdminClaimsResponse",
|
|
"class ClaimDecisionResponse",
|
|
"class AdminAuditEvent",
|
|
"class AdminAuditResponse",
|
|
"claims: list[EmployerClaim]",
|
|
"claim: EmployerClaim",
|
|
"events: list[AdminAuditEvent]",
|
|
]) {
|
|
assert.ok(pySrc.includes(f), `employers_admin.py missing wire contract ${f}`);
|
|
}
|
|
});
|
|
|
|
test("pricing pin: purchasable CTAs route to /employer/login; the enterprise Contact Sales pin STAYS (Task 5-2-01, MH-5d)", async () => {
|
|
const client = readFileSync(
|
|
web("components/marketplace/pricing-client.tsx"),
|
|
"utf8",
|
|
);
|
|
// The employer-login path is the purchasable-tier CTA href.
|
|
assert.ok(
|
|
/href=\{isEnterprise \? '[^']*' : '\/employer\/login'\}/.test(client),
|
|
"purchasable tiers route to the employer-login path",
|
|
);
|
|
// The enterprise contact CTA is RETAINED (R-61: the pins.test.ts:250
|
|
// pin holds — price_cents=0 rows stay operator-mediated).
|
|
assert.ok(/Contact Sales/.test(client), "enterprise Contact Sales retained");
|
|
assert.ok(
|
|
/if \(isEnterprise\) return 'Contact Sales';/.test(client),
|
|
"enterprise arm keeps the contact copy",
|
|
);
|
|
// The operator-mediated-only pilot copy is GONE — replaced by the
|
|
// self-serve truth with the admin fallback noted.
|
|
assert.ok(
|
|
!/operator-mediated during the pilot/.test(client),
|
|
"the D-079 operator-mediated copy is superseded",
|
|
);
|
|
assert.ok(
|
|
/operator-assisted checkout remains available as a fallback/.test(client),
|
|
"the admin-fallback is noted honestly",
|
|
);
|
|
assert.ok(/mock provider \(honestly labeled/.test(client), "the pilot mock path is labeled");
|
|
});
|
|
|
|
// v0.11 P3 (D-110): the placement wire pins — placement.ts mirrors
|
|
// placements/base.py field-for-field (the G-108 discipline; the
|
|
// employer.ts block's template). Every change happens in both places.
|
|
|
|
test("wire pin: Placement mirrors placements/base.py PlacementRead field-for-field (dual-schema sync)", async () => {
|
|
const typesSrc = readFileSync(web("../../packages/types/placement.ts"), "utf8");
|
|
const pySrc = readFileSync(
|
|
web("../../apps/ai-service/ai_service/placements/base.py"),
|
|
"utf8",
|
|
);
|
|
const block = typesSrc.slice(
|
|
typesSrc.indexOf("export interface Placement {"),
|
|
typesSrc.indexOf("export interface PlacementEvent"),
|
|
);
|
|
const fields = [
|
|
"id:", "learner_id:", "employer_id:", "posting_id:",
|
|
"application_id:", "role_title:", "start_date:", "status:",
|
|
"created_at:", "decided_at:",
|
|
];
|
|
for (const f of fields) {
|
|
assert.ok(block.includes(f), `Placement missing base.py field ${f}`);
|
|
}
|
|
// start_date is a DATE on both sides (D-103) — never a datetime
|
|
assert.ok(
|
|
/start_date: date/.test(pySrc),
|
|
"base.py start_date is a pydantic date",
|
|
);
|
|
assert.ok(
|
|
/start_date: string;/.test(block),
|
|
"placement.ts start_date is the ISO date string",
|
|
);
|
|
});
|
|
|
|
test("wire pin: ApplicationStatus union mirrors the D-099 matrix (status-union regex pin)", async () => {
|
|
const typesSrc = readFileSync(web("../../packages/types/placement.ts"), "utf8");
|
|
const block = typesSrc.slice(
|
|
typesSrc.indexOf("export type ApplicationStatus"),
|
|
typesSrc.indexOf("export type PlacementStatus"),
|
|
);
|
|
// the seven D-099 statuses, in matrix order
|
|
const statuses = [
|
|
"'submitted'", "'in_review'", "'interview'", "'offer'",
|
|
"'hired'", "'rejected'", "'withdrawn'",
|
|
];
|
|
for (const status of statuses) {
|
|
assert.ok(block.includes(status), `ApplicationStatus missing ${status}`);
|
|
}
|
|
assert.ok(
|
|
!/"submitted"/.test(block),
|
|
"snake_case on the wire (no kebab 'in-review')",
|
|
);
|
|
});
|
|
|
|
test("wire pin: PlacementOverview mirrors the D-107 computation shape", async () => {
|
|
const typesSrc = readFileSync(web("../../packages/types/placement.ts"), "utf8");
|
|
const block = typesSrc.slice(
|
|
typesSrc.indexOf("export interface PlacementOverview"),
|
|
);
|
|
const fields = [
|
|
"total_active_placements:", "distinct_learners_placed:",
|
|
"guiding_goal:", "by_employer:", "by_cohort_month:",
|
|
];
|
|
for (const f of fields) {
|
|
assert.ok(block.includes(f), `PlacementOverview missing ${f}`);
|
|
}
|
|
assert.ok(
|
|
/export const GUIDING_GOAL = 1000;/.test(typesSrc),
|
|
"the north star is 1,000 learners",
|
|
);
|
|
});
|
|
|
|
test("wire pin: EmployerApplicationSummary carries learner_id per the G-319 supersession (D-105)", async () => {
|
|
const typesSrc = readFileSync(web("../../packages/types/placement.ts"), "utf8");
|
|
const block = typesSrc.slice(
|
|
typesSrc.indexOf("export interface EmployerApplicationSummary"),
|
|
typesSrc.indexOf("export interface EmployerApplicationsResponse"),
|
|
);
|
|
assert.ok(/learner_id: string;/.test(block), "learner_id rides the summary");
|
|
// the own-postings LIST stays counts-only: OwnPostingSummary (the
|
|
// v0.10 contract) still carries ONLY applicant_count — never a
|
|
// learner_id field
|
|
const employerTypes = readFileSync(web("../../packages/types/employer.ts"), "utf8");
|
|
const ownBlock = employerTypes.slice(
|
|
employerTypes.indexOf("export interface OwnPostingSummary"),
|
|
employerTypes.indexOf("export interface OwnPostingsFacets"),
|
|
);
|
|
assert.ok(
|
|
!/learner_id/.test(ownBlock),
|
|
"the G-319 pin split: OwnPostingSummary stays counts-only",
|
|
);
|
|
});
|
|
|
|
// v0.11 P5 (REQ-11-005, D-110): the learner status-read wire pins +
|
|
// the A-304 badge-honesty census — no placement badge on the mock
|
|
// talent surfaces (D-092 binding: a badge on a mock page is
|
|
// fabricated placement); the learner's-own application surface is the
|
|
// ONLY placement render.
|
|
|
|
test("wire pin: LearnerApplicationStatus mirrors the base.py learner status read (D-104)", async () => {
|
|
const typesSrc = readFileSync(web("../../packages/types/placement.ts"), "utf8");
|
|
const pySrc = readFileSync(
|
|
web("../../apps/ai-service/ai_service/placements/base.py"),
|
|
"utf8",
|
|
);
|
|
const summaryBlock = typesSrc.slice(
|
|
typesSrc.indexOf("export interface LearnerApplicationSummary"),
|
|
typesSrc.indexOf("export interface LearnerApplicationStatus"),
|
|
);
|
|
for (const f of ["id:", "posting_id:", "learner_id:", "status:", "created_at:"]) {
|
|
assert.ok(summaryBlock.includes(f), `LearnerApplicationSummary missing ${f}`);
|
|
}
|
|
const statusBlock = typesSrc.slice(
|
|
typesSrc.indexOf("export interface LearnerApplicationStatus"),
|
|
);
|
|
assert.ok(/application: LearnerApplicationSummary;/.test(statusBlock));
|
|
assert.ok(/events: PlacementEvent\[\];/.test(statusBlock));
|
|
assert.ok(/placement: Placement \| null;/.test(statusBlock));
|
|
// the backend contract carries the same three arms (base.py)
|
|
const pyBlock = pySrc.slice(pySrc.indexOf("class LearnerApplicationStatusResponse"));
|
|
assert.ok(/application: LearnerApplicationSummary/.test(pyBlock));
|
|
assert.ok(/events: list\[PlacementEventRead\]/.test(pyBlock));
|
|
assert.ok(/placement: PlacementRead \| None/.test(pyBlock));
|
|
// the mine-read contract (G-322)
|
|
const mineBlock = typesSrc.slice(typesSrc.indexOf("export interface MineApplication"));
|
|
assert.ok(/application_id: string;/.test(mineBlock));
|
|
assert.ok(/status: ApplicationStatus;/.test(mineBlock));
|
|
assert.ok(
|
|
/class MineApplicationResponse/.test(pySrc),
|
|
"base.py carries the mine contract",
|
|
);
|
|
});
|
|
|
|
test("pin: A-304 badge honesty — no placement badge on the mock talent surfaces (D-092/D-109)", async () => {
|
|
const surfaces = [
|
|
"app/(employer)/employer/talent/page.tsx",
|
|
"app/(employer)/employer/talent/[candidateId]/page.tsx",
|
|
"components/employer/talent-search.tsx",
|
|
];
|
|
for (const surface of surfaces) {
|
|
const src = readFileSync(web(surface), "utf8");
|
|
assert.ok(
|
|
!/placement/i.test(src),
|
|
`${surface}: a badge on a mock surface is fabricated placement (D-092 binding)`,
|
|
);
|
|
}
|
|
// the learner's-own application surface is the ONLY placement render
|
|
const jobDetail = readFileSync(
|
|
web("components/marketplace/job-detail-client.tsx"),
|
|
"utf8",
|
|
);
|
|
assert.ok(
|
|
/appStatus\.placement &&/.test(jobDetail),
|
|
"the placement confirmation renders only on the learner's-own application surface",
|
|
);
|
|
const placementBlock = jobDetail.slice(
|
|
jobDetail.indexOf("appStatus.placement &&"),
|
|
jobDetail.indexOf("{WITHDRAWABLE_STATUSES.has("),
|
|
);
|
|
assert.ok(
|
|
/Placement recorded/.test(placementBlock),
|
|
"G-328: the placement copy says RECORDED",
|
|
);
|
|
assert.ok(
|
|
!/verified/i.test(placementBlock),
|
|
"G-328: no verification claim on the placement surface",
|
|
);
|
|
});
|