| 1 |
import { afterAll, expect, test } from "bun:test"; |
| 2 |
import { mkdtempSync } from "node:fs"; |
| 3 |
import { tmpdir } from "node:os"; |
| 4 |
import { join } from "node:path"; |
| 5 |
import { serve } from "./server.ts"; |
| 6 |
import { blobOid, type Forge } from "./sync.ts"; |
| 7 |
import { sign } from "./who.ts"; |
| 8 |
|
| 9 |
|
| 10 |
|
| 11 |
const files = new Map<string, string>([["docs/Welcome.md", "Hello from git. [[Plan]]"]]); |
| 12 |
let head = "c0"; |
| 13 |
const forge: Forge = { |
| 14 |
head: async () => head, |
| 15 |
list: async (_r, _ref, path) => { |
| 16 |
const prefix = path === "" ? "" : `${path}/`; |
| 17 |
const seen = new Map<string, { name: string; type: string; oid: string; size: number }>(); |
| 18 |
for (const [p, c] of files) { |
| 19 |
if (!p.startsWith(prefix)) continue; |
| 20 |
const rest = p.slice(prefix.length); |
| 21 |
const name = rest.split("/")[0]!; |
| 22 |
seen.set(name, rest.includes("/") ? { name, type: "tree", oid: "t", size: -1 } : { name, type: "blob", oid: blobOid(c), size: c.length }); |
| 23 |
} |
| 24 |
return [...seen.values()]; |
| 25 |
}, |
| 26 |
read: async (_r, _ref, path) => files.get(path)!, |
| 27 |
commit: async (_r, _b, _m, changes) => { |
| 28 |
for (const f of changes) f.content === null ? files.delete(f.path) : files.set(f.path, f.content); |
| 29 |
head = `c${Number(head.slice(1)) + 1}`; |
| 30 |
return head; |
| 31 |
}, |
| 32 |
}; |
| 33 |
|
| 34 |
const KEY = "k"; |
| 35 |
const server = serve({ port: 0, dataDir: mkdtempSync(join(tmpdir(), "app-shared-")), identityKey: KEY, forge }); |
| 36 |
const base = `http://${server.hostname}:${server.port}`; |
| 37 |
afterAll(() => server.stop(true)); |
| 38 |
|
| 39 |
const as = (id: string, name: string, role: "user" | "admin" = "user"): Record<string, string> => { |
| 40 |
const identity = `${id}|${name}|${role}|${Date.now() + 60_000}`; |
| 41 |
return { "x-forge-identity": identity, "x-forge-signature": sign(identity, KEY) }; |
| 42 |
}; |
| 43 |
const ben = as("1", "ben", "admin"); |
| 44 |
const sam = as("2", "sam"); |
| 45 |
const same = { origin: base, "sec-fetch-site": "same-origin" }; |
| 46 |
const get = (path: string, who: Record<string, string> = {}) => |
| 47 |
fetch(base + path, { headers: { ...who, accept: "text/html" }, redirect: "manual" }); |
| 48 |
const post = (path: string, form: Record<string, string>, who: Record<string, string>) => |
| 49 |
fetch(base + path, { |
| 50 |
method: "POST", |
| 51 |
headers: { ...who, ...same, "content-type": "application/x-www-form-urlencoded" }, |
| 52 |
body: new URLSearchParams(form).toString(), |
| 53 |
redirect: "manual", |
| 54 |
}); |
| 55 |
|
| 56 |
test("connect a folder, invite someone by link, and write in it together", async () => { |
| 57 |
|
| 58 |
expect((await post("/spaces", { name: "X", repo: "ben/x" }, sam)).headers.get("location")).toBe("/"); |
| 59 |
const made = await post("/spaces", { name: "Brainstorm", repo: "ben/brainstorm", branch: "main", folder: "docs" }, ben); |
| 60 |
expect(made.status).toBe(303); |
| 61 |
const at = made.headers.get("location")!; |
| 62 |
expect(at).toMatch(/^\/s\/\d+$/); |
| 63 |
const sid = Number(at.slice(3)); |
| 64 |
|
| 65 |
|
| 66 |
const home = await get(at, ben); |
| 67 |
expect(home.status).toBe(303); |
| 68 |
const page = await (await get(home.headers.get("location")!, ben)).text(); |
| 69 |
expect(page).toContain("Hello from git."); |
| 70 |
expect(page).toContain(`href="/s/${sid}/open?title=Plan"`); |
| 71 |
expect(page).toContain("docs/Welcome.md"); |
| 72 |
|
| 73 |
|
| 74 |
expect((await get(at, sam)).headers.get("location")).toBe("/"); |
| 75 |
const settings = await (await get(`${at}/settings`, ben)).text(); |
| 76 |
const link = /value="https?:\/\/[^"]*(\/join\/[\w-]+)"/.exec(settings)![1]!; |
| 77 |
|
| 78 |
const guest = await (await get(link)).text(); |
| 79 |
expect(guest).toContain(`/login?to=${encodeURIComponent(link)}`); |
| 80 |
const joined = await get(link, sam); |
| 81 |
expect(joined.headers.get("location")).toBe(at); |
| 82 |
|
| 83 |
|
| 84 |
const note = await fetch(`${base}/api/notes`, { |
| 85 |
method: "POST", |
| 86 |
headers: { ...sam, ...same, "content-type": "application/json" }, |
| 87 |
body: JSON.stringify({ title: "ideas/Plan", body: "Ship it.", space: sid }), |
| 88 |
}); |
| 89 |
expect(note.status).toBe(201); |
| 90 |
const made2 = (await note.json()) as { id: number; path: string; updated_at: string }; |
| 91 |
expect(made2.path).toBe("docs/ideas/Plan.md"); |
| 92 |
const synced = await post(`${at}/sync`, {}, sam); |
| 93 |
expect(synced.headers.get("location")).toBe(`${at}/settings`); |
| 94 |
expect(files.get("docs/ideas/Plan.md")).toBe("Ship it."); |
| 95 |
|
| 96 |
|
| 97 |
const patch = (who: Record<string, string>, body: string, b: string) => |
| 98 |
fetch(`${base}/api/notes/${made2.id}`, { |
| 99 |
method: "PATCH", |
| 100 |
headers: { ...who, ...same, "content-type": "application/json" }, |
| 101 |
body: JSON.stringify({ body, base: b }), |
| 102 |
}); |
| 103 |
await Bun.sleep(3); |
| 104 |
expect((await patch(ben, "Ben's take", made2.updated_at)).status).toBe(200); |
| 105 |
const late = await patch(sam, "Sam's take", made2.updated_at); |
| 106 |
expect(late.status).toBe(409); |
| 107 |
expect(((await late.json()) as { message: string }).message).toContain("ben"); |
| 108 |
|
| 109 |
|
| 110 |
const mine = (await (await fetch(`${base}/api/notes`, { headers: ben })).json()) as unknown[]; |
| 111 |
expect(mine).toEqual([]); |
| 112 |
const theirs = (await (await fetch(`${base}/api/notes?space=${sid}`, { headers: sam })).json()) as { title: string }[]; |
| 113 |
expect(theirs.map((n) => n.title).sort()).toEqual(["Plan", "Welcome"]); |
| 114 |
}); |