feat(linkedin-search): add --jobage-minutes for sub-day freshness windows (#302)

jobageToTPR() only emits whole-day f_TPR windows, so a search can't be
restricted to postings from the last N minutes. LinkedIn's f_TPR filters
server-side down to one-second granularity (confirmed empirically), so
this is a pure window-construction change via a new minutesToTPR()
helper - no HTML parsing changes needed.

--jobage-minutes and --jobage both express a freshness window; passing
both is rejected with CONFLICTING_AGE_FLAGS rather than one silently
overriding the other.
This commit is contained in:
Gurnoor Kaur
2026-08-07 15:50:23 +02:00
committed by GitHub
parent a7ac6fea75
commit b167efae3b
7 changed files with 113 additions and 2 deletions
@@ -47,6 +47,48 @@ describe("LinkedIn CLI flag validation", () => {
});
});
describe("--jobage-minutes validation", () => {
test("non-numeric string exits 1 with BAD_ARG", async () => {
const result = await runCLI(["search", "-l", LOCATION, "--jobage-minutes", "foo"]);
expect(result.exitCode).not.toBe(0);
const err = parsedStderr(result.stderr);
expect(err.code).toBe("BAD_ARG");
expect(err.error).toMatch(/jobage-minutes/);
});
test("zero exits 1 with BAD_ARG", async () => {
const result = await runCLI(["search", "-l", LOCATION, "--jobage-minutes", "0"]);
expect(result.exitCode).not.toBe(0);
const err = parsedStderr(result.stderr);
expect(err.code).toBe("BAD_ARG");
expect(err.error).toMatch(/jobage-minutes/);
});
test("negative value is parsed as a missing value and exits 1 with BAD_ARG", async () => {
// parseFlags in cli.ts treats a next-token starting with "-" as absent
// (`next.startsWith("-")` → flag becomes boolean `true`), and there is no
// `--flag=value` syntax. So "-5" never reaches --jobage-minutes as a value;
// parseInt("true") is NaN, and BAD_ARG comes from the NaN branch, not the
// `v <= 0` guard. Negatives are unreachable through the CLI as currently parsed.
const result = await runCLI(["search", "-l", LOCATION, "--jobage-minutes", "-5"]);
expect(result.exitCode).not.toBe(0);
const err = parsedStderr(result.stderr);
expect(err.code).toBe("BAD_ARG");
expect(err.error).toMatch(/jobage-minutes/);
});
});
describe("--jobage / --jobage-minutes conflict", () => {
test("both set exits 1 with CONFLICTING_AGE_FLAGS", async () => {
const result = await runCLI([
"search", "-l", LOCATION, "--jobage", "7", "--jobage-minutes", "30",
]);
expect(result.exitCode).not.toBe(0);
const err = parsedStderr(result.stderr);
expect(err.code).toBe("CONFLICTING_AGE_FLAGS");
});
});
describe("--page NaN validation", () => {
test("non-numeric string exits 1 with BAD_ARG", async () => {
const result = await runCLI(["search", "-l", LOCATION, "--page", "abc"]);
@@ -1,5 +1,5 @@
import { describe, test, expect } from "bun:test";
import { parseJobCards, parseJobDetail, extractDivContent } from "../src/helpers";
import { parseJobCards, parseJobDetail, extractDivContent, minutesToTPR } from "../src/helpers";
// Minimal search-card markup: parseJobCards splits on the job-posting URN and
// needs an id, a base-search-card__title, and a full-link. Everything else is
@@ -111,3 +111,16 @@ describe("extractDivContent", () => {
expect(job.description).toContain("We are hiring!");
});
});
describe("minutesToTPR", () => {
test("converts minutes to an f_TPR seconds window", () => {
expect(minutesToTPR(30)).toBe("r1800");
expect(minutesToTPR(1)).toBe("r60");
expect(minutesToTPR(1440)).toBe("r86400"); // matches jobageToTPR(1)
});
test("returns null for non-positive input", () => {
expect(minutesToTPR(0)).toBeNull();
expect(minutesToTPR(-5)).toBeNull();
});
});
@@ -39,4 +39,23 @@ describe("runSearch", () => {
expect(code).toBe(0);
expect(JSON.parse(stdout).results).toHaveLength(0);
});
test("--jobage-minutes 30 constructs f_TPR=r1800 in the request URL", async () => {
let capturedUrl = "";
globalThis.fetch = (async (input: RequestInfo | URL) => {
capturedUrl = typeof input === "string" ? input : input.toString();
return new Response("");
}) as typeof fetch;
const code = await runSearch({
location: "Remote",
jobage: 9999,
jobageMinutes: 30,
page: 1,
format: "json",
});
expect(code).toBe(0);
expect(capturedUrl).toContain("f_TPR=r1800");
});
});