| 1 | // Fold tests for the GitHub companion app's reducers (pure, no DOM): |
| 2 | // pick-projected rows normalise into plain fields, malformed rows drop, the |
| 3 | // per-repo issues map stays bounded. node test/github.test.mjs |
| 4 | import assert from "node:assert/strict"; |
| 5 | import { app } from "../web/static/apps/github.js"; |
| 6 | |
| 7 | let failures = 0; |
| 8 | async function test(name, fn) { |
| 9 | try { |
| 10 | await fn(); |
| 11 | console.log(`PASS ${name}`); |
| 12 | } catch (e) { |
| 13 | failures++; |
| 14 | console.log(`FAIL ${name} — ${e.message.split("\n")[0]}`); |
| 15 | } |
| 16 | } |
| 17 | |
| 18 | const initial = () => app.reduce(undefined, { type: "noop" }) || { repos: [], fetchedAt: 0, issues: {} }; |
| 19 | const fold = (state, event) => app.reduce(state, event); |
| 20 | |
| 21 | await test("github.repos replaces the list and normalises projected rows", () => { |
| 22 | const s = fold( |
| 23 | { repos: [{ full_name: "old/gone" }], fetchedAt: 1, issues: {} }, |
| 24 | { |
| 25 | type: "github.repos", |
| 26 | ts: 42, |
| 27 | repos: [ |
| 28 | { full_name: "paul/lchat", description: "chat", private: true, archived: false, fork: false, html_url: "https://github.com/paul/lchat", open_issues_count: 3, stargazers_count: 7, pushed_at: "2026-07-01T00:00:00Z" }, |
| 29 | { full_name: "paul/min" }, // sparse row survives with defaults |
| 30 | { description: "no name — dropped" }, |
| 31 | "not-an-object", |
| 32 | ], |
| 33 | }, |
| 34 | ); |
| 35 | assert.equal(s.repos.length, 2); |
| 36 | assert.deepEqual(s.repos[0], { |
| 37 | full_name: "paul/lchat", |
| 38 | description: "chat", |
| 39 | private: true, |
| 40 | archived: false, |
| 41 | fork: false, |
| 42 | html_url: "https://github.com/paul/lchat", |
| 43 | open_issues: 3, |
| 44 | stars: 7, |
| 45 | pushed_at: "2026-07-01T00:00:00Z", |
| 46 | }); |
| 47 | assert.equal(s.repos[1].full_name, "paul/min"); |
| 48 | assert.equal(s.repos[1].open_issues, null); // absent count reads "—", never 0 |
| 49 | assert.equal(s.fetchedAt, 42); |
| 50 | }); |
| 51 | |
| 52 | await test("a malformed github.repos folds to a no-op", () => { |
| 53 | const before = { repos: [{ full_name: "keep/me" }], fetchedAt: 5, issues: {} }; |
| 54 | assert.equal(fold(before, { type: "github.repos", repos: "nope" }), before); |
| 55 | }); |
| 56 | |
| 57 | await test("github.issues normalises flattened dot-path keys into plain fields", () => { |
| 58 | const s = fold(initial(), { |
| 59 | type: "github.issues", |
| 60 | repo: "paul/lchat", |
| 61 | ts: 9, |
| 62 | issues: [ |
| 63 | { number: 12, title: "Fix login", html_url: "https://github.com/paul/lchat/issues/12", "user.login": "amy", comments: 4, updated_at: "2026-07-08T00:00:00Z" }, |
| 64 | { number: 13, title: "Ship it", "pull_request.url": "https://api.github.com/x", draft: true }, |
| 65 | { title: "no number — dropped" }, |
| 66 | ], |
| 67 | }); |
| 68 | const items = s.issues["paul/lchat"].items; |
| 69 | assert.equal(items.length, 2); |
| 70 | assert.equal(items[0].by, "amy"); |
| 71 | assert.equal(items[0].is_pr, false); |
| 72 | assert.equal(items[1].is_pr, true); |
| 73 | assert.equal(items[1].draft, true); |
| 74 | }); |
| 75 | |
| 76 | await test("the issues map is trimmed to the most recently fetched repos", () => { |
| 77 | let s = initial(); |
| 78 | for (let i = 0; i < 9; i++) { |
| 79 | s = fold(s, { type: "github.issues", repo: `o/r${i}`, ts: i + 1, issues: [{ number: 1, title: "t" }] }); |
| 80 | } |
| 81 | const keys = Object.keys(s.issues); |
| 82 | assert.equal(keys.length, 6); // bounded — the snapshot can't balloon |
| 83 | assert.ok(!keys.includes("o/r0") && !keys.includes("o/r1") && !keys.includes("o/r2")); // oldest dropped |
| 84 | assert.ok(keys.includes("o/r8")); |
| 85 | }); |
| 86 | |
| 87 | await test("the app is gated on the github connected service", () => { |
| 88 | assert.equal(app.requiresService, "github"); |
| 89 | assert.deepEqual([...app.types].sort(), ["github.issues", "github.repos"]); |
| 90 | }); |
| 91 | |
| 92 | console.log(failures === 0 ? "\nALL PASSED" : `\n${failures} FAILED`); |
| 93 | process.exit(failures === 0 ? 0 : 1); |
| 94 | |