mirror of
https://github.com/MadsLorentzen/ai-job-search.git
synced 2026-11-02 13:12:50 +00:00
test(portal-clis): isolate valid flag controls from live services (#581)
* test(portal-clis): isolate valid flag controls from live services The LinkedIn and Freehire positive controls spawned live search CLIs and only checked for the absence of BAD_ARG, so attempted portal requests still left a green suite. Use loopback responses and assert successful output plus the constructed freshness, pagination and result-limit contract. LinkedIn cards are verbatim excerpts from portapulito's actual captured response in PR #153; fixture provenance and original hashes are retained. Four Danish validation suites already stop at required-argument guards and need no change. This isolates these controls rather than claiming a whole-CI egress policy. * docs(changelog): record fix for #581
This commit is contained in:
1 parent
23876bfa95
commit
2017521d2e
6 files changed
+104
-16
No files matched your search
@@ -1,9 +1,8 @@
|
|||||||
import { describe, test, expect } from "bun:test";
|
import { describe, test, expect } from "bun:test";
|
||||||
import { runCLI } from "./helpers";
|
import { runCLI } from "./helpers";
|
||||||
|
|
||||||
// These assert on validation error codes that are emitted BEFORE any network
|
// Invalid flags fail before any request. Valid-flag controls run against a
|
||||||
// call (or independently of it), so the suite is network-free: a valid-flag case
|
// loopback fixture and assert the requested filters, not just an absent error.
|
||||||
// still runs offline because it only checks the ABSENCE of a validation error.
|
|
||||||
|
|
||||||
function parsedStderr(stderr: string): { error?: string; code?: string } {
|
function parsedStderr(stderr: string): { error?: string; code?: string } {
|
||||||
try {
|
try {
|
||||||
@@ -51,9 +50,30 @@ describe("freehire CLI flag validation", () => {
|
|||||||
expect(parsedStderr(result.stderr).code).toBe("BAD_ARG");
|
expect(parsedStderr(result.stderr).code).toBe("BAD_ARG");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("valid integers produce no BAD_ARG", async () => {
|
test("valid integers construct the requested search offline", async () => {
|
||||||
const result = await runCLI(["search", "--jobage", "7", "--page", "1", "--limit", "1"]);
|
const requests: URL[] = [];
|
||||||
expect(parsedStderr(result.stderr).code).not.toBe("BAD_ARG");
|
const server = Bun.serve({
|
||||||
|
hostname: "127.0.0.1",
|
||||||
|
port: 0,
|
||||||
|
fetch(request) {
|
||||||
|
requests.push(new URL(request.url));
|
||||||
|
return Response.json({ data: [], meta: { total: 0 } });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const result = await runCLI(["search", "--jobage", "7", "--page", "1", "--limit", "1"],
|
||||||
|
{ FREEHIRE_API_URL: server.url.toString() });
|
||||||
|
expect(result.exitCode).toBe(0);
|
||||||
|
expect(result.stderr).toBe("");
|
||||||
|
expect(requests).toHaveLength(1);
|
||||||
|
expect(requests[0].pathname).toBe("/api/v1/agent/jobs/search");
|
||||||
|
expect(requests[0].searchParams.get("posted_within_days")).toBe("7");
|
||||||
|
expect(requests[0].searchParams.get("limit")).toBe("1");
|
||||||
|
expect(requests[0].searchParams.get("offset")).toBe("0");
|
||||||
|
expect(JSON.parse(result.stdout)).toEqual({ meta: { count: 0, page: 1, total: 0 }, results: [] });
|
||||||
|
} finally {
|
||||||
|
server.stop(true);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, test, expect } from "bun:test";
|
import { describe, test, expect } from "bun:test";
|
||||||
import { runCLI } from "./helpers";
|
import { runCLI, runSearchFixture } from "./helpers";
|
||||||
|
|
||||||
const LOCATION = "Copenhagen, Denmark";
|
const LOCATION = "Copenhagen, Denmark";
|
||||||
|
|
||||||
@@ -28,9 +28,15 @@ describe("LinkedIn CLI flag validation", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("valid integer passes validation", async () => {
|
test("valid integer passes validation", async () => {
|
||||||
const result = await runCLI(["search", "-l", LOCATION, "--jobage", "7", "--limit", "1"]);
|
const { result, requests } = await runSearchFixture(["search", "-l", LOCATION, "--jobage", "7", "--limit", "1"]);
|
||||||
const err = parsedStderr(result.stderr);
|
expect(result.exitCode).toBe(0);
|
||||||
expect(err.code).not.toBe("BAD_ARG");
|
expect(result.stderr).toBe("");
|
||||||
|
expect(requests).toHaveLength(1);
|
||||||
|
expect(requests[0].searchParams.get("location")).toBe(LOCATION);
|
||||||
|
expect(requests[0].searchParams.get("f_TPR")).toBe("r604800");
|
||||||
|
const output = JSON.parse(result.stdout);
|
||||||
|
expect(output.meta.count).toBe(1);
|
||||||
|
expect(output.results.map((job: { id: string }) => job.id)).toEqual(["4425498329"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fractional values must be rejected, not truncated: parseInt("0.5") is 0,
|
// Fractional values must be rejected, not truncated: parseInt("0.5") is 0,
|
||||||
@@ -126,12 +132,18 @@ describe("LinkedIn CLI flag validation", () => {
|
|||||||
expect(err.code).toBe("NO_LOCATION");
|
expect(err.code).toBe("NO_LOCATION");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("all valid flags produce no BAD_ARG", async () => {
|
test("all valid flags construct the requested search offline", async () => {
|
||||||
const result = await runCLI([
|
const { result, requests } = await runSearchFixture([
|
||||||
"search", "-l", LOCATION, "--jobage", "7", "--page", "1", "--limit", "5",
|
"search", "-l", LOCATION, "--jobage", "7", "--page", "1", "--limit", "5",
|
||||||
]);
|
]);
|
||||||
const err = parsedStderr(result.stderr);
|
expect(result.exitCode).toBe(0);
|
||||||
expect(err.code).not.toBe("BAD_ARG");
|
expect(result.stderr).toBe("");
|
||||||
|
expect(requests).toHaveLength(1);
|
||||||
|
expect(requests[0].searchParams.get("f_TPR")).toBe("r604800");
|
||||||
|
expect(requests[0].searchParams.get("start")).toBe("0");
|
||||||
|
const output = JSON.parse(result.stdout);
|
||||||
|
expect(output.meta).toEqual({ count: 2, page: 1 });
|
||||||
|
expect(output.results.map((job: { id: string }) => job.id)).toEqual(["4425498329", "4425494976"]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
"<li>\n \n \n\n \n \n \n <div class=\"base-card relative w-full hover:no-underline focus:no-underline\n base-card--link\n base-search-card base-search-card--link job-search-card\" data-entity-urn=\"urn:li:jobPosting:4425498329\" data-impression-id=\"jobs-search-result-0\" data-reference-id=\"rgFs/22WxICKVQ1aeDSZxA==\" data-tracking-id=\"xlkXMyr4ByKPoDagDz77pg==\" data-column=\"1\" data-row=\"1\">\n \n\n <a class=\"base-card__full-link absolute top-0 right-0 bottom-0 left-0 p-0 z-[2] outline-offset-[4px]\" href=\"https://it.linkedin.com/jobs/view/artificial-intelligence-engineer-at-humans-tech-4425498329?position=1&pageNum=0&refId=rgFs%2F22WxICKVQ1aeDSZxA%3D%3D&trackingId=xlkXMyr4ByKPoDagDz77pg%3D%3D\" data-tracking-control-name=\"public_jobs_jserp-result_search-card\" data-tracking-client-ingraph data-tracking-will-navigate>\n \n <span class=\"sr-only\">\n \n \n Artificial Intelligence Engineer\n \n \n </span>\n </a>\n\n \n \n <div class=\"search-entity-media\">\n \n <img class=\"artdeco-entity-image artdeco-entity-image--square-4\n \" data-delayed-url=\"https://media.licdn.com/dms/image/v2/D4D0BAQGzAG6Xx3nmcA/company-logo_100_100/company-logo_100_100/0/1703243057096/wearekromin_logo?e=2147483647&v=beta&t=zaP3tbQvW4WbshE93SaJi-v-NuDmtSlfvCz7lr6xXCE\" data-ghost-classes=\"artdeco-entity-image--ghost\" data-ghost-url=\"https://static.licdn.com/aero-v1/sc/h/6puxblwmhnodu6fjircz4dn4h\" alt>\n \n </div>\n \n\n <div class=\"base-search-card__info\">\n <h3 class=\"base-search-card__title\">\n \n Artificial Intelligence Engineer\n \n </h3>\n\n <h4 class=\"base-search-card__subtitle\">\n \n <a class=\"hidden-nested-link\" data-tracking-client-ingraph data-tracking-control-name=\"public_jobs_jserp-result_job-search-card-subtitle\" data-tracking-will-navigate href=\"https://it.linkedin.com/company/wearehumanstech?trk=public_jobs_jserp-result_job-search-card-subtitle\">\n Humans.tech\n </a>\n \n </h4>\n\n<!---->\n <div class=\"base-search-card__metadata\">\n \n <span class=\"job-search-card__location\">\n Italy\n </span>\n\n \n \n \n \n \n\n <div class=\"job-posting-benefits text-sm\">\n <icon class=\"job-posting-benefits__icon\" data-delayed-url=\"https://static.licdn.com/aero-v1/sc/h/3p1v0uhy7uq0cm5zdvzp4eo18\" data-svg-class-name=\"job-posting-benefits__icon-svg\"></icon>\n <span class=\"job-posting-benefits__text\">\n Actively Hiring\n<!----> </span>\n </div>\n \n\n <time class=\"job-search-card__listdate\" datetime=\"2026-07-10\">\n \n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n 4 days ago\n \n </time>\n\n<!----> \n </div>\n </div>\n<!----> \n \n </div>\n \n \n \n \n </li>\n<li>\n \n \n\n \n \n \n <div class=\"base-card relative w-full hover:no-underline focus:no-underline\n base-card--link\n base-search-card base-search-card--link job-search-card\" data-entity-urn=\"urn:li:jobPosting:4425494976\" data-impression-id=\"jobs-search-result-1\" data-reference-id=\"rgFs/22WxICKVQ1aeDSZxA==\" data-tracking-id=\"qBQxhsuHhbnEPiON5x8+yQ==\" data-column=\"1\" data-row=\"2\">\n \n\n <a class=\"base-card__full-link absolute top-0 right-0 bottom-0 left-0 p-0 z-[2] outline-offset-[4px]\" href=\"https://it.linkedin.com/jobs/view/machine-learning-engineer-at-humans-tech-4425494976?position=2&pageNum=0&refId=rgFs%2F22WxICKVQ1aeDSZxA%3D%3D&trackingId=qBQxhsuHhbnEPiON5x8%2ByQ%3D%3D\" data-tracking-control-name=\"public_jobs_jserp-result_search-card\" data-tracking-client-ingraph data-tracking-will-navigate>\n \n <span class=\"sr-only\">\n \n \n Machine Learning Engineer\n \n \n </span>\n </a>\n\n \n \n <div class=\"search-entity-media\">\n \n <img class=\"artdeco-entity-image artdeco-entity-image--square-4\n \" data-delayed-url=\"https://media.licdn.com/dms/image/v2/D4D0BAQGzAG6Xx3nmcA/company-logo_100_100/company-logo_100_100/0/1703243057096/wearekromin_logo?e=2147483647&v=beta&t=zaP3tbQvW4WbshE93SaJi-v-NuDmtSlfvCz7lr6xXCE\" data-ghost-classes=\"artdeco-entity-image--ghost\" data-ghost-url=\"https://static.licdn.com/aero-v1/sc/h/6puxblwmhnodu6fjircz4dn4h\" alt>\n \n </div>\n \n\n <div class=\"base-search-card__info\">\n <h3 class=\"base-search-card__title\">\n \n Machine Learning Engineer\n Line truncated
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# Offline search cards
|
||||||
|
|
||||||
|
`search-cards.json` stores the HTML string for the first two verbatim `<li>` cards from a real
|
||||||
|
LinkedIn guest search response captured by **portapulito** through the CLI's
|
||||||
|
`htmlFetch` on 2026-07-14 and published in [PR #153](https://github.com/MadsLorentzen/ai-job-search/pull/153).
|
||||||
|
That PR was closed as an accidental personalized-fork submission, not merged.
|
||||||
|
|
||||||
|
Source: `.agents/skills/linkedin-search/cli/test/__fixtures__/search-list.html`
|
||||||
|
at commit `5bc3afb94d19b670ecf09287c20d0c95e5d13ba0`.
|
||||||
|
Original response SHA-256: `ff5fb9009f26c8f602b1ac4b018f471e76784300723b22db64d9d19c2b7f557c`.
|
||||||
|
Two-card excerpt SHA-256: `1ab95e6ccb38b50d922e4d714ff95bb58219c012489a3a94aca5e0adb7b552fb`.
|
||||||
|
|
||||||
|
The JSON string encoding preserves the original HTML whitespace without adding
|
||||||
|
trailing whitespace to source lines. The excerpt hash above is of the decoded HTML.
|
||||||
|
|
||||||
|
The valid-flag controls serve these saved cards over loopback HTTP. Their child
|
||||||
|
CLI preload accepts only the expected search endpoint and redirects that request
|
||||||
|
to loopback. Tests assert the actual URL parameters, successful output, IDs and
|
||||||
|
client-side result limits. No fixture regeneration or portal request occurs.
|
||||||
|
This isolates these controls; it is not an OS-level egress policy for the entire CI.
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
import { join } from "path";
|
import { join } from "path";
|
||||||
|
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
|
||||||
const CLI_PATH = join(import.meta.dir, "../src/cli.ts");
|
const CLI_PATH = join(import.meta.dir, "../src/cli.ts");
|
||||||
|
|
||||||
@@ -8,8 +10,8 @@ export interface CLIResult {
|
|||||||
exitCode: number;
|
exitCode: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function runCLI(args: string[]): Promise<CLIResult> {
|
export async function runCLI(args: string[], preload?: string): Promise<CLIResult> {
|
||||||
const proc = Bun.spawn(["bun", "run", CLI_PATH, ...args], {
|
const proc = Bun.spawn(["bun", "run", ...(preload ? ["--preload", preload] : []), CLI_PATH, ...args], {
|
||||||
stdout: "pipe",
|
stdout: "pipe",
|
||||||
stderr: "pipe",
|
stderr: "pipe",
|
||||||
});
|
});
|
||||||
@@ -23,6 +25,37 @@ export async function runCLI(args: string[]): Promise<CLIResult> {
|
|||||||
return { stdout: stdout.trim(), stderr: stderr.trim(), exitCode };
|
return { stdout: stdout.trim(), stderr: stderr.trim(), exitCode };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Run valid search controls through the real CLI with a saved, offline response. */
|
||||||
|
export async function runSearchFixture(args: string[]): Promise<{ result: CLIResult; requests: URL[] }> {
|
||||||
|
const requests: URL[] = [];
|
||||||
|
const server = Bun.serve({
|
||||||
|
hostname: "127.0.0.1",
|
||||||
|
port: 0,
|
||||||
|
async fetch(request) {
|
||||||
|
requests.push(new URL(request.url));
|
||||||
|
return new Response(await Bun.file(join(import.meta.dir, "fixtures/search-cards.json")).json());
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), "linkedin-valid-flags-"));
|
||||||
|
const preload = join(dir, "fetch.ts");
|
||||||
|
writeFileSync(preload, `
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
globalThis.fetch = (input, init) => {
|
||||||
|
const url = new URL(input instanceof Request ? input.url : String(input));
|
||||||
|
if (url.origin !== "https://www.linkedin.com" || url.pathname !== "/jobs-guest/jobs/api/seeMoreJobPostings/search") {
|
||||||
|
throw new Error("Unexpected search fixture request");
|
||||||
|
}
|
||||||
|
return originalFetch(${JSON.stringify(server.url.origin)} + url.pathname + url.search, init);
|
||||||
|
};
|
||||||
|
`);
|
||||||
|
try {
|
||||||
|
return { result: await runCLI(args, preload), requests };
|
||||||
|
} finally {
|
||||||
|
server.stop(true);
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function parseJSON<T = unknown>(result: CLIResult): T {
|
export function parseJSON<T = unknown>(result: CLIResult): T {
|
||||||
if (result.exitCode !== 0) {
|
if (result.exitCode !== 0) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
|
|||||||
@@ -324,6 +324,8 @@ per-file diff commands.
|
|||||||
unchanged, an absent one stays `""`. The JSON-LD test that pinned the raw `<p>...</p>` now
|
unchanged, an absent one stays `""`. The JSON-LD test that pinned the raw `<p>...</p>` now
|
||||||
expects the text, plus two new cases; the HTML case fails on master.
|
expects the text, plus two new cases; the HTML case fails on master.
|
||||||
|
|
||||||
|
- **Valid portal flag tests stay offline** (#581) - success controls use local fixtures instead of depending on live job portals or their credentials.
|
||||||
|
|
||||||
- **`job_key.py` no longer gives two postings one key when a title or company is partly
|
- **`job_key.py` no longer gives two postings one key when a title or company is partly
|
||||||
non-Latin** (`tools/job_key.py`, `tests/test_job_key.py`) - the hash fallbacks from #487
|
non-Latin** (`tools/job_key.py`, `tests/test_job_key.py`) - the hash fallbacks from #487
|
||||||
and #502 fired only when the slug was completely empty, so a mixed-script name kept its
|
and #502 fired only when the slug was completely empty, so a mixed-script name kept its
|
||||||
|
|||||||
Reference in new issue
Block a user