@jant/core 0.7.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (158) hide show
  1. package/bin/commands/export.js +3 -1
  2. package/bin/commands/import-site.js +689 -216
  3. package/bin/commands/setup.js +136 -0
  4. package/bin/commands/site/export.js +71 -34
  5. package/bin/commands/site/pull-media.js +2 -6
  6. package/bin/commands/site/snapshot/export.js +18 -60
  7. package/bin/commands/site/snapshot/import.js +22 -23
  8. package/bin/lib/d1-query.js +87 -2
  9. package/bin/lib/hugo-markdown.js +4 -0
  10. package/bin/lib/site-pull-media.js +21 -28
  11. package/bin/lib/site-selection.js +10 -1
  12. package/bin/lib/site-snapshot.js +338 -3
  13. package/bin/lib/sql-export.js +68 -5
  14. package/bin/lib/wrangler-cli.js +9 -0
  15. package/bin/lib/zip-archive.js +187 -0
  16. package/dist/{app-K_Aa1MMn.js → app-ZcaI1kPN.js} +732 -217
  17. package/dist/client/.vite/manifest.json +20 -20
  18. package/dist/client/_assets/chunks/{create-editor-CD3FhrOB.js → create-editor-B-m7X7S5.js} +50 -46
  19. package/dist/client/_assets/chunks/{sortable-list-CgaL2jCs.js → sortable-list-BJyd-LXE.js} +1 -1
  20. package/dist/client/_assets/chunks/{unsafe-svg-0QCkP0vZ.js → unsafe-svg-BkscJx69.js} +2 -2
  21. package/dist/client/_assets/{client-GiYENVw8.css → client-DAhoqPdr.css} +1 -1
  22. package/dist/client/_assets/{client-auth-k8gJ6QJj.js → client-auth-CAMfTrKW.js} +1 -1
  23. package/dist/client/_assets/{client-compose-B_kDtWkW.js → client-compose-D6rExKVK.js} +1 -1
  24. package/dist/client/_assets/{client-VnFxJN7G.js → client-knSEyUJO.js} +1 -1
  25. package/dist/client/_assets/{client-manage-M90aSTOk.js → client-manage-sVOkqVbH.js} +1 -1
  26. package/dist/client/_assets/{client-settings-DfYs9n1F.js → client-settings-CGR_nZ78.js} +7 -7
  27. package/dist/{github-sync-BPAvT999.js → github-sync-Gw4orAHk.js} +799 -110
  28. package/dist/index.js +2 -2
  29. package/dist/node.js +9 -5
  30. package/package.json +13 -3
  31. package/src/__tests__/dev-scripts.test.ts +203 -0
  32. package/src/__tests__/export-collection-order.test.ts +276 -0
  33. package/src/__tests__/export-feed-ids.test.ts +184 -0
  34. package/src/__tests__/export-feed-order.test.ts +294 -0
  35. package/src/__tests__/export-hugo-build.test.ts +130 -0
  36. package/src/__tests__/export-import-roundtrip.test.ts +94 -0
  37. package/src/__tests__/export-service.test.ts +611 -28
  38. package/src/__tests__/export-smart-collection.test.ts +329 -0
  39. package/src/__tests__/helpers/hugo-site.ts +146 -0
  40. package/src/__tests__/import-site-command.test.ts +487 -1
  41. package/src/__tests__/mise-config.test.ts +75 -4
  42. package/src/__tests__/node-dev-tasks.test.ts +264 -0
  43. package/src/__tests__/site-export-canonical-import.test.ts +149 -0
  44. package/src/__tests__/snapshot-canonical-replay.test.ts +179 -0
  45. package/src/__tests__/snapshot-settings.test.ts +97 -0
  46. package/src/__tests__/snapshot-tables.test.ts +219 -0
  47. package/src/__tests__/sql-export.test.ts +173 -0
  48. package/src/__tests__/zip-archive.test.ts +106 -0
  49. package/src/app.tsx +15 -6
  50. package/src/client/components/__tests__/jant-settings-avatar.test.ts +1 -1
  51. package/src/client/components/__tests__/jant-settings-general.test.ts +21 -1
  52. package/src/client/components/jant-repo-picker-types.ts +6 -1
  53. package/src/client/components/jant-repo-picker.ts +4 -7
  54. package/src/client/components/jant-settings-general.ts +17 -12
  55. package/src/client/tiptap/__tests__/list-editing.test.ts +224 -86
  56. package/src/client/tiptap/__tests__/mark-exit.test.ts +1 -2
  57. package/src/client/tiptap/__tests__/markdown-clipboard.test.ts +26 -0
  58. package/src/client/tiptap/extensions.ts +0 -3
  59. package/src/client/tiptap/structural-keymap.ts +158 -47
  60. package/src/db/__tests__/d1-query.test.ts +56 -1
  61. package/src/i18n/locales/settings/en.po +8 -8
  62. package/src/i18n/locales/settings/en.ts +1 -1
  63. package/src/i18n/locales/settings/zh-Hans.po +8 -8
  64. package/src/i18n/locales/settings/zh-Hans.ts +1 -1
  65. package/src/i18n/locales/settings/zh-Hant.po +8 -8
  66. package/src/i18n/locales/settings/zh-Hant.ts +1 -1
  67. package/src/lib/__tests__/github-sync-repo-name.test.ts +40 -0
  68. package/src/lib/__tests__/image.test.ts +27 -1
  69. package/src/lib/__tests__/markdown-to-tiptap.test.ts +105 -0
  70. package/src/lib/__tests__/markdown.test.ts +10 -0
  71. package/src/lib/__tests__/resolve-config.test.ts +67 -0
  72. package/src/lib/__tests__/schemas.test.ts +27 -1
  73. package/src/lib/__tests__/timeline.test.ts +87 -0
  74. package/src/lib/__tests__/tiptap-to-markdown.test.ts +172 -4
  75. package/src/lib/discover.ts +3 -1
  76. package/src/lib/github-sync-repo-name.ts +45 -0
  77. package/src/lib/hugo-markdown.ts +46 -0
  78. package/src/lib/image.ts +17 -4
  79. package/src/lib/markdown-manager.ts +392 -2
  80. package/src/lib/post-body-html.ts +11 -4
  81. package/src/lib/resolve-config.ts +58 -1
  82. package/src/lib/schemas.ts +50 -5
  83. package/src/lib/thread-fold.ts +3 -3
  84. package/src/lib/timeline.ts +1 -1
  85. package/src/lib/tiptap-to-markdown.ts +13 -7
  86. package/src/lib/url.ts +20 -0
  87. package/src/lib/view.ts +2 -2
  88. package/src/node/__tests__/cli-setup.test.ts +163 -0
  89. package/src/node/__tests__/cli-site-snapshot.test.ts +67 -0
  90. package/src/node/__tests__/cli-snapshot-meta.test.ts +20 -0
  91. package/src/node/__tests__/runtime.test.ts +38 -0
  92. package/src/node/index.ts +2 -0
  93. package/src/node/request-handler.ts +3 -1
  94. package/src/routes/api/__tests__/posts.test.ts +24 -0
  95. package/src/routes/api/__tests__/upload.test.ts +34 -0
  96. package/src/routes/api/export.ts +3 -3
  97. package/src/routes/api/internal/sites.ts +0 -1
  98. package/src/routes/api/posts.ts +2 -0
  99. package/src/routes/api/public/posts.ts +21 -1
  100. package/src/routes/api/upload.ts +10 -3
  101. package/src/routes/compose.tsx +4 -0
  102. package/src/routes/dash/__tests__/github-sync-app.test.ts +365 -0
  103. package/src/routes/dash/settings.tsx +212 -70
  104. package/src/routes/pages/__tests__/post-page-round-trips.test.ts +211 -0
  105. package/src/routes/pages/__tests__/thread-order.test.ts +179 -0
  106. package/src/routes/pages/archive.tsx +17 -11
  107. package/src/routes/pages/featured.tsx +11 -5
  108. package/src/routes/pages/page.tsx +53 -31
  109. package/src/routes/pages/search.tsx +4 -2
  110. package/src/runtime/__tests__/readiness.test.ts +23 -0
  111. package/src/runtime/node.ts +49 -0
  112. package/src/runtime/readiness.ts +15 -0
  113. package/src/services/__tests__/bootstrap-setup-instance.test.ts +230 -0
  114. package/src/services/__tests__/custom-url.test.ts +34 -0
  115. package/src/services/__tests__/github-app-installations.test.ts +153 -0
  116. package/src/services/__tests__/github-sync-push.test.ts +46 -0
  117. package/src/services/__tests__/media.test.ts +31 -0
  118. package/src/services/__tests__/path.test.ts +56 -0
  119. package/src/services/__tests__/post-timeline.test.ts +127 -0
  120. package/src/services/__tests__/post.test.ts +74 -0
  121. package/src/services/bootstrap.ts +263 -44
  122. package/src/services/custom-url.ts +2 -2
  123. package/src/services/export-theme/layouts/_default/alias.html +27 -1
  124. package/src/services/export-theme/layouts/_default/list.html +2 -63
  125. package/src/services/export-theme/layouts/_default/rss.xml +27 -38
  126. package/src/services/export-theme/layouts/collections/list.html +3 -3
  127. package/src/services/export-theme/layouts/featured/list.html +1 -4
  128. package/src/services/export-theme/layouts/index.html +40 -26
  129. package/src/services/export-theme/layouts/partials/collection-members.html +100 -0
  130. package/src/services/export-theme/layouts/partials/collection-threads.html +33 -0
  131. package/src/services/export-theme/layouts/partials/featured-members.html +35 -0
  132. package/src/services/export-theme/layouts/partials/featured-thread.html +1 -1
  133. package/src/services/export-theme/layouts/partials/footer.html +1 -1
  134. package/src/services/export-theme/layouts/partials/head.html +1 -1
  135. package/src/services/export-theme/layouts/partials/header.html +5 -3
  136. package/src/services/export-theme/layouts/partials/jant-data.html +23 -0
  137. package/src/services/export-theme/layouts/partials/latest-members.html +48 -0
  138. package/src/services/export-theme/layouts/partials/smart-collection-members.html +129 -0
  139. package/src/services/export-theme/layouts/partials/thread-preview.html +1 -1
  140. package/src/services/export-theme/layouts/post/list.html +1 -1
  141. package/src/services/export-theme/layouts/smart_collection/list.html +24 -0
  142. package/src/services/export-theme/styles/main.css +0 -1
  143. package/src/services/export-theme/theme.toml +1 -1
  144. package/src/services/export.ts +620 -71
  145. package/src/services/github-app-installations.ts +171 -4
  146. package/src/services/github-sync.ts +69 -15
  147. package/src/services/mcp.ts +19 -1
  148. package/src/services/media.ts +8 -1
  149. package/src/services/path.ts +34 -1
  150. package/src/services/post.ts +214 -39
  151. package/src/services/site-admin.ts +3 -7
  152. package/src/services/site.ts +83 -31
  153. package/src/styles/ui.css +7 -1
  154. package/src/types/app-context.ts +16 -0
  155. package/src/types/bindings.ts +5 -0
  156. package/src/types/operations.ts +10 -0
  157. package/src/ui/dash/settings/GeneralContent.tsx +10 -8
  158. package/src/client/tiptap/exitable-marks.ts +0 -73
