From 238374a055d3b404c7918cff5aaa51e166b4e1ae Mon Sep 17 00:00:00 2001 From: josh Date: Fri, 20 Mar 2026 19:19:31 -0700 Subject: [PATCH] Add bot tests --- packages/bot/package.json | 5 +- packages/bot/src/index.ts | 16 +- packages/bot/src/shorter-client.ts | 23 ++- packages/bot/test/index.test.ts | 209 +++++++++++++++++++++++ packages/bot/test/shorter-client.test.ts | 81 +++++++++ packages/bot/vitest.config.ts | 7 + 6 files changed, 325 insertions(+), 16 deletions(-) create mode 100644 packages/bot/test/index.test.ts create mode 100644 packages/bot/test/shorter-client.test.ts create mode 100644 packages/bot/vitest.config.ts diff --git a/packages/bot/package.json b/packages/bot/package.json index b40d802..86004c6 100644 --- a/packages/bot/package.json +++ b/packages/bot/package.json @@ -6,6 +6,7 @@ "deploy": "wrangler deploy -e production", "dev": "wrangler dev --port 8787", "start": "bun run dev", + "test": "vitest run", "cf-typegen": "wrangler types", "format": "biome format --write ./src", "lint": "biome lint --write ./src", @@ -19,10 +20,10 @@ "devDependencies": { "@biomejs/biome": "^2.3.12", "@cloudflare/vitest-pool-workers": "^0.12.4", + "@types/bun": "latest", "typescript": "^5.5.2", "vitest": "~3.2.0", - "wrangler": "^4.67.0", - "@types/bun": "latest" + "wrangler": "^4.67.0" }, "dependencies": { "@shorter2/types": "workspace:*", diff --git a/packages/bot/src/index.ts b/packages/bot/src/index.ts index a30662b..d7258e0 100644 --- a/packages/bot/src/index.ts +++ b/packages/bot/src/index.ts @@ -116,10 +116,16 @@ app.post("/", async (c) => { const slug = subcommand.options?.find((opt) => opt.name === "alias") ?.value as string; - await deleteLink(slug, c.env.SHORTER_API_KEY); - return sendChannelMessage( - `Shortlink https://s.acmcsuf.com/${slug} deleted successfully`, - ); + try { + await deleteLink(slug, c.env.SHORTER_API_KEY); + return sendChannelMessage( + `Shortlink https://s.acmcsuf.com/${slug} deleted successfully`, + ); + } catch (error: unknown) { + return sendChannelMessage( + `Failed to delete shortlink: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + } } // ==== Update Subcommand ==================================================================== @@ -161,7 +167,7 @@ app.post("/", async (c) => { `now redirects with HTTP ${resp.isPermanent ? 301 : 302}`, ); return sendChannelMessage( - `Shortlink created: ${parts.join(" and ")}`, + `Shortlink updated: ${parts.join(" and ")}`, ); } catch (error: unknown) { return sendChannelMessage( diff --git a/packages/bot/src/shorter-client.ts b/packages/bot/src/shorter-client.ts index 96e07ac..6dc77e7 100644 --- a/packages/bot/src/shorter-client.ts +++ b/packages/bot/src/shorter-client.ts @@ -1,12 +1,17 @@ import { env } from "cloudflare:workers"; -import type { - CreateLinkDto, - CreateLinkInputDto, - UpdateLinkDto, - UpdateLinkInputDto, -} from "@shorter/service"; +import { ShortlinkModel, ShortlinkUpdateRequest } from "@shorter2/types"; +import type { z } from "zod"; -const endpoint = `${env.SHORTER_ENDPOINT}/links`; +type ShortlinkDto = z.infer; +type CreateLinkInputDto = { + slug?: string; + url: string; + isPermanent?: boolean; +}; +type UpdateLinkInputDto = z.input; +type UpdateLinkDto = Pick; + +const endpoint = `${env.SHORTER_ENDPOINT}/_links`; const setHeaders = (authToken: string) => { return { @@ -18,7 +23,7 @@ const setHeaders = (authToken: string) => { export async function addLink( link: CreateLinkInputDto, authToken: string, -): Promise { +): Promise { const response = await fetch(endpoint, { method: "POST", headers: setHeaders(authToken), @@ -30,7 +35,7 @@ export async function addLink( throw new Error(`HTTP ${response.status}: ${errText}`); } - const data = (await response.json()) as { success: boolean; link: CreateLinkDto }; + const data = (await response.json()) as { success: boolean; link: ShortlinkDto }; return data.link; } diff --git a/packages/bot/test/index.test.ts b/packages/bot/test/index.test.ts new file mode 100644 index 0000000..4b6d2b7 --- /dev/null +++ b/packages/bot/test/index.test.ts @@ -0,0 +1,209 @@ +import { InteractionResponseType, InteractionType } from "discord-interactions"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const verifyKeyMock = vi.fn(); +const addLinkMock = vi.fn(); +const deleteLinkMock = vi.fn(); +const updateLinkMock = vi.fn(); + +vi.mock("discord-interactions", async () => { + const actual = await vi.importActual( + "discord-interactions", + ); + return { + ...actual, + verifyKey: (...args: unknown[]) => verifyKeyMock(...args), + }; +}); + +vi.mock("../src/shorter-client", () => ({ + addLink: (...args: unknown[]) => addLinkMock(...args), + deleteLink: (...args: unknown[]) => deleteLinkMock(...args), + updateLink: (...args: unknown[]) => updateLinkMock(...args), +})); + +const { default: app } = await import("../src/index"); + +function createEnv(overrides: Partial = {}): Env { + return { + DISCORD_PUBLIC_KEY: "public-key", + DISCORD_GUILD_ID: "allowed-guild", + SHORTER_API_KEY: "test-api-key", + SHORTER_ENDPOINT: "https://s.acmcsuf.com", + ...overrides, + } as Env; +} + +function commandBody( + subcommandName: string, + options: Array<{ name: string; value: string | boolean }> = [], + overrides: Record = {}, +) { + return JSON.stringify({ + type: InteractionType.APPLICATION_COMMAND, + guild_id: "allowed-guild", + data: { + name: "shorter2", + options: [ + { + name: subcommandName, + type: 1, + options, + }, + ], + }, + ...overrides, + }); +} + +async function postInteraction(body: string, env: Env = createEnv()) { + return app.request( + "/", + { + method: "POST", + headers: { + "x-signature-ed25519": "signature", + "x-signature-timestamp": "timestamp", + "content-type": "application/json", + }, + body, + }, + env, + ); +} + +beforeEach(() => { + vi.clearAllMocks(); + verifyKeyMock.mockResolvedValue(true); +}); + +describe("bot interaction handler", () => { + it("rejects requests with an invalid Discord signature", async () => { + verifyKeyMock.mockResolvedValue(false); + + const response = await postInteraction(JSON.stringify({ type: InteractionType.PING })); + + expect(response.status).toBe(401); + expect(await response.text()).toBe("Bad request signature."); + }); + + it("responds to Discord ping interactions", async () => { + const response = await postInteraction( + JSON.stringify({ type: InteractionType.PING }), + ); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ + type: InteractionResponseType.PONG, + }); + }); + + it("blocks commands from the wrong guild before mutating resources", async () => { + const response = await postInteraction( + commandBody("add"), + createEnv({ DISCORD_GUILD_ID: "different-guild" }), + ); + + expect(addLinkMock).not.toHaveBeenCalled(); + await expect(response.json()).resolves.toEqual({ + type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, + data: { + content: "Error: resource cannot be modified from this Discord server", + }, + }); + }); + + it("rejects invalid add destinations without calling the service", async () => { + const response = await postInteraction( + commandBody("add", [{ name: "destination", value: "ftp://example.com" }]), + ); + + expect(addLinkMock).not.toHaveBeenCalled(); + await expect(response.json()).resolves.toEqual({ + type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, + data: { + content: + "Error: invalid URL. Does the destination URL start with `http://` or `https://`?", + }, + }); + }); + + it("surfaces service failures when creating links", async () => { + addLinkMock.mockRejectedValue(new Error("HTTP 409: duplicate slug")); + + const response = await postInteraction( + commandBody("add", [ + { name: "destination", value: "https://example.com" }, + { name: "alias", value: "example" }, + ]), + ); + + expect(addLinkMock).toHaveBeenCalledWith( + { + slug: "example", + url: "https://example.com", + isPermanent: undefined, + }, + "test-api-key", + ); + await expect(response.json()).resolves.toEqual({ + type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, + data: { + content: "Failed to create shortlink: HTTP 409: duplicate slug", + }, + }); + }); + + it("rejects update commands that do not include any changes", async () => { + const response = await postInteraction( + commandBody("update", [{ name: "alias", value: "example" }]), + ); + + expect(updateLinkMock).not.toHaveBeenCalled(); + await expect(response.json()).resolves.toEqual({ + type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, + data: { + content: "Error: no modifications to shortlink provided", + }, + }); + }); + + it("reports delete failures as channel messages instead of throwing", async () => { + deleteLinkMock.mockRejectedValue(new Error("HTTP 404: missing")); + + const response = await postInteraction( + commandBody("delete", [{ name: "alias", value: "missing" }]), + ); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ + type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, + data: { + content: "Failed to delete shortlink: HTTP 404: missing", + }, + }); + }); + + it("describes update results with the updated redirect mode", async () => { + updateLinkMock.mockResolvedValue({ + url: "https://example.com/new", + isPermanent: true, + }); + + const response = await postInteraction( + commandBody("update", [ + { name: "alias", value: "example" }, + { name: "destination", value: "https://example.com/new" }, + { name: "is_permanent", value: true }, + ]), + ); + + await expect(response.json()).resolves.toEqual({ + type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, + data: { + content: + "Shortlink updated: https://s.acmcsuf.com/example -> https://example.com/new and now redirects with HTTP 301", + }, + }); + }); +}); diff --git a/packages/bot/test/shorter-client.test.ts b/packages/bot/test/shorter-client.test.ts new file mode 100644 index 0000000..829dbb1 --- /dev/null +++ b/packages/bot/test/shorter-client.test.ts @@ -0,0 +1,81 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const fetchMock = vi.fn(); + +vi.mock("cloudflare:workers", () => ({ + env: { + SHORTER_ENDPOINT: "https://s.acmcsuf.com", + }, +})); + +const { addLink, deleteLink, updateLink } = await import("../src/shorter-client"); + +beforeEach(() => { + fetchMock.mockReset(); + vi.stubGlobal("fetch", fetchMock); +}); + +describe("shorter client", () => { + it("posts new links to the protected service endpoint", async () => { + fetchMock.mockResolvedValue( + new Response( + JSON.stringify({ + success: true, + link: { + slug: "example", + url: "https://example.com", + isPermanent: false, + }, + }), + { status: 200 }, + ), + ); + + const link = await addLink( + { slug: "example", url: "https://example.com" }, + "api-key", + ); + + expect(fetchMock).toHaveBeenCalledWith("https://s.acmcsuf.com/_links", { + method: "POST", + headers: { + Authorization: "Bearer api-key", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + slug: "example", + url: "https://example.com", + }), + }); + expect(link.slug).toBe("example"); + }); + + it("uses the protected slug route for deletes", async () => { + fetchMock.mockResolvedValue(new Response(null, { status: 204 })); + + await deleteLink("example", "api-key"); + + expect(fetchMock).toHaveBeenCalledWith( + "https://s.acmcsuf.com/_links/example", + { + method: "DELETE", + headers: { + Authorization: "Bearer api-key", + "Content-Type": "application/json", + }, + }, + ); + }); + + it("throws the upstream status and body when updates fail", async () => { + fetchMock.mockResolvedValue( + new Response("not found", { + status: 404, + }), + ); + + await expect( + updateLink("missing", { url: "https://example.com/new" }, "api-key"), + ).rejects.toThrow("HTTP 404: not found"); + }); +}); diff --git a/packages/bot/vitest.config.ts b/packages/bot/vitest.config.ts new file mode 100644 index 0000000..c931b88 --- /dev/null +++ b/packages/bot/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["test/**/*.test.ts"], + }, +});