diff --git a/db/migrations/0002_saved_search.sql b/db/migrations/0002_saved_search.sql new file mode 100644 index 0000000..f1ba166 --- /dev/null +++ b/db/migrations/0002_saved_search.sql @@ -0,0 +1,14 @@ +CREATE TABLE IF NOT EXISTS "saved_search" ( + "id" text PRIMARY KEY NOT NULL, + "user_id" text NOT NULL, + "name" text NOT NULL, + "tech" text NOT NULL, + "label" text NOT NULL, + "sort" text NOT NULL, + "linked_pr" text NOT NULL, + "hacktoberfest" text NOT NULL, + "created_at" integer NOT NULL, + FOREIGN KEY ("user_id") REFERENCES "user"("id") ON UPDATE no action ON DELETE cascade +); + +CREATE INDEX IF NOT EXISTS "saved_search_userId_idx" ON "saved_search" ("user_id"); diff --git a/public/openissue-logo.png b/public/openissue-logo.png new file mode 100644 index 0000000..3f4850c Binary files /dev/null and b/public/openissue-logo.png differ diff --git a/sonar-project.properties b/sonar-project.properties index b27e3a8..5172db2 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -4,5 +4,5 @@ sonar.sources=src sonar.tests=tests sonar.test.inclusions=tests/**/*.test.ts sonar.javascript.lcov.reportPaths=coverage/lcov.info -sonar.coverage.exclusions=src/components/ui/**,src/app/layout.tsx,src/app/page.tsx,src/components/theme-provider.tsx,src/app/api/auth/**,src/lib/auth-schema.ts,src/lib/auth.ts,src/features/issues/types/**,src/features/issues/data/** +sonar.coverage.exclusions=src/components/ui/**,src/app/layout.tsx,src/app/page.tsx,src/components/theme-provider.tsx,src/app/api/auth/**,src/lib/auth-schema.ts,src/lib/auth.ts,src/lib/auth-client.ts,src/features/issues/types/**,src/features/issues/data/** sonar.sourceEncoding=UTF-8 diff --git a/src/app/api/saved-searches/[id]/route.ts b/src/app/api/saved-searches/[id]/route.ts new file mode 100644 index 0000000..dbdb157 --- /dev/null +++ b/src/app/api/saved-searches/[id]/route.ts @@ -0,0 +1,25 @@ +import { and, eq } from "drizzle-orm"; +import { auth } from "@/lib/auth"; +import { savedSearch } from "@/lib/auth-schema"; +import { getDatabase } from "@/lib/db"; + +export async function DELETE( + request: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const session = await auth.api.getSession({ headers: request.headers }); + + if (!session) { + return Response.json({ error: "Unauthorized." }, { status: 401 }); + } + + const { id } = await params; + + await getDatabase() + .delete(savedSearch) + .where( + and(eq(savedSearch.id, id), eq(savedSearch.userId, session.user.id)), + ); + + return new Response(null, { status: 204 }); +} diff --git a/src/app/api/saved-searches/route.ts b/src/app/api/saved-searches/route.ts new file mode 100644 index 0000000..5bdf424 --- /dev/null +++ b/src/app/api/saved-searches/route.ts @@ -0,0 +1,87 @@ +import { asc, eq } from "drizzle-orm"; +import { auth } from "@/lib/auth"; +import { savedSearch } from "@/lib/auth-schema"; +import { getDatabase } from "@/lib/db"; +import { + isValidSavedSearch, + type SavedSearch, +} from "@/features/issues/lib/saved-searches"; + +const MAX_SAVED_SEARCHES_PER_SYNC = 100; +const MAX_TEXT_LENGTH = 200; + +function isSafeSavedSearch(value: unknown): value is SavedSearch { + if (!isValidSavedSearch(value)) return false; + + return ( + value.id.length > 0 && + value.id.length <= MAX_TEXT_LENGTH && + value.name.length > 0 && + value.name.length <= MAX_TEXT_LENGTH && + value.tech.length > 0 && + value.tech.length <= MAX_TEXT_LENGTH && + !Number.isNaN(Date.parse(value.createdAt)) + ); +} + +async function listSavedSearches(userId: string): Promise { + const rows = await getDatabase() + .select() + .from(savedSearch) + .where(eq(savedSearch.userId, userId)) + .orderBy(asc(savedSearch.createdAt)); + + return rows.map((row) => ({ + id: row.id, + name: row.name, + tech: row.tech, + label: row.label, + sort: row.sort, + linkedPr: row.linkedPr, + hacktoberfest: row.hacktoberfest, + createdAt: row.createdAt.toISOString(), + })); +} + +export async function POST(request: Request) { + const session = await auth.api.getSession({ headers: request.headers }); + + if (!session) { + return Response.json({ error: "Unauthorized." }, { status: 401 }); + } + + let body: unknown; + + try { + body = await request.json(); + } catch { + return Response.json({ error: "Invalid request body." }, { status: 400 }); + } + + const searches = (body as { searches?: unknown } | null)?.searches; + + if ( + !Array.isArray(searches) || + searches.length > MAX_SAVED_SEARCHES_PER_SYNC || + !searches.every(isSafeSavedSearch) + ) { + return Response.json({ error: "Invalid saved searches." }, { status: 400 }); + } + + const database = getDatabase(); + + for (const search of searches) { + await database + .insert(savedSearch) + .values({ + ...search, + userId: session.user.id, + createdAt: new Date(search.createdAt), + }) + .onConflictDoNothing(); + } + + return Response.json({ + searches: await listSavedSearches(session.user.id), + }); +} diff --git a/src/app/apple-icon.png b/src/app/apple-icon.png new file mode 100644 index 0000000..15d5079 Binary files /dev/null and b/src/app/apple-icon.png differ diff --git a/src/app/favicon.ico b/src/app/favicon.ico deleted file mode 100644 index 718d6fe..0000000 Binary files a/src/app/favicon.ico and /dev/null differ diff --git a/src/app/icon.png b/src/app/icon.png new file mode 100644 index 0000000..7db31d6 Binary files /dev/null and b/src/app/icon.png differ diff --git a/src/features/issues/components/issue-finder.tsx b/src/features/issues/components/issue-finder.tsx index 5d16e86..d567d1e 100644 --- a/src/features/issues/components/issue-finder.tsx +++ b/src/features/issues/components/issue-finder.tsx @@ -1,7 +1,8 @@ "use client"; import { FormEvent, useEffect, useMemo, useState } from "react"; -import { Bookmark, GitPullRequest, Search, Trash2 } from "lucide-react"; +import Image from "next/image"; +import { Bookmark, Search, Trash2 } from "lucide-react"; import { ThemeToggle } from "@/components/theme-toggle"; import { AuthControls } from "@/components/auth-controls"; import { Badge } from "@/components/ui/badge"; @@ -10,8 +11,13 @@ import { addSavedSearch, deleteSavedSearch, getSavedSearches, + replaceSavedSearches, type SavedSearch, } from "@/features/issues/lib/saved-searches"; +import { + deleteCloudSavedSearch, + syncSavedSearches, +} from "@/features/issues/lib/saved-search-cloud"; import { Card, CardContent, @@ -40,8 +46,10 @@ import { import { compactNumber } from "@/features/issues/lib/format"; import { mergeRankedIssues, rankIssues } from "@/features/issues/lib/ranking"; import type { SearchResponse, Issue } from "@/features/issues/types/search"; +import { authClient } from "@/lib/auth-client"; export function IssueFinder() { + const { data: session, isPending: isSessionPending } = authClient.useSession(); const [tech, setTech] = useState("Java"); const [label, setLabel] = useState("help-wanted"); const [sort, setSort] = useState("updated"); @@ -59,9 +67,36 @@ export function IssueFinder() { const [savedSearchName, setSavedSearchName] = useState(""); useEffect(() => { + // Hydration must start with the server's empty snapshot before reading browser storage. + // eslint-disable-next-line react-hooks/set-state-in-effect setSavedSearches(getSavedSearches()); }, []); + useEffect(() => { + if (isSessionPending || !session?.user.id) return; + + let cancelled = false; + + async function syncWithAccount() { + try { + const syncedSearches = await syncSavedSearches(getSavedSearches()); + + if (!cancelled) { + replaceSavedSearches(syncedSearches); + setSavedSearches(syncedSearches); + } + } catch { + // Local saved searches remain available if account sync is unavailable. + } + } + + void syncWithAccount(); + + return () => { + cancelled = true; + }; + }, [isSessionPending, session?.user.id]); + const selectedLabel = useMemo( () => LABEL_OPTIONS.find((item) => item.value === label) ?? LABEL_OPTIONS[0], @@ -114,18 +149,43 @@ export function IssueFinder() { setSavedSearches((current) => [...current, savedSearch]); setSavedSearchName(""); setError(null); - }catch (saveError) { + + if (session?.user.id) { + void syncSavedSearches(getSavedSearches()) + .then((syncedSearches) => { + replaceSavedSearches(syncedSearches); + setSavedSearches(syncedSearches); + }) + .catch(() => { + setError("Search saved locally, but account sync failed."); + }); + } + } catch (saveError) { setError( saveError instanceof Error - ? saveError.message - : "Unable to save search.", + ? saveError.message + : "Unable to save search.", ); } } - function handleDeleteSavedSearch(id: string) { + async function handleDeleteSavedSearch(id: string) { + if (session?.user.id) { + try { + await deleteCloudSavedSearch(id); + } catch (deleteError) { + setError( + deleteError instanceof Error + ? deleteError.message + : "Unable to remove the saved search from your account.", + ); + return; + } + } + deleteSavedSearch(id); setSavedSearches(getSavedSearches()); + setError(null); } function handleRunSavedSearch(savedSearch: SavedSearch) { @@ -252,7 +312,13 @@ export function IssueFinder() {
- + OSS Issue Finder GitHub Search API @@ -513,9 +579,9 @@ export function IssueFinder() { variant="outline" size="sm" aria-label={`Delete ${savedSearch.name}`} - onClick={() => - handleDeleteSavedSearch(savedSearch.id) - } + onClick={() => { + void handleDeleteSavedSearch(savedSearch.id); + }} > diff --git a/src/features/issues/lib/saved-search-cloud.ts b/src/features/issues/lib/saved-search-cloud.ts new file mode 100644 index 0000000..70e465e --- /dev/null +++ b/src/features/issues/lib/saved-search-cloud.ts @@ -0,0 +1,46 @@ +import type { SavedSearch } from "@/features/issues/lib/saved-searches"; + +const MAX_SEARCHES_PER_REQUEST = 100; + +export async function syncSavedSearches( + searches: SavedSearch[], +): Promise { + const batches = searches.length + ? Array.from( + { length: Math.ceil(searches.length / MAX_SEARCHES_PER_REQUEST) }, + (_, index) => + searches.slice( + index * MAX_SEARCHES_PER_REQUEST, + (index + 1) * MAX_SEARCHES_PER_REQUEST, + ), + ) + : [[]]; + let syncedSearches: SavedSearch[] = []; + + for (const batch of batches) { + const response = await fetch("/api/saved-searches", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ searches: batch }), + }); + + if (!response.ok) { + throw new Error("Unable to sync saved searches."); + } + + const result = (await response.json()) as { searches: SavedSearch[] }; + syncedSearches = result.searches; + } + + return syncedSearches; +} + +export async function deleteCloudSavedSearch(id: string): Promise { + const response = await fetch(`/api/saved-searches/${encodeURIComponent(id)}`, { + method: "DELETE", + }); + + if (!response.ok) { + throw new Error("Unable to remove the saved search from your account."); + } +} diff --git a/src/features/issues/lib/saved-searches.ts b/src/features/issues/lib/saved-searches.ts index 3b6dd9c..3eb1517 100644 --- a/src/features/issues/lib/saved-searches.ts +++ b/src/features/issues/lib/saved-searches.ts @@ -18,7 +18,7 @@ export type SavedSearch = { const STORAGE_KEY = "openissue:saved-searches"; -function isValidSavedSearch(value: unknown): value is SavedSearch { +export function isValidSavedSearch(value: unknown): value is SavedSearch { if (!value || typeof value !== "object") { return false; } @@ -79,6 +79,12 @@ function saveSavedSearches(searches: SavedSearch[]): boolean { } } +export function replaceSavedSearches(searches: SavedSearch[]): void { + if (!saveSavedSearches(searches)) { + throw new Error("Unable to update saved searches."); + } +} + let fallbackIdCounter = 0; function createSavedSearchId(): string { diff --git a/src/lib/auth-schema.ts b/src/lib/auth-schema.ts index 7eb8d29..667689f 100644 --- a/src/lib/auth-schema.ts +++ b/src/lib/auth-schema.ts @@ -100,9 +100,28 @@ export const verification = sqliteTable( (table) => [index("verification_identifier_idx").on(table.identifier)], ); +export const savedSearch = sqliteTable( + "saved_search", + { + id: text("id").primaryKey(), + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + name: text("name").notNull(), + tech: text("tech").notNull(), + label: text("label").notNull(), + sort: text("sort").notNull(), + linkedPr: text("linked_pr").notNull(), + hacktoberfest: text("hacktoberfest").notNull(), + createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(), + }, + (table) => [index("saved_search_userId_idx").on(table.userId)], +); + export const userRelations = relations(user, ({ many }) => ({ sessions: many(session), accounts: many(account), + savedSearches: many(savedSearch), })); export const sessionRelations = relations(session, ({ one }) => ({ @@ -118,3 +137,10 @@ export const accountRelations = relations(account, ({ one }) => ({ references: [user.id], }), })); + +export const savedSearchRelations = relations(savedSearch, ({ one }) => ({ + user: one(user, { + fields: [savedSearch.userId], + references: [user.id], + }), +})); diff --git a/tests/app/api/saved-searches/route.test.ts b/tests/app/api/saved-searches/route.test.ts new file mode 100644 index 0000000..327fc2d --- /dev/null +++ b/tests/app/api/saved-searches/route.test.ts @@ -0,0 +1,151 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { + getSession, + insert, + values, + onConflictDoNothing, + select, + from, + selectWhere, + orderBy, + deleteFromDatabase, + deleteWhere, +} = vi.hoisted(() => ({ + getSession: vi.fn(), + insert: vi.fn(), + values: vi.fn(), + onConflictDoNothing: vi.fn(), + select: vi.fn(), + from: vi.fn(), + selectWhere: vi.fn(), + orderBy: vi.fn(), + deleteFromDatabase: vi.fn(), + deleteWhere: vi.fn(), +})); + +vi.mock("server-only", () => ({})); +vi.mock("@/lib/auth", () => ({ auth: { api: { getSession } } })); +vi.mock("@/lib/db", () => ({ + getDatabase: () => ({ + insert, + select, + delete: deleteFromDatabase, + }), +})); + +import { POST } from "@/app/api/saved-searches/route"; +import { DELETE } from "@/app/api/saved-searches/[id]/route"; + +const savedSearch = { + id: "saved-1", + name: "React help", + tech: "React", + label: "help-wanted", + sort: "updated", + linkedPr: "any", + hacktoberfest: "any", + createdAt: "2026-08-19T00:00:00.000Z", +}; + +describe("saved searches API", () => { + beforeEach(() => { + vi.clearAllMocks(); + getSession.mockResolvedValue({ user: { id: "user-1" } }); + insert.mockReturnValue({ values }); + values.mockReturnValue({ onConflictDoNothing }); + onConflictDoNothing.mockResolvedValue(undefined); + select.mockReturnValue({ from }); + from.mockReturnValue({ where: selectWhere }); + selectWhere.mockReturnValue({ orderBy }); + orderBy.mockResolvedValue([ + { ...savedSearch, userId: "user-1", createdAt: new Date(savedSearch.createdAt) }, + ]); + deleteFromDatabase.mockReturnValue({ where: deleteWhere }); + deleteWhere.mockResolvedValue(undefined); + }); + + it("requires authentication for syncing and deleting", async () => { + getSession.mockResolvedValue(null); + + const syncResponse = await POST( + new Request("http://localhost/api/saved-searches", { + method: "POST", + body: JSON.stringify({ searches: [] }), + }), + ); + const deleteResponse = await DELETE( + new Request("http://localhost/api/saved-searches/saved-1", { + method: "DELETE", + }), + { params: Promise.resolve({ id: "saved-1" }) }, + ); + + expect(syncResponse.status).toBe(401); + expect(deleteResponse.status).toBe(401); + }); + + it("rejects malformed or unsafe sync payloads", async () => { + const invalidJson = await POST( + new Request("http://localhost/api/saved-searches", { + method: "POST", + body: "not-json", + }), + ); + const invalidSearch = await POST( + new Request("http://localhost/api/saved-searches", { + method: "POST", + body: JSON.stringify({ searches: [{ ...savedSearch, createdAt: "invalid" }] }), + }), + ); + const malformedSearch = await POST( + new Request("http://localhost/api/saved-searches", { + method: "POST", + body: JSON.stringify({ searches: [{}] }), + }), + ); + const oversizedBatch = await POST( + new Request("http://localhost/api/saved-searches", { + method: "POST", + body: JSON.stringify({ searches: Array(101).fill(savedSearch) }), + }), + ); + + expect(invalidJson.status).toBe(400); + expect(invalidSearch.status).toBe(400); + expect(malformedSearch.status).toBe(400); + expect(oversizedBatch.status).toBe(400); + expect(insert).not.toHaveBeenCalled(); + }); + + it("migrates local searches and returns all searches for the user", async () => { + const response = await POST( + new Request("http://localhost/api/saved-searches", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ searches: [savedSearch] }), + }), + ); + + expect(response.status).toBe(200); + expect(values).toHaveBeenCalledWith({ + ...savedSearch, + userId: "user-1", + createdAt: new Date(savedSearch.createdAt), + }); + expect(await response.json()).toEqual({ searches: [savedSearch] }); + }); + + it("deletes only the signed-in user's record", async () => { + const response = await DELETE( + new Request("http://localhost/api/saved-searches/saved-1", { + method: "DELETE", + }), + { params: Promise.resolve({ id: "saved-1" }) }, + ); + + expect(response.status).toBe(204); + expect(deleteFromDatabase).toHaveBeenCalledOnce(); + expect(deleteWhere).toHaveBeenCalledOnce(); + }); +}); diff --git a/tests/features/issues/components/issue-finder.test.tsx b/tests/features/issues/components/issue-finder.test.tsx index b4bc637..6457eec 100644 --- a/tests/features/issues/components/issue-finder.test.tsx +++ b/tests/features/issues/components/issue-finder.test.tsx @@ -4,16 +4,42 @@ import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/re import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { Issue, SearchResponse } from "@/features/issues/types/search"; -const { addSavedSearch, deleteSavedSearch, getSavedSearches } = vi.hoisted(() => ({ +const { + addSavedSearch, + deleteSavedSearch, + getSavedSearches, + replaceSavedSearches, + syncSavedSearches, + deleteCloudSavedSearch, + useSession, +} = vi.hoisted(() => ({ addSavedSearch: vi.fn(), deleteSavedSearch: vi.fn(), getSavedSearches: vi.fn(), + replaceSavedSearches: vi.fn(), + syncSavedSearches: vi.fn(), + deleteCloudSavedSearch: vi.fn(), + useSession: vi.fn(), })); vi.mock("@/features/issues/lib/saved-searches", () => ({ addSavedSearch, deleteSavedSearch, getSavedSearches, + replaceSavedSearches, +})); + +vi.mock("@/features/issues/lib/saved-search-cloud", () => ({ + syncSavedSearches, + deleteCloudSavedSearch, +})); + +vi.mock("@/lib/auth-client", () => ({ + authClient: { + useSession, + signIn: { social: vi.fn() }, + signOut: vi.fn(), + }, })); vi.mock("@/components/theme-toggle", () => ({ @@ -74,6 +100,10 @@ beforeEach(() => { getSavedSearches.mockReset().mockReturnValue([]); addSavedSearch.mockReset(); deleteSavedSearch.mockReset(); + replaceSavedSearches.mockReset(); + syncSavedSearches.mockReset().mockResolvedValue([]); + deleteCloudSavedSearch.mockReset().mockResolvedValue(undefined); + useSession.mockReset().mockReturnValue({ data: null, isPending: false }); vi.stubGlobal("fetch", vi.fn()); }); @@ -83,6 +113,30 @@ afterEach(() => { }); describe("IssueFinder", () => { + it("restores and caches account searches after sign-in", async () => { + const saved = { + id: "saved-cloud", + name: "Cloud search", + tech: "Go", + label: "bug", + sort: "created", + linkedPr: "any", + hacktoberfest: "any", + createdAt: "2026-08-19T00:00:00.000Z", + }; + useSession.mockReturnValue({ + data: { user: { id: "user-1", name: "Octo Cat" } }, + isPending: false, + }); + syncSavedSearches.mockResolvedValue([saved]); + + render(); + + expect(await screen.findByText("Cloud search")).toBeTruthy(); + expect(syncSavedSearches).toHaveBeenCalledWith([]); + expect(replaceSavedSearches).toHaveBeenCalledWith([saved]); + }); + it("validates and manages saved searches", async () => { const saved = { id: "saved-1", @@ -125,6 +179,135 @@ describe("IssueFinder", () => { expect(screen.getByText("Storage unavailable")).toBeTruthy(); }); + it("keeps an authenticated save locally when account sync fails", async () => { + useSession.mockReturnValue({ + data: { user: { id: "user-1", name: "Octo Cat" } }, + isPending: false, + }); + const saved = { + id: "saved-1", + name: "Java", + tech: "Java", + label: "help-wanted", + sort: "updated", + linkedPr: "any", + hacktoberfest: "any", + createdAt: "2026-08-19T00:00:00.000Z", + }; + addSavedSearch.mockReturnValue(saved); + syncSavedSearches + .mockResolvedValueOnce([]) + .mockRejectedValueOnce(new Error("offline")); + + render(); + fireEvent.change(screen.getByLabelText("Saved search name"), { + target: { value: "Java" }, + }); + fireEvent.click(screen.getByRole("button", { name: /save current search/i })); + + expect( + await screen.findByText("Search saved locally, but account sync failed."), + ).toBeTruthy(); + }); + + it("syncs the complete local collection after an authenticated save", async () => { + useSession.mockReturnValue({ + data: { user: { id: "user-1", name: "Octo Cat" } }, + isPending: false, + }); + const olderSearch = { + id: "saved-older", + name: "Older local search", + tech: "Rust", + label: "bug", + sort: "updated", + linkedPr: "any", + hacktoberfest: "any", + createdAt: "2026-08-18T00:00:00.000Z", + }; + const saved = { + ...olderSearch, + id: "saved-new", + name: "Java", + tech: "Java", + createdAt: "2026-08-19T00:00:00.000Z", + }; + addSavedSearch.mockReturnValue(saved); + getSavedSearches + .mockReturnValueOnce([olderSearch]) + .mockReturnValueOnce([olderSearch]) + .mockReturnValue([olderSearch, saved]); + syncSavedSearches + .mockResolvedValueOnce([olderSearch]) + .mockResolvedValueOnce([olderSearch, saved]); + + render(); + await waitFor(() => expect(syncSavedSearches).toHaveBeenCalledTimes(1)); + fireEvent.change(screen.getByLabelText("Saved search name"), { + target: { value: "Java" }, + }); + fireEvent.click(screen.getByRole("button", { name: /save current search/i })); + + await waitFor(() => + expect(syncSavedSearches).toHaveBeenLastCalledWith([olderSearch, saved]), + ); + expect(replaceSavedSearches).toHaveBeenLastCalledWith([ + olderSearch, + saved, + ]); + }); + + it("removes an authenticated search from cloud and local storage", async () => { + const saved = { + id: "saved-1", + name: "React bugs", + tech: "React", + label: "bug", + sort: "created", + linkedPr: "no", + hacktoberfest: "only", + createdAt: "2026-08-19T00:00:00.000Z", + }; + useSession.mockReturnValue({ + data: { user: { id: "user-1", name: "Octo Cat" } }, + isPending: false, + }); + getSavedSearches.mockReturnValue([saved]); + syncSavedSearches.mockResolvedValue([saved]); + + render(); + fireEvent.click(await screen.findByRole("button", { name: "Delete React bugs" })); + + await waitFor(() => expect(deleteCloudSavedSearch).toHaveBeenCalledWith("saved-1")); + expect(deleteSavedSearch).toHaveBeenCalledWith("saved-1"); + }); + + it("keeps a saved search locally when account deletion fails", async () => { + const saved = { + id: "saved-1", + name: "React bugs", + tech: "React", + label: "bug", + sort: "created", + linkedPr: "no", + hacktoberfest: "only", + createdAt: "2026-08-19T00:00:00.000Z", + }; + useSession.mockReturnValue({ + data: { user: { id: "user-1", name: "Octo Cat" } }, + isPending: false, + }); + getSavedSearches.mockReturnValue([saved]); + syncSavedSearches.mockResolvedValue([saved]); + deleteCloudSavedSearch.mockRejectedValue(new Error("Cloud unavailable")); + + render(); + fireEvent.click(await screen.findByRole("button", { name: "Delete React bugs" })); + + expect(await screen.findByText("Cloud unavailable")).toBeTruthy(); + expect(deleteSavedSearch).not.toHaveBeenCalled(); + }); + it("searches, ranks, and loads another page", async () => { const fetchMock = vi.mocked(fetch); fetchMock diff --git a/tests/features/issues/lib/saved-search-cloud.test.ts b/tests/features/issues/lib/saved-search-cloud.test.ts new file mode 100644 index 0000000..05d4d0b --- /dev/null +++ b/tests/features/issues/lib/saved-search-cloud.test.ts @@ -0,0 +1,82 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + deleteCloudSavedSearch, + syncSavedSearches, +} from "@/features/issues/lib/saved-search-cloud"; + +const search = { + id: "saved-1", + name: "React help", + tech: "React", + label: "help-wanted", + sort: "updated", + linkedPr: "any", + hacktoberfest: "any", + createdAt: "2026-08-19T00:00:00.000Z", +}; + +describe("saved search cloud client", () => { + beforeEach(() => { + vi.stubGlobal("fetch", vi.fn()); + }); + + it("syncs local searches and returns the merged account list", async () => { + vi.mocked(fetch).mockResolvedValue( + new Response(JSON.stringify({ searches: [search] }), { status: 200 }), + ); + + await expect(syncSavedSearches([search])).resolves.toEqual([search]); + expect(fetch).toHaveBeenCalledWith( + "/api/saved-searches", + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ searches: [search] }), + }), + ); + }); + + it("deletes a saved search from the account", async () => { + vi.mocked(fetch).mockResolvedValue(new Response(null, { status: 204 })); + + await deleteCloudSavedSearch("saved/search"); + expect(fetch).toHaveBeenCalledWith("/api/saved-searches/saved%2Fsearch", { + method: "DELETE", + }); + }); + + it("uploads large local collections in server-safe batches", async () => { + const searches = Array.from({ length: 101 }, (_, index) => ({ + ...search, + id: `saved-${index}`, + })); + vi.mocked(fetch) + .mockResolvedValueOnce( + new Response(JSON.stringify({ searches: searches.slice(0, 100) }), { + status: 200, + }), + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ searches }), { status: 200 }), + ); + + await expect(syncSavedSearches(searches)).resolves.toEqual(searches); + expect(fetch).toHaveBeenCalledTimes(2); + expect( + JSON.parse(vi.mocked(fetch).mock.calls[0][1]?.body as string).searches, + ).toHaveLength(100); + expect( + JSON.parse(vi.mocked(fetch).mock.calls[1][1]?.body as string).searches, + ).toHaveLength(1); + }); + + it("reports failed sync and delete requests", async () => { + vi.mocked(fetch).mockResolvedValue(new Response(null, { status: 500 })); + + await expect(syncSavedSearches([])).rejects.toThrow( + "Unable to sync saved searches.", + ); + await expect(deleteCloudSavedSearch("saved-1")).rejects.toThrow( + "Unable to remove the saved search from your account.", + ); + }); +}); diff --git a/tests/features/issues/lib/saved-searches.test.ts b/tests/features/issues/lib/saved-searches.test.ts index fe630e4..5d3cac3 100644 --- a/tests/features/issues/lib/saved-searches.test.ts +++ b/tests/features/issues/lib/saved-searches.test.ts @@ -5,6 +5,8 @@ import { addSavedSearch, deleteSavedSearch, getSavedSearches, + isValidSavedSearch, + replaceSavedSearches, } from "@/features/issues/lib/saved-searches"; const validSearch = { @@ -68,6 +70,13 @@ describe("saved searches", () => { expect(getSavedSearches()).toEqual([]); }); + it("replaces the local cache with searches restored from an account", () => { + replaceSavedSearches([validSearch]); + + expect(getSavedSearches()).toEqual([validSearch]); + expect(isValidSavedSearch(validSearch)).toBe(true); + }); + it("reports storage write failures", () => { vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { throw new Error("quota exceeded"); @@ -83,5 +92,8 @@ describe("saved searches", () => { hacktoberfest: "any", }), ).toThrow("Unable to save search."); + expect(() => replaceSavedSearches([])).toThrow( + "Unable to update saved searches.", + ); }); }); diff --git a/vitest.config.ts b/vitest.config.ts index d49f039..6bf1ac8 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -22,6 +22,7 @@ export default defineConfig({ "src/app/api/auth/**", "src/lib/auth-schema.ts", "src/lib/auth.ts", + "src/lib/auth-client.ts", "src/features/issues/types/**", "src/features/issues/data/**", ],