@@ -0,0 +1,365 @@
1
+ /**
2
+ * GitHub App connect flow: who may see and use an installation.
3
+ *
4
+ * A GitHub App installs once per GitHub account, so the author of a
5
+ * second site cannot install it again — GitHub sends them to the
6
+ * existing installation's Configure page and never calls back. These
7
+ * tests pin the way out of that: installations follow the author across
8
+ * the sites they belong to, and stop at every other tenant.
9
+ */
10
+
11
+ import { beforeEach, describe, expect, it, vi } from "vitest";
12
+ import { createTestApp } from "../../../__tests__/helpers/app.js";
13
+ import { DEFAULT_TEST_SITE_ID } from "../../../__tests__/helpers/db.js";
14
+ import { settingsRoutes } from "../settings.js";
15
+
16
+ const SECOND_SITE_ID = "sit_second00000000000000000000000";
17
+ const OTHER_TENANT_SITE_ID = "sit_other000000000000000000000000";
18
+ /** The user id `createTestApp({ authenticated: true })` signs in as. */
19
+ const AUTHOR_ID = "test-user";
20
+
21
+ const setupWebhook = vi.fn();
22
+ const pushFullSync = vi.fn();
23
+ const classifyRepoForSync = vi.fn();
24
+ const getInstallation = vi.fn();
25
+
26
+ vi.mock("../../../services/github-sync.js", () => ({
27
+ classifyRepoForSync: (...args: unknown[]) => classifyRepoForSync(...args),
28
+ createGitHubSyncService: () => ({
29
+ setupWebhook,
30
+ pushFullSync,
31
+ }),
32
+ }));
33
+
34
+ vi.mock("../../../lib/github-api.js", async (importOriginal) => ({
35
+ ...(await importOriginal<typeof import("../../../lib/github-api.js")>()),
36
+ createGitHubClient: () => ({}),
37
+ }));
38
+
39
+ vi.mock("../../../lib/github-app.js", async (importOriginal) => ({
40
+ ...(await importOriginal<typeof import("../../../lib/github-app.js")>()),
41
+ getInstallation: (...args: unknown[]) => getInstallation(...args),
42
+ }));
43
+
44
+ function createGitHubApp(options: { authenticated?: boolean } = {}) {
45
+ const harness = createTestApp({
46
+ authenticated: options.authenticated ?? true,
47
+ });
48
+ harness.app.use("*", async (c, next) => {
49
+ c.env.GITHUB_APP_ID = "12345";
50
+ c.env.GITHUB_APP_PRIVATE_KEY =
51
+ "-----BEGIN PRIVATE KEY-----\nx\n-----END PRIVATE KEY-----";
52
+ c.env.GITHUB_APP_SLUG = "jant-sync";
53
+ await next();
54
+ });
55
+ harness.app.route("/settings", settingsRoutes);
56
+ return harness;
57
+ }
58
+
59
+ function insertSite(
60
+ sqlite: ReturnType<typeof createTestApp>["sqlite"],
61
+ siteId: string,
62
+ key: string,
63
+ ) {
64
+ const timestamp = Math.floor(Date.now() / 1000);
65
+ sqlite
66
+ .prepare(
67
+ `INSERT INTO site (id, key, status, created_at, updated_at)
68
+ VALUES (?, ?, 'active', ?, ?)`,
69
+ )
70
+ .run(siteId, key, timestamp, timestamp);
71
+ }
72
+
73
+ function insertMember(
74
+ sqlite: ReturnType<typeof createTestApp>["sqlite"],
75
+ siteId: string,
76
+ userId: string,
77
+ ) {
78
+ const timestamp = Math.floor(Date.now() / 1000);
79
+ sqlite
80
+ .prepare(
81
+ `INSERT INTO site_member (site_id, user_id, role, created_at, updated_at)
82
+ VALUES (?, ?, 'owner', ?, ?)`,
83
+ )
84
+ .run(siteId, userId, timestamp, timestamp);
85
+ }
86
+
87
+ function insertInstallation(
88
+ sqlite: ReturnType<typeof createTestApp>["sqlite"],
89
+ installationId: string,
90
+ siteId: string,
91
+ login: string,
92
+ ) {
93
+ sqlite
94
+ .prepare(
95
+ `INSERT INTO github_app_installation
96
+ (installation_id, site_id, account_login, account_type, account_avatar_url, added_at)
97
+ VALUES (?, ?, ?, 'User', '', ?)`,
98
+ )
99
+ .run(installationId, siteId, login, Math.floor(Date.now() / 1000));
100
+ }
101
+
102
+ /** An installation on a site belonging to somebody else entirely. */
103
+ function insertOtherTenantInstallation(
104
+ sqlite: ReturnType<typeof createTestApp>["sqlite"],
105
+ ) {
106
+ insertSite(sqlite, OTHER_TENANT_SITE_ID, "other-tenant");
107
+ insertMember(sqlite, OTHER_TENANT_SITE_ID, "somebody-else");
108
+ insertInstallation(sqlite, "inst-theirs", OTHER_TENANT_SITE_ID, "stranger");
109
+ }
110
+
111
+ /**
112
+ * The author's *other* site already connected through the App, and the
113
+ * site under test has no binding of its own — the second-blog case.
114
+ */
115
+ function insertSiblingSiteInstallation(
116
+ sqlite: ReturnType<typeof createTestApp>["sqlite"],
117
+ ) {
118
+ insertSite(sqlite, SECOND_SITE_ID, "second");
119
+ insertMember(sqlite, SECOND_SITE_ID, AUTHOR_ID);
120
+ insertInstallation(sqlite, "inst-mine", SECOND_SITE_ID, "octo");
121
+ }
122
+
123
+ describe("GitHub App connect flow", () => {
124
+ beforeEach(() => {
125
+ vi.clearAllMocks();
126
+ });
127
+
128
+ describe("GET /settings/github-sync/app/install", () => {
129
+ it("sends a first-time author to GitHub to install the App", async () => {
130
+ const { app } = createGitHubApp();
131
+
132
+ const res = await app.request("/settings/github-sync/app/install");
133
+
134
+ expect(res.status).toBe(302);
135
+ expect(res.headers.get("location")).toContain(
136
+ "https://github.com/apps/jant-sync/installations/new",
137
+ );
138
+ });
139
+
140
+ it("renders the picker when the author authorized an account on another of their sites", async () => {
141
+ const { app, sqlite } = createGitHubApp();
142
+ insertSiblingSiteInstallation(sqlite);
143
+
144
+ const res = await app.request("/settings/github-sync/app/install");
145
+
146
+ expect(res.status).toBe(200);
147
+ expect(await res.text()).toContain("<jant-repo-picker");
148
+ });
149
+
150
+ it("still reaches GitHub when the author asks to add another account", async () => {
151
+ const { app, sqlite } = createGitHubApp();
152
+ insertSiblingSiteInstallation(sqlite);
153
+
154
+ const res = await app.request(
155
+ "/settings/github-sync/app/install?force=new",
156
+ );
157
+
158
+ expect(res.status).toBe(302);
159
+ expect(res.headers.get("location")).toContain("github.com");
160
+ });
161
+
162
+ it("ignores an installation bound to another tenant's site", async () => {
163
+ const { app, sqlite } = createGitHubApp();
164
+ insertOtherTenantInstallation(sqlite);
165
+
166
+ const res = await app.request("/settings/github-sync/app/install");
167
+
168
+ expect(res.status).toBe(302);
169
+ });
170
+ });
171
+
172
+ describe("the labels the picker is handed", () => {
173
+ async function renderPicker() {
174
+ const { app, sqlite } = createGitHubApp();
175
+ insertSiblingSiteInstallation(sqlite);
176
+ const res = await app.request("/settings/github-sync/app/install");
177
+ expect(res.status).toBe(200);
178
+ return await res.text();
179
+ }
180
+
181
+ it("fills in the suggested repository name", async () => {
182
+ // `i18n._()` renders a placeholder it has no value for as an empty
183
+ // string, which left the hint reading "Name prefilled as ."
184
+ expect(await renderPicker()).toMatch(
185
+ /Name prefilled as [a-z0-9-]+-jant-sync\./,
186
+ );
187
+ });
188
+
189
+ it("keeps the placeholders the component fills in itself", async () => {
190
+ const html = await renderPicker();
191
+
192
+ expect(html).toContain("Showing {shown} of {total}");
193
+ expect(html).toContain("Type {repo} to confirm");
194
+ });
195
+ });
196
+
197
+ describe("GET /settings/github-sync/app/installations", () => {
198
+ it("lists the account the author authorized on another of their sites", async () => {
199
+ const { app, sqlite } = createGitHubApp();
200
+ insertSiblingSiteInstallation(sqlite);
201
+ insertOtherTenantInstallation(sqlite);
202
+
203
+ const res = await app.request("/settings/github-sync/app/installations");
204
+
205
+ expect(res.status).toBe(200);
206
+ const body = (await res.json()) as {
207
+ installations: Array<{
208
+ installationId: string;
209
+ account: { login: string };
210
+ }>;
211
+ };
212
+ expect(body.installations).toHaveLength(1);
213
+ expect(body.installations[0]).toMatchObject({
214
+ installationId: "inst-mine",
215
+ account: { login: "octo" },
216
+ });
217
+ });
218
+ });
219
+
220
+ describe("another tenant's installation id", () => {
221
+ it("is not a repository list", async () => {
222
+ const { app, sqlite } = createGitHubApp();
223
+ insertOtherTenantInstallation(sqlite);
224
+
225
+ const res = await app.request(
226
+ "/settings/github-sync/app/repos?installationId=inst-theirs",
227
+ );
228
+
229
+ expect(res.status).toBe(404);
230
+ });
231
+
232
+ it("is not classifiable", async () => {
233
+ const { app, sqlite } = createGitHubApp();
234
+ insertOtherTenantInstallation(sqlite);
235
+
236
+ const res = await app.request("/settings/github-sync/app/classify", {
237
+ method: "POST",
238
+ headers: { "content-type": "application/json" },
239
+ body: JSON.stringify({
240
+ installationId: "inst-theirs",
241
+ repo: "stranger/private-notes",
242
+ }),
243
+ });
244
+
245
+ expect(res.status).toBe(404);
246
+ expect(classifyRepoForSync).not.toHaveBeenCalled();
247
+ });
248
+
249
+ it("is not connectable", async () => {
250
+ const { app, sqlite } = createGitHubApp();
251
+ insertOtherTenantInstallation(sqlite);
252
+
253
+ const res = await app.request("/settings/github-sync/app/connect", {
254
+ method: "POST",
255
+ headers: { "content-type": "application/json" },
256
+ body: JSON.stringify({
257
+ installationId: "inst-theirs",
258
+ repo: "stranger/private-notes",
259
+ confirmForeign: true,
260
+ }),
261
+ });
262
+
263
+ expect(res.status).toBe(404);
264
+ expect(setupWebhook).not.toHaveBeenCalled();
265
+ });
266
+ });
267
+
268
+ describe("POST /settings/github-sync/app/connect", () => {
269
+ it("records the installation against this site", async () => {
270
+ const { app, sqlite, services } = createGitHubApp();
271
+ insertSiblingSiteInstallation(sqlite);
272
+ classifyRepoForSync.mockResolvedValue({ kind: "empty" });
273
+
274
+ const res = await app.request("/settings/github-sync/app/connect", {
275
+ method: "POST",
276
+ headers: { "content-type": "application/json" },
277
+ body: JSON.stringify({
278
+ installationId: "inst-mine",
279
+ repo: "octo/second-blog",
280
+ }),
281
+ });
282
+
283
+ expect(res.status).toBe(200);
284
+ expect(setupWebhook).toHaveBeenCalledOnce();
285
+
286
+ // Without this row the App webhook's fan-out — uninstall, suspend,
287
+ // repository removed — never reaches the site it just connected.
288
+ const bound =
289
+ await services.githubAppInstallations.listInstallationsForSite(
290
+ DEFAULT_TEST_SITE_ID,
291
+ );
292
+ expect(bound).toHaveLength(1);
293
+ expect(bound[0]).toMatchObject({
294
+ installationId: "inst-mine",
295
+ account: { login: "octo" },
296
+ });
297
+ expect(await services.settings.get("GITHUB_SYNC_REPO")).toBe(
298
+ "octo/second-blog",
299
+ );
300
+ expect(await services.settings.get("GITHUB_SYNC_ENABLED")).toBe("true");
301
+ });
302
+ });
303
+ describe("a site syncing through the App with no binding to show for it", () => {
304
+ /** What the old connect path left behind: settings, no junction row. */
305
+ function insertUnboundSyncingSite(
306
+ sqlite: ReturnType<typeof createTestApp>["sqlite"],
307
+ ) {
308
+ insertSite(sqlite, SECOND_SITE_ID, "second");
309
+ insertMember(sqlite, SECOND_SITE_ID, AUTHOR_ID);
310
+ const timestamp = Math.floor(Date.now() / 1000);
311
+ for (const [key, value] of [
312
+ ["GITHUB_SYNC_AUTH_MODE", "app"],
313
+ ["GITHUB_SYNC_APP_INSTALLATION_ID", "inst-orphan"],
314
+ ]) {
315
+ sqlite
316
+ .prepare(
317
+ `INSERT INTO site_setting (site_id, key, value, updated_at)
318
+ VALUES (?, ?, ?, ?)`,
319
+ )
320
+ .run(SECOND_SITE_ID, key, value, timestamp);
321
+ }
322
+ }
323
+
324
+ it("rebuilds the binding from GitHub instead of sending the author to a dead end", async () => {
325
+ const { app, sqlite, services } = createGitHubApp();
326
+ insertUnboundSyncingSite(sqlite);
327
+ getInstallation.mockResolvedValue({
328
+ account: { login: "octo", type: "User", avatarUrl: "" },
329
+ });
330
+
331
+ const res = await app.request("/settings/github-sync/app/install");
332
+
333
+ expect(res.status).toBe(200);
334
+ expect(await res.text()).toContain("<jant-repo-picker");
335
+ expect(getInstallation).toHaveBeenCalledOnce();
336
+ expect(
337
+ await services.githubAppInstallations.listInstallationsForSite(
338
+ SECOND_SITE_ID,
339
+ ),
340
+ ).toMatchObject([{ installationId: "inst-orphan" }]);
341
+ });
342
+
343
+ it("falls back to the GitHub redirect when the installation is gone", async () => {
344
+ const { app, sqlite } = createGitHubApp();
345
+ insertUnboundSyncingSite(sqlite);
346
+ getInstallation.mockRejectedValue(
347
+ new Error("Fetching installation failed (404)"),
348
+ );
349
+
350
+ const res = await app.request("/settings/github-sync/app/install");
351
+
352
+ expect(res.status).toBe(302);
353
+ });
354
+
355
+ it("does not touch GitHub when the author already has a binding", async () => {
356
+ const { app, sqlite } = createGitHubApp();
357
+ insertSiblingSiteInstallation(sqlite);
358
+
359
+ const res = await app.request("/settings/github-sync/app/install");
360
+
361
+ expect(res.status).toBe(200);
362
+ expect(getInstallation).not.toHaveBeenCalled();
363
+ });
364
+ });
365
+ });