diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 80600ae5..c1398b7d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,5 +29,9 @@ jobs: - name: Run TypeScript type checking run: bunx turbo run check-types --filter='@supermemory/ai-sdk' --filter='@supermemory/memory-graph' + - name: Run web unit tests + working-directory: apps/web + run: bun test + - name: Run Biome CI (format & lint on changed files) run: bunx biome ci --changed --since=origin/main --no-errors-on-unmatched diff --git a/apps/web/components/integrations-view.tsx b/apps/web/components/integrations-view.tsx index 5a37af45..8a16adcd 100644 --- a/apps/web/components/integrations-view.tsx +++ b/apps/web/components/integrations-view.tsx @@ -55,6 +55,7 @@ import { useViewMode } from "@/lib/view-mode-context" import type { ViewParamValue } from "@/lib/search-params" import { parseAsString, parseAsStringEnum, useQueryState } from "nuqs" import { addDocumentParam, docParam } from "@/lib/search-params" +import { revokePluginKey } from "@/components/integrations/plugin-key-revocation" import { useCallback, useEffect, @@ -2971,9 +2972,11 @@ export function IntegrationsView({ const handleRevokePluginKey = async (keyId: string) => { try { - await authClient.apiKey.delete({ keyId }) - toast.success("Plugin disconnected") - refetchKeys() + await revokePluginKey({ + deleteKey: () => authClient.apiKey.delete({ keyId }), + onSuccess: () => toast.success("Plugin disconnected"), + refetch: () => void refetchKeys(), + }) } catch { toast.error("Failed to disconnect plugin") } diff --git a/apps/web/components/integrations/plugin-key-revocation.test.ts b/apps/web/components/integrations/plugin-key-revocation.test.ts new file mode 100644 index 00000000..cb7bd28f --- /dev/null +++ b/apps/web/components/integrations/plugin-key-revocation.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it, mock } from "bun:test" +import { readFile } from "node:fs/promises" +import { revokePluginKey } from "./plugin-key-revocation" + +describe("revokePluginKey", () => { + it("does not report success or refetch when Better Auth resolves an error", async () => { + const onSuccess = mock(() => {}) + const refetch = mock(() => {}) + + await expect( + revokePluginKey({ + deleteKey: async () => ({ + data: null, + error: { message: "API key not found" }, + }), + onSuccess, + refetch, + }), + ).rejects.toThrow("API key not found") + expect(onSuccess).not.toHaveBeenCalled() + expect(refetch).not.toHaveBeenCalled() + }) + + it("does not report success or refetch when deletion rejects", async () => { + const onSuccess = mock(() => {}) + const refetch = mock(() => {}) + const deleteError = new Error("Network unavailable") + + await expect( + revokePluginKey({ + deleteKey: async () => { + throw deleteError + }, + onSuccess, + refetch, + }), + ).rejects.toBe(deleteError) + expect(onSuccess).not.toHaveBeenCalled() + expect(refetch).not.toHaveBeenCalled() + }) + + it("uses a fallback message when Better Auth omits one", async () => { + const onSuccess = mock(() => {}) + const refetch = mock(() => {}) + + await expect( + revokePluginKey({ + deleteKey: async () => ({ data: null, error: {} }), + onSuccess, + refetch, + }), + ).rejects.toThrow("Failed to disconnect plugin") + expect(onSuccess).not.toHaveBeenCalled() + expect(refetch).not.toHaveBeenCalled() + }) + + it("reports success and refetches after Better Auth resolves success", async () => { + const onSuccess = mock(() => {}) + const refetch = mock(() => {}) + + await revokePluginKey({ + deleteKey: async () => ({ + data: { success: true }, + error: null, + }), + onSuccess, + refetch, + }) + + expect(onSuccess).toHaveBeenCalledTimes(1) + expect(refetch).toHaveBeenCalledTimes(1) + }) +}) + +describe("plugin revoke handler wiring", () => { + const handlers = [ + { + name: "integrations view", + file: new URL("../integrations-view.tsx", import.meta.url), + handler: "handleRevokePluginKey", + }, + { + name: "plugin detail", + file: new URL("./plugins-detail.tsx", import.meta.url), + handler: "handleRevoke", + }, + ] + + for (const { name, file, handler } of handlers) { + it(`${name} delegates revocation through the guarded helper`, async () => { + const source = await readFile(file, "utf8") + const handlerStart = source.indexOf(`const ${handler} = async`) + const handlerSource = source.slice(handlerStart, handlerStart + 500) + + expect(handlerStart).toBeGreaterThanOrEqual(0) + expect(handlerSource).toContain("await revokePluginKey({") + expect(handlerSource).toMatch( + /deleteKey:\s*\(\)\s*=>\s*authClient\.apiKey\.delete\(\{\s*keyId\s*\}\)/, + ) + expect( + handlerSource.match(/authClient\.apiKey\.delete/g) ?? [], + ).toHaveLength(1) + expect(handlerSource).toContain("onSuccess:") + expect(handlerSource).toContain("refetch:") + }) + } +}) diff --git a/apps/web/components/integrations/plugin-key-revocation.ts b/apps/web/components/integrations/plugin-key-revocation.ts new file mode 100644 index 00000000..19797d3b --- /dev/null +++ b/apps/web/components/integrations/plugin-key-revocation.ts @@ -0,0 +1,24 @@ +type PluginKeyDeleteResult = { + data: unknown + error: { message?: string } | null +} + +type RevokePluginKeyOptions = { + deleteKey: () => Promise + onSuccess: () => void + refetch: () => void +} + +export async function revokePluginKey({ + deleteKey, + onSuccess, + refetch, +}: RevokePluginKeyOptions): Promise { + const result = await deleteKey() + if (result.error) { + throw new Error(result.error.message ?? "Failed to disconnect plugin") + } + + onSuccess() + refetch() +} diff --git a/apps/web/components/integrations/plugins-detail.tsx b/apps/web/components/integrations/plugins-detail.tsx index cfcba481..a7d89980 100644 --- a/apps/web/components/integrations/plugins-detail.tsx +++ b/apps/web/components/integrations/plugins-detail.tsx @@ -30,6 +30,7 @@ import { type PluginInfo, } from "@/lib/plugin-catalog" import { INSET, InstallSteps, PillButton } from "./install-steps" +import { revokePluginKey } from "./plugin-key-revocation" import { usePromoCode } from "@/hooks/use-promo-code" interface ConnectedPlugin { @@ -560,9 +561,11 @@ export function PluginsDetail() { const handleRevoke = async (keyId: string) => { try { - await authClient.apiKey.delete({ keyId }) - toast.success("Plugin disconnected") - refetchKeys() + await revokePluginKey({ + deleteKey: () => authClient.apiKey.delete({ keyId }), + onSuccess: () => toast.success("Plugin disconnected"), + refetch: () => void refetchKeys(), + }) } catch { toast.error("Failed to disconnect plugin") }