🌿 Collaborative wiki on ATProto
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Update tests suite

juprodh b39e0bb6 10341576

+1401 -473
+148
tests/atproto/env.test.ts
··· 1 + import { afterAll, beforeAll, describe, expect, test } from "bun:test"; 2 + import { 3 + getAtprotoEnv, 4 + getDevAccounts, 5 + getDevPdsUrl, 6 + getDevPlcUrl, 7 + getHandleResolverUrl, 8 + getRelayUrl, 9 + isAuthEnabled, 10 + } from "../../src/atproto/env.ts"; 11 + 12 + // Save original env to restore after tests 13 + const origEnv: Record<string, string | undefined> = {}; 14 + const ENV_KEYS = [ 15 + "PUBLIC_URL", 16 + "OAUTH_PRIVATE_KEY_PATH", 17 + "RELAY_URL", 18 + "HANDLE_RESOLVER_URL", 19 + "DEV_PDS_URL", 20 + "DEV_PLC_URL", 21 + "DEV_ACCOUNTS", 22 + ]; 23 + 24 + beforeAll(() => { 25 + for (const key of ENV_KEYS) { 26 + origEnv[key] = process.env[key]; 27 + } 28 + }); 29 + 30 + afterAll(() => { 31 + for (const key of ENV_KEYS) { 32 + if (origEnv[key] === undefined) { 33 + delete process.env[key]; 34 + } else { 35 + process.env[key] = origEnv[key]; 36 + } 37 + } 38 + }); 39 + 40 + describe("getAtprotoEnv", () => { 41 + test("returns null when PUBLIC_URL is missing", () => { 42 + delete process.env.PUBLIC_URL; 43 + delete process.env.OAUTH_PRIVATE_KEY_PATH; 44 + expect(getAtprotoEnv()).toBeNull(); 45 + }); 46 + 47 + test("returns null when OAUTH_PRIVATE_KEY_PATH is missing", () => { 48 + process.env.PUBLIC_URL = "https://lichen.wiki"; 49 + delete process.env.OAUTH_PRIVATE_KEY_PATH; 50 + expect(getAtprotoEnv()).toBeNull(); 51 + }); 52 + 53 + test("returns env when both are set", () => { 54 + process.env.PUBLIC_URL = "https://lichen.wiki"; 55 + process.env.OAUTH_PRIVATE_KEY_PATH = "/path/to/key.pem"; 56 + const env = getAtprotoEnv(); 57 + expect(env).toEqual({ 58 + publicUrl: "https://lichen.wiki", 59 + privateKeyPath: "/path/to/key.pem", 60 + }); 61 + }); 62 + }); 63 + 64 + describe("isAuthEnabled", () => { 65 + test("returns false when env vars missing", () => { 66 + delete process.env.PUBLIC_URL; 67 + delete process.env.OAUTH_PRIVATE_KEY_PATH; 68 + expect(isAuthEnabled()).toBe(false); 69 + }); 70 + 71 + test("returns true when env vars set", () => { 72 + process.env.PUBLIC_URL = "https://lichen.wiki"; 73 + process.env.OAUTH_PRIVATE_KEY_PATH = "/path/to/key.pem"; 74 + expect(isAuthEnabled()).toBe(true); 75 + }); 76 + }); 77 + 78 + describe("getRelayUrl", () => { 79 + test("defaults to bsky.network", () => { 80 + delete process.env.RELAY_URL; 81 + expect(getRelayUrl()).toBe("wss://bsky.network"); 82 + }); 83 + 84 + test("reads from env", () => { 85 + process.env.RELAY_URL = "wss://custom.relay"; 86 + expect(getRelayUrl()).toBe("wss://custom.relay"); 87 + }); 88 + }); 89 + 90 + describe("getHandleResolverUrl", () => { 91 + test("defaults to bsky.social", () => { 92 + delete process.env.HANDLE_RESOLVER_URL; 93 + expect(getHandleResolverUrl()).toBe("https://bsky.social"); 94 + }); 95 + 96 + test("reads from env", () => { 97 + process.env.HANDLE_RESOLVER_URL = "https://custom.resolver"; 98 + expect(getHandleResolverUrl()).toBe("https://custom.resolver"); 99 + }); 100 + }); 101 + 102 + describe("getDevPdsUrl", () => { 103 + test("returns null when not set", () => { 104 + delete process.env.DEV_PDS_URL; 105 + expect(getDevPdsUrl()).toBeNull(); 106 + }); 107 + 108 + test("reads from env", () => { 109 + process.env.DEV_PDS_URL = "http://localhost:2583"; 110 + expect(getDevPdsUrl()).toBe("http://localhost:2583"); 111 + }); 112 + }); 113 + 114 + describe("getDevPlcUrl", () => { 115 + test("returns null when not set", () => { 116 + delete process.env.DEV_PLC_URL; 117 + expect(getDevPlcUrl()).toBeNull(); 118 + }); 119 + 120 + test("reads from env", () => { 121 + process.env.DEV_PLC_URL = "http://localhost:2582"; 122 + expect(getDevPlcUrl()).toBe("http://localhost:2582"); 123 + }); 124 + }); 125 + 126 + describe("getDevAccounts", () => { 127 + test("returns null when not set", () => { 128 + delete process.env.DEV_ACCOUNTS; 129 + expect(getDevAccounts()).toBeNull(); 130 + }); 131 + 132 + test("parses valid JSON", () => { 133 + process.env.DEV_ACCOUNTS = JSON.stringify({ 134 + alice: { 135 + did: "did:plc:alice", 136 + handle: "alice.test", 137 + password: "pw", 138 + }, 139 + }); 140 + const accounts = getDevAccounts(); 141 + expect(accounts?.alice?.did).toBe("did:plc:alice"); 142 + }); 143 + 144 + test("returns null for malformed JSON", () => { 145 + process.env.DEV_ACCOUNTS = "{not json}"; 146 + expect(getDevAccounts()).toBeNull(); 147 + }); 148 + });
+2 -2
tests/atproto/pds.test.ts
··· 215 215 agent, 216 216 DID, 217 217 "tid-b1", 218 - "at://did:plc:test/wiki.lichen.note/tid123", 218 + "at://did:plc:test/wiki.lichen.wiki/my-wiki", 219 219 TIMESTAMP, 220 220 ); 221 221 ··· 223 223 expect(call.collection).toBe(COLLECTIONS.bookmark); 224 224 225 225 const record = call.record as Record<string, unknown>; 226 - expect(record.noteRef).toBe("at://did:plc:test/wiki.lichen.note/tid123"); 226 + expect(record.wikiRef).toBe("at://did:plc:test/wiki.lichen.wiki/my-wiki"); 227 227 }); 228 228 229 229 test("deleteRecord passes correct arguments", async () => {
+68 -11
tests/atproto/session.test.ts
··· 1 - import { describe, expect, test } from "bun:test"; 1 + import { afterAll, beforeAll, describe, expect, test } from "bun:test"; 2 2 import type { NodeOAuthClient } from "@atproto/oauth-client-node"; 3 - import { getSession } from "../../src/atproto/session.ts"; 3 + import { 4 + DEV_DID, 5 + getDevSession, 6 + getEffectiveDid, 7 + getSession, 8 + } from "../../src/atproto/session.ts"; 4 9 5 10 // Minimal mock of NodeOAuthClient for testing cookie parsing 6 11 function createMockClient( ··· 38 43 39 44 test("handles did cookie among other cookies", async () => { 40 45 const client = createMockClient((did) => ({ sub: did })); 41 - const session = await getSession( 46 + // did in the middle 47 + const session1 = await getSession( 42 48 client, 43 49 "theme=dark; did=did%3Aplc%3Axyz; lang=en", 44 50 ); 45 - expect(session).not.toBeNull(); 46 - expect(session?.did).toBe("did:plc:xyz"); 47 - }); 51 + expect(session1).not.toBeNull(); 52 + expect(session1?.did).toBe("did:plc:xyz"); 48 53 49 - test("handles did as first cookie", async () => { 50 - const client = createMockClient((did) => ({ sub: did })); 51 - const session = await getSession( 54 + // did as first cookie 55 + const session2 = await getSession( 52 56 client, 53 57 "did=did%3Aplc%3Afirst; other=val", 54 58 ); 55 - expect(session).not.toBeNull(); 56 - expect(session?.did).toBe("did:plc:first"); 59 + expect(session2).not.toBeNull(); 60 + expect(session2?.did).toBe("did:plc:first"); 57 61 }); 58 62 59 63 test("returns null when client.restore throws", async () => { ··· 76 80 expect(session?.handle).toBe("did:plc:custom-sub"); 77 81 }); 78 82 }); 83 + 84 + describe("getDevSession", () => { 85 + const origAccounts = process.env.DEV_ACCOUNTS; 86 + 87 + beforeAll(() => { 88 + process.env.DEV_ACCOUNTS = JSON.stringify({ 89 + alice: { 90 + did: "did:plc:devalice", 91 + handle: "alice.test", 92 + password: "pw", 93 + }, 94 + }); 95 + }); 96 + 97 + afterAll(() => { 98 + if (origAccounts === undefined) { 99 + delete process.env.DEV_ACCOUNTS; 100 + } else { 101 + process.env.DEV_ACCOUNTS = origAccounts; 102 + } 103 + }); 104 + 105 + test("returns null when no cookie header", () => { 106 + expect(getDevSession(undefined)).toBeNull(); 107 + }); 108 + 109 + test("returns null when cookie has no did", () => { 110 + expect(getDevSession("other=value")).toBeNull(); 111 + }); 112 + 113 + test("returns session for matching dev account", () => { 114 + const session = getDevSession("did=did%3Aplc%3Adevalice"); 115 + expect(session).not.toBeNull(); 116 + expect(session?.did).toBe("did:plc:devalice"); 117 + expect(session?.handle).toBe("alice.test"); 118 + }); 119 + 120 + test("returns null for unknown DID", () => { 121 + expect(getDevSession("did=did%3Aplc%3Aunknown")).toBeNull(); 122 + }); 123 + }); 124 + 125 + describe("getEffectiveDid", () => { 126 + test("returns session DID when session exists", () => { 127 + expect(getEffectiveDid({ did: "did:plc:real", handle: "real.test" })).toBe( 128 + "did:plc:real", 129 + ); 130 + }); 131 + 132 + test("returns DEV_DID when session is null", () => { 133 + expect(getEffectiveDid(null)).toBe(DEV_DID); 134 + }); 135 + });
+77 -12
tests/firehose/handlers.test.ts
··· 4 4 import { getDb } from "../../src/server/db/index.ts"; 5 5 import { 6 6 getCurrentNote, 7 - getCursor, 8 7 getNoteBySlug, 9 8 getWiki, 10 - setCursor, 9 + isBookmarked, 11 10 } from "../../src/server/db/queries/index.ts"; 12 11 13 12 const db = getDb(); ··· 72 71 db.run("DELETE FROM requests WHERE wiki_slug = ?", [slug]); 73 72 db.run("DELETE FROM wikis WHERE slug = ?", [slug]); 74 73 } 74 + db.run("DELETE FROM bookmarks WHERE did IN (?, ?)", [ALICE_DID, BOB_DID]); 75 75 } 76 76 77 77 beforeAll(() => { 78 78 cleanupHandlerTestData(); 79 - db.run("DELETE FROM firehose_cursor"); 80 79 }); 81 80 82 81 afterAll(() => { ··· 540 539 }); 541 540 }); 542 541 543 - describe("cursor persistence", () => { 544 - test("getCursor returns null initially", () => { 545 - expect(getCursor()).toBeNull(); 542 + describe("bookmark handler", () => { 543 + test("creates bookmark from firehose event", () => { 544 + // Ensure wiki exists first 545 + handleCommitEvent( 546 + makeCommitEvt({ 547 + event: "create", 548 + collection: "wiki.lichen.wiki", 549 + rkey: "test-wiki", 550 + did: ALICE_DID, 551 + record: { 552 + name: "BK Test Wiki", 553 + visibility: "public", 554 + createdAt: "2026-01-01T00:00:00.000Z", 555 + }, 556 + }), 557 + ); 558 + 559 + handleCommitEvent( 560 + makeCommitEvt({ 561 + event: "create", 562 + collection: "wiki.lichen.bookmark", 563 + rkey: "bk1", 564 + did: BOB_DID, 565 + record: { 566 + wikiRef: WIKI_AT_URI, 567 + createdAt: "2026-01-02T00:00:00.000Z", 568 + }, 569 + }), 570 + ); 571 + 572 + expect(isBookmarked(BOB_DID, WIKI_AT_URI)).toBe(true); 546 573 }); 547 574 548 - test("setCursor and getCursor roundtrip", () => { 549 - setCursor(12345); 550 - expect(getCursor()).toBe(12345); 575 + test("skips bookmark with missing wikiRef", () => { 576 + handleCommitEvent( 577 + makeCommitEvt({ 578 + event: "create", 579 + collection: "wiki.lichen.bookmark", 580 + rkey: "bk-bad", 581 + did: BOB_DID, 582 + record: { 583 + createdAt: "2026-01-02T00:00:00.000Z", 584 + }, 585 + }), 586 + ); 551 587 }); 552 588 553 - test("setCursor updates existing cursor", () => { 554 - setCursor(99999); 555 - expect(getCursor()).toBe(99999); 589 + test("skips bookmark for non-existent wiki", () => { 590 + const fakeWikiUri = "at://did:plc:ghost/wiki.lichen.wiki/nope"; 591 + handleCommitEvent( 592 + makeCommitEvt({ 593 + event: "create", 594 + collection: "wiki.lichen.bookmark", 595 + rkey: "bk-ghost", 596 + did: BOB_DID, 597 + record: { 598 + wikiRef: fakeWikiUri, 599 + createdAt: "2026-01-02T00:00:00.000Z", 600 + }, 601 + }), 602 + ); 603 + expect(isBookmarked(BOB_DID, fakeWikiUri)).toBe(false); 604 + }); 605 + 606 + test("deletes bookmark on delete event", () => { 607 + const bookmarkUri = `at://${BOB_DID}/wiki.lichen.bookmark/bk1`; 608 + expect(isBookmarked(BOB_DID, WIKI_AT_URI)).toBe(true); 609 + 610 + handleCommitEvent( 611 + makeCommitEvt({ 612 + event: "delete", 613 + collection: "wiki.lichen.bookmark", 614 + rkey: "bk1", 615 + did: BOB_DID, 616 + uri: { toString: () => bookmarkUri }, 617 + }), 618 + ); 619 + 620 + expect(isBookmarked(BOB_DID, WIKI_AT_URI)).toBe(false); 556 621 }); 557 622 });
+51
tests/lib/at-uri.test.ts
··· 1 + import { describe, expect, test } from "bun:test"; 2 + import { didOwnsUri, parseAtUri } from "../../src/lib/at-uri.ts"; 3 + 4 + describe("parseAtUri", () => { 5 + test("parses valid AT URI", () => { 6 + const result = parseAtUri("at://did:plc:abc123/wiki.lichen.note/my-rkey"); 7 + expect(result).toEqual({ 8 + did: "did:plc:abc123", 9 + collection: "wiki.lichen.note", 10 + rkey: "my-rkey", 11 + }); 12 + }); 13 + 14 + test("returns null for missing rkey", () => { 15 + expect(parseAtUri("at://did:plc:abc/wiki.lichen.note/")).toBeNull(); 16 + }); 17 + 18 + test("returns null for missing collection", () => { 19 + expect(parseAtUri("at://did:plc:abc")).toBeNull(); 20 + }); 21 + 22 + test("returns null for empty string", () => { 23 + expect(parseAtUri("")).toBeNull(); 24 + }); 25 + 26 + test("returns null for non-AT URI", () => { 27 + expect(parseAtUri("https://example.com/path")).toBeNull(); 28 + }); 29 + 30 + test("returns null for extra path segments", () => { 31 + expect(parseAtUri("at://did:plc:abc/collection/rkey/extra")).toBeNull(); 32 + }); 33 + }); 34 + 35 + describe("didOwnsUri", () => { 36 + test("returns true when DID matches URI", () => { 37 + expect( 38 + didOwnsUri("did:plc:abc", "at://did:plc:abc/wiki.lichen.wiki/slug"), 39 + ).toBe(true); 40 + }); 41 + 42 + test("returns false when DID does not match URI", () => { 43 + expect( 44 + didOwnsUri("did:plc:other", "at://did:plc:abc/wiki.lichen.wiki/slug"), 45 + ).toBe(false); 46 + }); 47 + 48 + test("returns false for unparseable URI", () => { 49 + expect(didOwnsUri("did:plc:abc", "garbage")).toBe(false); 50 + }); 51 + });
+1 -4
tests/lib/constants.test.ts
··· 4 4 describe("normalizeRole", () => { 5 5 test("passes through valid roles", () => { 6 6 expect(normalizeRole("admin")).toBe("admin"); 7 + expect(normalizeRole("contributor")).toBe("contributor"); 7 8 expect(normalizeRole("viewer")).toBe("viewer"); 8 - }); 9 - 10 - test("defaults contributor for valid contributor input", () => { 11 - expect(normalizeRole("contributor")).toBe("contributor"); 12 9 }); 13 10 14 11 test("defaults to contributor for invalid or missing input", () => {
-6
tests/lib/diff.test.ts
··· 26 26 current = applyDiff(current, diff3); 27 27 expect(current).toBe(v4); 28 28 }); 29 - 30 - test("diff output is a string", () => { 31 - const diff = createDiff("a", "b"); 32 - expect(typeof diff).toBe("string"); 33 - expect(diff.length).toBeGreaterThan(0); 34 - }); 35 29 });
+52
tests/lib/errors.test.ts
··· 1 + import { describe, expect, test } from "bun:test"; 2 + import { 3 + AppError, 4 + ForbiddenError, 5 + formatError, 6 + NotFoundError, 7 + PdsWriteError, 8 + ValidationError, 9 + } from "../../src/lib/errors.ts"; 10 + 11 + describe("error classes", () => { 12 + test("each subclass has the correct statusCode", () => { 13 + expect(new NotFoundError().statusCode).toBe(404); 14 + expect(new ForbiddenError().statusCode).toBe(403); 15 + expect(new PdsWriteError("fail").statusCode).toBe(502); 16 + expect(new ValidationError("bad").statusCode).toBe(400); 17 + }); 18 + 19 + test("all subclasses extend AppError and Error", () => { 20 + for (const err of [ 21 + new NotFoundError(), 22 + new ForbiddenError(), 23 + new PdsWriteError("fail"), 24 + new ValidationError("bad"), 25 + ]) { 26 + expect(err).toBeInstanceOf(AppError); 27 + expect(err).toBeInstanceOf(Error); 28 + } 29 + }); 30 + 31 + test("default messages are set", () => { 32 + expect(new NotFoundError().message).toBe("Not found"); 33 + expect(new ForbiddenError().message).toBe("Forbidden"); 34 + }); 35 + 36 + test("custom messages are preserved", () => { 37 + expect(new NotFoundError("wiki gone").message).toBe("wiki gone"); 38 + expect(new PdsWriteError("PDS down").message).toBe("PDS down"); 39 + }); 40 + }); 41 + 42 + describe("formatError", () => { 43 + test("extracts message from Error instances", () => { 44 + expect(formatError(new Error("boom"))).toBe("boom"); 45 + }); 46 + 47 + test("converts non-Error values to string", () => { 48 + expect(formatError("string error")).toBe("string error"); 49 + expect(formatError(42)).toBe("42"); 50 + expect(formatError(null)).toBe("null"); 51 + }); 52 + });
+22
tests/lib/html.test.ts
··· 1 + import { describe, expect, test } from "bun:test"; 2 + import { escapeHtml } from "../../src/lib/html.ts"; 3 + 4 + describe("escapeHtml", () => { 5 + test('escapes & < > "', () => { 6 + expect(escapeHtml('a & b < c > d "e"')).toBe( 7 + "a &amp; b &lt; c &gt; d &quot;e&quot;", 8 + ); 9 + }); 10 + 11 + test("returns empty string unchanged", () => { 12 + expect(escapeHtml("")).toBe(""); 13 + }); 14 + 15 + test("leaves safe text unchanged", () => { 16 + expect(escapeHtml("hello world 123")).toBe("hello world 123"); 17 + }); 18 + 19 + test("handles string with only special characters", () => { 20 + expect(escapeHtml('<"&">')).toBe("&lt;&quot;&amp;&quot;&gt;"); 21 + }); 22 + });
+44 -13
tests/lib/i18n.test.ts
··· 2 2 import { fmt, resolveLocale, t } from "../../src/lib/i18n/index.ts"; 3 3 4 4 describe("t()", () => { 5 - test("returns English messages for 'en'", () => { 5 + const EXPECTED_SECTIONS = [ 6 + "nav", 7 + "login", 8 + "search", 9 + "home", 10 + "pagination", 11 + "wiki", 12 + "editor", 13 + "createWiki", 14 + "access", 15 + "profile", 16 + "error", 17 + ]; 18 + 19 + test("returns all sections with non-empty strings for 'en'", () => { 6 20 const msg = t("en"); 7 - expect(msg.nav.login).toBe("Log in"); 8 - expect(msg.home.wikis).toBe("Wikis"); 9 - expect(msg.editor.save).toBe("Save"); 10 - expect(msg.createWiki.heading).toBe("Create a Wiki"); 11 - expect(msg.error.wikiNameRequired).toBe("Wiki name is required."); 21 + for (const section of EXPECTED_SECTIONS) { 22 + const entries = Object.values( 23 + msg[section as keyof typeof msg] as Record<string, string>, 24 + ); 25 + expect(entries.length).toBeGreaterThan(0); 26 + for (const val of entries) { 27 + expect(typeof val).toBe("string"); 28 + expect(val.length).toBeGreaterThan(0); 29 + } 30 + } 31 + }); 32 + 33 + test("French overrides differ from English where provided", () => { 34 + const en = t("en"); 35 + const fr = t("fr"); 36 + // Spot-check: fr has its own translations for these 37 + expect(fr.nav.login).not.toBe(en.nav.login); 38 + expect(fr.editor.save).not.toBe(en.editor.save); 12 39 }); 13 40 14 - test("returns French messages for 'fr'", () => { 41 + test("French fills all sections (merges with English fallback)", () => { 15 42 const msg = t("fr"); 16 - expect(msg.nav.login).toBe("Connexion"); 17 - expect(msg.home.noWikisYet).toBe("Aucun wiki pour le moment."); 18 - expect(msg.editor.save).toBe("Enregistrer"); 19 - expect(msg.createWiki.heading).toBe("Créer un wiki"); 20 - expect(msg.error.wikiNameRequired).toBe("Le nom du wiki est requis."); 43 + for (const section of EXPECTED_SECTIONS) { 44 + const entries = Object.values( 45 + msg[section as keyof typeof msg] as Record<string, string>, 46 + ); 47 + for (const val of entries) { 48 + expect(typeof val).toBe("string"); 49 + expect(val.length).toBeGreaterThan(0); 50 + } 51 + } 21 52 }); 22 53 23 54 test("falls back to English for unknown locale", () => { 24 55 // @ts-expect-error testing invalid locale 25 56 const msg = t("zz"); 26 - expect(msg.nav.login).toBe("Log in"); 57 + expect(msg.nav.login).toBe(t("en").nav.login); 27 58 }); 28 59 }); 29 60
+1 -15
tests/lib/image-validation.test.ts
··· 1 1 import { describe, expect, test } from "bun:test"; 2 - import { 3 - ALLOWED_MIME_TYPES, 4 - ImageValidationError, 5 - MAX_IMAGE_SIZE, 6 - } from "../../src/lib/image.ts"; 2 + import { ALLOWED_MIME_TYPES } from "../../src/lib/image.ts"; 7 3 8 4 // These tests validate the image constraints WITHOUT calling processImage 9 5 // (which requires sharp). They verify the exported constants and error class. ··· 18 14 // Dangerous types must not be present 19 15 expect(ALLOWED_MIME_TYPES).not.toContain("image/svg+xml"); 20 16 expect(ALLOWED_MIME_TYPES).not.toContain("application/pdf"); 21 - }); 22 - 23 - test("max image size is 10MB", () => { 24 - expect(MAX_IMAGE_SIZE).toBe(10 * 1024 * 1024); 25 - }); 26 - 27 - test("ImageValidationError is an Error", () => { 28 - const err = new ImageValidationError("test"); 29 - expect(err).toBeInstanceOf(Error); 30 - expect(err.name).toBe("ImageValidationError"); 31 17 }); 32 18 });
+104
tests/lib/orchestrators/bookmark.test.ts
··· 1 + import { afterAll, describe, expect, mock, test } from "bun:test"; 2 + import { getDb } from "../../../src/server/db/index.ts"; 3 + 4 + const realPds = await import("../../../src/atproto/pds.ts"); 5 + const realSession = await import("../../../src/atproto/session.ts"); 6 + 7 + const mockWriteBookmarkRecord = mock(async () => ({ 8 + uri: "at://did:plc:bkuser/wiki.lichen.bookmark/abc", 9 + cid: "bafyrei123", 10 + })); 11 + const mockDeleteRecord = mock(async () => {}); 12 + const mockGetAgent = mock(async () => ({}) as never); 13 + 14 + mock.module("../../../src/atproto/pds.ts", () => ({ 15 + ...realPds, 16 + writeBookmarkRecord: mockWriteBookmarkRecord, 17 + deleteRecord: mockDeleteRecord, 18 + })); 19 + mock.module("../../../src/atproto/session.ts", () => ({ 20 + ...realSession, 21 + getAgent: mockGetAgent, 22 + })); 23 + 24 + const { addBookmarkAction, removeBookmarkAction } = await import( 25 + "../../../src/lib/orchestrators/bookmark.ts" 26 + ); 27 + const { isBookmarked, upsertBookmark } = await import( 28 + "../../../src/server/db/queries/index.ts" 29 + ); 30 + 31 + const USER_DID = "did:plc:bkuser"; 32 + const WIKI_AT_URI = "at://did:plc:wikiowner/wiki.lichen.wiki/test-wiki"; 33 + 34 + const session = { did: USER_DID, handle: "bkuser.bsky.social" }; 35 + 36 + afterAll(() => { 37 + const db = getDb(); 38 + db.run("DELETE FROM bookmarks WHERE did = ?", [USER_DID]); 39 + mock.module("../../../src/atproto/pds.ts", () => realPds); 40 + mock.module("../../../src/atproto/session.ts", () => realSession); 41 + }); 42 + 43 + describe("addBookmarkAction", () => { 44 + test("writes to PDS and DB with session", async () => { 45 + mockWriteBookmarkRecord.mockClear(); 46 + await addBookmarkAction(USER_DID, WIKI_AT_URI, session); 47 + 48 + expect(mockWriteBookmarkRecord).toHaveBeenCalledTimes(1); 49 + expect(isBookmarked(USER_DID, WIKI_AT_URI)).toBe(true); 50 + }); 51 + 52 + test("writes to DB only without session (dev mode)", async () => { 53 + const db = getDb(); 54 + db.run("DELETE FROM bookmarks WHERE did = ?", [USER_DID]); 55 + 56 + mockWriteBookmarkRecord.mockClear(); 57 + await addBookmarkAction(USER_DID, WIKI_AT_URI, null); 58 + 59 + expect(mockWriteBookmarkRecord).not.toHaveBeenCalled(); 60 + expect(isBookmarked(USER_DID, WIKI_AT_URI)).toBe(true); 61 + }); 62 + }); 63 + 64 + describe("removeBookmarkAction", () => { 65 + test("deletes from DB and PDS with session", async () => { 66 + upsertBookmark( 67 + USER_DID, 68 + WIKI_AT_URI, 69 + `at://${USER_DID}/wiki.lichen.bookmark/rm1`, 70 + "2026-01-01T00:00:00.000Z", 71 + ); 72 + mockDeleteRecord.mockClear(); 73 + 74 + await removeBookmarkAction(USER_DID, WIKI_AT_URI, session); 75 + 76 + expect(mockDeleteRecord).toHaveBeenCalledTimes(1); 77 + expect(isBookmarked(USER_DID, WIKI_AT_URI)).toBe(false); 78 + }); 79 + 80 + test("deletes from DB only without session (dev mode)", async () => { 81 + upsertBookmark( 82 + USER_DID, 83 + WIKI_AT_URI, 84 + `at://${USER_DID}/wiki.lichen.bookmark/rm2`, 85 + "2026-01-01T00:00:00.000Z", 86 + ); 87 + mockDeleteRecord.mockClear(); 88 + 89 + await removeBookmarkAction(USER_DID, WIKI_AT_URI, null); 90 + 91 + expect(mockDeleteRecord).not.toHaveBeenCalled(); 92 + expect(isBookmarked(USER_DID, WIKI_AT_URI)).toBe(false); 93 + }); 94 + 95 + test("no-op when bookmark does not exist", async () => { 96 + mockDeleteRecord.mockClear(); 97 + await removeBookmarkAction( 98 + USER_DID, 99 + "at://did:plc:x/wiki.lichen.wiki/fake", 100 + session, 101 + ); 102 + expect(mockDeleteRecord).not.toHaveBeenCalled(); 103 + }); 104 + });
+29 -30
tests/lib/profile.test.ts
··· 1 - import { afterAll, describe, expect, mock, test } from "bun:test"; 1 + import { describe, expect, mock, test } from "bun:test"; 2 2 3 3 // Mock the IdResolver before importing the module 4 4 const mockResolveHandle = mock(async () => "did:plc:resolved"); ··· 13 13 }, 14 14 })); 15 15 16 - // Mock fetch for profile resolution 17 - const originalFetch = globalThis.fetch; 18 - const mockFetch = mock( 19 - async () => 20 - new Response( 21 - JSON.stringify({ 22 - handle: "alice.bsky.social", 23 - displayName: "Alice", 24 - avatar: "https://cdn.example.com/avatar.jpg", 25 - }), 26 - { status: 200, headers: { "Content-Type": "application/json" } }, 27 - ), 28 - ); 29 - globalThis.fetch = mockFetch as unknown as typeof fetch; 30 - 31 16 const { resolveHandleToDid, resolveProfile, resolveProfiles } = await import( 32 17 "../../src/lib/profile.ts" 33 18 ); 34 19 35 - afterAll(() => { 36 - globalThis.fetch = originalFetch; 37 - }); 20 + // Mock fetch passed via dependency injection (no global replacement) 21 + function createMockFetch( 22 + body: Record<string, unknown> = { 23 + handle: "alice.bsky.social", 24 + displayName: "Alice", 25 + avatar: "https://cdn.example.com/avatar.jpg", 26 + }, 27 + status = 200, 28 + ) { 29 + return mock( 30 + async () => 31 + new Response(JSON.stringify(body), { 32 + status, 33 + headers: { "Content-Type": "application/json" }, 34 + }), 35 + ) as unknown as typeof fetch; 36 + } 38 37 39 38 describe("resolveHandleToDid", () => { 40 39 test("returns DID unchanged if input starts with did:", async () => { ··· 67 66 describe("resolveProfile", () => { 68 67 test("returns handle, displayName, and avatar on success", async () => { 69 68 mockResolveDid.mockClear(); 70 - mockFetch.mockClear(); 69 + const mockFetch = createMockFetch(); 71 70 72 - const result = await resolveProfile("did:plc:alice"); 71 + const result = await resolveProfile("did:plc:alice", mockFetch); 73 72 74 73 expect(result.handle).toBe("alice.bsky.social"); 75 74 expect(result.displayName).toBe("Alice"); ··· 80 79 mockResolveDid.mockResolvedValueOnce({ 81 80 alsoKnownAs: ["at://bob.bsky.social"], 82 81 }); 83 - mockFetch.mockResolvedValueOnce(new Response("", { status: 404 })); 82 + const mockFetch = createMockFetch({}, 404); 84 83 85 - const result = await resolveProfile("did:plc:bob"); 84 + const result = await resolveProfile("did:plc:bob", mockFetch); 86 85 87 86 expect(result.handle).toBe("bob.bsky.social"); 88 87 expect(result.displayName).toBeNull(); ··· 93 92 mockResolveDid.mockImplementationOnce(async () => { 94 93 throw new Error("DID resolution failed"); 95 94 }); 95 + const mockFetch = createMockFetch(); 96 96 97 - const result = await resolveProfile("did:plc:broken"); 97 + const result = await resolveProfile("did:plc:broken", mockFetch); 98 98 99 99 expect(result.handle).toBeNull(); 100 100 expect(result.displayName).toBeNull(); ··· 105 105 describe("resolveProfiles", () => { 106 106 test("resolves multiple DIDs and deduplicates", async () => { 107 107 mockResolveDid.mockClear(); 108 - mockFetch.mockClear(); 108 + const mockFetch = createMockFetch(); 109 109 110 - const result = await resolveProfiles([ 111 - "did:plc:alice", 112 - "did:plc:bob", 113 - "did:plc:alice", // duplicate 114 - ]); 110 + const result = await resolveProfiles( 111 + ["did:plc:alice", "did:plc:bob", "did:plc:alice"], 112 + mockFetch, 113 + ); 115 114 116 115 expect(result.size).toBe(2); 117 116 expect(result.has("did:plc:alice")).toBe(true);
+23
tests/lib/tid.test.ts
··· 1 + import { describe, expect, test } from "bun:test"; 2 + import { generateTid } from "../../src/lib/tid.ts"; 3 + 4 + describe("generateTid", () => { 5 + test("returns 13-character base32 string", () => { 6 + const tid = generateTid(); 7 + expect(tid).toMatch(/^[2-7a-z]{13}$/); 8 + }); 9 + 10 + test("successive calls produce unique values", () => { 11 + const tids = new Set(Array.from({ length: 100 }, () => generateTid())); 12 + expect(tids.size).toBe(100); 13 + }); 14 + 15 + test("successive calls are monotonically increasing", () => { 16 + const a = generateTid(); 17 + const b = generateTid(); 18 + const c = generateTid(); 19 + // Lexicographic ordering matches temporal ordering for base32 TIDs 20 + expect(a < b).toBe(true); 21 + expect(b < c).toBe(true); 22 + }); 23 + });
+119
tests/server/db/queries/bookmark.test.ts
··· 1 + import { afterAll, describe, expect, test } from "bun:test"; 2 + import { getDb } from "../../../../src/server/db/index.ts"; 3 + import { 4 + deleteBookmarkByUri, 5 + deleteBookmarkByWiki, 6 + getBookmarksForUser, 7 + isBookmarked, 8 + upsertBookmark, 9 + } from "../../../../src/server/db/queries/index.ts"; 10 + 11 + const WIKI_SLUG = "test"; // seeded wiki 12 + const DID = "did:plc:bookmark-tester"; 13 + const OTHER_DID = "did:plc:bookmark-other"; 14 + 15 + // Get wiki at_uri from seeded data 16 + const db = getDb(); 17 + const seededWiki = db 18 + .query("SELECT at_uri FROM wikis WHERE slug = ?") 19 + .get(WIKI_SLUG) as { at_uri: string }; 20 + const WIKI_AT_URI = seededWiki.at_uri; 21 + 22 + const BOOKMARK_AT_URI = `at://${DID}/wiki.lichen.bookmark/bk1`; 23 + const OTHER_BOOKMARK_AT_URI = `at://${OTHER_DID}/wiki.lichen.bookmark/bk2`; 24 + 25 + // Set up test data 26 + upsertBookmark(DID, WIKI_AT_URI, BOOKMARK_AT_URI, "2026-01-01T00:00:00.000Z"); 27 + 28 + afterAll(() => { 29 + const db = getDb(); 30 + db.run("DELETE FROM bookmarks WHERE did IN (?, ?)", [DID, OTHER_DID]); 31 + }); 32 + 33 + describe("upsertBookmark", () => { 34 + test("inserts a bookmark", () => { 35 + expect(isBookmarked(DID, WIKI_AT_URI)).toBe(true); 36 + }); 37 + 38 + test("is idempotent on conflict", () => { 39 + const newUri = `at://${DID}/wiki.lichen.bookmark/bk1-new`; 40 + upsertBookmark(DID, WIKI_AT_URI, newUri, "2026-01-02T00:00:00.000Z"); 41 + expect(isBookmarked(DID, WIKI_AT_URI)).toBe(true); 42 + // Restore original at_uri 43 + upsertBookmark( 44 + DID, 45 + WIKI_AT_URI, 46 + BOOKMARK_AT_URI, 47 + "2026-01-01T00:00:00.000Z", 48 + ); 49 + }); 50 + }); 51 + 52 + describe("isBookmarked", () => { 53 + test("returns true for existing bookmark", () => { 54 + expect(isBookmarked(DID, WIKI_AT_URI)).toBe(true); 55 + }); 56 + 57 + test("returns false for non-existent bookmark", () => { 58 + expect(isBookmarked("did:plc:nobody", WIKI_AT_URI)).toBe(false); 59 + }); 60 + 61 + test("returns false for non-existent wiki", () => { 62 + expect(isBookmarked(DID, "at://did:plc:x/wiki.lichen.wiki/fake")).toBe( 63 + false, 64 + ); 65 + }); 66 + }); 67 + 68 + describe("getBookmarksForUser", () => { 69 + test("returns bookmarked wikis with note count", () => { 70 + const bookmarks = getBookmarksForUser(DID); 71 + expect(bookmarks.length).toBeGreaterThanOrEqual(1); 72 + const bm = bookmarks.find((w) => w.slug === WIKI_SLUG); 73 + expect(bm).toBeDefined(); 74 + expect(bm?.name).toBeTruthy(); 75 + expect(bm?.note_count).toBeGreaterThanOrEqual(0); 76 + }); 77 + 78 + test("returns empty for user with no bookmarks", () => { 79 + expect(getBookmarksForUser("did:plc:no-bookmarks")).toEqual([]); 80 + }); 81 + }); 82 + 83 + describe("deleteBookmarkByUri", () => { 84 + test("deletes bookmark by at_uri", () => { 85 + upsertBookmark( 86 + OTHER_DID, 87 + WIKI_AT_URI, 88 + OTHER_BOOKMARK_AT_URI, 89 + "2026-01-01T00:00:00.000Z", 90 + ); 91 + expect(isBookmarked(OTHER_DID, WIKI_AT_URI)).toBe(true); 92 + 93 + deleteBookmarkByUri(OTHER_BOOKMARK_AT_URI); 94 + expect(isBookmarked(OTHER_DID, WIKI_AT_URI)).toBe(false); 95 + }); 96 + 97 + test("no-op for non-existent uri", () => { 98 + deleteBookmarkByUri("at://did:plc:x/wiki.lichen.bookmark/fake"); 99 + }); 100 + }); 101 + 102 + describe("deleteBookmarkByWiki", () => { 103 + test("deletes bookmark and returns at_uri", () => { 104 + upsertBookmark( 105 + OTHER_DID, 106 + WIKI_AT_URI, 107 + OTHER_BOOKMARK_AT_URI, 108 + "2026-01-01T00:00:00.000Z", 109 + ); 110 + const atUri = deleteBookmarkByWiki(OTHER_DID, WIKI_AT_URI); 111 + expect(atUri).toBe(OTHER_BOOKMARK_AT_URI); 112 + expect(isBookmarked(OTHER_DID, WIKI_AT_URI)).toBe(false); 113 + }); 114 + 115 + test("returns null when no bookmark exists", () => { 116 + const atUri = deleteBookmarkByWiki("did:plc:nobody", WIKI_AT_URI); 117 + expect(atUri).toBeNull(); 118 + }); 119 + });
+23 -22
tests/server/db/queries/membership.test.ts
··· 1 - import { afterAll, describe, expect, test } from "bun:test"; 1 + import { afterAll, beforeAll, describe, expect, test } from "bun:test"; 2 2 import { getDb } from "../../../../src/server/db/index.ts"; 3 3 import { 4 4 getMemberRole, ··· 15 15 const VIEWER_DID = "did:plc:member-viewer"; 16 16 const REQUESTER_DID = "did:plc:requester"; 17 17 18 - // Set up test data 19 - upsertMembership( 20 - WIKI_SLUG, 21 - ADMIN_DID, 22 - "admin", 23 - "at://did:plc:member-admin/wiki.lichen.membership/m1", 24 - "2026-01-01T00:00:00.000Z", 25 - ); 26 - upsertMembership( 27 - WIKI_SLUG, 28 - VIEWER_DID, 29 - "viewer", 30 - "at://did:plc:member-viewer/wiki.lichen.membership/m2", 31 - "2026-01-02T00:00:00.000Z", 32 - ); 33 - upsertRequest( 34 - WIKI_SLUG, 35 - REQUESTER_DID, 36 - "at://did:plc:requester/wiki.lichen.memberRequest/r1", 37 - "2026-01-03T00:00:00.000Z", 38 - ); 18 + beforeAll(() => { 19 + upsertMembership( 20 + WIKI_SLUG, 21 + ADMIN_DID, 22 + "admin", 23 + "at://did:plc:member-admin/wiki.lichen.membership/m1", 24 + "2026-01-01T00:00:00.000Z", 25 + ); 26 + upsertMembership( 27 + WIKI_SLUG, 28 + VIEWER_DID, 29 + "viewer", 30 + "at://did:plc:member-viewer/wiki.lichen.membership/m2", 31 + "2026-01-02T00:00:00.000Z", 32 + ); 33 + upsertRequest( 34 + WIKI_SLUG, 35 + REQUESTER_DID, 36 + "at://did:plc:requester/wiki.lichen.memberRequest/r1", 37 + "2026-01-03T00:00:00.000Z", 38 + ); 39 + }); 39 40 40 41 afterAll(() => { 41 42 const db = getDb();
+50
tests/server/routes/blob.test.ts
··· 1 + import { describe, expect, test } from "bun:test"; 2 + import { createTestApp } from "./helpers.ts"; 3 + 4 + const app = createTestApp(); 5 + 6 + describe("blob routes", () => { 7 + test("POST /api/upload-image without file returns 400", async () => { 8 + const formData = new FormData(); 9 + const res = await app.handle( 10 + new Request("http://localhost/api/upload-image", { 11 + method: "POST", 12 + body: formData, 13 + }), 14 + ); 15 + expect(res.status).toBe(400); 16 + const json = (await res.json()) as { error: string }; 17 + expect(json.error).toContain("No file"); 18 + }); 19 + 20 + test("POST /api/upload-image with unsupported mime type returns 400", async () => { 21 + const formData = new FormData(); 22 + formData.set( 23 + "file", 24 + new File([new Uint8Array(100)], "test.svg", { type: "image/svg+xml" }), 25 + ); 26 + const res = await app.handle( 27 + new Request("http://localhost/api/upload-image", { 28 + method: "POST", 29 + body: formData, 30 + }), 31 + ); 32 + expect(res.status).toBe(400); 33 + const json = (await res.json()) as { error: string }; 34 + expect(json.error).toContain("Unsupported"); 35 + }); 36 + 37 + test("GET /blob/local/ rejects path traversal", async () => { 38 + const res = await app.handle( 39 + new Request("http://localhost/blob/local/..%2F..%2Fetc%2Fpasswd"), 40 + ); 41 + expect(res.status).toBe(400); 42 + }); 43 + 44 + test("GET /blob/local/ rejects filename with double dots", async () => { 45 + const res = await app.handle( 46 + new Request("http://localhost/blob/local/..secret.jpg"), 47 + ); 48 + expect(res.status).toBe(400); 49 + }); 50 + });
+67
tests/server/routes/bookmark.test.ts
··· 1 + import { afterAll, beforeAll, describe, expect, test } from "bun:test"; 2 + import { DEV_DID } from "../../../src/atproto/session.ts"; 3 + import { 4 + isBookmarked, 5 + upsertMembership, 6 + upsertWiki, 7 + } from "../../../src/server/db/queries/index.ts"; 8 + import { cleanupWikiAndDependents } from "../../helpers/cleanup.ts"; 9 + import { createTestApp } from "./helpers.ts"; 10 + 11 + const app = createTestApp(); 12 + 13 + const SLUG = "bm-test-wiki"; 14 + const AT_URI = `at://${DEV_DID}/wiki.lichen.wiki/${SLUG}`; 15 + 16 + beforeAll(() => { 17 + upsertWiki( 18 + SLUG, 19 + DEV_DID, 20 + "Bookmark Test Wiki", 21 + "public", 22 + AT_URI, 23 + new Date().toISOString(), 24 + ); 25 + upsertMembership( 26 + SLUG, 27 + DEV_DID, 28 + "admin", 29 + `at://${DEV_DID}/wiki.lichen.membership/bm1`, 30 + new Date().toISOString(), 31 + ); 32 + }); 33 + 34 + afterAll(() => { 35 + cleanupWikiAndDependents(SLUG); 36 + }); 37 + 38 + describe("bookmark routes", () => { 39 + test("adds bookmark and returns HTML partial", async () => { 40 + const formData = new FormData(); 41 + formData.set("wikiAtUri", AT_URI); 42 + formData.set("action", "add"); 43 + const res = await app.handle( 44 + new Request("http://localhost/api/bookmark", { 45 + method: "POST", 46 + body: formData, 47 + }), 48 + ); 49 + expect(res.status).toBe(200); 50 + expect(res.headers.get("Content-Type")).toContain("text/html"); 51 + expect(isBookmarked(DEV_DID, AT_URI)).toBe(true); 52 + }); 53 + 54 + test("removes bookmark and returns HTML partial", async () => { 55 + const formData = new FormData(); 56 + formData.set("wikiAtUri", AT_URI); 57 + formData.set("action", "remove"); 58 + const res = await app.handle( 59 + new Request("http://localhost/api/bookmark", { 60 + method: "POST", 61 + body: formData, 62 + }), 63 + ); 64 + expect(res.status).toBe(200); 65 + expect(isBookmarked(DEV_DID, AT_URI)).toBe(false); 66 + }); 67 + });
+26
tests/server/routes/helpers.ts
··· 1 + import { Elysia } from "elysia"; 2 + import { AppError } from "../../../src/lib/errors.ts"; 3 + import { blobRoutes } from "../../../src/server/routes/blob.ts"; 4 + import { bookmarkRoutes } from "../../../src/server/routes/bookmark.ts"; 5 + import { homeRoute } from "../../../src/server/routes/home.ts"; 6 + import { membershipRoutes } from "../../../src/server/routes/membership.ts"; 7 + import { noteRoutes } from "../../../src/server/routes/note.ts"; 8 + import { searchRoutes } from "../../../src/server/routes/search.ts"; 9 + import { wikiRoutes } from "../../../src/server/routes/wiki.ts"; 10 + 11 + export function createTestApp() { 12 + return new Elysia() 13 + .onError(({ error }) => { 14 + if (error instanceof AppError) { 15 + return new Response(error.message, { status: error.statusCode }); 16 + } 17 + return new Response("Internal server error", { status: 500 }); 18 + }) 19 + .use(blobRoutes) 20 + .use(bookmarkRoutes) 21 + .use(homeRoute) 22 + .use(searchRoutes) 23 + .use(membershipRoutes) 24 + .use(noteRoutes) 25 + .use(wikiRoutes); 26 + }
+12
tests/server/routes/home.test.ts
··· 1 + import { describe, expect, test } from "bun:test"; 2 + import { createTestApp } from "./helpers.ts"; 3 + 4 + const app = createTestApp(); 5 + 6 + describe("home route", () => { 7 + test("GET / returns 200 with HTML", async () => { 8 + const res = await app.handle(new Request("http://localhost/")); 9 + expect(res.status).toBe(200); 10 + expect(res.headers.get("Content-Type")).toContain("text/html"); 11 + }); 12 + });
+88
tests/server/routes/membership.test.ts
··· 1 + import { afterAll, beforeAll, describe, expect, test } from "bun:test"; 2 + import { DEV_DID } from "../../../src/atproto/session.ts"; 3 + import { 4 + getMemberRole, 5 + upsertMembership, 6 + upsertRequest, 7 + upsertWiki, 8 + } from "../../../src/server/db/queries/index.ts"; 9 + import { cleanupWikiAndDependents } from "../../helpers/cleanup.ts"; 10 + import { createTestApp } from "./helpers.ts"; 11 + 12 + const app = createTestApp(); 13 + 14 + const SLUG = "mb-test-wiki"; 15 + const MEMBER_DID = "did:plc:mb-member"; 16 + 17 + beforeAll(() => { 18 + upsertWiki( 19 + SLUG, 20 + DEV_DID, 21 + "Membership Test Wiki", 22 + "public", 23 + `at://${DEV_DID}/wiki.lichen.wiki/${SLUG}`, 24 + new Date().toISOString(), 25 + ); 26 + upsertMembership( 27 + SLUG, 28 + DEV_DID, 29 + "admin", 30 + `at://${DEV_DID}/wiki.lichen.membership/mb1`, 31 + new Date().toISOString(), 32 + ); 33 + }); 34 + 35 + afterAll(() => { 36 + cleanupWikiAndDependents(SLUG); 37 + }); 38 + 39 + describe("membership routes", () => { 40 + test("GET /wiki/:slug/-/members redirects to settings", async () => { 41 + const res = await app.handle( 42 + new Request(`http://localhost/wiki/${SLUG}/-/members`), 43 + ); 44 + expect(res.status).toBe(302); 45 + expect(res.headers.get("Location")).toBe(`/wiki/${SLUG}/-/settings`); 46 + }); 47 + 48 + test("POST request-access on nonexistent wiki returns 404", async () => { 49 + const res = await app.handle( 50 + new Request("http://localhost/wiki/no-such-wiki/-/request-access", { 51 + method: "POST", 52 + }), 53 + ); 54 + expect(res.status).toBe(404); 55 + }); 56 + 57 + test("approves member and redirects to settings", async () => { 58 + upsertRequest( 59 + SLUG, 60 + MEMBER_DID, 61 + `at://${MEMBER_DID}/wiki.lichen.memberRequest/r1`, 62 + new Date().toISOString(), 63 + ); 64 + 65 + const formData = new FormData(); 66 + formData.set("role", "contributor"); 67 + const res = await app.handle( 68 + new Request( 69 + `http://localhost/wiki/${SLUG}/-/members/${encodeURIComponent(MEMBER_DID)}/approve`, 70 + { method: "POST", body: formData }, 71 + ), 72 + ); 73 + expect(res.status).toBe(302); 74 + expect(res.headers.get("Location")).toBe(`/wiki/${SLUG}/-/settings`); 75 + expect(getMemberRole(SLUG, MEMBER_DID)).toBe("contributor"); 76 + }); 77 + 78 + test("removes member and redirects to settings", async () => { 79 + const res = await app.handle( 80 + new Request( 81 + `http://localhost/wiki/${SLUG}/-/members/${encodeURIComponent(MEMBER_DID)}/remove`, 82 + { method: "POST" }, 83 + ), 84 + ); 85 + expect(res.status).toBe(302); 86 + expect(getMemberRole(SLUG, MEMBER_DID)).toBeNull(); 87 + }); 88 + });
+145
tests/server/routes/note.test.ts
··· 1 + import { afterAll, beforeAll, describe, expect, test } from "bun:test"; 2 + import { DEV_DID } from "../../../src/atproto/session.ts"; 3 + import { 4 + createNote, 5 + upsertMembership, 6 + upsertWiki, 7 + } from "../../../src/server/db/queries/index.ts"; 8 + import { cleanupWikiAndDependents } from "../../helpers/cleanup.ts"; 9 + import { createTestApp } from "./helpers.ts"; 10 + 11 + const app = createTestApp(); 12 + 13 + const SLUG = "nt-test-wiki"; 14 + const PRIVATE_SLUG = "nt-test-private"; 15 + const OTHER_DID = "did:plc:other"; 16 + 17 + beforeAll(() => { 18 + upsertWiki( 19 + SLUG, 20 + DEV_DID, 21 + "Note Test Wiki", 22 + "public", 23 + `at://${DEV_DID}/wiki.lichen.wiki/${SLUG}`, 24 + new Date().toISOString(), 25 + ); 26 + upsertMembership( 27 + SLUG, 28 + DEV_DID, 29 + "admin", 30 + `at://${DEV_DID}/wiki.lichen.membership/nt1`, 31 + new Date().toISOString(), 32 + ); 33 + createNote( 34 + `at://${DEV_DID}/wiki.lichen.note/nt-home`, 35 + `at://${DEV_DID}/wiki.lichen.noteRevision/nt-home-rev`, 36 + SLUG, 37 + "home", 38 + "Home", 39 + DEV_DID, 40 + "# Welcome", 41 + ); 42 + createNote( 43 + `at://${DEV_DID}/wiki.lichen.note/nt-test`, 44 + `at://${DEV_DID}/wiki.lichen.noteRevision/nt-test-rev`, 45 + SLUG, 46 + "test-note", 47 + "Test Note", 48 + DEV_DID, 49 + "Some content", 50 + ); 51 + upsertWiki( 52 + PRIVATE_SLUG, 53 + OTHER_DID, 54 + "Private Wiki", 55 + "private", 56 + `at://${OTHER_DID}/wiki.lichen.wiki/${PRIVATE_SLUG}`, 57 + new Date().toISOString(), 58 + ); 59 + }); 60 + 61 + afterAll(() => { 62 + cleanupWikiAndDependents(SLUG); 63 + cleanupWikiAndDependents(PRIVATE_SLUG); 64 + }); 65 + 66 + describe("note routes", () => { 67 + test("GET /wiki/:slug/:noteSlug returns 200 for existing note", async () => { 68 + const res = await app.handle( 69 + new Request(`http://localhost/wiki/${SLUG}/test-note`), 70 + ); 71 + expect(res.status).toBe(200); 72 + expect(res.headers.get("Content-Type")).toContain("text/html"); 73 + }); 74 + 75 + test("GET /wiki/:slug/:noteSlug returns 404 for nonexistent note", async () => { 76 + const res = await app.handle( 77 + new Request(`http://localhost/wiki/${SLUG}/nonexistent-note-xyz`), 78 + ); 79 + expect(res.status).toBe(404); 80 + }); 81 + 82 + test("GET /wiki/:slug/:noteSlug/edit returns 200 for editable note", async () => { 83 + const res = await app.handle( 84 + new Request(`http://localhost/wiki/${SLUG}/test-note/edit`), 85 + ); 86 + expect(res.status).toBe(200); 87 + }); 88 + 89 + test("GET edit on private wiki returns 403 for non-member", async () => { 90 + const res = await app.handle( 91 + new Request(`http://localhost/wiki/${PRIVATE_SLUG}/some-note/edit`), 92 + ); 93 + expect(res.status).toBe(403); 94 + }); 95 + 96 + test("GET new note on private wiki returns 403 for non-member", async () => { 97 + const res = await app.handle( 98 + new Request(`http://localhost/wiki/${PRIVATE_SLUG}/new`), 99 + ); 100 + expect(res.status).toBe(403); 101 + }); 102 + 103 + test("POST /wiki/:slug/new creates a note and redirects", async () => { 104 + const formData = new FormData(); 105 + formData.set("title", "Note Created"); 106 + formData.set("content", "Test content"); 107 + const res = await app.handle( 108 + new Request(`http://localhost/wiki/${SLUG}/new`, { 109 + method: "POST", 110 + body: formData, 111 + }), 112 + ); 113 + expect(res.status).toBe(302); 114 + expect(res.headers.get("Location")).toContain(`/wiki/${SLUG}/`); 115 + }); 116 + 117 + test("POST /wiki/:slug/new with empty title returns validation error", async () => { 118 + const formData = new FormData(); 119 + formData.set("title", " "); 120 + formData.set("content", "content"); 121 + const res = await app.handle( 122 + new Request(`http://localhost/wiki/${SLUG}/new`, { 123 + method: "POST", 124 + body: formData, 125 + }), 126 + ); 127 + expect(res.status).toBe(200); 128 + const body = await res.text(); 129 + expect(body).toContain("required"); 130 + }); 131 + 132 + test("POST /wiki/:slug/:noteSlug/edit edits note and redirects", async () => { 133 + const formData = new FormData(); 134 + formData.set("title", "Test Note"); 135 + formData.set("content", "Updated content"); 136 + const res = await app.handle( 137 + new Request(`http://localhost/wiki/${SLUG}/test-note/edit`, { 138 + method: "POST", 139 + body: formData, 140 + }), 141 + ); 142 + expect(res.status).toBe(302); 143 + expect(res.headers.get("Location")).toBe(`/wiki/${SLUG}/test-note`); 144 + }); 145 + });
-358
tests/server/routes/routes.test.ts
··· 1 - import { afterAll, beforeAll, describe, expect, test } from "bun:test"; 2 - import { Elysia } from "elysia"; 3 - import { DEV_DID } from "../../../src/atproto/session.ts"; 4 - import { AppError } from "../../../src/lib/errors.ts"; 5 - import { 6 - createNote, 7 - upsertMembership, 8 - upsertWiki, 9 - } from "../../../src/server/db/queries/index.ts"; 10 - import { blobRoutes } from "../../../src/server/routes/blob.ts"; 11 - import { homeRoute } from "../../../src/server/routes/home.ts"; 12 - import { membershipRoutes } from "../../../src/server/routes/membership.ts"; 13 - import { noteRoutes } from "../../../src/server/routes/note.ts"; 14 - import { searchRoutes } from "../../../src/server/routes/search.ts"; 15 - import { wikiRoutes } from "../../../src/server/routes/wiki.ts"; 16 - import { cleanupWikiAndDependents } from "../../helpers/cleanup.ts"; 17 - 18 - // Build a test app with the same error handler as production 19 - const app = new Elysia() 20 - .onError(({ error }) => { 21 - if (error instanceof AppError) { 22 - return new Response(error.message, { status: error.statusCode }); 23 - } 24 - return new Response("Internal server error", { status: 500 }); 25 - }) 26 - .use(blobRoutes) 27 - .use(homeRoute) 28 - .use(searchRoutes) 29 - .use(membershipRoutes) 30 - .use(noteRoutes) 31 - .use(wikiRoutes); 32 - 33 - const WIKI_SLUG = "rt-test-wiki"; 34 - const WIKI_AT_URI = `at://${DEV_DID}/wiki.lichen.wiki/${WIKI_SLUG}`; 35 - const PRIVATE_WIKI_SLUG = "rt-test-private"; 36 - const PRIVATE_WIKI_AT_URI = `at://did:plc:other/wiki.lichen.wiki/${PRIVATE_WIKI_SLUG}`; 37 - const OTHER_DID = "did:plc:other"; 38 - 39 - beforeAll(() => { 40 - // Seed a public wiki owned by DEV_DID 41 - upsertWiki( 42 - WIKI_SLUG, 43 - DEV_DID, 44 - "Route Test Wiki", 45 - "public", 46 - WIKI_AT_URI, 47 - new Date().toISOString(), 48 - ); 49 - upsertMembership( 50 - WIKI_SLUG, 51 - DEV_DID, 52 - "admin", 53 - `at://${DEV_DID}/wiki.lichen.membership/rt1`, 54 - new Date().toISOString(), 55 - ); 56 - createNote( 57 - `at://${DEV_DID}/wiki.lichen.note/rt-home`, 58 - `at://${DEV_DID}/wiki.lichen.noteRevision/rt-home-rev`, 59 - WIKI_SLUG, 60 - "home", 61 - "Home", 62 - DEV_DID, 63 - "# Welcome", 64 - ); 65 - createNote( 66 - `at://${DEV_DID}/wiki.lichen.note/rt-test-note`, 67 - `at://${DEV_DID}/wiki.lichen.noteRevision/rt-test-rev`, 68 - WIKI_SLUG, 69 - "test-note", 70 - "Test Note", 71 - DEV_DID, 72 - "Some content", 73 - ); 74 - 75 - // Seed a private wiki owned by another DID (DEV_DID has no membership) 76 - upsertWiki( 77 - PRIVATE_WIKI_SLUG, 78 - OTHER_DID, 79 - "Private Wiki", 80 - "private", 81 - PRIVATE_WIKI_AT_URI, 82 - new Date().toISOString(), 83 - ); 84 - }); 85 - 86 - afterAll(() => { 87 - cleanupWikiAndDependents(WIKI_SLUG); 88 - cleanupWikiAndDependents(PRIVATE_WIKI_SLUG); 89 - }); 90 - 91 - // --- Wiki routes --- 92 - 93 - describe("wiki routes", () => { 94 - test("GET /wiki/:slug returns 200 for public wiki", async () => { 95 - const res = await app.handle( 96 - new Request(`http://localhost/wiki/${WIKI_SLUG}`), 97 - ); 98 - expect(res.status).toBe(200); 99 - expect(res.headers.get("Content-Type")).toContain("text/html"); 100 - }); 101 - 102 - test("GET /wiki/:slug returns 403 for private wiki (non-member)", async () => { 103 - const res = await app.handle( 104 - new Request(`http://localhost/wiki/${PRIVATE_WIKI_SLUG}`), 105 - ); 106 - expect(res.status).toBe(403); 107 - }); 108 - 109 - test("GET /wiki/nonexistent returns 404", async () => { 110 - const res = await app.handle( 111 - new Request("http://localhost/wiki/does-not-exist-xyz"), 112 - ); 113 - expect(res.status).toBe(404); 114 - }); 115 - 116 - test("GET /wiki/:slug/-/settings returns 200 for admin", async () => { 117 - const res = await app.handle( 118 - new Request(`http://localhost/wiki/${WIKI_SLUG}/-/settings`), 119 - ); 120 - expect(res.status).toBe(200); 121 - }); 122 - 123 - test("GET /wiki/:slug/-/settings returns 403 for non-admin", async () => { 124 - const res = await app.handle( 125 - new Request(`http://localhost/wiki/${PRIVATE_WIKI_SLUG}/-/settings`), 126 - ); 127 - expect(res.status).toBe(403); 128 - }); 129 - 130 - test("POST /wiki/:slug/-/delete rejects wrong confirmation name", async () => { 131 - const formData = new FormData(); 132 - formData.set("confirm", "wrong name"); 133 - const res = await app.handle( 134 - new Request(`http://localhost/wiki/${WIKI_SLUG}/-/delete`, { 135 - method: "POST", 136 - body: formData, 137 - }), 138 - ); 139 - expect(res.status).toBe(400); 140 - }); 141 - 142 - test("GET /wiki/new returns 200", async () => { 143 - const res = await app.handle(new Request("http://localhost/wiki/new")); 144 - expect(res.status).toBe(200); 145 - }); 146 - }); 147 - 148 - // --- Note routes --- 149 - 150 - describe("note routes", () => { 151 - test("GET /wiki/:slug/:noteSlug returns 200 for existing note", async () => { 152 - const res = await app.handle( 153 - new Request(`http://localhost/wiki/${WIKI_SLUG}/test-note`), 154 - ); 155 - expect(res.status).toBe(200); 156 - expect(res.headers.get("Content-Type")).toContain("text/html"); 157 - }); 158 - 159 - test("GET /wiki/:slug/:noteSlug returns 404 for nonexistent note", async () => { 160 - const res = await app.handle( 161 - new Request(`http://localhost/wiki/${WIKI_SLUG}/nonexistent-note-xyz`), 162 - ); 163 - expect(res.status).toBe(404); 164 - }); 165 - 166 - test("GET /wiki/:slug/:noteSlug/edit returns 200 for editable note", async () => { 167 - const res = await app.handle( 168 - new Request(`http://localhost/wiki/${WIKI_SLUG}/test-note/edit`), 169 - ); 170 - expect(res.status).toBe(200); 171 - }); 172 - 173 - test("GET edit on private wiki returns 403 for non-member", async () => { 174 - const res = await app.handle( 175 - new Request(`http://localhost/wiki/${PRIVATE_WIKI_SLUG}/some-note/edit`), 176 - ); 177 - expect(res.status).toBe(403); 178 - }); 179 - 180 - test("GET new note on private wiki returns 403 for non-member", async () => { 181 - const res = await app.handle( 182 - new Request(`http://localhost/wiki/${PRIVATE_WIKI_SLUG}/new`), 183 - ); 184 - expect(res.status).toBe(403); 185 - }); 186 - 187 - test("POST /wiki/:slug/new creates a note and redirects", async () => { 188 - const formData = new FormData(); 189 - formData.set("title", "Route Created Note"); 190 - formData.set("content", "Test content from route test"); 191 - const res = await app.handle( 192 - new Request(`http://localhost/wiki/${WIKI_SLUG}/new`, { 193 - method: "POST", 194 - body: formData, 195 - }), 196 - ); 197 - expect(res.status).toBe(302); 198 - expect(res.headers.get("Location")).toContain(`/wiki/${WIKI_SLUG}/`); 199 - }); 200 - 201 - test("POST /wiki/:slug/new with empty title returns validation error", async () => { 202 - const formData = new FormData(); 203 - formData.set("title", " "); 204 - formData.set("content", "content"); 205 - const res = await app.handle( 206 - new Request(`http://localhost/wiki/${WIKI_SLUG}/new`, { 207 - method: "POST", 208 - body: formData, 209 - }), 210 - ); 211 - // ValidationError is caught by the route and re-rendered as the form page 212 - expect(res.status).toBe(200); 213 - const body = await res.text(); 214 - expect(body).toContain("required"); 215 - }); 216 - }); 217 - 218 - // --- Search routes --- 219 - 220 - describe("search routes", () => { 221 - test("GET /search with empty query returns wiki grid", async () => { 222 - const res = await app.handle(new Request("http://localhost/search?q=")); 223 - expect(res.status).toBe(200); 224 - const body = await res.text(); 225 - // Empty query on home search returns all public wikis as a grid 226 - expect(body).toContain("grid"); 227 - }); 228 - 229 - test("GET /search with empty query inside wiki returns empty", async () => { 230 - const res = await app.handle( 231 - new Request("http://localhost/search?q=&wiki=rt-test-wiki"), 232 - ); 233 - expect(res.status).toBe(200); 234 - const body = await res.text(); 235 - expect(body).toBe(""); 236 - }); 237 - 238 - test("GET /search returns results for matching wikis", async () => { 239 - const res = await app.handle( 240 - new Request("http://localhost/search?q=Route+Test"), 241 - ); 242 - expect(res.status).toBe(200); 243 - const body = await res.text(); 244 - expect(body).toContain("Route Test Wiki"); 245 - }); 246 - 247 - test("GET /search within wiki returns note results", async () => { 248 - const res = await app.handle( 249 - new Request(`http://localhost/search?q=Test&wiki=${WIKI_SLUG}`), 250 - ); 251 - expect(res.status).toBe(200); 252 - }); 253 - 254 - test("GET /search within private wiki returns empty for non-member", async () => { 255 - const res = await app.handle( 256 - new Request( 257 - `http://localhost/search?q=anything&wiki=${PRIVATE_WIKI_SLUG}`, 258 - ), 259 - ); 260 - expect(res.status).toBe(200); 261 - const body = await res.text(); 262 - expect(body).toBe(""); 263 - }); 264 - }); 265 - 266 - // --- Membership routes --- 267 - 268 - describe("membership routes", () => { 269 - test("GET /wiki/:slug/-/members redirects to settings", async () => { 270 - const res = await app.handle( 271 - new Request(`http://localhost/wiki/${WIKI_SLUG}/-/members`), 272 - ); 273 - expect(res.status).toBe(302); 274 - expect(res.headers.get("Location")).toBe(`/wiki/${WIKI_SLUG}/-/settings`); 275 - }); 276 - 277 - test("GET /wiki/:slug/-/settings returns 200 for admin", async () => { 278 - const res = await app.handle( 279 - new Request(`http://localhost/wiki/${WIKI_SLUG}/-/settings`), 280 - ); 281 - expect(res.status).toBe(200); 282 - expect(res.headers.get("Content-Type")).toContain("text/html"); 283 - }); 284 - 285 - test("GET /wiki/:slug/-/settings returns 403 for non-admin", async () => { 286 - const res = await app.handle( 287 - new Request(`http://localhost/wiki/${PRIVATE_WIKI_SLUG}/-/settings`), 288 - ); 289 - expect(res.status).toBe(403); 290 - }); 291 - 292 - test("POST request-access on nonexistent wiki returns 404", async () => { 293 - const res = await app.handle( 294 - new Request("http://localhost/wiki/no-such-wiki/-/request-access", { 295 - method: "POST", 296 - }), 297 - ); 298 - expect(res.status).toBe(404); 299 - }); 300 - }); 301 - 302 - // --- Blob routes --- 303 - 304 - describe("blob routes", () => { 305 - test("POST /api/upload-image without file returns 400", async () => { 306 - const formData = new FormData(); 307 - const res = await app.handle( 308 - new Request("http://localhost/api/upload-image", { 309 - method: "POST", 310 - body: formData, 311 - }), 312 - ); 313 - expect(res.status).toBe(400); 314 - const json = (await res.json()) as { error: string }; 315 - expect(json.error).toContain("No file"); 316 - }); 317 - 318 - test("POST /api/upload-image with unsupported mime type returns 400", async () => { 319 - const formData = new FormData(); 320 - formData.set( 321 - "file", 322 - new File([new Uint8Array(100)], "test.svg", { type: "image/svg+xml" }), 323 - ); 324 - const res = await app.handle( 325 - new Request("http://localhost/api/upload-image", { 326 - method: "POST", 327 - body: formData, 328 - }), 329 - ); 330 - expect(res.status).toBe(400); 331 - const json = (await res.json()) as { error: string }; 332 - expect(json.error).toContain("Unsupported"); 333 - }); 334 - 335 - test("GET /blob/local/ rejects path traversal", async () => { 336 - const res = await app.handle( 337 - new Request("http://localhost/blob/local/..%2F..%2Fetc%2Fpasswd"), 338 - ); 339 - expect(res.status).toBe(400); 340 - }); 341 - 342 - test("GET /blob/local/ rejects filename with double dots", async () => { 343 - const res = await app.handle( 344 - new Request("http://localhost/blob/local/..secret.jpg"), 345 - ); 346 - expect(res.status).toBe(400); 347 - }); 348 - }); 349 - 350 - // --- Home route --- 351 - 352 - describe("home route", () => { 353 - test("GET / returns 200 with HTML", async () => { 354 - const res = await app.handle(new Request("http://localhost/")); 355 - expect(res.status).toBe(200); 356 - expect(res.headers.get("Content-Type")).toContain("text/html"); 357 - }); 358 - });
+107
tests/server/routes/search.test.ts
··· 1 + import { afterAll, beforeAll, describe, expect, test } from "bun:test"; 2 + import { DEV_DID } from "../../../src/atproto/session.ts"; 3 + import { 4 + createNote, 5 + upsertMembership, 6 + upsertWiki, 7 + } from "../../../src/server/db/queries/index.ts"; 8 + import { cleanupWikiAndDependents } from "../../helpers/cleanup.ts"; 9 + import { createTestApp } from "./helpers.ts"; 10 + 11 + const app = createTestApp(); 12 + 13 + const SLUG = "sr-test-wiki"; 14 + const PRIVATE_SLUG = "sr-test-private"; 15 + const OTHER_DID = "did:plc:other"; 16 + 17 + beforeAll(() => { 18 + upsertWiki( 19 + SLUG, 20 + DEV_DID, 21 + "Search Test Wiki", 22 + "public", 23 + `at://${DEV_DID}/wiki.lichen.wiki/${SLUG}`, 24 + new Date().toISOString(), 25 + ); 26 + upsertMembership( 27 + SLUG, 28 + DEV_DID, 29 + "admin", 30 + `at://${DEV_DID}/wiki.lichen.membership/sr1`, 31 + new Date().toISOString(), 32 + ); 33 + createNote( 34 + `at://${DEV_DID}/wiki.lichen.note/sr-home`, 35 + `at://${DEV_DID}/wiki.lichen.noteRevision/sr-home-rev`, 36 + SLUG, 37 + "home", 38 + "Home", 39 + DEV_DID, 40 + "# Welcome", 41 + ); 42 + createNote( 43 + `at://${DEV_DID}/wiki.lichen.note/sr-test`, 44 + `at://${DEV_DID}/wiki.lichen.noteRevision/sr-test-rev`, 45 + SLUG, 46 + "test-note", 47 + "Test Note", 48 + DEV_DID, 49 + "Some content", 50 + ); 51 + upsertWiki( 52 + PRIVATE_SLUG, 53 + OTHER_DID, 54 + "Private Wiki", 55 + "private", 56 + `at://${OTHER_DID}/wiki.lichen.wiki/${PRIVATE_SLUG}`, 57 + new Date().toISOString(), 58 + ); 59 + }); 60 + 61 + afterAll(() => { 62 + cleanupWikiAndDependents(SLUG); 63 + cleanupWikiAndDependents(PRIVATE_SLUG); 64 + }); 65 + 66 + describe("search routes", () => { 67 + test("GET /search with empty query returns wiki grid", async () => { 68 + const res = await app.handle(new Request("http://localhost/search?q=")); 69 + expect(res.status).toBe(200); 70 + const body = await res.text(); 71 + expect(body).toContain("grid"); 72 + }); 73 + 74 + test("GET /search with empty query inside wiki returns empty", async () => { 75 + const res = await app.handle( 76 + new Request(`http://localhost/search?q=&wiki=${SLUG}`), 77 + ); 78 + expect(res.status).toBe(200); 79 + const body = await res.text(); 80 + expect(body).toBe(""); 81 + }); 82 + 83 + test("GET /search returns results for matching wikis", async () => { 84 + const res = await app.handle( 85 + new Request("http://localhost/search?q=Search+Test"), 86 + ); 87 + expect(res.status).toBe(200); 88 + const body = await res.text(); 89 + expect(body).toContain("Search Test Wiki"); 90 + }); 91 + 92 + test("GET /search within wiki returns note results", async () => { 93 + const res = await app.handle( 94 + new Request(`http://localhost/search?q=Test&wiki=${SLUG}`), 95 + ); 96 + expect(res.status).toBe(200); 97 + }); 98 + 99 + test("GET /search within private wiki returns empty for non-member", async () => { 100 + const res = await app.handle( 101 + new Request(`http://localhost/search?q=anything&wiki=${PRIVATE_SLUG}`), 102 + ); 103 + expect(res.status).toBe(200); 104 + const body = await res.text(); 105 + expect(body).toBe(""); 106 + }); 107 + });
+142
tests/server/routes/wiki.test.ts
··· 1 + import { afterAll, beforeAll, describe, expect, test } from "bun:test"; 2 + import { DEV_DID } from "../../../src/atproto/session.ts"; 3 + import { 4 + upsertMembership, 5 + upsertWiki, 6 + } from "../../../src/server/db/queries/index.ts"; 7 + import { cleanupWikiAndDependents } from "../../helpers/cleanup.ts"; 8 + import { createTestApp } from "./helpers.ts"; 9 + 10 + const app = createTestApp(); 11 + 12 + const SLUG = "wk-test-wiki"; 13 + const AT_URI = `at://${DEV_DID}/wiki.lichen.wiki/${SLUG}`; 14 + const PRIVATE_SLUG = "wk-test-private"; 15 + const OTHER_DID = "did:plc:other"; 16 + 17 + beforeAll(() => { 18 + upsertWiki( 19 + SLUG, 20 + DEV_DID, 21 + "Wiki Test Wiki", 22 + "public", 23 + AT_URI, 24 + new Date().toISOString(), 25 + ); 26 + upsertMembership( 27 + SLUG, 28 + DEV_DID, 29 + "admin", 30 + `at://${DEV_DID}/wiki.lichen.membership/wk1`, 31 + new Date().toISOString(), 32 + ); 33 + upsertWiki( 34 + PRIVATE_SLUG, 35 + OTHER_DID, 36 + "Private Wiki", 37 + "private", 38 + `at://${OTHER_DID}/wiki.lichen.wiki/${PRIVATE_SLUG}`, 39 + new Date().toISOString(), 40 + ); 41 + }); 42 + 43 + const extraSlugs: string[] = []; 44 + 45 + afterAll(() => { 46 + cleanupWikiAndDependents(SLUG); 47 + cleanupWikiAndDependents(PRIVATE_SLUG); 48 + for (const s of extraSlugs) cleanupWikiAndDependents(s); 49 + }); 50 + 51 + describe("wiki routes", () => { 52 + test("GET /wiki/:slug returns 200 for public wiki", async () => { 53 + const res = await app.handle(new Request(`http://localhost/wiki/${SLUG}`)); 54 + expect(res.status).toBe(200); 55 + expect(res.headers.get("Content-Type")).toContain("text/html"); 56 + }); 57 + 58 + test("GET /wiki/:slug returns 403 for private wiki (non-member)", async () => { 59 + const res = await app.handle( 60 + new Request(`http://localhost/wiki/${PRIVATE_SLUG}`), 61 + ); 62 + expect(res.status).toBe(403); 63 + }); 64 + 65 + test("GET /wiki/nonexistent returns 404", async () => { 66 + const res = await app.handle( 67 + new Request("http://localhost/wiki/does-not-exist-xyz"), 68 + ); 69 + expect(res.status).toBe(404); 70 + }); 71 + 72 + test("GET /wiki/:slug/-/settings returns 200 for admin", async () => { 73 + const res = await app.handle( 74 + new Request(`http://localhost/wiki/${SLUG}/-/settings`), 75 + ); 76 + expect(res.status).toBe(200); 77 + }); 78 + 79 + test("GET /wiki/:slug/-/settings returns 403 for non-admin", async () => { 80 + const res = await app.handle( 81 + new Request(`http://localhost/wiki/${PRIVATE_SLUG}/-/settings`), 82 + ); 83 + expect(res.status).toBe(403); 84 + }); 85 + 86 + test("POST /wiki/:slug/-/delete rejects wrong confirmation name", async () => { 87 + const formData = new FormData(); 88 + formData.set("confirm", "wrong name"); 89 + const res = await app.handle( 90 + new Request(`http://localhost/wiki/${SLUG}/-/delete`, { 91 + method: "POST", 92 + body: formData, 93 + }), 94 + ); 95 + expect(res.status).toBe(400); 96 + }); 97 + 98 + test("GET /wiki/new returns 200", async () => { 99 + const res = await app.handle(new Request("http://localhost/wiki/new")); 100 + expect(res.status).toBe(200); 101 + }); 102 + 103 + test("POST /wiki/new creates wiki and redirects", async () => { 104 + const formData = new FormData(); 105 + formData.set("name", "Wiki New Test"); 106 + formData.set("language", "en"); 107 + formData.set("visibility", "public"); 108 + formData.set("description", ""); 109 + const res = await app.handle( 110 + new Request("http://localhost/wiki/new", { 111 + method: "POST", 112 + body: formData, 113 + }), 114 + ); 115 + extraSlugs.push("wiki-new-test"); 116 + expect(res.status).toBe(302); 117 + expect(res.headers.get("Location")).toBe("/wiki/wiki-new-test"); 118 + }); 119 + 120 + test("POST /wiki/:slug/-/delete with correct name redirects to /", async () => { 121 + const slug = "wk-delete-target"; 122 + upsertWiki( 123 + slug, 124 + DEV_DID, 125 + "Delete Target", 126 + "public", 127 + `at://${DEV_DID}/wiki.lichen.wiki/${slug}`, 128 + new Date().toISOString(), 129 + ); 130 + 131 + const formData = new FormData(); 132 + formData.set("confirm", "Delete Target"); 133 + const res = await app.handle( 134 + new Request(`http://localhost/wiki/${slug}/-/delete`, { 135 + method: "POST", 136 + body: formData, 137 + }), 138 + ); 139 + expect(res.status).toBe(302); 140 + expect(res.headers.get("Location")).toBe("/"); 141 + }); 142 + });