From b39e0bb69ff24c504a98a4930c729fe833b61f4d Mon Sep 17 00:00:00 2001 From: juprodh Date: Mon, 23 Mar 2026 13:10:59 +0800 Subject: [PATCH] Update tests suite --- tests/atproto/env.test.ts | 148 +++++++++ tests/atproto/pds.test.ts | 4 +- tests/atproto/session.test.ts | 79 ++++- tests/firehose/handlers.test.ts | 89 ++++- tests/lib/at-uri.test.ts | 51 +++ tests/lib/constants.test.ts | 5 +- tests/lib/diff.test.ts | 6 - tests/lib/errors.test.ts | 52 +++ tests/lib/html.test.ts | 22 ++ tests/lib/i18n.test.ts | 57 +++- tests/lib/image-validation.test.ts | 16 +- tests/lib/orchestrators/bookmark.test.ts | 104 ++++++ tests/lib/profile.test.ts | 59 ++-- tests/lib/tid.test.ts | 23 ++ tests/server/db/queries/bookmark.test.ts | 119 +++++++ tests/server/db/queries/membership.test.ts | 45 +-- tests/server/routes/blob.test.ts | 50 +++ tests/server/routes/bookmark.test.ts | 67 ++++ tests/server/routes/helpers.ts | 26 ++ tests/server/routes/home.test.ts | 12 + tests/server/routes/membership.test.ts | 88 +++++ tests/server/routes/note.test.ts | 145 +++++++++ tests/server/routes/routes.test.ts | 358 --------------------- tests/server/routes/search.test.ts | 107 ++++++ tests/server/routes/wiki.test.ts | 142 ++++++++ 25 files changed, 1401 insertions(+), 473 deletions(-) create mode 100644 tests/atproto/env.test.ts create mode 100644 tests/lib/at-uri.test.ts create mode 100644 tests/lib/errors.test.ts create mode 100644 tests/lib/html.test.ts create mode 100644 tests/lib/orchestrators/bookmark.test.ts create mode 100644 tests/lib/tid.test.ts create mode 100644 tests/server/db/queries/bookmark.test.ts create mode 100644 tests/server/routes/blob.test.ts create mode 100644 tests/server/routes/bookmark.test.ts create mode 100644 tests/server/routes/helpers.ts create mode 100644 tests/server/routes/home.test.ts create mode 100644 tests/server/routes/membership.test.ts create mode 100644 tests/server/routes/note.test.ts delete mode 100644 tests/server/routes/routes.test.ts create mode 100644 tests/server/routes/search.test.ts create mode 100644 tests/server/routes/wiki.test.ts diff --git a/tests/atproto/env.test.ts b/tests/atproto/env.test.ts new file mode 100644 index 0000000..3009c19 --- /dev/null +++ b/tests/atproto/env.test.ts @@ -0,0 +1,148 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { + getAtprotoEnv, + getDevAccounts, + getDevPdsUrl, + getDevPlcUrl, + getHandleResolverUrl, + getRelayUrl, + isAuthEnabled, +} from "../../src/atproto/env.ts"; + +// Save original env to restore after tests +const origEnv: Record = {}; +const ENV_KEYS = [ + "PUBLIC_URL", + "OAUTH_PRIVATE_KEY_PATH", + "RELAY_URL", + "HANDLE_RESOLVER_URL", + "DEV_PDS_URL", + "DEV_PLC_URL", + "DEV_ACCOUNTS", +]; + +beforeAll(() => { + for (const key of ENV_KEYS) { + origEnv[key] = process.env[key]; + } +}); + +afterAll(() => { + for (const key of ENV_KEYS) { + if (origEnv[key] === undefined) { + delete process.env[key]; + } else { + process.env[key] = origEnv[key]; + } + } +}); + +describe("getAtprotoEnv", () => { + test("returns null when PUBLIC_URL is missing", () => { + delete process.env.PUBLIC_URL; + delete process.env.OAUTH_PRIVATE_KEY_PATH; + expect(getAtprotoEnv()).toBeNull(); + }); + + test("returns null when OAUTH_PRIVATE_KEY_PATH is missing", () => { + process.env.PUBLIC_URL = "https://lichen.wiki"; + delete process.env.OAUTH_PRIVATE_KEY_PATH; + expect(getAtprotoEnv()).toBeNull(); + }); + + test("returns env when both are set", () => { + process.env.PUBLIC_URL = "https://lichen.wiki"; + process.env.OAUTH_PRIVATE_KEY_PATH = "/path/to/key.pem"; + const env = getAtprotoEnv(); + expect(env).toEqual({ + publicUrl: "https://lichen.wiki", + privateKeyPath: "/path/to/key.pem", + }); + }); +}); + +describe("isAuthEnabled", () => { + test("returns false when env vars missing", () => { + delete process.env.PUBLIC_URL; + delete process.env.OAUTH_PRIVATE_KEY_PATH; + expect(isAuthEnabled()).toBe(false); + }); + + test("returns true when env vars set", () => { + process.env.PUBLIC_URL = "https://lichen.wiki"; + process.env.OAUTH_PRIVATE_KEY_PATH = "/path/to/key.pem"; + expect(isAuthEnabled()).toBe(true); + }); +}); + +describe("getRelayUrl", () => { + test("defaults to bsky.network", () => { + delete process.env.RELAY_URL; + expect(getRelayUrl()).toBe("wss://bsky.network"); + }); + + test("reads from env", () => { + process.env.RELAY_URL = "wss://custom.relay"; + expect(getRelayUrl()).toBe("wss://custom.relay"); + }); +}); + +describe("getHandleResolverUrl", () => { + test("defaults to bsky.social", () => { + delete process.env.HANDLE_RESOLVER_URL; + expect(getHandleResolverUrl()).toBe("https://bsky.social"); + }); + + test("reads from env", () => { + process.env.HANDLE_RESOLVER_URL = "https://custom.resolver"; + expect(getHandleResolverUrl()).toBe("https://custom.resolver"); + }); +}); + +describe("getDevPdsUrl", () => { + test("returns null when not set", () => { + delete process.env.DEV_PDS_URL; + expect(getDevPdsUrl()).toBeNull(); + }); + + test("reads from env", () => { + process.env.DEV_PDS_URL = "http://localhost:2583"; + expect(getDevPdsUrl()).toBe("http://localhost:2583"); + }); +}); + +describe("getDevPlcUrl", () => { + test("returns null when not set", () => { + delete process.env.DEV_PLC_URL; + expect(getDevPlcUrl()).toBeNull(); + }); + + test("reads from env", () => { + process.env.DEV_PLC_URL = "http://localhost:2582"; + expect(getDevPlcUrl()).toBe("http://localhost:2582"); + }); +}); + +describe("getDevAccounts", () => { + test("returns null when not set", () => { + delete process.env.DEV_ACCOUNTS; + expect(getDevAccounts()).toBeNull(); + }); + + test("parses valid JSON", () => { + process.env.DEV_ACCOUNTS = JSON.stringify({ + alice: { + did: "did:plc:alice", + handle: "alice.test", + password: "pw", + }, + }); + const accounts = getDevAccounts(); + expect(accounts?.alice?.did).toBe("did:plc:alice"); + }); + + test("returns null for malformed JSON", () => { + process.env.DEV_ACCOUNTS = "{not json}"; + expect(getDevAccounts()).toBeNull(); + }); +}); diff --git a/tests/atproto/pds.test.ts b/tests/atproto/pds.test.ts index 2c5499e..1243d52 100644 --- a/tests/atproto/pds.test.ts +++ b/tests/atproto/pds.test.ts @@ -215,7 +215,7 @@ describe("PDS write functions — contract tests", () => { agent, DID, "tid-b1", - "at://did:plc:test/wiki.lichen.note/tid123", + "at://did:plc:test/wiki.lichen.wiki/my-wiki", TIMESTAMP, ); @@ -223,7 +223,7 @@ describe("PDS write functions — contract tests", () => { expect(call.collection).toBe(COLLECTIONS.bookmark); const record = call.record as Record; - expect(record.noteRef).toBe("at://did:plc:test/wiki.lichen.note/tid123"); + expect(record.wikiRef).toBe("at://did:plc:test/wiki.lichen.wiki/my-wiki"); }); test("deleteRecord passes correct arguments", async () => { diff --git a/tests/atproto/session.test.ts b/tests/atproto/session.test.ts index f76d214..a2dee12 100644 --- a/tests/atproto/session.test.ts +++ b/tests/atproto/session.test.ts @@ -1,6 +1,11 @@ -import { describe, expect, test } from "bun:test"; +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import type { NodeOAuthClient } from "@atproto/oauth-client-node"; -import { getSession } from "../../src/atproto/session.ts"; +import { + DEV_DID, + getDevSession, + getEffectiveDid, + getSession, +} from "../../src/atproto/session.ts"; // Minimal mock of NodeOAuthClient for testing cookie parsing function createMockClient( @@ -38,22 +43,21 @@ describe("getSession", () => { test("handles did cookie among other cookies", async () => { const client = createMockClient((did) => ({ sub: did })); - const session = await getSession( + // did in the middle + const session1 = await getSession( client, "theme=dark; did=did%3Aplc%3Axyz; lang=en", ); - expect(session).not.toBeNull(); - expect(session?.did).toBe("did:plc:xyz"); - }); + expect(session1).not.toBeNull(); + expect(session1?.did).toBe("did:plc:xyz"); - test("handles did as first cookie", async () => { - const client = createMockClient((did) => ({ sub: did })); - const session = await getSession( + // did as first cookie + const session2 = await getSession( client, "did=did%3Aplc%3Afirst; other=val", ); - expect(session).not.toBeNull(); - expect(session?.did).toBe("did:plc:first"); + expect(session2).not.toBeNull(); + expect(session2?.did).toBe("did:plc:first"); }); test("returns null when client.restore throws", async () => { @@ -76,3 +80,56 @@ describe("getSession", () => { expect(session?.handle).toBe("did:plc:custom-sub"); }); }); + +describe("getDevSession", () => { + const origAccounts = process.env.DEV_ACCOUNTS; + + beforeAll(() => { + process.env.DEV_ACCOUNTS = JSON.stringify({ + alice: { + did: "did:plc:devalice", + handle: "alice.test", + password: "pw", + }, + }); + }); + + afterAll(() => { + if (origAccounts === undefined) { + delete process.env.DEV_ACCOUNTS; + } else { + process.env.DEV_ACCOUNTS = origAccounts; + } + }); + + test("returns null when no cookie header", () => { + expect(getDevSession(undefined)).toBeNull(); + }); + + test("returns null when cookie has no did", () => { + expect(getDevSession("other=value")).toBeNull(); + }); + + test("returns session for matching dev account", () => { + const session = getDevSession("did=did%3Aplc%3Adevalice"); + expect(session).not.toBeNull(); + expect(session?.did).toBe("did:plc:devalice"); + expect(session?.handle).toBe("alice.test"); + }); + + test("returns null for unknown DID", () => { + expect(getDevSession("did=did%3Aplc%3Aunknown")).toBeNull(); + }); +}); + +describe("getEffectiveDid", () => { + test("returns session DID when session exists", () => { + expect(getEffectiveDid({ did: "did:plc:real", handle: "real.test" })).toBe( + "did:plc:real", + ); + }); + + test("returns DEV_DID when session is null", () => { + expect(getEffectiveDid(null)).toBe(DEV_DID); + }); +}); diff --git a/tests/firehose/handlers.test.ts b/tests/firehose/handlers.test.ts index d6e88c2..c5df3ff 100644 --- a/tests/firehose/handlers.test.ts +++ b/tests/firehose/handlers.test.ts @@ -4,10 +4,9 @@ import { handleCommitEvent } from "../../src/firehose/handlers.ts"; import { getDb } from "../../src/server/db/index.ts"; import { getCurrentNote, - getCursor, getNoteBySlug, getWiki, - setCursor, + isBookmarked, } from "../../src/server/db/queries/index.ts"; const db = getDb(); @@ -72,11 +71,11 @@ function cleanupHandlerTestData() { db.run("DELETE FROM requests WHERE wiki_slug = ?", [slug]); db.run("DELETE FROM wikis WHERE slug = ?", [slug]); } + db.run("DELETE FROM bookmarks WHERE did IN (?, ?)", [ALICE_DID, BOB_DID]); } beforeAll(() => { cleanupHandlerTestData(); - db.run("DELETE FROM firehose_cursor"); }); afterAll(() => { @@ -540,18 +539,84 @@ describe("member request handler", () => { }); }); -describe("cursor persistence", () => { - test("getCursor returns null initially", () => { - expect(getCursor()).toBeNull(); +describe("bookmark handler", () => { + test("creates bookmark from firehose event", () => { + // Ensure wiki exists first + handleCommitEvent( + makeCommitEvt({ + event: "create", + collection: "wiki.lichen.wiki", + rkey: "test-wiki", + did: ALICE_DID, + record: { + name: "BK Test Wiki", + visibility: "public", + createdAt: "2026-01-01T00:00:00.000Z", + }, + }), + ); + + handleCommitEvent( + makeCommitEvt({ + event: "create", + collection: "wiki.lichen.bookmark", + rkey: "bk1", + did: BOB_DID, + record: { + wikiRef: WIKI_AT_URI, + createdAt: "2026-01-02T00:00:00.000Z", + }, + }), + ); + + expect(isBookmarked(BOB_DID, WIKI_AT_URI)).toBe(true); + }); + + test("skips bookmark with missing wikiRef", () => { + handleCommitEvent( + makeCommitEvt({ + event: "create", + collection: "wiki.lichen.bookmark", + rkey: "bk-bad", + did: BOB_DID, + record: { + createdAt: "2026-01-02T00:00:00.000Z", + }, + }), + ); }); - test("setCursor and getCursor roundtrip", () => { - setCursor(12345); - expect(getCursor()).toBe(12345); + test("skips bookmark for non-existent wiki", () => { + const fakeWikiUri = "at://did:plc:ghost/wiki.lichen.wiki/nope"; + handleCommitEvent( + makeCommitEvt({ + event: "create", + collection: "wiki.lichen.bookmark", + rkey: "bk-ghost", + did: BOB_DID, + record: { + wikiRef: fakeWikiUri, + createdAt: "2026-01-02T00:00:00.000Z", + }, + }), + ); + expect(isBookmarked(BOB_DID, fakeWikiUri)).toBe(false); }); - test("setCursor updates existing cursor", () => { - setCursor(99999); - expect(getCursor()).toBe(99999); + test("deletes bookmark on delete event", () => { + const bookmarkUri = `at://${BOB_DID}/wiki.lichen.bookmark/bk1`; + expect(isBookmarked(BOB_DID, WIKI_AT_URI)).toBe(true); + + handleCommitEvent( + makeCommitEvt({ + event: "delete", + collection: "wiki.lichen.bookmark", + rkey: "bk1", + did: BOB_DID, + uri: { toString: () => bookmarkUri }, + }), + ); + + expect(isBookmarked(BOB_DID, WIKI_AT_URI)).toBe(false); }); }); diff --git a/tests/lib/at-uri.test.ts b/tests/lib/at-uri.test.ts new file mode 100644 index 0000000..883239d --- /dev/null +++ b/tests/lib/at-uri.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, test } from "bun:test"; +import { didOwnsUri, parseAtUri } from "../../src/lib/at-uri.ts"; + +describe("parseAtUri", () => { + test("parses valid AT URI", () => { + const result = parseAtUri("at://did:plc:abc123/wiki.lichen.note/my-rkey"); + expect(result).toEqual({ + did: "did:plc:abc123", + collection: "wiki.lichen.note", + rkey: "my-rkey", + }); + }); + + test("returns null for missing rkey", () => { + expect(parseAtUri("at://did:plc:abc/wiki.lichen.note/")).toBeNull(); + }); + + test("returns null for missing collection", () => { + expect(parseAtUri("at://did:plc:abc")).toBeNull(); + }); + + test("returns null for empty string", () => { + expect(parseAtUri("")).toBeNull(); + }); + + test("returns null for non-AT URI", () => { + expect(parseAtUri("https://example.com/path")).toBeNull(); + }); + + test("returns null for extra path segments", () => { + expect(parseAtUri("at://did:plc:abc/collection/rkey/extra")).toBeNull(); + }); +}); + +describe("didOwnsUri", () => { + test("returns true when DID matches URI", () => { + expect( + didOwnsUri("did:plc:abc", "at://did:plc:abc/wiki.lichen.wiki/slug"), + ).toBe(true); + }); + + test("returns false when DID does not match URI", () => { + expect( + didOwnsUri("did:plc:other", "at://did:plc:abc/wiki.lichen.wiki/slug"), + ).toBe(false); + }); + + test("returns false for unparseable URI", () => { + expect(didOwnsUri("did:plc:abc", "garbage")).toBe(false); + }); +}); diff --git a/tests/lib/constants.test.ts b/tests/lib/constants.test.ts index b89ab9d..6384f3b 100644 --- a/tests/lib/constants.test.ts +++ b/tests/lib/constants.test.ts @@ -4,11 +4,8 @@ import { normalizeRole } from "../../src/lib/constants.ts"; describe("normalizeRole", () => { test("passes through valid roles", () => { expect(normalizeRole("admin")).toBe("admin"); - expect(normalizeRole("viewer")).toBe("viewer"); - }); - - test("defaults contributor for valid contributor input", () => { expect(normalizeRole("contributor")).toBe("contributor"); + expect(normalizeRole("viewer")).toBe("viewer"); }); test("defaults to contributor for invalid or missing input", () => { diff --git a/tests/lib/diff.test.ts b/tests/lib/diff.test.ts index d8e4ec4..c32bb84 100644 --- a/tests/lib/diff.test.ts +++ b/tests/lib/diff.test.ts @@ -26,10 +26,4 @@ describe("createDiff and applyDiff", () => { current = applyDiff(current, diff3); expect(current).toBe(v4); }); - - test("diff output is a string", () => { - const diff = createDiff("a", "b"); - expect(typeof diff).toBe("string"); - expect(diff.length).toBeGreaterThan(0); - }); }); diff --git a/tests/lib/errors.test.ts b/tests/lib/errors.test.ts new file mode 100644 index 0000000..45b8096 --- /dev/null +++ b/tests/lib/errors.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, test } from "bun:test"; +import { + AppError, + ForbiddenError, + formatError, + NotFoundError, + PdsWriteError, + ValidationError, +} from "../../src/lib/errors.ts"; + +describe("error classes", () => { + test("each subclass has the correct statusCode", () => { + expect(new NotFoundError().statusCode).toBe(404); + expect(new ForbiddenError().statusCode).toBe(403); + expect(new PdsWriteError("fail").statusCode).toBe(502); + expect(new ValidationError("bad").statusCode).toBe(400); + }); + + test("all subclasses extend AppError and Error", () => { + for (const err of [ + new NotFoundError(), + new ForbiddenError(), + new PdsWriteError("fail"), + new ValidationError("bad"), + ]) { + expect(err).toBeInstanceOf(AppError); + expect(err).toBeInstanceOf(Error); + } + }); + + test("default messages are set", () => { + expect(new NotFoundError().message).toBe("Not found"); + expect(new ForbiddenError().message).toBe("Forbidden"); + }); + + test("custom messages are preserved", () => { + expect(new NotFoundError("wiki gone").message).toBe("wiki gone"); + expect(new PdsWriteError("PDS down").message).toBe("PDS down"); + }); +}); + +describe("formatError", () => { + test("extracts message from Error instances", () => { + expect(formatError(new Error("boom"))).toBe("boom"); + }); + + test("converts non-Error values to string", () => { + expect(formatError("string error")).toBe("string error"); + expect(formatError(42)).toBe("42"); + expect(formatError(null)).toBe("null"); + }); +}); diff --git a/tests/lib/html.test.ts b/tests/lib/html.test.ts new file mode 100644 index 0000000..81ea35d --- /dev/null +++ b/tests/lib/html.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, test } from "bun:test"; +import { escapeHtml } from "../../src/lib/html.ts"; + +describe("escapeHtml", () => { + test('escapes & < > "', () => { + expect(escapeHtml('a & b < c > d "e"')).toBe( + "a & b < c > d "e"", + ); + }); + + test("returns empty string unchanged", () => { + expect(escapeHtml("")).toBe(""); + }); + + test("leaves safe text unchanged", () => { + expect(escapeHtml("hello world 123")).toBe("hello world 123"); + }); + + test("handles string with only special characters", () => { + expect(escapeHtml('<"&">')).toBe("<"&">"); + }); +}); diff --git a/tests/lib/i18n.test.ts b/tests/lib/i18n.test.ts index 06d2693..ef47566 100644 --- a/tests/lib/i18n.test.ts +++ b/tests/lib/i18n.test.ts @@ -2,28 +2,59 @@ import { describe, expect, test } from "bun:test"; import { fmt, resolveLocale, t } from "../../src/lib/i18n/index.ts"; describe("t()", () => { - test("returns English messages for 'en'", () => { + const EXPECTED_SECTIONS = [ + "nav", + "login", + "search", + "home", + "pagination", + "wiki", + "editor", + "createWiki", + "access", + "profile", + "error", + ]; + + test("returns all sections with non-empty strings for 'en'", () => { const msg = t("en"); - expect(msg.nav.login).toBe("Log in"); - expect(msg.home.wikis).toBe("Wikis"); - expect(msg.editor.save).toBe("Save"); - expect(msg.createWiki.heading).toBe("Create a Wiki"); - expect(msg.error.wikiNameRequired).toBe("Wiki name is required."); + for (const section of EXPECTED_SECTIONS) { + const entries = Object.values( + msg[section as keyof typeof msg] as Record, + ); + expect(entries.length).toBeGreaterThan(0); + for (const val of entries) { + expect(typeof val).toBe("string"); + expect(val.length).toBeGreaterThan(0); + } + } + }); + + test("French overrides differ from English where provided", () => { + const en = t("en"); + const fr = t("fr"); + // Spot-check: fr has its own translations for these + expect(fr.nav.login).not.toBe(en.nav.login); + expect(fr.editor.save).not.toBe(en.editor.save); }); - test("returns French messages for 'fr'", () => { + test("French fills all sections (merges with English fallback)", () => { const msg = t("fr"); - expect(msg.nav.login).toBe("Connexion"); - expect(msg.home.noWikisYet).toBe("Aucun wiki pour le moment."); - expect(msg.editor.save).toBe("Enregistrer"); - expect(msg.createWiki.heading).toBe("Créer un wiki"); - expect(msg.error.wikiNameRequired).toBe("Le nom du wiki est requis."); + for (const section of EXPECTED_SECTIONS) { + const entries = Object.values( + msg[section as keyof typeof msg] as Record, + ); + for (const val of entries) { + expect(typeof val).toBe("string"); + expect(val.length).toBeGreaterThan(0); + } + } }); test("falls back to English for unknown locale", () => { // @ts-expect-error testing invalid locale const msg = t("zz"); - expect(msg.nav.login).toBe("Log in"); + expect(msg.nav.login).toBe(t("en").nav.login); }); }); diff --git a/tests/lib/image-validation.test.ts b/tests/lib/image-validation.test.ts index 2f2b083..2801a06 100644 --- a/tests/lib/image-validation.test.ts +++ b/tests/lib/image-validation.test.ts @@ -1,9 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { - ALLOWED_MIME_TYPES, - ImageValidationError, - MAX_IMAGE_SIZE, -} from "../../src/lib/image.ts"; +import { ALLOWED_MIME_TYPES } from "../../src/lib/image.ts"; // These tests validate the image constraints WITHOUT calling processImage // (which requires sharp). They verify the exported constants and error class. @@ -19,14 +15,4 @@ describe("image validation constants", () => { expect(ALLOWED_MIME_TYPES).not.toContain("image/svg+xml"); expect(ALLOWED_MIME_TYPES).not.toContain("application/pdf"); }); - - test("max image size is 10MB", () => { - expect(MAX_IMAGE_SIZE).toBe(10 * 1024 * 1024); - }); - - test("ImageValidationError is an Error", () => { - const err = new ImageValidationError("test"); - expect(err).toBeInstanceOf(Error); - expect(err.name).toBe("ImageValidationError"); - }); }); diff --git a/tests/lib/orchestrators/bookmark.test.ts b/tests/lib/orchestrators/bookmark.test.ts new file mode 100644 index 0000000..ef176d3 --- /dev/null +++ b/tests/lib/orchestrators/bookmark.test.ts @@ -0,0 +1,104 @@ +import { afterAll, describe, expect, mock, test } from "bun:test"; +import { getDb } from "../../../src/server/db/index.ts"; + +const realPds = await import("../../../src/atproto/pds.ts"); +const realSession = await import("../../../src/atproto/session.ts"); + +const mockWriteBookmarkRecord = mock(async () => ({ + uri: "at://did:plc:bkuser/wiki.lichen.bookmark/abc", + cid: "bafyrei123", +})); +const mockDeleteRecord = mock(async () => {}); +const mockGetAgent = mock(async () => ({}) as never); + +mock.module("../../../src/atproto/pds.ts", () => ({ + ...realPds, + writeBookmarkRecord: mockWriteBookmarkRecord, + deleteRecord: mockDeleteRecord, +})); +mock.module("../../../src/atproto/session.ts", () => ({ + ...realSession, + getAgent: mockGetAgent, +})); + +const { addBookmarkAction, removeBookmarkAction } = await import( + "../../../src/lib/orchestrators/bookmark.ts" +); +const { isBookmarked, upsertBookmark } = await import( + "../../../src/server/db/queries/index.ts" +); + +const USER_DID = "did:plc:bkuser"; +const WIKI_AT_URI = "at://did:plc:wikiowner/wiki.lichen.wiki/test-wiki"; + +const session = { did: USER_DID, handle: "bkuser.bsky.social" }; + +afterAll(() => { + const db = getDb(); + db.run("DELETE FROM bookmarks WHERE did = ?", [USER_DID]); + mock.module("../../../src/atproto/pds.ts", () => realPds); + mock.module("../../../src/atproto/session.ts", () => realSession); +}); + +describe("addBookmarkAction", () => { + test("writes to PDS and DB with session", async () => { + mockWriteBookmarkRecord.mockClear(); + await addBookmarkAction(USER_DID, WIKI_AT_URI, session); + + expect(mockWriteBookmarkRecord).toHaveBeenCalledTimes(1); + expect(isBookmarked(USER_DID, WIKI_AT_URI)).toBe(true); + }); + + test("writes to DB only without session (dev mode)", async () => { + const db = getDb(); + db.run("DELETE FROM bookmarks WHERE did = ?", [USER_DID]); + + mockWriteBookmarkRecord.mockClear(); + await addBookmarkAction(USER_DID, WIKI_AT_URI, null); + + expect(mockWriteBookmarkRecord).not.toHaveBeenCalled(); + expect(isBookmarked(USER_DID, WIKI_AT_URI)).toBe(true); + }); +}); + +describe("removeBookmarkAction", () => { + test("deletes from DB and PDS with session", async () => { + upsertBookmark( + USER_DID, + WIKI_AT_URI, + `at://${USER_DID}/wiki.lichen.bookmark/rm1`, + "2026-01-01T00:00:00.000Z", + ); + mockDeleteRecord.mockClear(); + + await removeBookmarkAction(USER_DID, WIKI_AT_URI, session); + + expect(mockDeleteRecord).toHaveBeenCalledTimes(1); + expect(isBookmarked(USER_DID, WIKI_AT_URI)).toBe(false); + }); + + test("deletes from DB only without session (dev mode)", async () => { + upsertBookmark( + USER_DID, + WIKI_AT_URI, + `at://${USER_DID}/wiki.lichen.bookmark/rm2`, + "2026-01-01T00:00:00.000Z", + ); + mockDeleteRecord.mockClear(); + + await removeBookmarkAction(USER_DID, WIKI_AT_URI, null); + + expect(mockDeleteRecord).not.toHaveBeenCalled(); + expect(isBookmarked(USER_DID, WIKI_AT_URI)).toBe(false); + }); + + test("no-op when bookmark does not exist", async () => { + mockDeleteRecord.mockClear(); + await removeBookmarkAction( + USER_DID, + "at://did:plc:x/wiki.lichen.wiki/fake", + session, + ); + expect(mockDeleteRecord).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/lib/profile.test.ts b/tests/lib/profile.test.ts index fadd823..98ab99a 100644 --- a/tests/lib/profile.test.ts +++ b/tests/lib/profile.test.ts @@ -1,4 +1,4 @@ -import { afterAll, describe, expect, mock, test } from "bun:test"; +import { describe, expect, mock, test } from "bun:test"; // Mock the IdResolver before importing the module const mockResolveHandle = mock(async () => "did:plc:resolved"); @@ -13,28 +13,27 @@ mock.module("@atproto/identity", () => ({ }, })); -// Mock fetch for profile resolution -const originalFetch = globalThis.fetch; -const mockFetch = mock( - async () => - new Response( - JSON.stringify({ - handle: "alice.bsky.social", - displayName: "Alice", - avatar: "https://cdn.example.com/avatar.jpg", - }), - { status: 200, headers: { "Content-Type": "application/json" } }, - ), -); -globalThis.fetch = mockFetch as unknown as typeof fetch; - const { resolveHandleToDid, resolveProfile, resolveProfiles } = await import( "../../src/lib/profile.ts" ); -afterAll(() => { - globalThis.fetch = originalFetch; -}); +// Mock fetch passed via dependency injection (no global replacement) +function createMockFetch( + body: Record = { + handle: "alice.bsky.social", + displayName: "Alice", + avatar: "https://cdn.example.com/avatar.jpg", + }, + status = 200, +) { + return mock( + async () => + new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }), + ) as unknown as typeof fetch; +} describe("resolveHandleToDid", () => { test("returns DID unchanged if input starts with did:", async () => { @@ -67,9 +66,9 @@ describe("resolveHandleToDid", () => { describe("resolveProfile", () => { test("returns handle, displayName, and avatar on success", async () => { mockResolveDid.mockClear(); - mockFetch.mockClear(); + const mockFetch = createMockFetch(); - const result = await resolveProfile("did:plc:alice"); + const result = await resolveProfile("did:plc:alice", mockFetch); expect(result.handle).toBe("alice.bsky.social"); expect(result.displayName).toBe("Alice"); @@ -80,9 +79,9 @@ describe("resolveProfile", () => { mockResolveDid.mockResolvedValueOnce({ alsoKnownAs: ["at://bob.bsky.social"], }); - mockFetch.mockResolvedValueOnce(new Response("", { status: 404 })); + const mockFetch = createMockFetch({}, 404); - const result = await resolveProfile("did:plc:bob"); + const result = await resolveProfile("did:plc:bob", mockFetch); expect(result.handle).toBe("bob.bsky.social"); expect(result.displayName).toBeNull(); @@ -93,8 +92,9 @@ describe("resolveProfile", () => { mockResolveDid.mockImplementationOnce(async () => { throw new Error("DID resolution failed"); }); + const mockFetch = createMockFetch(); - const result = await resolveProfile("did:plc:broken"); + const result = await resolveProfile("did:plc:broken", mockFetch); expect(result.handle).toBeNull(); expect(result.displayName).toBeNull(); @@ -105,13 +105,12 @@ describe("resolveProfile", () => { describe("resolveProfiles", () => { test("resolves multiple DIDs and deduplicates", async () => { mockResolveDid.mockClear(); - mockFetch.mockClear(); + const mockFetch = createMockFetch(); - const result = await resolveProfiles([ - "did:plc:alice", - "did:plc:bob", - "did:plc:alice", // duplicate - ]); + const result = await resolveProfiles( + ["did:plc:alice", "did:plc:bob", "did:plc:alice"], + mockFetch, + ); expect(result.size).toBe(2); expect(result.has("did:plc:alice")).toBe(true); diff --git a/tests/lib/tid.test.ts b/tests/lib/tid.test.ts new file mode 100644 index 0000000..a1bf9ab --- /dev/null +++ b/tests/lib/tid.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, test } from "bun:test"; +import { generateTid } from "../../src/lib/tid.ts"; + +describe("generateTid", () => { + test("returns 13-character base32 string", () => { + const tid = generateTid(); + expect(tid).toMatch(/^[2-7a-z]{13}$/); + }); + + test("successive calls produce unique values", () => { + const tids = new Set(Array.from({ length: 100 }, () => generateTid())); + expect(tids.size).toBe(100); + }); + + test("successive calls are monotonically increasing", () => { + const a = generateTid(); + const b = generateTid(); + const c = generateTid(); + // Lexicographic ordering matches temporal ordering for base32 TIDs + expect(a < b).toBe(true); + expect(b < c).toBe(true); + }); +}); diff --git a/tests/server/db/queries/bookmark.test.ts b/tests/server/db/queries/bookmark.test.ts new file mode 100644 index 0000000..f45de80 --- /dev/null +++ b/tests/server/db/queries/bookmark.test.ts @@ -0,0 +1,119 @@ +import { afterAll, describe, expect, test } from "bun:test"; +import { getDb } from "../../../../src/server/db/index.ts"; +import { + deleteBookmarkByUri, + deleteBookmarkByWiki, + getBookmarksForUser, + isBookmarked, + upsertBookmark, +} from "../../../../src/server/db/queries/index.ts"; + +const WIKI_SLUG = "test"; // seeded wiki +const DID = "did:plc:bookmark-tester"; +const OTHER_DID = "did:plc:bookmark-other"; + +// Get wiki at_uri from seeded data +const db = getDb(); +const seededWiki = db + .query("SELECT at_uri FROM wikis WHERE slug = ?") + .get(WIKI_SLUG) as { at_uri: string }; +const WIKI_AT_URI = seededWiki.at_uri; + +const BOOKMARK_AT_URI = `at://${DID}/wiki.lichen.bookmark/bk1`; +const OTHER_BOOKMARK_AT_URI = `at://${OTHER_DID}/wiki.lichen.bookmark/bk2`; + +// Set up test data +upsertBookmark(DID, WIKI_AT_URI, BOOKMARK_AT_URI, "2026-01-01T00:00:00.000Z"); + +afterAll(() => { + const db = getDb(); + db.run("DELETE FROM bookmarks WHERE did IN (?, ?)", [DID, OTHER_DID]); +}); + +describe("upsertBookmark", () => { + test("inserts a bookmark", () => { + expect(isBookmarked(DID, WIKI_AT_URI)).toBe(true); + }); + + test("is idempotent on conflict", () => { + const newUri = `at://${DID}/wiki.lichen.bookmark/bk1-new`; + upsertBookmark(DID, WIKI_AT_URI, newUri, "2026-01-02T00:00:00.000Z"); + expect(isBookmarked(DID, WIKI_AT_URI)).toBe(true); + // Restore original at_uri + upsertBookmark( + DID, + WIKI_AT_URI, + BOOKMARK_AT_URI, + "2026-01-01T00:00:00.000Z", + ); + }); +}); + +describe("isBookmarked", () => { + test("returns true for existing bookmark", () => { + expect(isBookmarked(DID, WIKI_AT_URI)).toBe(true); + }); + + test("returns false for non-existent bookmark", () => { + expect(isBookmarked("did:plc:nobody", WIKI_AT_URI)).toBe(false); + }); + + test("returns false for non-existent wiki", () => { + expect(isBookmarked(DID, "at://did:plc:x/wiki.lichen.wiki/fake")).toBe( + false, + ); + }); +}); + +describe("getBookmarksForUser", () => { + test("returns bookmarked wikis with note count", () => { + const bookmarks = getBookmarksForUser(DID); + expect(bookmarks.length).toBeGreaterThanOrEqual(1); + const bm = bookmarks.find((w) => w.slug === WIKI_SLUG); + expect(bm).toBeDefined(); + expect(bm?.name).toBeTruthy(); + expect(bm?.note_count).toBeGreaterThanOrEqual(0); + }); + + test("returns empty for user with no bookmarks", () => { + expect(getBookmarksForUser("did:plc:no-bookmarks")).toEqual([]); + }); +}); + +describe("deleteBookmarkByUri", () => { + test("deletes bookmark by at_uri", () => { + upsertBookmark( + OTHER_DID, + WIKI_AT_URI, + OTHER_BOOKMARK_AT_URI, + "2026-01-01T00:00:00.000Z", + ); + expect(isBookmarked(OTHER_DID, WIKI_AT_URI)).toBe(true); + + deleteBookmarkByUri(OTHER_BOOKMARK_AT_URI); + expect(isBookmarked(OTHER_DID, WIKI_AT_URI)).toBe(false); + }); + + test("no-op for non-existent uri", () => { + deleteBookmarkByUri("at://did:plc:x/wiki.lichen.bookmark/fake"); + }); +}); + +describe("deleteBookmarkByWiki", () => { + test("deletes bookmark and returns at_uri", () => { + upsertBookmark( + OTHER_DID, + WIKI_AT_URI, + OTHER_BOOKMARK_AT_URI, + "2026-01-01T00:00:00.000Z", + ); + const atUri = deleteBookmarkByWiki(OTHER_DID, WIKI_AT_URI); + expect(atUri).toBe(OTHER_BOOKMARK_AT_URI); + expect(isBookmarked(OTHER_DID, WIKI_AT_URI)).toBe(false); + }); + + test("returns null when no bookmark exists", () => { + const atUri = deleteBookmarkByWiki("did:plc:nobody", WIKI_AT_URI); + expect(atUri).toBeNull(); + }); +}); diff --git a/tests/server/db/queries/membership.test.ts b/tests/server/db/queries/membership.test.ts index 266a671..e4498f6 100644 --- a/tests/server/db/queries/membership.test.ts +++ b/tests/server/db/queries/membership.test.ts @@ -1,4 +1,4 @@ -import { afterAll, describe, expect, test } from "bun:test"; +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { getDb } from "../../../../src/server/db/index.ts"; import { getMemberRole, @@ -15,27 +15,28 @@ const ADMIN_DID = "did:plc:member-admin"; const VIEWER_DID = "did:plc:member-viewer"; const REQUESTER_DID = "did:plc:requester"; -// Set up test data -upsertMembership( - WIKI_SLUG, - ADMIN_DID, - "admin", - "at://did:plc:member-admin/wiki.lichen.membership/m1", - "2026-01-01T00:00:00.000Z", -); -upsertMembership( - WIKI_SLUG, - VIEWER_DID, - "viewer", - "at://did:plc:member-viewer/wiki.lichen.membership/m2", - "2026-01-02T00:00:00.000Z", -); -upsertRequest( - WIKI_SLUG, - REQUESTER_DID, - "at://did:plc:requester/wiki.lichen.memberRequest/r1", - "2026-01-03T00:00:00.000Z", -); +beforeAll(() => { + upsertMembership( + WIKI_SLUG, + ADMIN_DID, + "admin", + "at://did:plc:member-admin/wiki.lichen.membership/m1", + "2026-01-01T00:00:00.000Z", + ); + upsertMembership( + WIKI_SLUG, + VIEWER_DID, + "viewer", + "at://did:plc:member-viewer/wiki.lichen.membership/m2", + "2026-01-02T00:00:00.000Z", + ); + upsertRequest( + WIKI_SLUG, + REQUESTER_DID, + "at://did:plc:requester/wiki.lichen.memberRequest/r1", + "2026-01-03T00:00:00.000Z", + ); +}); afterAll(() => { const db = getDb(); diff --git a/tests/server/routes/blob.test.ts b/tests/server/routes/blob.test.ts new file mode 100644 index 0000000..03ccba4 --- /dev/null +++ b/tests/server/routes/blob.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, test } from "bun:test"; +import { createTestApp } from "./helpers.ts"; + +const app = createTestApp(); + +describe("blob routes", () => { + test("POST /api/upload-image without file returns 400", async () => { + const formData = new FormData(); + const res = await app.handle( + new Request("http://localhost/api/upload-image", { + method: "POST", + body: formData, + }), + ); + expect(res.status).toBe(400); + const json = (await res.json()) as { error: string }; + expect(json.error).toContain("No file"); + }); + + test("POST /api/upload-image with unsupported mime type returns 400", async () => { + const formData = new FormData(); + formData.set( + "file", + new File([new Uint8Array(100)], "test.svg", { type: "image/svg+xml" }), + ); + const res = await app.handle( + new Request("http://localhost/api/upload-image", { + method: "POST", + body: formData, + }), + ); + expect(res.status).toBe(400); + const json = (await res.json()) as { error: string }; + expect(json.error).toContain("Unsupported"); + }); + + test("GET /blob/local/ rejects path traversal", async () => { + const res = await app.handle( + new Request("http://localhost/blob/local/..%2F..%2Fetc%2Fpasswd"), + ); + expect(res.status).toBe(400); + }); + + test("GET /blob/local/ rejects filename with double dots", async () => { + const res = await app.handle( + new Request("http://localhost/blob/local/..secret.jpg"), + ); + expect(res.status).toBe(400); + }); +}); diff --git a/tests/server/routes/bookmark.test.ts b/tests/server/routes/bookmark.test.ts new file mode 100644 index 0000000..ca511b4 --- /dev/null +++ b/tests/server/routes/bookmark.test.ts @@ -0,0 +1,67 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { DEV_DID } from "../../../src/atproto/session.ts"; +import { + isBookmarked, + upsertMembership, + upsertWiki, +} from "../../../src/server/db/queries/index.ts"; +import { cleanupWikiAndDependents } from "../../helpers/cleanup.ts"; +import { createTestApp } from "./helpers.ts"; + +const app = createTestApp(); + +const SLUG = "bm-test-wiki"; +const AT_URI = `at://${DEV_DID}/wiki.lichen.wiki/${SLUG}`; + +beforeAll(() => { + upsertWiki( + SLUG, + DEV_DID, + "Bookmark Test Wiki", + "public", + AT_URI, + new Date().toISOString(), + ); + upsertMembership( + SLUG, + DEV_DID, + "admin", + `at://${DEV_DID}/wiki.lichen.membership/bm1`, + new Date().toISOString(), + ); +}); + +afterAll(() => { + cleanupWikiAndDependents(SLUG); +}); + +describe("bookmark routes", () => { + test("adds bookmark and returns HTML partial", async () => { + const formData = new FormData(); + formData.set("wikiAtUri", AT_URI); + formData.set("action", "add"); + const res = await app.handle( + new Request("http://localhost/api/bookmark", { + method: "POST", + body: formData, + }), + ); + expect(res.status).toBe(200); + expect(res.headers.get("Content-Type")).toContain("text/html"); + expect(isBookmarked(DEV_DID, AT_URI)).toBe(true); + }); + + test("removes bookmark and returns HTML partial", async () => { + const formData = new FormData(); + formData.set("wikiAtUri", AT_URI); + formData.set("action", "remove"); + const res = await app.handle( + new Request("http://localhost/api/bookmark", { + method: "POST", + body: formData, + }), + ); + expect(res.status).toBe(200); + expect(isBookmarked(DEV_DID, AT_URI)).toBe(false); + }); +}); diff --git a/tests/server/routes/helpers.ts b/tests/server/routes/helpers.ts new file mode 100644 index 0000000..3a0dd96 --- /dev/null +++ b/tests/server/routes/helpers.ts @@ -0,0 +1,26 @@ +import { Elysia } from "elysia"; +import { AppError } from "../../../src/lib/errors.ts"; +import { blobRoutes } from "../../../src/server/routes/blob.ts"; +import { bookmarkRoutes } from "../../../src/server/routes/bookmark.ts"; +import { homeRoute } from "../../../src/server/routes/home.ts"; +import { membershipRoutes } from "../../../src/server/routes/membership.ts"; +import { noteRoutes } from "../../../src/server/routes/note.ts"; +import { searchRoutes } from "../../../src/server/routes/search.ts"; +import { wikiRoutes } from "../../../src/server/routes/wiki.ts"; + +export function createTestApp() { + return new Elysia() + .onError(({ error }) => { + if (error instanceof AppError) { + return new Response(error.message, { status: error.statusCode }); + } + return new Response("Internal server error", { status: 500 }); + }) + .use(blobRoutes) + .use(bookmarkRoutes) + .use(homeRoute) + .use(searchRoutes) + .use(membershipRoutes) + .use(noteRoutes) + .use(wikiRoutes); +} diff --git a/tests/server/routes/home.test.ts b/tests/server/routes/home.test.ts new file mode 100644 index 0000000..10df83e --- /dev/null +++ b/tests/server/routes/home.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, test } from "bun:test"; +import { createTestApp } from "./helpers.ts"; + +const app = createTestApp(); + +describe("home route", () => { + test("GET / returns 200 with HTML", async () => { + const res = await app.handle(new Request("http://localhost/")); + expect(res.status).toBe(200); + expect(res.headers.get("Content-Type")).toContain("text/html"); + }); +}); diff --git a/tests/server/routes/membership.test.ts b/tests/server/routes/membership.test.ts new file mode 100644 index 0000000..6e8f2b0 --- /dev/null +++ b/tests/server/routes/membership.test.ts @@ -0,0 +1,88 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { DEV_DID } from "../../../src/atproto/session.ts"; +import { + getMemberRole, + upsertMembership, + upsertRequest, + upsertWiki, +} from "../../../src/server/db/queries/index.ts"; +import { cleanupWikiAndDependents } from "../../helpers/cleanup.ts"; +import { createTestApp } from "./helpers.ts"; + +const app = createTestApp(); + +const SLUG = "mb-test-wiki"; +const MEMBER_DID = "did:plc:mb-member"; + +beforeAll(() => { + upsertWiki( + SLUG, + DEV_DID, + "Membership Test Wiki", + "public", + `at://${DEV_DID}/wiki.lichen.wiki/${SLUG}`, + new Date().toISOString(), + ); + upsertMembership( + SLUG, + DEV_DID, + "admin", + `at://${DEV_DID}/wiki.lichen.membership/mb1`, + new Date().toISOString(), + ); +}); + +afterAll(() => { + cleanupWikiAndDependents(SLUG); +}); + +describe("membership routes", () => { + test("GET /wiki/:slug/-/members redirects to settings", async () => { + const res = await app.handle( + new Request(`http://localhost/wiki/${SLUG}/-/members`), + ); + expect(res.status).toBe(302); + expect(res.headers.get("Location")).toBe(`/wiki/${SLUG}/-/settings`); + }); + + test("POST request-access on nonexistent wiki returns 404", async () => { + const res = await app.handle( + new Request("http://localhost/wiki/no-such-wiki/-/request-access", { + method: "POST", + }), + ); + expect(res.status).toBe(404); + }); + + test("approves member and redirects to settings", async () => { + upsertRequest( + SLUG, + MEMBER_DID, + `at://${MEMBER_DID}/wiki.lichen.memberRequest/r1`, + new Date().toISOString(), + ); + + const formData = new FormData(); + formData.set("role", "contributor"); + const res = await app.handle( + new Request( + `http://localhost/wiki/${SLUG}/-/members/${encodeURIComponent(MEMBER_DID)}/approve`, + { method: "POST", body: formData }, + ), + ); + expect(res.status).toBe(302); + expect(res.headers.get("Location")).toBe(`/wiki/${SLUG}/-/settings`); + expect(getMemberRole(SLUG, MEMBER_DID)).toBe("contributor"); + }); + + test("removes member and redirects to settings", async () => { + const res = await app.handle( + new Request( + `http://localhost/wiki/${SLUG}/-/members/${encodeURIComponent(MEMBER_DID)}/remove`, + { method: "POST" }, + ), + ); + expect(res.status).toBe(302); + expect(getMemberRole(SLUG, MEMBER_DID)).toBeNull(); + }); +}); diff --git a/tests/server/routes/note.test.ts b/tests/server/routes/note.test.ts new file mode 100644 index 0000000..d21a610 --- /dev/null +++ b/tests/server/routes/note.test.ts @@ -0,0 +1,145 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { DEV_DID } from "../../../src/atproto/session.ts"; +import { + createNote, + upsertMembership, + upsertWiki, +} from "../../../src/server/db/queries/index.ts"; +import { cleanupWikiAndDependents } from "../../helpers/cleanup.ts"; +import { createTestApp } from "./helpers.ts"; + +const app = createTestApp(); + +const SLUG = "nt-test-wiki"; +const PRIVATE_SLUG = "nt-test-private"; +const OTHER_DID = "did:plc:other"; + +beforeAll(() => { + upsertWiki( + SLUG, + DEV_DID, + "Note Test Wiki", + "public", + `at://${DEV_DID}/wiki.lichen.wiki/${SLUG}`, + new Date().toISOString(), + ); + upsertMembership( + SLUG, + DEV_DID, + "admin", + `at://${DEV_DID}/wiki.lichen.membership/nt1`, + new Date().toISOString(), + ); + createNote( + `at://${DEV_DID}/wiki.lichen.note/nt-home`, + `at://${DEV_DID}/wiki.lichen.noteRevision/nt-home-rev`, + SLUG, + "home", + "Home", + DEV_DID, + "# Welcome", + ); + createNote( + `at://${DEV_DID}/wiki.lichen.note/nt-test`, + `at://${DEV_DID}/wiki.lichen.noteRevision/nt-test-rev`, + SLUG, + "test-note", + "Test Note", + DEV_DID, + "Some content", + ); + upsertWiki( + PRIVATE_SLUG, + OTHER_DID, + "Private Wiki", + "private", + `at://${OTHER_DID}/wiki.lichen.wiki/${PRIVATE_SLUG}`, + new Date().toISOString(), + ); +}); + +afterAll(() => { + cleanupWikiAndDependents(SLUG); + cleanupWikiAndDependents(PRIVATE_SLUG); +}); + +describe("note routes", () => { + test("GET /wiki/:slug/:noteSlug returns 200 for existing note", async () => { + const res = await app.handle( + new Request(`http://localhost/wiki/${SLUG}/test-note`), + ); + expect(res.status).toBe(200); + expect(res.headers.get("Content-Type")).toContain("text/html"); + }); + + test("GET /wiki/:slug/:noteSlug returns 404 for nonexistent note", async () => { + const res = await app.handle( + new Request(`http://localhost/wiki/${SLUG}/nonexistent-note-xyz`), + ); + expect(res.status).toBe(404); + }); + + test("GET /wiki/:slug/:noteSlug/edit returns 200 for editable note", async () => { + const res = await app.handle( + new Request(`http://localhost/wiki/${SLUG}/test-note/edit`), + ); + expect(res.status).toBe(200); + }); + + test("GET edit on private wiki returns 403 for non-member", async () => { + const res = await app.handle( + new Request(`http://localhost/wiki/${PRIVATE_SLUG}/some-note/edit`), + ); + expect(res.status).toBe(403); + }); + + test("GET new note on private wiki returns 403 for non-member", async () => { + const res = await app.handle( + new Request(`http://localhost/wiki/${PRIVATE_SLUG}/new`), + ); + expect(res.status).toBe(403); + }); + + test("POST /wiki/:slug/new creates a note and redirects", async () => { + const formData = new FormData(); + formData.set("title", "Note Created"); + formData.set("content", "Test content"); + const res = await app.handle( + new Request(`http://localhost/wiki/${SLUG}/new`, { + method: "POST", + body: formData, + }), + ); + expect(res.status).toBe(302); + expect(res.headers.get("Location")).toContain(`/wiki/${SLUG}/`); + }); + + test("POST /wiki/:slug/new with empty title returns validation error", async () => { + const formData = new FormData(); + formData.set("title", " "); + formData.set("content", "content"); + const res = await app.handle( + new Request(`http://localhost/wiki/${SLUG}/new`, { + method: "POST", + body: formData, + }), + ); + expect(res.status).toBe(200); + const body = await res.text(); + expect(body).toContain("required"); + }); + + test("POST /wiki/:slug/:noteSlug/edit edits note and redirects", async () => { + const formData = new FormData(); + formData.set("title", "Test Note"); + formData.set("content", "Updated content"); + const res = await app.handle( + new Request(`http://localhost/wiki/${SLUG}/test-note/edit`, { + method: "POST", + body: formData, + }), + ); + expect(res.status).toBe(302); + expect(res.headers.get("Location")).toBe(`/wiki/${SLUG}/test-note`); + }); +}); diff --git a/tests/server/routes/routes.test.ts b/tests/server/routes/routes.test.ts deleted file mode 100644 index a652e50..0000000 --- a/tests/server/routes/routes.test.ts +++ /dev/null @@ -1,358 +0,0 @@ -import { afterAll, beforeAll, describe, expect, test } from "bun:test"; -import { Elysia } from "elysia"; -import { DEV_DID } from "../../../src/atproto/session.ts"; -import { AppError } from "../../../src/lib/errors.ts"; -import { - createNote, - upsertMembership, - upsertWiki, -} from "../../../src/server/db/queries/index.ts"; -import { blobRoutes } from "../../../src/server/routes/blob.ts"; -import { homeRoute } from "../../../src/server/routes/home.ts"; -import { membershipRoutes } from "../../../src/server/routes/membership.ts"; -import { noteRoutes } from "../../../src/server/routes/note.ts"; -import { searchRoutes } from "../../../src/server/routes/search.ts"; -import { wikiRoutes } from "../../../src/server/routes/wiki.ts"; -import { cleanupWikiAndDependents } from "../../helpers/cleanup.ts"; - -// Build a test app with the same error handler as production -const app = new Elysia() - .onError(({ error }) => { - if (error instanceof AppError) { - return new Response(error.message, { status: error.statusCode }); - } - return new Response("Internal server error", { status: 500 }); - }) - .use(blobRoutes) - .use(homeRoute) - .use(searchRoutes) - .use(membershipRoutes) - .use(noteRoutes) - .use(wikiRoutes); - -const WIKI_SLUG = "rt-test-wiki"; -const WIKI_AT_URI = `at://${DEV_DID}/wiki.lichen.wiki/${WIKI_SLUG}`; -const PRIVATE_WIKI_SLUG = "rt-test-private"; -const PRIVATE_WIKI_AT_URI = `at://did:plc:other/wiki.lichen.wiki/${PRIVATE_WIKI_SLUG}`; -const OTHER_DID = "did:plc:other"; - -beforeAll(() => { - // Seed a public wiki owned by DEV_DID - upsertWiki( - WIKI_SLUG, - DEV_DID, - "Route Test Wiki", - "public", - WIKI_AT_URI, - new Date().toISOString(), - ); - upsertMembership( - WIKI_SLUG, - DEV_DID, - "admin", - `at://${DEV_DID}/wiki.lichen.membership/rt1`, - new Date().toISOString(), - ); - createNote( - `at://${DEV_DID}/wiki.lichen.note/rt-home`, - `at://${DEV_DID}/wiki.lichen.noteRevision/rt-home-rev`, - WIKI_SLUG, - "home", - "Home", - DEV_DID, - "# Welcome", - ); - createNote( - `at://${DEV_DID}/wiki.lichen.note/rt-test-note`, - `at://${DEV_DID}/wiki.lichen.noteRevision/rt-test-rev`, - WIKI_SLUG, - "test-note", - "Test Note", - DEV_DID, - "Some content", - ); - - // Seed a private wiki owned by another DID (DEV_DID has no membership) - upsertWiki( - PRIVATE_WIKI_SLUG, - OTHER_DID, - "Private Wiki", - "private", - PRIVATE_WIKI_AT_URI, - new Date().toISOString(), - ); -}); - -afterAll(() => { - cleanupWikiAndDependents(WIKI_SLUG); - cleanupWikiAndDependents(PRIVATE_WIKI_SLUG); -}); - -// --- Wiki routes --- - -describe("wiki routes", () => { - test("GET /wiki/:slug returns 200 for public wiki", async () => { - const res = await app.handle( - new Request(`http://localhost/wiki/${WIKI_SLUG}`), - ); - expect(res.status).toBe(200); - expect(res.headers.get("Content-Type")).toContain("text/html"); - }); - - test("GET /wiki/:slug returns 403 for private wiki (non-member)", async () => { - const res = await app.handle( - new Request(`http://localhost/wiki/${PRIVATE_WIKI_SLUG}`), - ); - expect(res.status).toBe(403); - }); - - test("GET /wiki/nonexistent returns 404", async () => { - const res = await app.handle( - new Request("http://localhost/wiki/does-not-exist-xyz"), - ); - expect(res.status).toBe(404); - }); - - test("GET /wiki/:slug/-/settings returns 200 for admin", async () => { - const res = await app.handle( - new Request(`http://localhost/wiki/${WIKI_SLUG}/-/settings`), - ); - expect(res.status).toBe(200); - }); - - test("GET /wiki/:slug/-/settings returns 403 for non-admin", async () => { - const res = await app.handle( - new Request(`http://localhost/wiki/${PRIVATE_WIKI_SLUG}/-/settings`), - ); - expect(res.status).toBe(403); - }); - - test("POST /wiki/:slug/-/delete rejects wrong confirmation name", async () => { - const formData = new FormData(); - formData.set("confirm", "wrong name"); - const res = await app.handle( - new Request(`http://localhost/wiki/${WIKI_SLUG}/-/delete`, { - method: "POST", - body: formData, - }), - ); - expect(res.status).toBe(400); - }); - - test("GET /wiki/new returns 200", async () => { - const res = await app.handle(new Request("http://localhost/wiki/new")); - expect(res.status).toBe(200); - }); -}); - -// --- Note routes --- - -describe("note routes", () => { - test("GET /wiki/:slug/:noteSlug returns 200 for existing note", async () => { - const res = await app.handle( - new Request(`http://localhost/wiki/${WIKI_SLUG}/test-note`), - ); - expect(res.status).toBe(200); - expect(res.headers.get("Content-Type")).toContain("text/html"); - }); - - test("GET /wiki/:slug/:noteSlug returns 404 for nonexistent note", async () => { - const res = await app.handle( - new Request(`http://localhost/wiki/${WIKI_SLUG}/nonexistent-note-xyz`), - ); - expect(res.status).toBe(404); - }); - - test("GET /wiki/:slug/:noteSlug/edit returns 200 for editable note", async () => { - const res = await app.handle( - new Request(`http://localhost/wiki/${WIKI_SLUG}/test-note/edit`), - ); - expect(res.status).toBe(200); - }); - - test("GET edit on private wiki returns 403 for non-member", async () => { - const res = await app.handle( - new Request(`http://localhost/wiki/${PRIVATE_WIKI_SLUG}/some-note/edit`), - ); - expect(res.status).toBe(403); - }); - - test("GET new note on private wiki returns 403 for non-member", async () => { - const res = await app.handle( - new Request(`http://localhost/wiki/${PRIVATE_WIKI_SLUG}/new`), - ); - expect(res.status).toBe(403); - }); - - test("POST /wiki/:slug/new creates a note and redirects", async () => { - const formData = new FormData(); - formData.set("title", "Route Created Note"); - formData.set("content", "Test content from route test"); - const res = await app.handle( - new Request(`http://localhost/wiki/${WIKI_SLUG}/new`, { - method: "POST", - body: formData, - }), - ); - expect(res.status).toBe(302); - expect(res.headers.get("Location")).toContain(`/wiki/${WIKI_SLUG}/`); - }); - - test("POST /wiki/:slug/new with empty title returns validation error", async () => { - const formData = new FormData(); - formData.set("title", " "); - formData.set("content", "content"); - const res = await app.handle( - new Request(`http://localhost/wiki/${WIKI_SLUG}/new`, { - method: "POST", - body: formData, - }), - ); - // ValidationError is caught by the route and re-rendered as the form page - expect(res.status).toBe(200); - const body = await res.text(); - expect(body).toContain("required"); - }); -}); - -// --- Search routes --- - -describe("search routes", () => { - test("GET /search with empty query returns wiki grid", async () => { - const res = await app.handle(new Request("http://localhost/search?q=")); - expect(res.status).toBe(200); - const body = await res.text(); - // Empty query on home search returns all public wikis as a grid - expect(body).toContain("grid"); - }); - - test("GET /search with empty query inside wiki returns empty", async () => { - const res = await app.handle( - new Request("http://localhost/search?q=&wiki=rt-test-wiki"), - ); - expect(res.status).toBe(200); - const body = await res.text(); - expect(body).toBe(""); - }); - - test("GET /search returns results for matching wikis", async () => { - const res = await app.handle( - new Request("http://localhost/search?q=Route+Test"), - ); - expect(res.status).toBe(200); - const body = await res.text(); - expect(body).toContain("Route Test Wiki"); - }); - - test("GET /search within wiki returns note results", async () => { - const res = await app.handle( - new Request(`http://localhost/search?q=Test&wiki=${WIKI_SLUG}`), - ); - expect(res.status).toBe(200); - }); - - test("GET /search within private wiki returns empty for non-member", async () => { - const res = await app.handle( - new Request( - `http://localhost/search?q=anything&wiki=${PRIVATE_WIKI_SLUG}`, - ), - ); - expect(res.status).toBe(200); - const body = await res.text(); - expect(body).toBe(""); - }); -}); - -// --- Membership routes --- - -describe("membership routes", () => { - test("GET /wiki/:slug/-/members redirects to settings", async () => { - const res = await app.handle( - new Request(`http://localhost/wiki/${WIKI_SLUG}/-/members`), - ); - expect(res.status).toBe(302); - expect(res.headers.get("Location")).toBe(`/wiki/${WIKI_SLUG}/-/settings`); - }); - - test("GET /wiki/:slug/-/settings returns 200 for admin", async () => { - const res = await app.handle( - new Request(`http://localhost/wiki/${WIKI_SLUG}/-/settings`), - ); - expect(res.status).toBe(200); - expect(res.headers.get("Content-Type")).toContain("text/html"); - }); - - test("GET /wiki/:slug/-/settings returns 403 for non-admin", async () => { - const res = await app.handle( - new Request(`http://localhost/wiki/${PRIVATE_WIKI_SLUG}/-/settings`), - ); - expect(res.status).toBe(403); - }); - - test("POST request-access on nonexistent wiki returns 404", async () => { - const res = await app.handle( - new Request("http://localhost/wiki/no-such-wiki/-/request-access", { - method: "POST", - }), - ); - expect(res.status).toBe(404); - }); -}); - -// --- Blob routes --- - -describe("blob routes", () => { - test("POST /api/upload-image without file returns 400", async () => { - const formData = new FormData(); - const res = await app.handle( - new Request("http://localhost/api/upload-image", { - method: "POST", - body: formData, - }), - ); - expect(res.status).toBe(400); - const json = (await res.json()) as { error: string }; - expect(json.error).toContain("No file"); - }); - - test("POST /api/upload-image with unsupported mime type returns 400", async () => { - const formData = new FormData(); - formData.set( - "file", - new File([new Uint8Array(100)], "test.svg", { type: "image/svg+xml" }), - ); - const res = await app.handle( - new Request("http://localhost/api/upload-image", { - method: "POST", - body: formData, - }), - ); - expect(res.status).toBe(400); - const json = (await res.json()) as { error: string }; - expect(json.error).toContain("Unsupported"); - }); - - test("GET /blob/local/ rejects path traversal", async () => { - const res = await app.handle( - new Request("http://localhost/blob/local/..%2F..%2Fetc%2Fpasswd"), - ); - expect(res.status).toBe(400); - }); - - test("GET /blob/local/ rejects filename with double dots", async () => { - const res = await app.handle( - new Request("http://localhost/blob/local/..secret.jpg"), - ); - expect(res.status).toBe(400); - }); -}); - -// --- Home route --- - -describe("home route", () => { - test("GET / returns 200 with HTML", async () => { - const res = await app.handle(new Request("http://localhost/")); - expect(res.status).toBe(200); - expect(res.headers.get("Content-Type")).toContain("text/html"); - }); -}); diff --git a/tests/server/routes/search.test.ts b/tests/server/routes/search.test.ts new file mode 100644 index 0000000..51c0fdb --- /dev/null +++ b/tests/server/routes/search.test.ts @@ -0,0 +1,107 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { DEV_DID } from "../../../src/atproto/session.ts"; +import { + createNote, + upsertMembership, + upsertWiki, +} from "../../../src/server/db/queries/index.ts"; +import { cleanupWikiAndDependents } from "../../helpers/cleanup.ts"; +import { createTestApp } from "./helpers.ts"; + +const app = createTestApp(); + +const SLUG = "sr-test-wiki"; +const PRIVATE_SLUG = "sr-test-private"; +const OTHER_DID = "did:plc:other"; + +beforeAll(() => { + upsertWiki( + SLUG, + DEV_DID, + "Search Test Wiki", + "public", + `at://${DEV_DID}/wiki.lichen.wiki/${SLUG}`, + new Date().toISOString(), + ); + upsertMembership( + SLUG, + DEV_DID, + "admin", + `at://${DEV_DID}/wiki.lichen.membership/sr1`, + new Date().toISOString(), + ); + createNote( + `at://${DEV_DID}/wiki.lichen.note/sr-home`, + `at://${DEV_DID}/wiki.lichen.noteRevision/sr-home-rev`, + SLUG, + "home", + "Home", + DEV_DID, + "# Welcome", + ); + createNote( + `at://${DEV_DID}/wiki.lichen.note/sr-test`, + `at://${DEV_DID}/wiki.lichen.noteRevision/sr-test-rev`, + SLUG, + "test-note", + "Test Note", + DEV_DID, + "Some content", + ); + upsertWiki( + PRIVATE_SLUG, + OTHER_DID, + "Private Wiki", + "private", + `at://${OTHER_DID}/wiki.lichen.wiki/${PRIVATE_SLUG}`, + new Date().toISOString(), + ); +}); + +afterAll(() => { + cleanupWikiAndDependents(SLUG); + cleanupWikiAndDependents(PRIVATE_SLUG); +}); + +describe("search routes", () => { + test("GET /search with empty query returns wiki grid", async () => { + const res = await app.handle(new Request("http://localhost/search?q=")); + expect(res.status).toBe(200); + const body = await res.text(); + expect(body).toContain("grid"); + }); + + test("GET /search with empty query inside wiki returns empty", async () => { + const res = await app.handle( + new Request(`http://localhost/search?q=&wiki=${SLUG}`), + ); + expect(res.status).toBe(200); + const body = await res.text(); + expect(body).toBe(""); + }); + + test("GET /search returns results for matching wikis", async () => { + const res = await app.handle( + new Request("http://localhost/search?q=Search+Test"), + ); + expect(res.status).toBe(200); + const body = await res.text(); + expect(body).toContain("Search Test Wiki"); + }); + + test("GET /search within wiki returns note results", async () => { + const res = await app.handle( + new Request(`http://localhost/search?q=Test&wiki=${SLUG}`), + ); + expect(res.status).toBe(200); + }); + + test("GET /search within private wiki returns empty for non-member", async () => { + const res = await app.handle( + new Request(`http://localhost/search?q=anything&wiki=${PRIVATE_SLUG}`), + ); + expect(res.status).toBe(200); + const body = await res.text(); + expect(body).toBe(""); + }); +}); diff --git a/tests/server/routes/wiki.test.ts b/tests/server/routes/wiki.test.ts new file mode 100644 index 0000000..8b53228 --- /dev/null +++ b/tests/server/routes/wiki.test.ts @@ -0,0 +1,142 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { DEV_DID } from "../../../src/atproto/session.ts"; +import { + upsertMembership, + upsertWiki, +} from "../../../src/server/db/queries/index.ts"; +import { cleanupWikiAndDependents } from "../../helpers/cleanup.ts"; +import { createTestApp } from "./helpers.ts"; + +const app = createTestApp(); + +const SLUG = "wk-test-wiki"; +const AT_URI = `at://${DEV_DID}/wiki.lichen.wiki/${SLUG}`; +const PRIVATE_SLUG = "wk-test-private"; +const OTHER_DID = "did:plc:other"; + +beforeAll(() => { + upsertWiki( + SLUG, + DEV_DID, + "Wiki Test Wiki", + "public", + AT_URI, + new Date().toISOString(), + ); + upsertMembership( + SLUG, + DEV_DID, + "admin", + `at://${DEV_DID}/wiki.lichen.membership/wk1`, + new Date().toISOString(), + ); + upsertWiki( + PRIVATE_SLUG, + OTHER_DID, + "Private Wiki", + "private", + `at://${OTHER_DID}/wiki.lichen.wiki/${PRIVATE_SLUG}`, + new Date().toISOString(), + ); +}); + +const extraSlugs: string[] = []; + +afterAll(() => { + cleanupWikiAndDependents(SLUG); + cleanupWikiAndDependents(PRIVATE_SLUG); + for (const s of extraSlugs) cleanupWikiAndDependents(s); +}); + +describe("wiki routes", () => { + test("GET /wiki/:slug returns 200 for public wiki", async () => { + const res = await app.handle(new Request(`http://localhost/wiki/${SLUG}`)); + expect(res.status).toBe(200); + expect(res.headers.get("Content-Type")).toContain("text/html"); + }); + + test("GET /wiki/:slug returns 403 for private wiki (non-member)", async () => { + const res = await app.handle( + new Request(`http://localhost/wiki/${PRIVATE_SLUG}`), + ); + expect(res.status).toBe(403); + }); + + test("GET /wiki/nonexistent returns 404", async () => { + const res = await app.handle( + new Request("http://localhost/wiki/does-not-exist-xyz"), + ); + expect(res.status).toBe(404); + }); + + test("GET /wiki/:slug/-/settings returns 200 for admin", async () => { + const res = await app.handle( + new Request(`http://localhost/wiki/${SLUG}/-/settings`), + ); + expect(res.status).toBe(200); + }); + + test("GET /wiki/:slug/-/settings returns 403 for non-admin", async () => { + const res = await app.handle( + new Request(`http://localhost/wiki/${PRIVATE_SLUG}/-/settings`), + ); + expect(res.status).toBe(403); + }); + + test("POST /wiki/:slug/-/delete rejects wrong confirmation name", async () => { + const formData = new FormData(); + formData.set("confirm", "wrong name"); + const res = await app.handle( + new Request(`http://localhost/wiki/${SLUG}/-/delete`, { + method: "POST", + body: formData, + }), + ); + expect(res.status).toBe(400); + }); + + test("GET /wiki/new returns 200", async () => { + const res = await app.handle(new Request("http://localhost/wiki/new")); + expect(res.status).toBe(200); + }); + + test("POST /wiki/new creates wiki and redirects", async () => { + const formData = new FormData(); + formData.set("name", "Wiki New Test"); + formData.set("language", "en"); + formData.set("visibility", "public"); + formData.set("description", ""); + const res = await app.handle( + new Request("http://localhost/wiki/new", { + method: "POST", + body: formData, + }), + ); + extraSlugs.push("wiki-new-test"); + expect(res.status).toBe(302); + expect(res.headers.get("Location")).toBe("/wiki/wiki-new-test"); + }); + + test("POST /wiki/:slug/-/delete with correct name redirects to /", async () => { + const slug = "wk-delete-target"; + upsertWiki( + slug, + DEV_DID, + "Delete Target", + "public", + `at://${DEV_DID}/wiki.lichen.wiki/${slug}`, + new Date().toISOString(), + ); + + const formData = new FormData(); + formData.set("confirm", "Delete Target"); + const res = await app.handle( + new Request(`http://localhost/wiki/${slug}/-/delete`, { + method: "POST", + body: formData, + }), + ); + expect(res.status).toBe(302); + expect(res.headers.get("Location")).toBe("/"); + }); +}); -- 2.51.2