feat(api): add PATCH endpoint and fix preferences API paths #539
112
apps/api/src/users/preferences.controller.spec.ts
Normal file
112
apps/api/src/users/preferences.controller.spec.ts
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||||
|
import { UnauthorizedException } from "@nestjs/common";
|
||||||
|
import { PreferencesController } from "./preferences.controller";
|
||||||
|
import { PreferencesService } from "./preferences.service";
|
||||||
|
import type { UpdatePreferencesDto, PreferencesResponseDto } from "./dto";
|
||||||
|
import type { AuthenticatedRequest } from "../common/types/user.types";
|
||||||
|
|
||||||
|
describe("PreferencesController", () => {
|
||||||
|
let controller: PreferencesController;
|
||||||
|
let service: PreferencesService;
|
||||||
|
|
||||||
|
const mockPreferencesService = {
|
||||||
|
getPreferences: vi.fn(),
|
||||||
|
updatePreferences: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockUserId = "user-uuid-123";
|
||||||
|
|
||||||
|
const mockPreferencesResponse: PreferencesResponseDto = {
|
||||||
|
id: "pref-uuid-456",
|
||||||
|
userId: mockUserId,
|
||||||
|
theme: "system",
|
||||||
|
locale: "en",
|
||||||
|
timezone: null,
|
||||||
|
settings: {},
|
||||||
|
updatedAt: new Date("2026-01-01T00:00:00Z"),
|
||||||
|
};
|
||||||
|
|
||||||
|
function makeRequest(userId?: string): AuthenticatedRequest {
|
||||||
|
return {
|
||||||
|
user: userId ? { id: userId } : undefined,
|
||||||
|
} as unknown as AuthenticatedRequest;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
service = mockPreferencesService as unknown as PreferencesService;
|
||||||
|
controller = new PreferencesController(service);
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("GET /api/users/me/preferences", () => {
|
||||||
|
it("should return preferences for authenticated user", async () => {
|
||||||
|
mockPreferencesService.getPreferences.mockResolvedValue(mockPreferencesResponse);
|
||||||
|
|
||||||
|
const result = await controller.getPreferences(makeRequest(mockUserId));
|
||||||
|
|
||||||
|
expect(result).toEqual(mockPreferencesResponse);
|
||||||
|
expect(mockPreferencesService.getPreferences).toHaveBeenCalledWith(mockUserId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw UnauthorizedException when user is not authenticated", async () => {
|
||||||
|
await expect(controller.getPreferences(makeRequest())).rejects.toThrow(UnauthorizedException);
|
||||||
|
expect(mockPreferencesService.getPreferences).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("PUT /api/users/me/preferences", () => {
|
||||||
|
const updateDto: UpdatePreferencesDto = {
|
||||||
|
theme: "dark",
|
||||||
|
locale: "fr",
|
||||||
|
timezone: "Europe/Paris",
|
||||||
|
};
|
||||||
|
|
||||||
|
it("should update and return preferences for authenticated user", async () => {
|
||||||
|
const updatedResponse: PreferencesResponseDto = {
|
||||||
|
...mockPreferencesResponse,
|
||||||
|
theme: "dark",
|
||||||
|
locale: "fr",
|
||||||
|
timezone: "Europe/Paris",
|
||||||
|
};
|
||||||
|
mockPreferencesService.updatePreferences.mockResolvedValue(updatedResponse);
|
||||||
|
|
||||||
|
const result = await controller.updatePreferences(updateDto, makeRequest(mockUserId));
|
||||||
|
|
||||||
|
expect(result).toEqual(updatedResponse);
|
||||||
|
expect(mockPreferencesService.updatePreferences).toHaveBeenCalledWith(mockUserId, updateDto);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw UnauthorizedException when user is not authenticated", async () => {
|
||||||
|
await expect(controller.updatePreferences(updateDto, makeRequest())).rejects.toThrow(
|
||||||
|
UnauthorizedException
|
||||||
|
);
|
||||||
|
expect(mockPreferencesService.updatePreferences).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("PATCH /api/users/me/preferences", () => {
|
||||||
|
const patchDto: UpdatePreferencesDto = {
|
||||||
|
theme: "light",
|
||||||
|
};
|
||||||
|
|
||||||
|
it("should partially update and return preferences for authenticated user", async () => {
|
||||||
|
const patchedResponse: PreferencesResponseDto = {
|
||||||
|
...mockPreferencesResponse,
|
||||||
|
theme: "light",
|
||||||
|
};
|
||||||
|
mockPreferencesService.updatePreferences.mockResolvedValue(patchedResponse);
|
||||||
|
|
||||||
|
const result = await controller.patchPreferences(patchDto, makeRequest(mockUserId));
|
||||||
|
|
||||||
|
expect(result).toEqual(patchedResponse);
|
||||||
|
expect(mockPreferencesService.updatePreferences).toHaveBeenCalledWith(mockUserId, patchDto);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw UnauthorizedException when user is not authenticated", async () => {
|
||||||
|
await expect(controller.patchPreferences(patchDto, makeRequest())).rejects.toThrow(
|
||||||
|
UnauthorizedException
|
||||||
|
);
|
||||||
|
expect(mockPreferencesService.updatePreferences).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
Controller,
|
Controller,
|
||||||
Get,
|
Get,
|
||||||
Put,
|
Put,
|
||||||
|
Patch,
|
||||||
Body,
|
Body,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
Request,
|
Request,
|
||||||
@@ -38,7 +39,7 @@ export class PreferencesController {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* PUT /api/users/me/preferences
|
* PUT /api/users/me/preferences
|
||||||
* Update current user's preferences
|
* Full replace of current user's preferences
|
||||||
*/
|
*/
|
||||||
@Put()
|
@Put()
|
||||||
async updatePreferences(
|
async updatePreferences(
|
||||||
@@ -53,4 +54,22 @@ export class PreferencesController {
|
|||||||
|
|
||||||
return this.preferencesService.updatePreferences(userId, updatePreferencesDto);
|
return this.preferencesService.updatePreferences(userId, updatePreferencesDto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PATCH /api/users/me/preferences
|
||||||
|
* Partial update of current user's preferences
|
||||||
|
*/
|
||||||
|
@Patch()
|
||||||
|
async patchPreferences(
|
||||||
|
@Body() updatePreferencesDto: UpdatePreferencesDto,
|
||||||
|
@Request() req: AuthenticatedRequest
|
||||||
|
) {
|
||||||
|
const userId = req.user?.id;
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
throw new UnauthorizedException("Authentication required");
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.preferencesService.updatePreferences(userId, updatePreferencesDto);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
141
apps/api/src/users/preferences.service.spec.ts
Normal file
141
apps/api/src/users/preferences.service.spec.ts
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||||
|
import { PreferencesService } from "./preferences.service";
|
||||||
|
import type { PrismaService } from "../prisma/prisma.service";
|
||||||
|
import type { UpdatePreferencesDto } from "./dto";
|
||||||
|
|
||||||
|
describe("PreferencesService", () => {
|
||||||
|
let service: PreferencesService;
|
||||||
|
|
||||||
|
const mockPrisma = {
|
||||||
|
userPreference: {
|
||||||
|
findUnique: vi.fn(),
|
||||||
|
create: vi.fn(),
|
||||||
|
update: vi.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockUserId = "user-uuid-123";
|
||||||
|
|
||||||
|
const mockDbPreference = {
|
||||||
|
id: "pref-uuid-456",
|
||||||
|
userId: mockUserId,
|
||||||
|
theme: "system",
|
||||||
|
locale: "en",
|
||||||
|
timezone: null,
|
||||||
|
settings: {},
|
||||||
|
updatedAt: new Date("2026-01-01T00:00:00Z"),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
service = new PreferencesService(mockPrisma as unknown as PrismaService);
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getPreferences", () => {
|
||||||
|
it("should return existing preferences", async () => {
|
||||||
|
mockPrisma.userPreference.findUnique.mockResolvedValue(mockDbPreference);
|
||||||
|
|
||||||
|
const result = await service.getPreferences(mockUserId);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
id: mockDbPreference.id,
|
||||||
|
userId: mockUserId,
|
||||||
|
theme: "system",
|
||||||
|
locale: "en",
|
||||||
|
timezone: null,
|
||||||
|
settings: {},
|
||||||
|
});
|
||||||
|
expect(mockPrisma.userPreference.findUnique).toHaveBeenCalledWith({
|
||||||
|
where: { userId: mockUserId },
|
||||||
|
});
|
||||||
|
expect(mockPrisma.userPreference.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should create default preferences when none exist", async () => {
|
||||||
|
mockPrisma.userPreference.findUnique.mockResolvedValue(null);
|
||||||
|
mockPrisma.userPreference.create.mockResolvedValue(mockDbPreference);
|
||||||
|
|
||||||
|
const result = await service.getPreferences(mockUserId);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
id: mockDbPreference.id,
|
||||||
|
userId: mockUserId,
|
||||||
|
theme: "system",
|
||||||
|
locale: "en",
|
||||||
|
});
|
||||||
|
expect(mockPrisma.userPreference.create).toHaveBeenCalledWith({
|
||||||
|
data: expect.objectContaining({
|
||||||
|
userId: mockUserId,
|
||||||
|
theme: "system",
|
||||||
|
locale: "en",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("updatePreferences", () => {
|
||||||
|
it("should update existing preferences", async () => {
|
||||||
|
const updateDto: UpdatePreferencesDto = { theme: "dark", locale: "fr" };
|
||||||
|
const updatedPreference = { ...mockDbPreference, theme: "dark", locale: "fr" };
|
||||||
|
|
||||||
|
mockPrisma.userPreference.findUnique.mockResolvedValue(mockDbPreference);
|
||||||
|
mockPrisma.userPreference.update.mockResolvedValue(updatedPreference);
|
||||||
|
|
||||||
|
const result = await service.updatePreferences(mockUserId, updateDto);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ theme: "dark", locale: "fr" });
|
||||||
|
expect(mockPrisma.userPreference.update).toHaveBeenCalledWith({
|
||||||
|
where: { userId: mockUserId },
|
||||||
|
data: expect.objectContaining({ theme: "dark", locale: "fr" }),
|
||||||
|
});
|
||||||
|
expect(mockPrisma.userPreference.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should create preferences when updating non-existent record", async () => {
|
||||||
|
const updateDto: UpdatePreferencesDto = { theme: "light" };
|
||||||
|
const createdPreference = { ...mockDbPreference, theme: "light" };
|
||||||
|
|
||||||
|
mockPrisma.userPreference.findUnique.mockResolvedValue(null);
|
||||||
|
mockPrisma.userPreference.create.mockResolvedValue(createdPreference);
|
||||||
|
|
||||||
|
const result = await service.updatePreferences(mockUserId, updateDto);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ theme: "light" });
|
||||||
|
expect(mockPrisma.userPreference.create).toHaveBeenCalledWith({
|
||||||
|
data: expect.objectContaining({
|
||||||
|
userId: mockUserId,
|
||||||
|
theme: "light",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(mockPrisma.userPreference.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle timezone update", async () => {
|
||||||
|
const updateDto: UpdatePreferencesDto = { timezone: "America/New_York" };
|
||||||
|
const updatedPreference = { ...mockDbPreference, timezone: "America/New_York" };
|
||||||
|
|
||||||
|
mockPrisma.userPreference.findUnique.mockResolvedValue(mockDbPreference);
|
||||||
|
mockPrisma.userPreference.update.mockResolvedValue(updatedPreference);
|
||||||
|
|
||||||
|
const result = await service.updatePreferences(mockUserId, updateDto);
|
||||||
|
|
||||||
|
expect(result.timezone).toBe("America/New_York");
|
||||||
|
expect(mockPrisma.userPreference.update).toHaveBeenCalledWith({
|
||||||
|
where: { userId: mockUserId },
|
||||||
|
data: expect.objectContaining({ timezone: "America/New_York" }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle settings update", async () => {
|
||||||
|
const updateDto: UpdatePreferencesDto = { settings: { notifications: true } };
|
||||||
|
const updatedPreference = { ...mockDbPreference, settings: { notifications: true } };
|
||||||
|
|
||||||
|
mockPrisma.userPreference.findUnique.mockResolvedValue(mockDbPreference);
|
||||||
|
mockPrisma.userPreference.update.mockResolvedValue(updatedPreference);
|
||||||
|
|
||||||
|
const result = await service.updatePreferences(mockUserId, updateDto);
|
||||||
|
|
||||||
|
expect(result.settings).toEqual({ notifications: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -103,7 +103,7 @@ export default function ProfilePage(): ReactElement {
|
|||||||
setPrefsError(null);
|
setPrefsError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await apiGet<UserPreferences>("/users/me/preferences");
|
const data = await apiGet<UserPreferences>("/api/users/me/preferences");
|
||||||
setPreferences(data);
|
setPreferences(data);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const message = err instanceof Error ? err.message : "Could not load preferences";
|
const message = err instanceof Error ? err.message : "Could not load preferences";
|
||||||
|
|||||||
@@ -240,7 +240,7 @@ export default function AppearanceSettingsPage(): ReactElement {
|
|||||||
setLocalTheme(themeId);
|
setLocalTheme(themeId);
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
await apiPatch("/users/me/preferences", { theme: themeId });
|
await apiPatch("/api/users/me/preferences", { theme: themeId });
|
||||||
} catch {
|
} catch {
|
||||||
// Theme is still applied locally even if API save fails
|
// Theme is still applied locally even if API save fails
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
Reference in New Issue
Block a user