mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
199ba687c3
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
207 lines
7.6 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
});
|