1import { SourceControlProviderKind } from "@t3tools/contracts";
2import { ProjectId, type PullRequestSummary } from "@t3tools/contracts";
3import { describe, expect, it } from "vite-plus/test";
4
5import {
6 newestPullRequestObservation,
7 newestPullRequestSummary,
8 threadPullRequestLinkToSummary,
9} from "./pullRequests";
10
11function summary(overrides: Partial<PullRequestSummary> = {}): PullRequestSummary {
12 return {
13 provider: SourceControlProviderKind.make("github"),
14 projectId: ProjectId.make("pull-request-cache-test"),
15 repository: "acme/widget",
16 number: 7,
17 title: "Improve widget",
18 url: "https://github.com/acme/widget/pull/7",
19 state: "open",
20 isDraft: false,
21 headBranch: "improve-widget",
22 baseBranch: "main",
23 updatedAt: "2026-09-10T00:00:00Z",
24 author: { login: "oliver", name: null, avatarUrl: null },
25 mergeability: "mergeable",
26 checksState: "passing",
27 ...overrides,
28 };
29}
30
31const observed = (value: PullRequestSummary, observedAt: number) => ({
32 summary: value,
33 observedAt,
34});
35
36describe("pull request summary cache", () => {
37 it("orders same-dated snapshots by the server's read time, not by arrival", () => {
38 const stale = observed(summary({ observedAt: 200 }), 500);
39 const held = observed(
40 summary({ mergeability: "conflicting", checksState: "failing", observedAt: 300 }),
41 100,
42 );
43 expect(newestPullRequestObservation(stale, held)?.summary).toMatchObject({
44 mergeability: "conflicting",
45 checksState: "failing",
46 observedAt: 300,
47 });
48 // An older filtered or server-cached response finishing last must not roll status back.
49 expect(newestPullRequestObservation(held, stale)).toBe(held);
50 // The same read seen again is not a new observation.
51 expect(newestPullRequestObservation(held, { ...held, observedAt: 900 })).toBe(held);
52 });
53
54 it("uses arrival order only when neither snapshot carries a server read time", () => {
55 const first = observed(summary(), 100);
56 const later = observed(summary({ checksState: "failing" }), 200);
57 expect(newestPullRequestObservation(first, later)).toMatchObject({ observedAt: 200 });
58 expect(newestPullRequestObservation(later, first)).toBe(later);
59 // A stamped read beats an unstamped one regardless of which arrived last.
60 const stamped = observed(summary({ observedAt: 50 }), 1);
61 expect(newestPullRequestObservation(later, stamped)?.summary.observedAt).toBe(50);
62 expect(newestPullRequestObservation(stamped, later)).toBe(stamped);
63 });
64
65 it("keeps known status when a newer snapshot omits it, and clears it when told to", () => {
66 const list = observed(summary({ reviewDecision: "approved", observedAt: 100 }), 100);
67 const sparse = observed(
68 summary({ checksState: undefined, reviewDecision: undefined, observedAt: 200 }),
69 200,
70 );
71 expect(newestPullRequestObservation(list, sparse)?.summary).toMatchObject({
72 checksState: "passing",
73 reviewDecision: "approved",
74 });
75 const cleared = observed(summary({ checksState: null, observedAt: 300 }), 300);
76 expect(newestPullRequestObservation(list, cleared)?.summary.checksState).toBeNull();
77 });
78
79 it("treats merged as final and otherwise prefers the later host update", () => {
80 const merged = summary({ state: "merged", updatedAt: "2026-09-01T00:00:00Z" });
81 const reopened = summary({ updatedAt: "2026-09-12T00:00:00Z", observedAt: 900 });
82 expect(newestPullRequestSummary(merged, reopened)).toBe(merged);
83 expect(newestPullRequestSummary(reopened, merged)).toBe(merged);
84 const older = summary({ updatedAt: "2026-09-11T00:00:00Z", observedAt: 999 });
85 expect(newestPullRequestSummary(older, reopened)).toBe(reopened);
86 expect(newestPullRequestSummary(reopened, older)).toBe(reopened);
87 });
88
89 it("reuses saved thread state while detail is stale and lets newer detail replace it", () => {
90 const stale = summary();
91 const link = {
92 host: "github.com",
93 repository: stale.repository,
94 number: stale.number,
95 url: stale.url,
96 source: "manual" as const,
97 linkedAt: stale.updatedAt,
98 stack: null,
99 snapshot: {
100 ...stale,
101 state: "closed" as const,
102 isDraft: false,
103 updatedAt: "2026-09-11T00:00:00Z",
104 syncedAt: "2026-09-12T00:00:00Z",
105 },
106 };
107 const saved = threadPullRequestLinkToSummary(link, stale.projectId);
108 expect(newestPullRequestSummary(stale, saved)?.state).toBe("closed");
109 const fresh = summary({ isDraft: true, updatedAt: "2026-09-13T00:00:00Z" });
110 expect(newestPullRequestSummary(fresh, saved)).toBe(fresh);
111 expect(threadPullRequestLinkToSummary({ ...link, snapshot: null }, stale.projectId)).toBeNull();
112 expect(
113 threadPullRequestLinkToSummary(
114 { ...link, snapshot: { ...link.snapshot, updatedAt: null } },
115 stale.projectId,
116 ),
117 ).toBeNull();
118 });
119});