| 1 | import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; |
| 2 | import { fetchFeed, fetchRepoStats, lastPageFromLink, loadFeed, relativeAge, relativeTime } from "./github"; |
| 3 | |
| 4 | // We test the pure helper functions directly. |
| 5 | // The async fetch functions require mocking the global fetch. |
| 6 | |
| 7 | describe("production-build fallback", () => { |
| 8 | it("keeps static generation offline and uses truthful empty live chrome", async () => { |
| 9 | const previousPhase = process.env.NEXT_PHASE; |
| 10 | process.env.NEXT_PHASE = "phase-production-build"; |
| 11 | const fetchMock = vi.fn(); |
| 12 | vi.stubGlobal("fetch", fetchMock); |
| 13 | |
| 14 | try { |
| 15 | expect(await fetchFeed(undefined, 10)).toEqual([]); |
| 16 | // A page that is the feed needs to know nothing was asked for, so |
| 17 | // the prerender does not pass as an honest empty record. |
| 18 | expect(await loadFeed(undefined, 10)).toEqual({ |
| 19 | items: [], |
| 20 | issuesStatus: "skipped", |
| 21 | pullsStatus: "skipped", |
| 22 | }); |
| 23 | expect(await fetchRepoStats()).toMatchObject({ |
| 24 | stars: 0, |
| 25 | forks: 0, |
| 26 | openIssues: 0, |
| 27 | openPulls: 0, |
| 28 | }); |
| 29 | expect(fetchMock).not.toHaveBeenCalled(); |
| 30 | } finally { |
| 31 | if (previousPhase === undefined) delete process.env.NEXT_PHASE; |
| 32 | else process.env.NEXT_PHASE = previousPhase; |
| 33 | vi.unstubAllGlobals(); |
| 34 | } |
| 35 | }); |
| 36 | }); |
| 37 | |
| 38 | // ── relativeTime ────────────────────────────────────────────────────── |
| 39 | |
| 40 | describe("relativeTime", () => { |
| 41 | beforeEach(() => { |
| 42 | vi.useFakeTimers(); |
| 43 | vi.setSystemTime(new Date("2026-06-01T12:00:00Z")); |
| 44 | }); |
| 45 | |
| 46 | afterEach(() => { |
| 47 | vi.useRealTimers(); |
| 48 | }); |
| 49 | |
| 50 | it("returns 'just now' for less than 30 seconds ago", () => { |
| 51 | expect(relativeTime("2026-06-01T11:59:45Z")).toBe("just now"); |
| 52 | }); |
| 53 | |
| 54 | it("returns 'just now' for dates in the future", () => { |
| 55 | expect(relativeTime("2026-06-02T12:00:00Z")).toBe("just now"); |
| 56 | }); |
| 57 | |
| 58 | it("returns minutes for < 1 hour", () => { |
| 59 | expect(relativeTime("2026-06-01T11:55:00Z")).toBe("5m"); |
| 60 | expect(relativeTime("2026-06-01T11:30:00Z")).toBe("30m"); |
| 61 | }); |
| 62 | |
| 63 | it("returns hours for < 1 day", () => { |
| 64 | expect(relativeTime("2026-06-01T09:00:00Z")).toBe("3h"); |
| 65 | expect(relativeTime("2026-05-31T18:00:00Z")).toBe("18h"); |
| 66 | }); |
| 67 | |
| 68 | it("returns days for < 30 days", () => { |
| 69 | expect(relativeTime("2026-05-25T12:00:00Z")).toBe("7d"); |
| 70 | expect(relativeTime("2026-05-03T12:00:00Z")).toBe("29d"); |
| 71 | }); |
| 72 | |
| 73 | it("returns months for < 12 months", () => { |
| 74 | expect(relativeTime("2026-03-01T12:00:00Z")).toBe("3mo"); |
| 75 | expect(relativeTime("2025-08-01T12:00:00Z")).toBe("10mo"); |
| 76 | }); |
| 77 | |
| 78 | it("returns years for >= 12 months", () => { |
| 79 | expect(relativeTime("2024-06-01T12:00:00Z")).toBe("2y"); |
| 80 | expect(relativeTime("2025-01-01T00:00:00Z")).toBe("1y"); |
| 81 | }); |
| 82 | }); |
| 83 | |
| 84 | // ── relativeAge ─────────────────────────────────────────────────────── |
| 85 | |
| 86 | describe("relativeAge", () => { |
| 87 | beforeEach(() => { |
| 88 | vi.useFakeTimers(); |
| 89 | vi.setSystemTime(new Date("2026-06-01T12:00:00Z")); |
| 90 | }); |
| 91 | |
| 92 | afterEach(() => { |
| 93 | vi.useRealTimers(); |
| 94 | }); |
| 95 | |
| 96 | it("reports past ages as the negative counts Intl.RelativeTimeFormat wants", () => { |
| 97 | expect(relativeAge("2026-06-01T11:55:00Z")).toEqual({ value: -5, unit: "minute" }); |
| 98 | expect(relativeAge("2026-06-01T09:00:00Z")).toEqual({ value: -3, unit: "hour" }); |
| 99 | expect(relativeAge("2026-05-25T12:00:00Z")).toEqual({ value: -7, unit: "day" }); |
| 100 | expect(relativeAge("2026-03-01T12:00:00Z")).toEqual({ value: -3, unit: "month" }); |
| 101 | expect(relativeAge("2024-06-01T12:00:00Z")).toEqual({ value: -2, unit: "year" }); |
| 102 | }); |
| 103 | |
| 104 | it("collapses sub-minute, future, and unparseable dates to the locale's 'now'", () => { |
| 105 | expect(relativeAge("2026-06-01T11:59:45Z")).toEqual({ value: 0, unit: "second" }); |
| 106 | expect(relativeAge("2026-06-02T12:00:00Z")).toEqual({ value: 0, unit: "second" }); |
| 107 | expect(relativeAge("not-a-date")).toEqual({ value: 0, unit: "second" }); |
| 108 | }); |
| 109 | |
| 110 | it("agrees with the compact English form it backs", () => { |
| 111 | for (const iso of [ |
| 112 | "2026-06-01T11:59:45Z", |
| 113 | "2026-06-01T11:30:00Z", |
| 114 | "2026-05-31T18:00:00Z", |
| 115 | "2026-05-03T12:00:00Z", |
| 116 | "2025-08-01T12:00:00Z", |
| 117 | "2025-01-01T00:00:00Z", |
| 118 | ]) { |
| 119 | const age = relativeAge(iso); |
| 120 | const compact = relativeTime(iso); |
| 121 | if (age.unit === "second") expect(compact).toBe("just now"); |
| 122 | else expect(compact.startsWith(String(Math.abs(age.value)))).toBe(true); |
| 123 | } |
| 124 | }); |
| 125 | }); |
| 126 | |
| 127 | // ── fetchFeed ───────────────────────────────────────────────────────── |
| 128 | |
| 129 | function json(body: unknown): Response { |
| 130 | return new Response(JSON.stringify(body), { |
| 131 | status: 200, |
| 132 | headers: { "content-type": "application/json" }, |
| 133 | }); |
| 134 | } |
| 135 | |
| 136 | describe("fetchFeed", () => { |
| 137 | const ISSUES = [ |
| 138 | { |
| 139 | number: 4901, |
| 140 | title: "Ticker shows nothing on first paint", |
| 141 | html_url: "https://github.com/Hmbown/CodeWhale/issues/4901", |
| 142 | state: "open", |
| 143 | user: { login: "asto18089", avatar_url: "https://avatars/1" }, |
| 144 | created_at: "2026-06-01T09:00:00Z", |
| 145 | updated_at: "2026-06-01T11:00:00Z", |
| 146 | comments: 2, |
| 147 | labels: [{ name: "bug", color: "d73a4a" }], |
| 148 | author_association: "CONTRIBUTOR", |
| 149 | }, |
| 150 | { |
| 151 | number: 4880, |
| 152 | title: "Fixed upstream", |
| 153 | html_url: "https://github.com/Hmbown/CodeWhale/issues/4880", |
| 154 | state: "closed", |
| 155 | user: { login: "Inference1", avatar_url: "https://avatars/2" }, |
| 156 | created_at: "2026-03-02T09:00:00Z", |
| 157 | updated_at: "2026-06-01T10:00:00Z", |
| 158 | closed_at: "2026-05-30T09:00:00Z", |
| 159 | comments: 0, |
| 160 | labels: [], |
| 161 | author_association: "NONE", |
| 162 | }, |
| 163 | { |
| 164 | // The issues endpoint returns pull requests too; they must not double up. |
| 165 | number: 4899, |
| 166 | title: "PR echoed by the issues endpoint", |
| 167 | html_url: "https://github.com/Hmbown/CodeWhale/pull/4899", |
| 168 | state: "open", |
| 169 | user: { login: "bistack", avatar_url: "https://avatars/3" }, |
| 170 | created_at: "2026-06-01T08:00:00Z", |
| 171 | updated_at: "2026-06-01T08:30:00Z", |
| 172 | comments: 0, |
| 173 | labels: [], |
| 174 | pull_request: { url: "https://api.github.com/…" }, |
| 175 | }, |
| 176 | ]; |
| 177 | |
| 178 | const PULLS = [ |
| 179 | { |
| 180 | number: 4899, |
| 181 | title: "fix(tui): keep the composer caret on resize", |
| 182 | html_url: "https://github.com/Hmbown/CodeWhale/pull/4899", |
| 183 | state: "closed", |
| 184 | user: { login: "shenjackyuanjie", avatar_url: "https://avatars/4" }, |
| 185 | created_at: "2026-05-29T09:00:00Z", |
| 186 | updated_at: "2026-06-01T11:30:00Z", |
| 187 | closed_at: "2026-05-31T18:00:00Z", |
| 188 | merged_at: "2026-05-31T18:00:00Z", |
| 189 | comments: 4, |
| 190 | labels: [], |
| 191 | author_association: "FIRST_TIME_CONTRIBUTOR", |
| 192 | }, |
| 193 | { |
| 194 | number: 4902, |
| 195 | title: "wip: sandbox notes", |
| 196 | html_url: "https://github.com/Hmbown/CodeWhale/pull/4902", |
| 197 | state: "open", |
| 198 | user: { login: "h3c-hexin", avatar_url: "https://avatars/5" }, |
| 199 | created_at: "2026-06-01T07:00:00Z", |
| 200 | updated_at: "2026-06-01T07:30:00Z", |
| 201 | draft: true, |
| 202 | comments: 0, |
| 203 | labels: [], |
| 204 | author_association: "CONTRIBUTOR", |
| 205 | }, |
| 206 | ]; |
| 207 | |
| 208 | const RELEASES = [ |
| 209 | { |
| 210 | tag_name: "v0.9.4", |
| 211 | name: "v0.9.4", |
| 212 | html_url: "https://github.com/Hmbown/CodeWhale/releases/tag/v0.9.4", |
| 213 | created_at: "2026-05-28T09:00:00Z", |
| 214 | published_at: "2026-05-28T12:00:00Z", |
| 215 | author: { login: "Hmbown", avatar_url: "https://avatars/6" }, |
| 216 | }, |
| 217 | { |
| 218 | tag_name: "v0.9.5-rc1", |
| 219 | name: "v0.9.5-rc1", |
| 220 | html_url: "https://github.com/Hmbown/CodeWhale/releases/tag/v0.9.5-rc1", |
| 221 | created_at: "2026-06-01T09:00:00Z", |
| 222 | published_at: null, |
| 223 | draft: true, |
| 224 | author: { login: "Hmbown", avatar_url: "https://avatars/6" }, |
| 225 | }, |
| 226 | ]; |
| 227 | |
| 228 | const requested: string[] = []; |
| 229 | |
| 230 | beforeEach(() => { |
| 231 | requested.length = 0; |
| 232 | vi.stubGlobal( |
| 233 | "fetch", |
| 234 | vi.fn(async (url: string) => { |
| 235 | requested.push(url); |
| 236 | if (url.includes("/issues?")) return json(ISSUES); |
| 237 | if (url.includes("/pulls?")) return json(PULLS); |
| 238 | if (url.includes("/releases?")) return json(RELEASES); |
| 239 | return new Response("{}", { status: 404 }); |
| 240 | }), |
| 241 | ); |
| 242 | }); |
| 243 | |
| 244 | afterEach(() => { |
| 245 | vi.unstubAllGlobals(); |
| 246 | }); |
| 247 | |
| 248 | it("covers issues, pulls, and releases in three calls — no per-item follow-ups", async () => { |
| 249 | await fetchFeed(undefined, 10); |
| 250 | expect(requested).toHaveLength(3); |
| 251 | expect(requested.some((u) => u.includes("/issues?"))).toBe(true); |
| 252 | expect(requested.some((u) => u.includes("/pulls?"))).toBe(true); |
| 253 | expect(requested.some((u) => u.includes("/releases?"))).toBe(true); |
| 254 | }); |
| 255 | |
| 256 | it("names the contributor and dates each verb by the event, not the last comment", async () => { |
| 257 | const feed = await fetchFeed(undefined, 10); |
| 258 | |
| 259 | const merged = feed.find((f) => f.number === 4899 && f.kind === "pull"); |
| 260 | expect(merged?.state).toBe("merged"); |
| 261 | expect(merged?.author).toBe("shenjackyuanjie"); |
| 262 | // Merged at 18:00 on the 31st; last touched at 11:30 the next day. |
| 263 | expect(merged?.eventAt).toBe("2026-05-31T18:00:00Z"); |
| 264 | |
| 265 | const openIssue = feed.find((f) => f.number === 4901); |
| 266 | expect(openIssue?.state).toBe("open"); |
| 267 | expect(openIssue?.eventAt).toBe(openIssue?.createdAt); |
| 268 | |
| 269 | const closedIssue = feed.find((f) => f.number === 4880); |
| 270 | expect(closedIssue?.state).toBe("closed"); |
| 271 | expect(closedIssue?.eventAt).toBe("2026-05-30T09:00:00Z"); |
| 272 | |
| 273 | const draft = feed.find((f) => f.number === 4902); |
| 274 | expect(draft?.state).toBe("draft"); |
| 275 | }); |
| 276 | |
| 277 | it("passes GitHub's first-time-contributor verdict through verbatim", async () => { |
| 278 | const feed = await fetchFeed(undefined, 10); |
| 279 | expect(feed.find((f) => f.number === 4899 && f.kind === "pull")?.firstTimeContributor).toBe( |
| 280 | true, |
| 281 | ); |
| 282 | expect(feed.find((f) => f.number === 4901)?.firstTimeContributor).toBe(false); |
| 283 | // "NONE" is not a first-timer — only GitHub's own FIRST_TIME_CONTRIBUTOR is. |
| 284 | expect(feed.find((f) => f.number === 4880)?.firstTimeContributor).toBe(false); |
| 285 | }); |
| 286 | |
| 287 | it("carries published releases and drops unpublished drafts", async () => { |
| 288 | const feed = await fetchFeed(undefined, 10); |
| 289 | const releases = feed.filter((f) => f.kind === "release"); |
| 290 | expect(releases).toHaveLength(1); |
| 291 | expect(releases[0].tag).toBe("v0.9.4"); |
| 292 | expect(releases[0].state).toBe("published"); |
| 293 | expect(releases[0].eventAt).toBe("2026-05-28T12:00:00Z"); |
| 294 | expect(feed.some((f) => f.tag === "v0.9.5-rc1")).toBe(false); |
| 295 | }); |
| 296 | |
| 297 | it("keeps a recent release in view when a busy window would bury it", async () => { |
| 298 | vi.useFakeTimers(); |
| 299 | vi.setSystemTime(new Date("2026-06-01T12:00:00Z")); |
| 300 | try { |
| 301 | // Only two slots: pure recency would fill both with the newest threads. |
| 302 | const feed = await fetchFeed(undefined, 2); |
| 303 | expect(feed).toHaveLength(2); |
| 304 | expect(feed.filter((f) => f.kind === "release").map((f) => f.tag)).toEqual(["v0.9.4"]); |
| 305 | } finally { |
| 306 | vi.useRealTimers(); |
| 307 | } |
| 308 | }); |
| 309 | |
| 310 | it("does not pin a stale release beside today's threads", async () => { |
| 311 | vi.useFakeTimers(); |
| 312 | // Two years on from the last tag: a quiet quarter reads as a quiet quarter. |
| 313 | vi.setSystemTime(new Date("2028-06-01T12:00:00Z")); |
| 314 | try { |
| 315 | const feed = await fetchFeed(undefined, 2); |
| 316 | expect(feed).toHaveLength(2); |
| 317 | expect(feed.some((f) => f.kind === "release")).toBe(false); |
| 318 | } finally { |
| 319 | vi.useRealTimers(); |
| 320 | } |
| 321 | }); |
| 322 | |
| 323 | it("does not double-count pull requests echoed by the issues endpoint", async () => { |
| 324 | const feed = await fetchFeed(undefined, 10); |
| 325 | expect(feed.filter((f) => f.number === 4899)).toHaveLength(1); |
| 326 | expect(feed.filter((f) => f.kind === "issue").map((f) => f.number).sort()).toEqual([ |
| 327 | 4880, 4901, |
| 328 | ]); |
| 329 | }); |
| 330 | |
| 331 | it("returns an empty feed — never a placeholder — when GitHub is unreachable", async () => { |
| 332 | vi.stubGlobal( |
| 333 | "fetch", |
| 334 | vi.fn(async () => new Response("rate limited", { status: 403 })), |
| 335 | ); |
| 336 | expect(await fetchFeed(undefined, 10)).toEqual([]); |
| 337 | }); |
| 338 | |
| 339 | it("reports a refused list call as unavailable, not as an empty record", async () => { |
| 340 | vi.stubGlobal( |
| 341 | "fetch", |
| 342 | vi.fn(async () => new Response("rate limited", { status: 403 })), |
| 343 | ); |
| 344 | expect(await loadFeed(undefined, 10)).toEqual({ |
| 345 | items: [], |
| 346 | issuesStatus: "unavailable", |
| 347 | pullsStatus: "unavailable", |
| 348 | }); |
| 349 | }); |
| 350 | |
| 351 | it("keeps what arrived and still flags the load when one list call fails", async () => { |
| 352 | vi.stubGlobal( |
| 353 | "fetch", |
| 354 | vi.fn(async (url: string) => { |
| 355 | if (url.includes("/pulls?")) return new Response("rate limited", { status: 403 }); |
| 356 | if (url.includes("/issues?")) return json(ISSUES); |
| 357 | return json([]); |
| 358 | }), |
| 359 | ); |
| 360 | const load = await loadFeed(undefined, 10); |
| 361 | // Each column answers for itself: the issues list answered, so the |
| 362 | // issues column must not be told "the source did not answer". |
| 363 | expect(load.issuesStatus).toBe("ok"); |
| 364 | expect(load.pullsStatus).toBe("unavailable"); |
| 365 | expect(load.items.map((i) => i.number)).toEqual([4901, 4880]); |
| 366 | }); |
| 367 | |
| 368 | it("reports ok — so an empty list means empty — when every list call answered", async () => { |
| 369 | vi.stubGlobal("fetch", vi.fn(async () => json([]))); |
| 370 | expect(await loadFeed(undefined, 10)).toEqual({ |
| 371 | items: [], |
| 372 | issuesStatus: "ok", |
| 373 | pullsStatus: "ok", |
| 374 | }); |
| 375 | }); |
| 376 | }); |
| 377 | |
| 378 | describe("fetchFeed — bot authors", () => { |
| 379 | // The wire is contributor-forward: GitHub's own `[bot]` login marker keeps |
| 380 | // automated maintenance off the strip, while a bot-published release keeps |
| 381 | // its slot and drops its byline. |
| 382 | const ISSUES = [ |
| 383 | { |
| 384 | number: 101, |
| 385 | title: "Automated close sweep", |
| 386 | html_url: "https://github.com/Hmbown/CodeWhale/issues/101", |
| 387 | state: "closed", |
| 388 | user: { login: "github-actions[bot]", avatar_url: "https://avatars/bot1" }, |
| 389 | created_at: "2026-06-01T09:00:00Z", |
| 390 | updated_at: "2026-06-01T11:00:00Z", |
| 391 | closed_at: "2026-06-01T10:30:00Z", |
| 392 | comments: 0, |
| 393 | labels: [], |
| 394 | author_association: "NONE", |
| 395 | }, |
| 396 | { |
| 397 | number: 102, |
| 398 | title: "Real report", |
| 399 | html_url: "https://github.com/Hmbown/CodeWhale/issues/102", |
| 400 | state: "open", |
| 401 | user: { login: "vFONGv", avatar_url: "https://avatars/h1" }, |
| 402 | created_at: "2026-06-01T08:00:00Z", |
| 403 | updated_at: "2026-06-01T08:00:00Z", |
| 404 | comments: 0, |
| 405 | labels: [], |
| 406 | author_association: "FIRST_TIME_CONTRIBUTOR", |
| 407 | }, |
| 408 | ]; |
| 409 | |
| 410 | const PULLS = [ |
| 411 | { |
| 412 | number: 103, |
| 413 | title: "chore(deps): bump serde from 1.0.219 to 1.0.220", |
| 414 | html_url: "https://github.com/Hmbown/CodeWhale/pull/103", |
| 415 | state: "closed", |
| 416 | user: { login: "dependabot[bot]", avatar_url: "https://avatars/bot2" }, |
| 417 | created_at: "2026-05-31T09:00:00Z", |
| 418 | updated_at: "2026-06-01T07:00:00Z", |
| 419 | merged_at: "2026-06-01T07:00:00Z", |
| 420 | comments: 0, |
| 421 | labels: [], |
| 422 | author_association: "CONTRIBUTOR", |
| 423 | }, |
| 424 | { |
| 425 | number: 104, |
| 426 | title: "fix(tui): keep the whale rail steady", |
| 427 | html_url: "https://github.com/Hmbown/CodeWhale/pull/104", |
| 428 | state: "closed", |
| 429 | user: { login: "bistack", avatar_url: "https://avatars/h2" }, |
| 430 | created_at: "2026-05-30T09:00:00Z", |
| 431 | updated_at: "2026-06-01T06:00:00Z", |
| 432 | merged_at: "2026-06-01T06:00:00Z", |
| 433 | comments: 1, |
| 434 | labels: [], |
| 435 | author_association: "CONTRIBUTOR", |
| 436 | }, |
| 437 | ]; |
| 438 | |
| 439 | const RELEASES = [ |
| 440 | { |
| 441 | tag_name: "v0.9.4", |
| 442 | name: "v0.9.4", |
| 443 | html_url: "https://github.com/Hmbown/CodeWhale/releases/tag/v0.9.4", |
| 444 | created_at: "2026-05-28T09:00:00Z", |
| 445 | published_at: "2026-05-28T12:00:00Z", |
| 446 | author: { login: "release-train[bot]", avatar_url: "https://avatars/bot3" }, |
| 447 | }, |
| 448 | ]; |
| 449 | |
| 450 | beforeEach(() => { |
| 451 | vi.stubGlobal( |
| 452 | "fetch", |
| 453 | vi.fn(async (url: string) => { |
| 454 | if (url.includes("/issues?")) return json(ISSUES); |
| 455 | if (url.includes("/pulls?")) return json(PULLS); |
| 456 | if (url.includes("/releases?")) return json(RELEASES); |
| 457 | return new Response("{}", { status: 404 }); |
| 458 | }), |
| 459 | ); |
| 460 | }); |
| 461 | |
| 462 | afterEach(() => { |
| 463 | vi.unstubAllGlobals(); |
| 464 | }); |
| 465 | |
| 466 | it("keeps bot-authored issues and pulls off the contributor wire", async () => { |
| 467 | const feed = await fetchFeed(undefined, 10); |
| 468 | expect(feed.some((f) => f.author === "github-actions[bot]")).toBe(false); |
| 469 | expect(feed.some((f) => f.author === "dependabot[bot]")).toBe(false); |
| 470 | expect(feed.some((f) => f.number === 102 && f.kind === "issue")).toBe(true); |
| 471 | expect(feed.some((f) => f.number === 104 && f.kind === "pull")).toBe(true); |
| 472 | }); |
| 473 | |
| 474 | it("keeps a bot-published release in view, minus its byline", async () => { |
| 475 | const feed = await fetchFeed(undefined, 10); |
| 476 | const release = feed.find((f) => f.kind === "release"); |
| 477 | expect(release?.tag).toBe("v0.9.4"); |
| 478 | expect(release?.author).toBe(""); |
| 479 | expect(release?.authorAvatar).toBe(""); |
| 480 | }); |
| 481 | }); |
| 482 | |
| 483 | // ── lastPageFromLink (via re-export test) ────────────────────────────── |
| 484 | |
| 485 | describe("lastPageFromLink", () => { |
| 486 | it("returns undefined for null input", () => { |
| 487 | expect(lastPageFromLink(null)).toBeUndefined(); |
| 488 | }); |
| 489 | |
| 490 | it("returns undefined for empty string", () => { |
| 491 | expect(lastPageFromLink("")).toBeUndefined(); |
| 492 | }); |
| 493 | |
| 494 | it("extracts page from Link header with last rel", () => { |
| 495 | const link = |
| 496 | '<https://api.github.com/repos/Hmbown/CodeWhale/issues?page=5>; rel="last"'; |
| 497 | expect(lastPageFromLink(link)).toBe(5); |
| 498 | }); |
| 499 | |
| 500 | it("extracts page from multi-part Link header", () => { |
| 501 | const link = [ |
| 502 | '<https://api.github.com/repos/Hmbown/CodeWhale/issues?page=1>; rel="prev"', |
| 503 | '<https://api.github.com/repos/Hmbown/CodeWhale/issues?page=3>; rel="last"', |
| 504 | ].join(", "); |
| 505 | expect(lastPageFromLink(link)).toBe(3); |
| 506 | }); |
| 507 | |
| 508 | it("returns undefined when no last rel present", () => { |
| 509 | const link = |
| 510 | '<https://api.github.com/repos/Hmbown/CodeWhale/issues?page=1>; rel="prev"'; |
| 511 | expect(lastPageFromLink(link)).toBeUndefined(); |
| 512 | }); |
| 513 | |
| 514 | it("returns undefined for invalid URL format", () => { |
| 515 | const link = "not-a-valid-link-header; rel=last"; |
| 516 | expect(lastPageFromLink(link)).toBeUndefined(); |
| 517 | }); |
| 518 | }); |
| 519 |