mirror of
https://github.com/MadsLorentzen/ai-job-search.git
synced 2026-09-17 16:46:24 +00:00
The linkedin fixture was purpose-built for entity decoding and had no <time> or location element, so removing the date extraction - a /scrape contract field on a default-ON portal - survived the suite. jobindex's parseSearchPage (the Stash parser behind every search) had zero tests, so meta.total silently dropping hitcount survived too. Both mutations now fail exactly the new tests. The ASAP deadline branch is deliberately left to the F12 fix, which changes its behaviour to null. Review finding F35 (2026-08-19). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
167 lines
6.5 KiB
TypeScript
167 lines
6.5 KiB
TypeScript
import { describe, test, expect } from "bun:test";
|
|
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
|
|
// optional. We inject HTML entities into the title/company to exercise decoding.
|
|
function searchCard(id: string, title: string, company = "Acme"): string {
|
|
return `<li>
|
|
<div data-entity-urn="urn:li:jobPosting:${id}">
|
|
<a class="base-card__full-link" href="https://www.linkedin.com/jobs/view/${id}"></a>
|
|
<h3 class="base-search-card__title">${title}</h3>
|
|
<h4 class="base-search-card__subtitle"><a href="https://www.linkedin.com/company/acme">${company}</a></h4>
|
|
</div>
|
|
</li>`;
|
|
}
|
|
|
|
// The /scrape contract fields beyond title/company. The original fixture had
|
|
// no <time> or location element at all, so deleting the date extraction from
|
|
// parseJobCards left every test green (review finding F35, 2026-08-19).
|
|
function searchCardWithMeta(id: string, datetimeAttr: string, listdateClass = "job-search-card__listdate"): string {
|
|
return `<li>
|
|
<div data-entity-urn="urn:li:jobPosting:${id}">
|
|
<a class="base-card__full-link" href="https://www.linkedin.com/jobs/view/${id}"></a>
|
|
<h3 class="base-search-card__title">Data Engineer</h3>
|
|
<h4 class="base-search-card__subtitle"><a href="https://www.linkedin.com/company/acme">Acme</a></h4>
|
|
<span class="job-search-card__location">Copenhagen, Denmark</span>
|
|
<time class="${listdateClass}" datetime="${datetimeAttr}">3 days ago</time>
|
|
</div>
|
|
</li>`;
|
|
}
|
|
|
|
describe("parseJobCards contract fields", () => {
|
|
test("extracts date from the listdate <time> element", () => {
|
|
const [card] = parseJobCards(searchCardWithMeta("200", "2026-08-10"));
|
|
expect(card.date).toBe("2026-08-10");
|
|
});
|
|
|
|
test("extracts date from the listdate--new variant class", () => {
|
|
const [card] = parseJobCards(
|
|
searchCardWithMeta("201", "2026-08-15", "job-search-card__listdate--new"),
|
|
);
|
|
expect(card.date).toBe("2026-08-15");
|
|
});
|
|
|
|
test("extracts location from the location span", () => {
|
|
const [card] = parseJobCards(searchCardWithMeta("202", "2026-08-10"));
|
|
expect(card.location).toBe("Copenhagen, Denmark");
|
|
});
|
|
|
|
test("date and location are null when the elements are absent", () => {
|
|
const [card] = parseJobCards(searchCard("203", "Bare Card"));
|
|
expect(card.date).toBeNull();
|
|
expect(card.location).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("decodeHtmlEntities (via parseJobCards)", () => {
|
|
test("decodes hexadecimal numeric entities (é)", () => {
|
|
const [card] = parseJobCards(searchCard("123", "Café Manager"));
|
|
expect(card.title).toBe("Café Manager");
|
|
});
|
|
|
|
test("decodes uppercase-X hexadecimal entities (&#X...;)", () => {
|
|
const [card] = parseJobCards(searchCard("124", "Debüt Role")); // ü = ü
|
|
expect(card.title).toBe("Debüt Role");
|
|
});
|
|
|
|
test("still decodes decimal numeric entities (é) — regression", () => {
|
|
const [card] = parseJobCards(searchCard("125", "Café Lead"));
|
|
expect(card.title).toBe("Café Lead");
|
|
});
|
|
|
|
test("decodes supplementary-plane code points with fromCodePoint (😀)", () => {
|
|
const [card] = parseJobCards(searchCard("126", "Growth 😀"));
|
|
expect(card.title).toBe("Growth 😀");
|
|
});
|
|
|
|
test("decodes hex supplementary-plane code points (😀)", () => {
|
|
const [card] = parseJobCards(searchCard("127", "Growth 😀"));
|
|
expect(card.title).toBe("Growth 😀");
|
|
});
|
|
|
|
test("decodes hex entities in the company subtitle too", () => {
|
|
const [card] = parseJobCards(searchCard("128", "Engineer", "Nørrebro ApS"));
|
|
expect(card.company).toBe("Nørrebro ApS");
|
|
});
|
|
});
|
|
|
|
describe("decodeHtmlEntities (via parseJobDetail)", () => {
|
|
test("decodes hex entities inside the job title", () => {
|
|
const html = `<h1 class="topcard__title">Señor Engineer</h1>`;
|
|
const job = parseJobDetail(html, "999");
|
|
expect(job.title).toBe("Señor Engineer");
|
|
});
|
|
});
|
|
|
|
describe("extractDivContent", () => {
|
|
test("extracts content from simple div", () => {
|
|
const html = '<div class="description__text">Simple text</div>';
|
|
expect(extractDivContent(html, "description__text")).toBe("Simple text");
|
|
});
|
|
|
|
test("extracts content with nested divs — the regression case", () => {
|
|
const html = `<div class="description__text">
|
|
<div>Requirements:</div>
|
|
<ul><li>Skill A</li></ul>
|
|
<div>About Us:</div>
|
|
<p>We are...</p>
|
|
</div>`;
|
|
expect(extractDivContent(html, "description__text")).toBe(
|
|
'\n <div>Requirements:</div>\n <ul><li>Skill A</li></ul>\n <div>About Us:</div>\n <p>We are...</p>\n ',
|
|
);
|
|
});
|
|
|
|
test("returns null when class not found", () => {
|
|
expect(extractDivContent("<div>no class</div>", "nonexistent")).toBeNull();
|
|
});
|
|
|
|
test("works with show-more-less-html__markup class", () => {
|
|
const html = '<div class="show-more-less-html__markup">LinkedIn content</div>';
|
|
expect(extractDivContent(html, "show-more-less-html__markup")).toBe("LinkedIn content");
|
|
});
|
|
|
|
test("handles deeply nested divs (3 levels)", () => {
|
|
const html = `<div class="description__text">
|
|
<div>
|
|
<div>Deep content</div>
|
|
</div>
|
|
</div>`;
|
|
expect(extractDivContent(html, "description__text")).toBe(
|
|
'\n <div>\n <div>Deep content</div>\n </div>\n ',
|
|
);
|
|
});
|
|
|
|
test("handles empty content", () => {
|
|
const html = '<div class="description__text"></div>';
|
|
expect(extractDivContent(html, "description__text")).toBe("");
|
|
});
|
|
|
|
test("parseJobDetail uses extractDivContent and preserves full description", () => {
|
|
const html = `<div class="description__text">
|
|
<div>Requirements:</div>
|
|
<ul><li>5 years Python</li></ul>
|
|
<div>About Us:</div>
|
|
<p>We are hiring!</p>
|
|
</div>`;
|
|
const job = parseJobDetail(html, "999");
|
|
expect(job.description).toContain("Requirements:");
|
|
expect(job.description).toContain("5 years Python");
|
|
expect(job.description).toContain("About Us:");
|
|
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();
|
|
});
|
|
});
|