From 13f3173f791d0ecfeb32ce61e928a503c45e113f Mon Sep 17 00:00:00 2001 From: vbeni30 Date: Sun, 6 Sep 2026 02:12:32 +0300 Subject: [PATCH 1/2] feat(users): add GET/PUT client for /api/users/me/settings (#801) Co-authored-by: Cursor --- frontend/lib/users/api.test.ts | 133 ++++++++++++++++++++++++++++++++- frontend/lib/users/api.ts | 13 ++++ frontend/lib/users/types.ts | 8 ++ 3 files changed, 153 insertions(+), 1 deletion(-) diff --git a/frontend/lib/users/api.test.ts b/frontend/lib/users/api.test.ts index 9028c604..cbaf9efc 100644 --- a/frontend/lib/users/api.test.ts +++ b/frontend/lib/users/api.test.ts @@ -8,11 +8,34 @@ vi.mock("@/lib/api-base", () => ({ getServerApiBase: vi.fn(() => "https://api.example.test"), })) -import { createUserFolder } from "./api" +vi.mock("@/lib/http", () => ({ + apiFetchJson: vi.fn(), +})) + +import { getServerApiBase } from "@/lib/api-base" +import { getToken } from "@/lib/auth" +import { apiFetchJson } from "@/lib/http" +import { createUserFolder, getUserSettings, updateUserSettings } from "./api" + +const BASE = "https://api.example.test" + +function mockFetch(status: number, body: unknown = {}) { + global.fetch = vi.fn(async () => { + if (status === 204) { + return new Response(null, { status: 204 }) + } + return new Response(typeof body === "string" ? body : JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }) + }) as typeof fetch +} describe("createUserFolder", () => { beforeEach(() => { vi.restoreAllMocks() + vi.mocked(getToken).mockResolvedValue("test-token") + vi.mocked(getServerApiBase).mockReturnValue(BASE) global.fetch = vi.fn( async () => new Response(JSON.stringify({ id: 1, name: "Favorites" }), { @@ -40,3 +63,111 @@ describe("createUserFolder", () => { ) }) }) + +describe("user settings", () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(getToken).mockResolvedValue("test-token") + vi.mocked(getServerApiBase).mockReturnValue(BASE) + }) + + describe("getUserSettings", () => { + test("returns default-enabled state from GET", async () => { + vi.mocked(apiFetchJson).mockResolvedValueOnce({ + ok: true, + data: { shareNotificationEnabled: true }, + }) + + const result = await getUserSettings() + + expect(result).toEqual({ + data: { shareNotificationEnabled: true }, + error: null, + }) + expect(apiFetchJson).toHaveBeenCalledWith(`${BASE}/api/users/me/settings`, { + headers: { Authorization: "Bearer test-token" }, + cache: "no-store", + }) + }) + + test("returns disabled state from GET", async () => { + vi.mocked(apiFetchJson).mockResolvedValueOnce({ + ok: true, + data: { shareNotificationEnabled: false }, + }) + + const result = await getUserSettings() + + expect(result.data?.shareNotificationEnabled).toBe(false) + }) + + test("returns auth_required when token is missing", async () => { + vi.mocked(getToken).mockResolvedValue(null) + + const result = await getUserSettings() + + expect(result).toEqual({ data: null, error: "auth_required" }) + expect(apiFetchJson).not.toHaveBeenCalled() + }) + }) + + describe("updateUserSettings", () => { + test("enables share notifications via PUT", async () => { + mockFetch(204) + + const result = await updateUserSettings({ shareNotificationEnabled: true }) + + expect(result).toEqual({ ok: true, data: undefined }) + expect(global.fetch).toHaveBeenCalledWith( + `${BASE}/api/users/me/settings`, + expect.objectContaining({ + method: "PUT", + body: JSON.stringify({ shareNotificationEnabled: true }), + headers: expect.objectContaining({ + Authorization: "Bearer test-token", + "Content-Type": "application/json", + }), + }), + ) + }) + + test("disables share notifications via PUT", async () => { + mockFetch(204) + + const result = await updateUserSettings({ shareNotificationEnabled: false }) + + expect(result.ok).toBe(true) + expect(global.fetch).toHaveBeenCalledWith( + `${BASE}/api/users/me/settings`, + expect.objectContaining({ + body: JSON.stringify({ shareNotificationEnabled: false }), + }), + ) + }) + + test("returns auth_required when token is missing", async () => { + vi.mocked(getToken).mockResolvedValue(null) + + const result = await updateUserSettings({ shareNotificationEnabled: true }) + + expect(result).toEqual({ + ok: false, + message: expect.stringMatching(/sign in/i), + code: "auth_required", + }) + expect(global.fetch).not.toHaveBeenCalled() + }) + + test("surfaces invalid payload errors from PUT", async () => { + mockFetch(400, { error: "shareNotificationEnabled is required." }) + + const result = await updateUserSettings({ shareNotificationEnabled: true }) + + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.message).toBe("shareNotificationEnabled is required.") + expect(result.code).toBe("bad_request") + } + }) + }) +}) diff --git a/frontend/lib/users/api.ts b/frontend/lib/users/api.ts index 13fd25ac..56abe8a1 100644 --- a/frontend/lib/users/api.ts +++ b/frontend/lib/users/api.ts @@ -12,11 +12,13 @@ import type { ToggleUserFavoriteResponse, UpdateUserFavoriteFolderAssignmentRequest, UpdateUserFavoriteFolderRequest, + UpdateUserSettingsRequest, UserFavoriteFolder, UserGroup, UserHistorySection, UserPage, UserSearchHistoryItem, + UserSettings, UserSharedObjects, UserStars, UserSubscription, @@ -243,3 +245,14 @@ export function toggleAdminMode() { method: "POST", }) } + +export function getUserSettings() { + return authorizedGet("/api/users/me/settings") +} + +export function updateUserSettings(body: UpdateUserSettingsRequest) { + return authorizedMutation("/api/users/me/settings", { + method: "PUT", + body: JSON.stringify(body), + }) +} diff --git a/frontend/lib/users/types.ts b/frontend/lib/users/types.ts index 488e5a8e..8ffc82f8 100644 --- a/frontend/lib/users/types.ts +++ b/frontend/lib/users/types.ts @@ -257,3 +257,11 @@ export type ToggleUserFavoriteResponse = { export type ToggleAdminModeResponse = { adminEnabled?: string | null } + +export type UserSettings = { + shareNotificationEnabled: boolean +} + +export type UpdateUserSettingsRequest = { + shareNotificationEnabled: boolean +} From 299515b2054e3b957948ad2a7f7dc5dc7d413dcf Mon Sep 17 00:00:00 2001 From: vbeni30 Date: Sun, 6 Sep 2026 02:15:46 +0300 Subject: [PATCH 2/2] feat(users): add updateUserSettingsAction server action (#801) --- frontend/app/users/actions.test.ts | 41 ++++++++++++++++++++++++++++++ frontend/app/users/actions.ts | 10 ++++++++ 2 files changed, 51 insertions(+) create mode 100644 frontend/app/users/actions.test.ts diff --git a/frontend/app/users/actions.test.ts b/frontend/app/users/actions.test.ts new file mode 100644 index 00000000..7fae065c --- /dev/null +++ b/frontend/app/users/actions.test.ts @@ -0,0 +1,41 @@ +import { beforeEach, describe, expect, test, vi } from "vitest" + +vi.mock("next/cache", () => ({ + revalidatePath: vi.fn(), +})) + +vi.mock("@/lib/users/api", () => ({ + updateUserSettings: vi.fn(), +})) + +import { revalidatePath } from "next/cache" +import { updateUserSettings } from "@/lib/users/api" +import { updateUserSettingsAction } from "./actions" + +describe("updateUserSettingsAction", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + test("returns data and revalidates on success", async () => { + vi.mocked(updateUserSettings).mockResolvedValueOnce({ ok: true, data: undefined }) + + const result = await updateUserSettingsAction({ shareNotificationEnabled: true }) + + expect(result).toEqual({ data: {} }) + expect(revalidatePath).toHaveBeenCalledWith("/users/settings") + }) + + test("returns error without revalidating on failure", async () => { + vi.mocked(updateUserSettings).mockResolvedValueOnce({ + ok: false, + message: "shareNotificationEnabled is required.", + code: "bad_request", + }) + + const result = await updateUserSettingsAction({ shareNotificationEnabled: true }) + + expect(result).toEqual({ error: "shareNotificationEnabled is required." }) + expect(revalidatePath).not.toHaveBeenCalled() + }) +}) diff --git a/frontend/app/users/actions.ts b/frontend/app/users/actions.ts index 844deb5e..c03ee1dd 100644 --- a/frontend/app/users/actions.ts +++ b/frontend/app/users/actions.ts @@ -1,5 +1,6 @@ "use server" +import { revalidatePath } from "next/cache" import { createUserFolder, deleteUserFolder, @@ -10,6 +11,7 @@ import { toggleUserFavorite, updateUserFavoriteFolderAssignment, updateUserFolder, + updateUserSettings, } from "@/lib/users/api" import type { CreateUserFavoriteFolderRequest, @@ -18,6 +20,7 @@ import type { ToggleUserFavoriteRequest, UpdateUserFavoriteFolderAssignmentRequest, UpdateUserFavoriteFolderRequest, + UpdateUserSettingsRequest, } from "@/lib/users/types" export async function createUserFolderAction( @@ -84,3 +87,10 @@ export async function removeUserSharedObjectAction(id: number) { export async function toggleAdminModeAction() { return toggleAdminMode() } + +export async function updateUserSettingsAction(body: UpdateUserSettingsRequest) { + const result = await updateUserSettings(body) + if (!result.ok) return { error: result.message } + revalidatePath("/users/settings") + return { data: {} } +}