@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.
- package/bin/commands/export.js +3 -1
- package/bin/commands/import-site.js +689 -216
- package/bin/commands/setup.js +136 -0
- package/bin/commands/site/export.js +71 -34
- package/bin/commands/site/pull-media.js +2 -6
- package/bin/commands/site/snapshot/export.js +18 -60
- package/bin/commands/site/snapshot/import.js +22 -23
- package/bin/lib/d1-query.js +87 -2
- package/bin/lib/hugo-markdown.js +4 -0
- package/bin/lib/site-pull-media.js +21 -28
- package/bin/lib/site-selection.js +10 -1
- package/bin/lib/site-snapshot.js +338 -3
- package/bin/lib/sql-export.js +68 -5
- package/bin/lib/wrangler-cli.js +9 -0
- package/bin/lib/zip-archive.js +187 -0
- package/dist/{app-K_Aa1MMn.js → app-ZcaI1kPN.js} +732 -217
- package/dist/client/.vite/manifest.json +20 -20
- package/dist/client/_assets/chunks/{create-editor-CD3FhrOB.js → create-editor-B-m7X7S5.js} +50 -46
- package/dist/client/_assets/chunks/{sortable-list-CgaL2jCs.js → sortable-list-BJyd-LXE.js} +1 -1
- package/dist/client/_assets/chunks/{unsafe-svg-0QCkP0vZ.js → unsafe-svg-BkscJx69.js} +2 -2
- package/dist/client/_assets/{client-GiYENVw8.css → client-DAhoqPdr.css} +1 -1
- package/dist/client/_assets/{client-auth-k8gJ6QJj.js → client-auth-CAMfTrKW.js} +1 -1
- package/dist/client/_assets/{client-compose-B_kDtWkW.js → client-compose-D6rExKVK.js} +1 -1
- package/dist/client/_assets/{client-VnFxJN7G.js → client-knSEyUJO.js} +1 -1
- package/dist/client/_assets/{client-manage-M90aSTOk.js → client-manage-sVOkqVbH.js} +1 -1
- package/dist/client/_assets/{client-settings-DfYs9n1F.js → client-settings-CGR_nZ78.js} +7 -7
- package/dist/{github-sync-BPAvT999.js → github-sync-Gw4orAHk.js} +799 -110
- package/dist/index.js +2 -2
- package/dist/node.js +9 -5
- package/package.json +13 -3
- package/src/__tests__/dev-scripts.test.ts +203 -0
- package/src/__tests__/export-collection-order.test.ts +276 -0
- package/src/__tests__/export-feed-ids.test.ts +184 -0
- package/src/__tests__/export-feed-order.test.ts +294 -0
- package/src/__tests__/export-hugo-build.test.ts +130 -0
- package/src/__tests__/export-import-roundtrip.test.ts +94 -0
- package/src/__tests__/export-service.test.ts +611 -28
- package/src/__tests__/export-smart-collection.test.ts +329 -0
- package/src/__tests__/helpers/hugo-site.ts +146 -0
- package/src/__tests__/import-site-command.test.ts +487 -1
- package/src/__tests__/mise-config.test.ts +75 -4
- package/src/__tests__/node-dev-tasks.test.ts +264 -0
- package/src/__tests__/site-export-canonical-import.test.ts +149 -0
- package/src/__tests__/snapshot-canonical-replay.test.ts +179 -0
- package/src/__tests__/snapshot-settings.test.ts +97 -0
- package/src/__tests__/snapshot-tables.test.ts +219 -0
- package/src/__tests__/sql-export.test.ts +173 -0
- package/src/__tests__/zip-archive.test.ts +106 -0
- package/src/app.tsx +15 -6
- package/src/client/components/__tests__/jant-settings-avatar.test.ts +1 -1
- package/src/client/components/__tests__/jant-settings-general.test.ts +21 -1
- package/src/client/components/jant-repo-picker-types.ts +6 -1
- package/src/client/components/jant-repo-picker.ts +4 -7
- package/src/client/components/jant-settings-general.ts +17 -12
- package/src/client/tiptap/__tests__/list-editing.test.ts +224 -86
- package/src/client/tiptap/__tests__/mark-exit.test.ts +1 -2
- package/src/client/tiptap/__tests__/markdown-clipboard.test.ts +26 -0
- package/src/client/tiptap/extensions.ts +0 -3
- package/src/client/tiptap/structural-keymap.ts +158 -47
- package/src/db/__tests__/d1-query.test.ts +56 -1
- package/src/i18n/locales/settings/en.po +8 -8
- package/src/i18n/locales/settings/en.ts +1 -1
- package/src/i18n/locales/settings/zh-Hans.po +8 -8
- package/src/i18n/locales/settings/zh-Hans.ts +1 -1
- package/src/i18n/locales/settings/zh-Hant.po +8 -8
- package/src/i18n/locales/settings/zh-Hant.ts +1 -1
- package/src/lib/__tests__/github-sync-repo-name.test.ts +40 -0
- package/src/lib/__tests__/image.test.ts +27 -1
- package/src/lib/__tests__/markdown-to-tiptap.test.ts +105 -0
- package/src/lib/__tests__/markdown.test.ts +10 -0
- package/src/lib/__tests__/resolve-config.test.ts +67 -0
- package/src/lib/__tests__/schemas.test.ts +27 -1
- package/src/lib/__tests__/timeline.test.ts +87 -0
- package/src/lib/__tests__/tiptap-to-markdown.test.ts +172 -4
- package/src/lib/discover.ts +3 -1
- package/src/lib/github-sync-repo-name.ts +45 -0
- package/src/lib/hugo-markdown.ts +46 -0
- package/src/lib/image.ts +17 -4
- package/src/lib/markdown-manager.ts +392 -2
- package/src/lib/post-body-html.ts +11 -4
- package/src/lib/resolve-config.ts +58 -1
- package/src/lib/schemas.ts +50 -5
- package/src/lib/thread-fold.ts +3 -3
- package/src/lib/timeline.ts +1 -1
- package/src/lib/tiptap-to-markdown.ts +13 -7
- package/src/lib/url.ts +20 -0
- package/src/lib/view.ts +2 -2
- package/src/node/__tests__/cli-setup.test.ts +163 -0
- package/src/node/__tests__/cli-site-snapshot.test.ts +67 -0
- package/src/node/__tests__/cli-snapshot-meta.test.ts +20 -0
- package/src/node/__tests__/runtime.test.ts +38 -0
- package/src/node/index.ts +2 -0
- package/src/node/request-handler.ts +3 -1
- package/src/routes/api/__tests__/posts.test.ts +24 -0
- package/src/routes/api/__tests__/upload.test.ts +34 -0
- package/src/routes/api/export.ts +3 -3
- package/src/routes/api/internal/sites.ts +0 -1
- package/src/routes/api/posts.ts +2 -0
- package/src/routes/api/public/posts.ts +21 -1
- package/src/routes/api/upload.ts +10 -3
- package/src/routes/compose.tsx +4 -0
- package/src/routes/dash/__tests__/github-sync-app.test.ts +365 -0
- package/src/routes/dash/settings.tsx +212 -70
- package/src/routes/pages/__tests__/post-page-round-trips.test.ts +211 -0
- package/src/routes/pages/__tests__/thread-order.test.ts +179 -0
- package/src/routes/pages/archive.tsx +17 -11
- package/src/routes/pages/featured.tsx +11 -5
- package/src/routes/pages/page.tsx +53 -31
- package/src/routes/pages/search.tsx +4 -2
- package/src/runtime/__tests__/readiness.test.ts +23 -0
- package/src/runtime/node.ts +49 -0
- package/src/runtime/readiness.ts +15 -0
- package/src/services/__tests__/bootstrap-setup-instance.test.ts +230 -0
- package/src/services/__tests__/custom-url.test.ts +34 -0
- package/src/services/__tests__/github-app-installations.test.ts +153 -0
- package/src/services/__tests__/github-sync-push.test.ts +46 -0
- package/src/services/__tests__/media.test.ts +31 -0
- package/src/services/__tests__/path.test.ts +56 -0
- package/src/services/__tests__/post-timeline.test.ts +127 -0
- package/src/services/__tests__/post.test.ts +74 -0
- package/src/services/bootstrap.ts +263 -44
- package/src/services/custom-url.ts +2 -2
- package/src/services/export-theme/layouts/_default/alias.html +27 -1
- package/src/services/export-theme/layouts/_default/list.html +2 -63
- package/src/services/export-theme/layouts/_default/rss.xml +27 -38
- package/src/services/export-theme/layouts/collections/list.html +3 -3
- package/src/services/export-theme/layouts/featured/list.html +1 -4
- package/src/services/export-theme/layouts/index.html +40 -26
- package/src/services/export-theme/layouts/partials/collection-members.html +100 -0
- package/src/services/export-theme/layouts/partials/collection-threads.html +33 -0
- package/src/services/export-theme/layouts/partials/featured-members.html +35 -0
- package/src/services/export-theme/layouts/partials/featured-thread.html +1 -1
- package/src/services/export-theme/layouts/partials/footer.html +1 -1
- package/src/services/export-theme/layouts/partials/head.html +1 -1
- package/src/services/export-theme/layouts/partials/header.html +5 -3
- package/src/services/export-theme/layouts/partials/jant-data.html +23 -0
- package/src/services/export-theme/layouts/partials/latest-members.html +48 -0
- package/src/services/export-theme/layouts/partials/smart-collection-members.html +129 -0
- package/src/services/export-theme/layouts/partials/thread-preview.html +1 -1
- package/src/services/export-theme/layouts/post/list.html +1 -1
- package/src/services/export-theme/layouts/smart_collection/list.html +24 -0
- package/src/services/export-theme/styles/main.css +0 -1
- package/src/services/export-theme/theme.toml +1 -1
- package/src/services/export.ts +620 -71
- package/src/services/github-app-installations.ts +171 -4
- package/src/services/github-sync.ts +69 -15
- package/src/services/mcp.ts +19 -1
- package/src/services/media.ts +8 -1
- package/src/services/path.ts +34 -1
- package/src/services/post.ts +214 -39
- package/src/services/site-admin.ts +3 -7
- package/src/services/site.ts +83 -31
- package/src/styles/ui.css +7 -1
- package/src/types/app-context.ts +16 -0
- package/src/types/bindings.ts +5 -0
- package/src/types/operations.ts +10 -0
- package/src/ui/dash/settings/GeneralContent.tsx +10 -8
- package/src/client/tiptap/exitable-marks.ts +0 -73
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { sql } from "drizzle-orm";
|
|
2
|
+
import { beforeEach, describe, expect, it } from "vitest";
|
|
3
|
+
import { createTestDatabase } from "../../__tests__/helpers/db.js";
|
|
4
|
+
import type { Database } from "../../db/index.js";
|
|
5
|
+
import {
|
|
6
|
+
account,
|
|
7
|
+
navItems,
|
|
8
|
+
settings,
|
|
9
|
+
siteDomains,
|
|
10
|
+
siteMembers,
|
|
11
|
+
sites,
|
|
12
|
+
user,
|
|
13
|
+
} from "../../db/schema.js";
|
|
14
|
+
import { verifyPassword } from "../../lib/password.js";
|
|
15
|
+
import { createBootstrapService } from "../bootstrap.js";
|
|
16
|
+
import { createSiteService, TRANSIENT_SINGLE_SITE_ID } from "../site.js";
|
|
17
|
+
|
|
18
|
+
const SNAPSHOT_SITE_ID = "sit_01kn8jq3t4famtyg9hjd074ckr";
|
|
19
|
+
const OWNER = { email: "owner@example.com", password: "correct horse" };
|
|
20
|
+
|
|
21
|
+
async function readSettings(db: Database, siteId: string) {
|
|
22
|
+
const rows = await db.select().from(settings);
|
|
23
|
+
return Object.fromEntries(
|
|
24
|
+
rows
|
|
25
|
+
.filter((row) => row.siteId === siteId)
|
|
26
|
+
.map((row) => [row.key, row.value]),
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function storedPasswordHash(db: Database) {
|
|
31
|
+
const rows = await db.select({ password: account.password }).from(account);
|
|
32
|
+
expect(rows).toHaveLength(1);
|
|
33
|
+
return rows[0]?.password ?? "";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe("EnsureSingleSiteOptions.id", () => {
|
|
37
|
+
let db: Database;
|
|
38
|
+
|
|
39
|
+
beforeEach(async () => {
|
|
40
|
+
db = createTestDatabase().db as unknown as Database;
|
|
41
|
+
await db.run(sql`DELETE FROM "site"`);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("creates the site with the given id", async () => {
|
|
45
|
+
const { site } = await createSiteService(db).ensureSingleSite({
|
|
46
|
+
id: SNAPSHOT_SITE_ID,
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
expect(site.id).toBe(SNAPSHOT_SITE_ID);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// Like the host and key, the id says what a new site is created with. It is
|
|
53
|
+
// never a request to rename a site that exists.
|
|
54
|
+
it("leaves an existing site's id alone", async () => {
|
|
55
|
+
const { site: existing } = await createSiteService(db).ensureSingleSite();
|
|
56
|
+
const { site } = await createSiteService(db).ensureSingleSite({
|
|
57
|
+
id: SNAPSHOT_SITE_ID,
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
expect(site.id).toBe(existing.id);
|
|
61
|
+
expect(await db.select().from(sites)).toHaveLength(1);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe("BootstrapService.setUpInstance", () => {
|
|
66
|
+
let db: Database;
|
|
67
|
+
|
|
68
|
+
function bootstrap(options?: Parameters<typeof createBootstrapService>[2]) {
|
|
69
|
+
return createBootstrapService(db, TRANSIENT_SINGLE_SITE_ID, options);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
beforeEach(async () => {
|
|
73
|
+
db = createTestDatabase().db as unknown as Database;
|
|
74
|
+
await db.run(sql`DELETE FROM "site"`);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("leaves a fresh install set up, the way both screens would", async () => {
|
|
78
|
+
const result = await bootstrap().setUpInstance({
|
|
79
|
+
...OWNER,
|
|
80
|
+
siteId: SNAPSHOT_SITE_ID,
|
|
81
|
+
siteName: "Field Notes",
|
|
82
|
+
siteLanguage: "zh-Hans",
|
|
83
|
+
timeZone: "Asia/Shanghai",
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
expect(result).toEqual({ outcome: "created", siteId: SNAPSHOT_SITE_ID });
|
|
87
|
+
|
|
88
|
+
const [owner] = await db.select().from(user);
|
|
89
|
+
expect(owner).toMatchObject({
|
|
90
|
+
email: OWNER.email,
|
|
91
|
+
name: "Field Notes",
|
|
92
|
+
role: "admin",
|
|
93
|
+
});
|
|
94
|
+
expect(
|
|
95
|
+
await verifyPassword({
|
|
96
|
+
hash: await storedPasswordHash(db),
|
|
97
|
+
password: OWNER.password,
|
|
98
|
+
}),
|
|
99
|
+
).toBe(true);
|
|
100
|
+
|
|
101
|
+
expect(await db.select().from(siteMembers)).toEqual([
|
|
102
|
+
expect.objectContaining({
|
|
103
|
+
siteId: SNAPSHOT_SITE_ID,
|
|
104
|
+
userId: owner?.id,
|
|
105
|
+
role: "owner",
|
|
106
|
+
}),
|
|
107
|
+
]);
|
|
108
|
+
expect(await db.select().from(navItems)).toHaveLength(5);
|
|
109
|
+
expect(await readSettings(db, SNAPSHOT_SITE_ID)).toMatchObject({
|
|
110
|
+
ONBOARDING_STATUS: "completed",
|
|
111
|
+
SITE_NAME: "Field Notes",
|
|
112
|
+
SITE_LANGUAGE: "zh-Hans",
|
|
113
|
+
TIME_ZONE: "Asia/Shanghai",
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// An empty answer on the screens: the base locale and UTC, and no name, so
|
|
118
|
+
// the site keeps its fallback and the account keeps the address's stand-in.
|
|
119
|
+
it("gives unanswered questions the screens' defaults", async () => {
|
|
120
|
+
const result = await bootstrap().setUpInstance(OWNER);
|
|
121
|
+
|
|
122
|
+
expect(result.outcome).toBe("created");
|
|
123
|
+
expect(result.siteId).toMatch(/^sit_/);
|
|
124
|
+
const stored = await readSettings(db, result.siteId);
|
|
125
|
+
expect(stored).toMatchObject({
|
|
126
|
+
ONBOARDING_STATUS: "completed",
|
|
127
|
+
SITE_LANGUAGE: "en",
|
|
128
|
+
TIME_ZONE: "UTC",
|
|
129
|
+
});
|
|
130
|
+
expect(stored.SITE_NAME).toBeUndefined();
|
|
131
|
+
expect((await db.select().from(user))[0]?.name).toBe("owner");
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("gives the site its configured domain", async () => {
|
|
135
|
+
await createBootstrapService(db, TRANSIENT_SINGLE_SITE_ID, {
|
|
136
|
+
bootstrapSite: { host: "notes.example.com" },
|
|
137
|
+
}).setUpInstance(OWNER);
|
|
138
|
+
|
|
139
|
+
expect(await db.select().from(siteDomains)).toEqual([
|
|
140
|
+
expect.objectContaining({ host: "notes.example.com", kind: "primary" }),
|
|
141
|
+
]);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// A deployment runs this on every start. The owner may have changed the
|
|
145
|
+
// password since, and a restart must not quietly put the old one back.
|
|
146
|
+
it("changes nothing on an install that finished setup", async () => {
|
|
147
|
+
const first = await bootstrap().setUpInstance({
|
|
148
|
+
...OWNER,
|
|
149
|
+
siteName: "Field Notes",
|
|
150
|
+
});
|
|
151
|
+
const hashBefore = await storedPasswordHash(db);
|
|
152
|
+
|
|
153
|
+
const second = await bootstrap().setUpInstance({
|
|
154
|
+
email: "someone-else@example.com",
|
|
155
|
+
password: "a different password",
|
|
156
|
+
siteName: "Renamed",
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
expect(second).toEqual({
|
|
160
|
+
outcome: "already-set-up",
|
|
161
|
+
siteId: first.siteId,
|
|
162
|
+
});
|
|
163
|
+
expect(await storedPasswordHash(db)).toBe(hashBefore);
|
|
164
|
+
expect(await db.select().from(user)).toHaveLength(1);
|
|
165
|
+
expect((await readSettings(db, first.siteId)).SITE_NAME).toBe(
|
|
166
|
+
"Field Notes",
|
|
167
|
+
);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("refuses a site id that is not the install's", async () => {
|
|
171
|
+
await bootstrap().setUpInstance(OWNER);
|
|
172
|
+
|
|
173
|
+
await expect(
|
|
174
|
+
bootstrap().setUpInstance({ ...OWNER, siteId: SNAPSHOT_SITE_ID }),
|
|
175
|
+
).rejects.toThrow(`not ${SNAPSHOT_SITE_ID}`);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
describe("after a run that stopped once the account existed", () => {
|
|
179
|
+
beforeEach(async () => {
|
|
180
|
+
await bootstrap().setUpInstance(OWNER);
|
|
181
|
+
// Undo everything past the account, which is where an interrupted run
|
|
182
|
+
// leaves an install.
|
|
183
|
+
await db.run(sql`DELETE FROM "site"`);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("finishes with the same address and password", async () => {
|
|
187
|
+
const result = await bootstrap().setUpInstance({
|
|
188
|
+
...OWNER,
|
|
189
|
+
siteId: SNAPSHOT_SITE_ID,
|
|
190
|
+
siteName: "Field Notes",
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
expect(result).toEqual({ outcome: "resumed", siteId: SNAPSHOT_SITE_ID });
|
|
194
|
+
expect(await db.select().from(user)).toHaveLength(1);
|
|
195
|
+
expect((await db.select().from(user))[0]?.name).toBe("Field Notes");
|
|
196
|
+
expect((await readSettings(db, SNAPSHOT_SITE_ID)).ONBOARDING_STATUS).toBe(
|
|
197
|
+
"completed",
|
|
198
|
+
);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("refuses the wrong password", async () => {
|
|
202
|
+
await expect(
|
|
203
|
+
bootstrap().setUpInstance({ ...OWNER, password: "not the password" }),
|
|
204
|
+
).rejects.toThrow("does not match");
|
|
205
|
+
expect(await db.select().from(sites)).toHaveLength(0);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("refuses another address", async () => {
|
|
209
|
+
await expect(
|
|
210
|
+
bootstrap().setUpInstance({
|
|
211
|
+
email: "someone-else@example.com",
|
|
212
|
+
password: OWNER.password,
|
|
213
|
+
}),
|
|
214
|
+
).rejects.toThrow("already has an account under another address");
|
|
215
|
+
expect(await db.select().from(user)).toHaveLength(1);
|
|
216
|
+
expect(await db.select().from(sites)).toHaveLength(0);
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
// A hosted site's owner arrives through the control plane's handoff, so
|
|
221
|
+
// this refuses before creating an account in a database every tenant shares.
|
|
222
|
+
it("refuses a host-based install before writing anything", async () => {
|
|
223
|
+
await expect(
|
|
224
|
+
bootstrap({ siteResolutionMode: "host-based" }).setUpInstance(OWNER),
|
|
225
|
+
).rejects.toThrow("setUpInstance runs on self-hosted installs only");
|
|
226
|
+
|
|
227
|
+
expect(await db.select().from(user)).toHaveLength(0);
|
|
228
|
+
expect(await db.select().from(sites)).toHaveLength(0);
|
|
229
|
+
});
|
|
230
|
+
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect, beforeEach } from "vitest";
|
|
2
|
+
import { sql } from "drizzle-orm";
|
|
2
3
|
import {
|
|
3
4
|
createTestDatabase,
|
|
4
5
|
DEFAULT_TEST_SITE_ID,
|
|
@@ -96,6 +97,39 @@ describe("CustomUrlService", () => {
|
|
|
96
97
|
});
|
|
97
98
|
});
|
|
98
99
|
|
|
100
|
+
// Postgres gives no order among rows that tie on the sort key, so paging
|
|
101
|
+
// by created_at alone repeated some custom URLs and never showed others
|
|
102
|
+
// (482 rows listed, 478 distinct, on a real site).
|
|
103
|
+
describe("list order", () => {
|
|
104
|
+
it("orders custom URLs created in the same second by ID", async () => {
|
|
105
|
+
const created = [];
|
|
106
|
+
for (const path of ["a-page", "b-page", "c-page"]) {
|
|
107
|
+
created.push(
|
|
108
|
+
await customUrlService.create({
|
|
109
|
+
path,
|
|
110
|
+
targetType: "redirect",
|
|
111
|
+
toPath: "/target",
|
|
112
|
+
redirectType: 301,
|
|
113
|
+
}),
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
await db.run(sql`UPDATE path_registry SET created_at = 1000`);
|
|
117
|
+
|
|
118
|
+
const listed = await customUrlService.list();
|
|
119
|
+
const expected = created
|
|
120
|
+
.map((url) => url.id)
|
|
121
|
+
.sort()
|
|
122
|
+
.reverse();
|
|
123
|
+
expect(listed.map((url) => url.id)).toEqual(expected);
|
|
124
|
+
|
|
125
|
+
const pages = [
|
|
126
|
+
...(await customUrlService.list({ limit: 2, offset: 0 })),
|
|
127
|
+
...(await customUrlService.list({ limit: 2, offset: 2 })),
|
|
128
|
+
];
|
|
129
|
+
expect(pages.map((url) => url.id)).toEqual(expected);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
99
133
|
describe("getByPath", () => {
|
|
100
134
|
it("returns custom URL by path", async () => {
|
|
101
135
|
await customUrlService.create({
|
|
@@ -26,6 +26,23 @@ function insertSecondSite(
|
|
|
26
26
|
.run(SECOND_SITE_ID, SECOND_SITE_KEY, timestamp, timestamp);
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
const OWNER_USER_ID = "usr_owner0000000000000000000000000";
|
|
30
|
+
const OTHER_USER_ID = "usr_other0000000000000000000000000";
|
|
31
|
+
|
|
32
|
+
function addMember(
|
|
33
|
+
sqlite: ReturnType<typeof createTestDatabase>["sqlite"],
|
|
34
|
+
siteId: string,
|
|
35
|
+
userId: string,
|
|
36
|
+
) {
|
|
37
|
+
const timestamp = Math.floor(Date.now() / 1000);
|
|
38
|
+
sqlite
|
|
39
|
+
.prepare(
|
|
40
|
+
`INSERT INTO site_member (site_id, user_id, role, created_at, updated_at)
|
|
41
|
+
VALUES (?, ?, 'owner', ?, ?)`,
|
|
42
|
+
)
|
|
43
|
+
.run(siteId, userId, timestamp, timestamp);
|
|
44
|
+
}
|
|
45
|
+
|
|
29
46
|
function makeAccount(
|
|
30
47
|
overrides: Partial<GitHubInstallationAccount> = {},
|
|
31
48
|
): GitHubInstallationAccount {
|
|
@@ -178,4 +195,140 @@ describe("GitHubAppInstallationsService", () => {
|
|
|
178
195
|
"inst-older",
|
|
179
196
|
]);
|
|
180
197
|
});
|
|
198
|
+
describe("user-scoped visibility", () => {
|
|
199
|
+
it("returns nothing for a user with no sites", async () => {
|
|
200
|
+
await service.upsertInstallation(
|
|
201
|
+
"inst-1",
|
|
202
|
+
DEFAULT_TEST_SITE_ID,
|
|
203
|
+
makeAccount(),
|
|
204
|
+
);
|
|
205
|
+
expect(await service.listInstallationsForUser(OWNER_USER_ID)).toEqual([]);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("surfaces an installation bound to another site the user belongs to", async () => {
|
|
209
|
+
insertSecondSite(sqlite);
|
|
210
|
+
addMember(sqlite, DEFAULT_TEST_SITE_ID, OWNER_USER_ID);
|
|
211
|
+
addMember(sqlite, SECOND_SITE_ID, OWNER_USER_ID);
|
|
212
|
+
|
|
213
|
+
// Bound to the first site only — the second site is the one trying
|
|
214
|
+
// to connect, and GitHub will not install the App twice on one
|
|
215
|
+
// account, so this row is its only way in.
|
|
216
|
+
await service.upsertInstallation(
|
|
217
|
+
"inst-1",
|
|
218
|
+
DEFAULT_TEST_SITE_ID,
|
|
219
|
+
makeAccount({ login: "octo", type: "User" }),
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
const visible = await service.listInstallationsForUser(OWNER_USER_ID);
|
|
223
|
+
expect(visible).toHaveLength(1);
|
|
224
|
+
expect(visible[0]).toMatchObject({
|
|
225
|
+
installationId: "inst-1",
|
|
226
|
+
account: { login: "octo", type: "User" },
|
|
227
|
+
});
|
|
228
|
+
expect(await service.listInstallationsForSite(SECOND_SITE_ID)).toEqual(
|
|
229
|
+
[],
|
|
230
|
+
);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("hides installations bound to sites the user is not a member of", async () => {
|
|
234
|
+
insertSecondSite(sqlite);
|
|
235
|
+
addMember(sqlite, DEFAULT_TEST_SITE_ID, OWNER_USER_ID);
|
|
236
|
+
addMember(sqlite, SECOND_SITE_ID, OTHER_USER_ID);
|
|
237
|
+
|
|
238
|
+
await service.upsertInstallation(
|
|
239
|
+
"inst-theirs",
|
|
240
|
+
SECOND_SITE_ID,
|
|
241
|
+
makeAccount({ login: "somebody-else" }),
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
expect(await service.listInstallationsForUser(OWNER_USER_ID)).toEqual([]);
|
|
245
|
+
expect(
|
|
246
|
+
await service.findInstallationForUser("inst-theirs", OWNER_USER_ID),
|
|
247
|
+
).toBeNull();
|
|
248
|
+
expect(
|
|
249
|
+
await service.findInstallationForUser("inst-theirs", OTHER_USER_ID),
|
|
250
|
+
).toMatchObject({ installationId: "inst-theirs" });
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it("lists an installation once when several of the user's sites share it", async () => {
|
|
254
|
+
insertSecondSite(sqlite);
|
|
255
|
+
addMember(sqlite, DEFAULT_TEST_SITE_ID, OWNER_USER_ID);
|
|
256
|
+
addMember(sqlite, SECOND_SITE_ID, OWNER_USER_ID);
|
|
257
|
+
|
|
258
|
+
await service.upsertInstallation(
|
|
259
|
+
"inst-1",
|
|
260
|
+
DEFAULT_TEST_SITE_ID,
|
|
261
|
+
makeAccount(),
|
|
262
|
+
);
|
|
263
|
+
await service.upsertInstallation("inst-1", SECOND_SITE_ID, makeAccount());
|
|
264
|
+
|
|
265
|
+
const visible = await service.listInstallationsForUser(OWNER_USER_ID);
|
|
266
|
+
expect(visible.map((entry) => entry.installationId)).toEqual(["inst-1"]);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it("removeInstallationForUser clears every binding the user can see", async () => {
|
|
270
|
+
insertSecondSite(sqlite);
|
|
271
|
+
addMember(sqlite, DEFAULT_TEST_SITE_ID, OWNER_USER_ID);
|
|
272
|
+
|
|
273
|
+
await service.upsertInstallation(
|
|
274
|
+
"inst-1",
|
|
275
|
+
DEFAULT_TEST_SITE_ID,
|
|
276
|
+
makeAccount(),
|
|
277
|
+
);
|
|
278
|
+
// A site the user has nothing to do with keeps its binding: only
|
|
279
|
+
// the App webhook's `installation.deleted` speaks for everyone.
|
|
280
|
+
await service.upsertInstallation("inst-1", SECOND_SITE_ID, makeAccount());
|
|
281
|
+
|
|
282
|
+
await service.removeInstallationForUser("inst-1", OWNER_USER_ID);
|
|
283
|
+
|
|
284
|
+
expect(await service.listInstallationsForUser(OWNER_USER_ID)).toEqual([]);
|
|
285
|
+
expect(await service.listSitesForInstallation("inst-1")).toEqual([
|
|
286
|
+
SECOND_SITE_ID,
|
|
287
|
+
]);
|
|
288
|
+
});
|
|
289
|
+
it("reads syncing installations from settings only for the user's own sites", async () => {
|
|
290
|
+
insertSecondSite(sqlite);
|
|
291
|
+
addMember(sqlite, DEFAULT_TEST_SITE_ID, OWNER_USER_ID);
|
|
292
|
+
addMember(sqlite, SECOND_SITE_ID, OTHER_USER_ID);
|
|
293
|
+
const timestamp = Math.floor(Date.now() / 1000);
|
|
294
|
+
const setSetting = (siteId: string, key: string, value: string) =>
|
|
295
|
+
sqlite
|
|
296
|
+
.prepare(
|
|
297
|
+
`INSERT INTO site_setting (site_id, key, value, updated_at)
|
|
298
|
+
VALUES (?, ?, ?, ?)`,
|
|
299
|
+
)
|
|
300
|
+
.run(siteId, key, value, timestamp);
|
|
301
|
+
|
|
302
|
+
setSetting(
|
|
303
|
+
DEFAULT_TEST_SITE_ID,
|
|
304
|
+
"GITHUB_SYNC_APP_INSTALLATION_ID",
|
|
305
|
+
"inst-mine",
|
|
306
|
+
);
|
|
307
|
+
setSetting(
|
|
308
|
+
SECOND_SITE_ID,
|
|
309
|
+
"GITHUB_SYNC_APP_INSTALLATION_ID",
|
|
310
|
+
"inst-theirs",
|
|
311
|
+
);
|
|
312
|
+
|
|
313
|
+
expect(
|
|
314
|
+
await service.listSyncingInstallationsForUser(OWNER_USER_ID),
|
|
315
|
+
).toEqual([
|
|
316
|
+
{ siteId: DEFAULT_TEST_SITE_ID, installationId: "inst-mine" },
|
|
317
|
+
]);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it("skips a site whose stored installation id was cleared", async () => {
|
|
321
|
+
addMember(sqlite, DEFAULT_TEST_SITE_ID, OWNER_USER_ID);
|
|
322
|
+
sqlite
|
|
323
|
+
.prepare(
|
|
324
|
+
`INSERT INTO site_setting (site_id, key, value, updated_at)
|
|
325
|
+
VALUES (?, 'GITHUB_SYNC_APP_INSTALLATION_ID', '', ?)`,
|
|
326
|
+
)
|
|
327
|
+
.run(DEFAULT_TEST_SITE_ID, Math.floor(Date.now() / 1000));
|
|
328
|
+
|
|
329
|
+
expect(
|
|
330
|
+
await service.listSyncingInstallationsForUser(OWNER_USER_ID),
|
|
331
|
+
).toEqual([]);
|
|
332
|
+
});
|
|
333
|
+
});
|
|
181
334
|
});
|
|
@@ -11,10 +11,12 @@
|
|
|
11
11
|
|
|
12
12
|
import { describe, expect, it } from "vitest";
|
|
13
13
|
import type { GitHubTreeItem } from "../../lib/github-api.js";
|
|
14
|
+
import type { ExportFile } from "../export.js";
|
|
14
15
|
import {
|
|
15
16
|
computeManagedDeletions,
|
|
16
17
|
isManagedPath,
|
|
17
18
|
pathMatchesManagedGlob,
|
|
19
|
+
selectFilesToWrite,
|
|
18
20
|
} from "../github-sync.js";
|
|
19
21
|
|
|
20
22
|
describe("pathMatchesManagedGlob", () => {
|
|
@@ -66,6 +68,50 @@ describe("isManagedPath", () => {
|
|
|
66
68
|
});
|
|
67
69
|
});
|
|
68
70
|
|
|
71
|
+
describe("selectFilesToWrite", () => {
|
|
72
|
+
const generated: ExportFile[] = [
|
|
73
|
+
{ path: "hugo.toml", content: 'baseURL = "/"' },
|
|
74
|
+
{ path: "content/hello/_index.md", content: "---\n---\n" },
|
|
75
|
+
{ path: "wrangler.jsonc", content: "{}", scaffoldOnce: true },
|
|
76
|
+
];
|
|
77
|
+
|
|
78
|
+
it("writes scaffolding the repository does not have yet", () => {
|
|
79
|
+
const written = selectFilesToWrite(generated, new Set(["hugo.toml"]));
|
|
80
|
+
|
|
81
|
+
expect(written.map((f) => f.path)).toEqual([
|
|
82
|
+
"hugo.toml",
|
|
83
|
+
"content/hello/_index.md",
|
|
84
|
+
"wrangler.jsonc",
|
|
85
|
+
]);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("leaves scaffolding alone once the repository has it", () => {
|
|
89
|
+
// The Worker name in `wrangler.jsonc` defaults to the repository name,
|
|
90
|
+
// and a user whose Worker is named differently corrects it by hand.
|
|
91
|
+
// Rewriting it every push would revert that, and every build after it
|
|
92
|
+
// would fail on the name mismatch.
|
|
93
|
+
const written = selectFilesToWrite(
|
|
94
|
+
generated,
|
|
95
|
+
new Set(["hugo.toml", "wrangler.jsonc"]),
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
expect(written.map((f) => f.path)).toEqual([
|
|
99
|
+
"hugo.toml",
|
|
100
|
+
"content/hello/_index.md",
|
|
101
|
+
]);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("still rewrites ordinary files the repository already has", () => {
|
|
105
|
+
const written = selectFilesToWrite(
|
|
106
|
+
generated,
|
|
107
|
+
new Set(["hugo.toml", "content/hello/_index.md"]),
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
expect(written.map((f) => f.path)).toContain("hugo.toml");
|
|
111
|
+
expect(written.map((f) => f.path)).toContain("content/hello/_index.md");
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
69
115
|
describe("computeManagedDeletions", () => {
|
|
70
116
|
function blob(path: string): GitHubTreeItem {
|
|
71
117
|
return { path, mode: "100644", type: "blob", sha: "sha-" + path };
|
|
@@ -1203,6 +1203,37 @@ describe("MediaService", () => {
|
|
|
1203
1203
|
const list = await mediaService.list({ limit: 2 });
|
|
1204
1204
|
expect(list).toHaveLength(2);
|
|
1205
1205
|
});
|
|
1206
|
+
|
|
1207
|
+
// The API used to stop at 200 items with no way to ask for the rest.
|
|
1208
|
+
it("pages through every item with an ID cursor", async () => {
|
|
1209
|
+
const created = [];
|
|
1210
|
+
for (let i = 0; i < 5; i++) {
|
|
1211
|
+
created.push(
|
|
1212
|
+
await mediaService.create({
|
|
1213
|
+
...sampleMedia,
|
|
1214
|
+
storageKey: `page${i}.jpg`,
|
|
1215
|
+
}),
|
|
1216
|
+
);
|
|
1217
|
+
}
|
|
1218
|
+
const newestFirst = created
|
|
1219
|
+
.map((media) => media.id)
|
|
1220
|
+
.sort()
|
|
1221
|
+
.reverse();
|
|
1222
|
+
|
|
1223
|
+
const first = await mediaService.list({ limit: 2 });
|
|
1224
|
+
const second = await mediaService.list({
|
|
1225
|
+
limit: 2,
|
|
1226
|
+
cursor: first.at(-1)?.id,
|
|
1227
|
+
});
|
|
1228
|
+
const third = await mediaService.list({
|
|
1229
|
+
limit: 2,
|
|
1230
|
+
cursor: second.at(-1)?.id,
|
|
1231
|
+
});
|
|
1232
|
+
|
|
1233
|
+
expect([...first, ...second, ...third].map((media) => media.id)).toEqual(
|
|
1234
|
+
newestFirst,
|
|
1235
|
+
);
|
|
1236
|
+
});
|
|
1206
1237
|
});
|
|
1207
1238
|
|
|
1208
1239
|
describe("attachToPost", () => {
|
|
@@ -178,3 +178,59 @@ describe("PathService.getPostAliases", () => {
|
|
|
178
178
|
expect(after.get(post.id)?.[0]).toBe("/original");
|
|
179
179
|
});
|
|
180
180
|
});
|
|
181
|
+
|
|
182
|
+
describe("PathService.listStandalonePaths", () => {
|
|
183
|
+
it("lists redirects and archive URLs, not a post's own addresses", async () => {
|
|
184
|
+
const testDb = createTestDatabase();
|
|
185
|
+
const db = testDb.db as unknown as Database;
|
|
186
|
+
const paths = createPathService(db, DEFAULT_TEST_SITE_ID);
|
|
187
|
+
const postService = createPostService(
|
|
188
|
+
db,
|
|
189
|
+
{ slugIdLength: 5 },
|
|
190
|
+
DEFAULT_TEST_SITE_ID,
|
|
191
|
+
paths,
|
|
192
|
+
);
|
|
193
|
+
const post = await postService.create({
|
|
194
|
+
format: "note",
|
|
195
|
+
title: "Kept",
|
|
196
|
+
slug: "kept",
|
|
197
|
+
body: JSON.stringify({ type: "doc", content: [{ type: "paragraph" }] }),
|
|
198
|
+
});
|
|
199
|
+
await paths.create({ path: "old-kept", kind: "alias", postId: post.id });
|
|
200
|
+
await paths.create({
|
|
201
|
+
path: "atom.xml",
|
|
202
|
+
kind: "redirect",
|
|
203
|
+
redirectToPath: "feed",
|
|
204
|
+
redirectType: 301,
|
|
205
|
+
});
|
|
206
|
+
await paths.create({
|
|
207
|
+
path: "links",
|
|
208
|
+
kind: "archive",
|
|
209
|
+
archiveQuery: "format=link",
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
const standalone = await paths.listStandalonePaths();
|
|
213
|
+
|
|
214
|
+
expect(
|
|
215
|
+
standalone.map(({ path, kind, redirectToPath, archiveQuery }) => ({
|
|
216
|
+
path,
|
|
217
|
+
kind,
|
|
218
|
+
redirectToPath,
|
|
219
|
+
archiveQuery,
|
|
220
|
+
})),
|
|
221
|
+
).toEqual([
|
|
222
|
+
{
|
|
223
|
+
path: "atom.xml",
|
|
224
|
+
kind: "redirect",
|
|
225
|
+
redirectToPath: "feed",
|
|
226
|
+
archiveQuery: null,
|
|
227
|
+
},
|
|
228
|
+
{
|
|
229
|
+
path: "links",
|
|
230
|
+
kind: "archive",
|
|
231
|
+
redirectToPath: null,
|
|
232
|
+
archiveQuery: "format=link",
|
|
233
|
+
},
|
|
234
|
+
]);
|
|
235
|
+
});
|
|
236
|
+
});
|