Files
bookorbit/server/src/modules/dashboard/dashboard.controller.test.ts
T
neon 199ba687c3 feat(achievements): answer in the language the client asks for
Achievement names, descriptions and category labels, the secret placeholder and the monthly
challenge are text the server writes and every client shows as is, so they stayed English in a
translated web page and will in the localized iOS app. The routes that return them now read
Accept-Language and answer in that language, with Vary: Accept-Language.

The translations live in the web's catalogs under serverText, so Crowdin, its checks and its
translators handle them through the existing pipeline; the server reads that subtree, and the image
copies the catalogs beside dist. English is never looked up: a request without the header, and
every client that predates it, gets the bytes it got before. A test keeps serverText in en.json
equal to the seed and the constants it mirrors. The web now sends its chosen language on every API
request, and the Accept-Language matcher moved into @bookorbit/types so the client and the server
negotiate the same way.

The iOS app is a consumer the web client does not show: it will send its own interface language.
App Store builds that predate that still send the device language, so on a non-English device they
will start getting translated achievement names inside English screens. That was accepted; the
response shape does not change.

Ref bookorbit/bookorbit-ios-support#79
2026-10-30 02:21:07 +00:00

207 lines
7.6 KiB
TypeScript

import type { RequestUser } from '../../common/types/request-user';
import { DashboardController } from './dashboard.controller';
import { EMPTY_CONTENT_FILTER_RULES } from '@bookorbit/types';
function makeUser(overrides: Partial<RequestUser> = {}): RequestUser {
return {
id: 42,
username: 'reader',
name: 'Reader',
email: null,
active: true,
isSuperuser: false,
isDefaultPassword: false,
tokenVersion: 1,
settings: {},
avatarUrl: null,
provisioningMethod: 'local',
permissions: [],
...overrides,
contentFilters: EMPTY_CONTENT_FILTER_RULES,
};
}
function makeController() {
const dashboardService = {
getScroller: vi.fn(),
getScrollers: vi.fn(),
};
const widgetService = {
clearCacheForUser: vi.fn(),
getReadingGoal: vi.fn(),
getCurrentlyReading: vi.fn(),
getReadingStreak: vi.fn(),
getLibraryOverview: vi.fn(),
getHighlightOfTheDay: vi.fn(),
getHighlights: vi.fn(),
getMonthlyChallenge: vi.fn(),
getYearProjection: vi.fn(),
getNeglectedGems: vi.fn(),
getReadingDna: vi.fn(),
getLongWait: vi.fn(),
getDiversityScore: vi.fn(),
getReadingRhythm: vi.fn(),
getWidgets: vi.fn(),
};
const controller = new DashboardController(dashboardService as never, widgetService as never);
return { controller, dashboardService, widgetService };
}
describe('DashboardController', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('scroller endpoint', () => {
it('getScroller delegates to dashboardService with the provided scroller type and params', async () => {
const { controller, dashboardService } = makeController();
const user = makeUser({ id: 7 });
const mockResult = [{ id: 101 }];
dashboardService.getScroller.mockResolvedValue(mockResult);
const result = await controller.getScroller('up-next-in-series', 33, 0, user);
expect(dashboardService.getScroller).toHaveBeenCalledWith('up-next-in-series', user, 33, 0);
expect(result).toEqual(mockResult);
});
it('getScrollers delegates the bounded batch to dashboardService', async () => {
const { controller, dashboardService } = makeController();
const user = makeUser({ id: 7 });
const dto = { items: [{ id: 'one', type: 'recently-added' as const, limit: 20 }] };
const mockResult = { items: [{ id: 'one', books: [], failed: false }] };
dashboardService.getScrollers.mockResolvedValue(mockResult);
const result = await controller.getScrollers(user, dto);
expect(dashboardService.getScrollers).toHaveBeenCalledWith(dto, user);
expect(result).toEqual(mockResult);
});
});
describe('widget endpoints', () => {
it('getWidgets delegates the batch to widgetService', async () => {
const { controller, widgetService } = makeController();
const user = makeUser();
const mockResult = { items: [{ type: 'reading-goal' as const, data: null, failed: false }] };
widgetService.getWidgets.mockResolvedValue(mockResult);
const result = await controller.getWidgets(user, { widgets: ['reading-goal', 'reading-dna'] }, 'de');
expect(widgetService.getWidgets).toHaveBeenCalledWith(['reading-goal', 'reading-dna'], user, 'de');
expect(result).toBe(mockResult);
});
it('getWidgets asks for each widget once even when the client repeats one', async () => {
const { controller, widgetService } = makeController();
const user = makeUser();
widgetService.getWidgets.mockResolvedValue({ items: [] });
await controller.getWidgets(user, { widgets: ['reading-goal', 'reading-goal', 'reading-dna'] }, 'en');
expect(widgetService.getWidgets).toHaveBeenCalledWith(['reading-goal', 'reading-dna'], user, 'en');
});
it('refresh clears cached widgets for the current user', () => {
const { controller, widgetService } = makeController();
const user = makeUser({ id: 7 });
controller.refresh(user);
expect(widgetService.clearCacheForUser).toHaveBeenCalledWith(7);
});
it('getReadingGoal delegates to widgetService', async () => {
const { controller, widgetService } = makeController();
const user = makeUser();
const mockResult = { goalBooks: 12, completedBooks: 5, year: 2025 };
widgetService.getReadingGoal.mockResolvedValue(mockResult);
const result = await controller.getReadingGoal(user);
expect(widgetService.getReadingGoal).toHaveBeenCalledWith(user);
expect(result).toEqual(mockResult);
});
it('getCurrentlyReading delegates to widgetService', async () => {
const { controller, widgetService } = makeController();
const user = makeUser({ id: 7 });
const mockResult = { books: [] };
widgetService.getCurrentlyReading.mockResolvedValue(mockResult);
const result = await controller.getCurrentlyReading(user);
expect(widgetService.getCurrentlyReading).toHaveBeenCalledWith(user);
expect(result).toEqual(mockResult);
});
it('getReadingStreak delegates to widgetService', async () => {
const { controller, widgetService } = makeController();
const user = makeUser({ id: 99 });
const mockResult = {
currentStreak: 3,
longestStreak: 10,
lastSevenDays: [false, false, true, true, true, false, false],
};
widgetService.getReadingStreak.mockResolvedValue(mockResult);
const result = await controller.getReadingStreak(user);
expect(widgetService.getReadingStreak).toHaveBeenCalledWith(user);
expect(result).toEqual(mockResult);
});
it('getLibraryOverview delegates to widgetService', async () => {
const { controller, widgetService } = makeController();
const user = makeUser({ id: 55 });
const mockResult = {
totalBooks: 100,
totalAuthors: 40,
totalSeries: 10,
totalStorageBytes: 1234567890,
booksAddedThisYear: 15,
};
widgetService.getLibraryOverview.mockResolvedValue(mockResult);
const result = await controller.getLibraryOverview(user);
expect(widgetService.getLibraryOverview).toHaveBeenCalledWith(user);
expect(result).toEqual(mockResult);
});
it('getMonthlyChallenge passes the request language to widgetService', async () => {
const { controller, widgetService } = makeController();
const user = makeUser();
const mockResult = { challengeType: 'page-milestone', progress: 0, target: 500 };
widgetService.getMonthlyChallenge.mockResolvedValue(mockResult);
const result = await controller.getMonthlyChallenge(user, 'fr');
expect(widgetService.getMonthlyChallenge).toHaveBeenCalledWith(user, 'fr');
expect(result).toEqual(mockResult);
});
it.each([
['getHighlightOfTheDay', { text: 'quote', bookId: 1 }],
['getHighlights', [{ text: 'quote', bookId: 1 }]],
['getYearProjection', { projectedBooks: 30, daysRemaining: 200 }],
['getNeglectedGems', { gems: [] }],
['getReadingDna', { archetype: 'Reader', booksAnalyzed: 10 }],
['getLongWait', null],
['getDiversityScore', { score: 50, label: 'Curious Explorer' }],
['getReadingRhythm', { days: [], consistencyPercent: 0 }],
] as const)('%s delegates to widgetService', async (method, mockResult) => {
const { controller, widgetService } = makeController();
const user = makeUser();
(widgetService[method] as ReturnType<typeof vi.fn>).mockResolvedValue(mockResult);
const result = await (controller as Record<string, (u: RequestUser) => Promise<unknown>>)[method]!(user);
expect(widgetService[method]).toHaveBeenCalledWith(user);
expect(result).toEqual(mockResult);
});
});
});