mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
8810d47658
Resolve book languages stated as names ("English") to a code, so the
dialog and the server's fallback stop calling English unsupported. The
dialog groups the ebook and audiobook in a Sources card, shows
Highlighting without the Advanced disclosure, and saves a per-user
default under /user-preferences/read-aloud for books it cannot match.
962 lines
44 KiB
TypeScript
962 lines
44 KiB
TypeScript
import { BadRequestException } from '@nestjs/common';
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import type {
|
|
Accent,
|
|
BookRequestPreferences,
|
|
CoverSearchPreferences,
|
|
DisplayPreferences,
|
|
LocalePreferences,
|
|
PodcastPlaylistPreferences,
|
|
ServerFontPreferences,
|
|
ThemePreferences,
|
|
} from '@bookorbit/types';
|
|
import {
|
|
ANNOTATION_COLOR_NAME_MAX_ENTRIES,
|
|
ANNOTATION_COLOR_NAME_PREFERENCES_DEFAULTS,
|
|
MAX_SERVER_FONTS,
|
|
STYLE_IDS,
|
|
SUPPORTED_LOCALES,
|
|
SURFACE_PALETTE_IDS,
|
|
} from '@bookorbit/types';
|
|
|
|
import { UserPreferencesRepository } from './user-preferences.repository';
|
|
import { UserPreferencesService } from './user-preferences.service';
|
|
|
|
const validThemePreferences: ThemePreferences = {
|
|
theme: 'dark',
|
|
accent: 'blue',
|
|
radius: 'rounded',
|
|
background: 'vinyl',
|
|
brightness: 35,
|
|
};
|
|
|
|
const validPodcastPlaylistPreferences: PodcastPlaylistPreferences = {
|
|
playlists: [
|
|
{
|
|
id: 'playlist-1',
|
|
name: 'Morning commute',
|
|
libraryId: 4,
|
|
rules: {
|
|
filter: 'unplayed',
|
|
sort: 'shortest',
|
|
minDurationMinutes: null,
|
|
maxDurationMinutes: 30,
|
|
publishedWithinDays: 14,
|
|
podcastIds: [8, 9],
|
|
followedOnly: true,
|
|
},
|
|
},
|
|
],
|
|
};
|
|
|
|
const addedAccentIds: readonly Accent[] = [
|
|
'scarlet',
|
|
'marigold',
|
|
'viridian',
|
|
'iris',
|
|
'rosewater',
|
|
'flax',
|
|
'foam',
|
|
'cornflower',
|
|
'vermilion',
|
|
'salmon',
|
|
'copper',
|
|
'sand',
|
|
'chartreuse',
|
|
'pear',
|
|
'wasabi',
|
|
'sprout',
|
|
'malachite',
|
|
'aloe',
|
|
'turquoise',
|
|
'aqua',
|
|
'acid-green',
|
|
'pistachio',
|
|
'electric-blue',
|
|
'baby-blue',
|
|
'ultramarine',
|
|
'bluebell',
|
|
'purple',
|
|
'thistle',
|
|
'amethyst',
|
|
'mauve',
|
|
'raspberry',
|
|
'rose-quartz',
|
|
'jade',
|
|
'sea-glass',
|
|
];
|
|
|
|
const validDisplayPreferences: DisplayPreferences = {
|
|
portraitCoverSize: 180,
|
|
squareCoverSize: 160,
|
|
coverSizeScope: 'per-view',
|
|
gridGap: 28,
|
|
portraitGridGap: 24,
|
|
squareGridGap: 20,
|
|
viewMode: 'grid',
|
|
cardOverlays: ['progress-bar', 'format', 'rating'],
|
|
showJumpRails: true,
|
|
smartScopeFilterExpanded: true,
|
|
authorCoverSize: 140,
|
|
authorCoverShape: 'circle',
|
|
authorRowDensity: 'comfortable',
|
|
authorCoverFallback: false,
|
|
tableZebraStriping: false,
|
|
tableDensity: 'comfortable',
|
|
bookSpineOverlay: 'subtle',
|
|
showSpineOnComics: false,
|
|
bookShadowStrength: 'strong',
|
|
bookCoverDisplayMode: 'natural-bottom',
|
|
bookDetailCoverTint: 'duotone',
|
|
seriesCardCoverMode: 'stack',
|
|
gridCardPrimaryLabel: 'hidden',
|
|
gridCardSecondaryLabel: 'hidden',
|
|
cardInfoMode: 'hover-overlay',
|
|
thumbnailClickAction: 'reader',
|
|
placeholderCoverStyle: 'bauhaus',
|
|
};
|
|
|
|
const validLocalePreferences: LocalePreferences = {
|
|
locale: 'it',
|
|
};
|
|
|
|
const validCoverSearchPreferences: CoverSearchPreferences = {
|
|
defaultProvider: 'itunes',
|
|
};
|
|
|
|
const validBookRequestPreferences: BookRequestPreferences = { defaultLanguage: 'de' };
|
|
|
|
const repo = {
|
|
findByCategory: vi.fn<
|
|
(...args: [number, string]) => Promise<
|
|
| {
|
|
data: ThemePreferences | DisplayPreferences | LocalePreferences | ServerFontPreferences | CoverSearchPreferences | BookRequestPreferences;
|
|
}
|
|
| undefined
|
|
>
|
|
>(),
|
|
upsert: vi.fn<(...args: [number, string, Record<string, unknown>]) => Promise<void>>(),
|
|
delete: vi.fn<(...args: [number, string]) => Promise<void>>(),
|
|
};
|
|
|
|
describe('UserPreferencesService', () => {
|
|
let service: UserPreferencesService;
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
repo.findByCategory.mockResolvedValue(undefined);
|
|
repo.upsert.mockResolvedValue(undefined);
|
|
service = new UserPreferencesService(repo as unknown as UserPreferencesRepository);
|
|
});
|
|
|
|
it('getThemePreferences returns null when repository has no row', async () => {
|
|
await expect(service.getThemePreferences(7)).resolves.toBeNull();
|
|
expect(repo.findByCategory).toHaveBeenCalledWith(7, 'theme');
|
|
});
|
|
|
|
it('getThemePreferences returns saved theme settings when row exists', async () => {
|
|
repo.findByCategory.mockResolvedValueOnce({ data: validThemePreferences });
|
|
|
|
await expect(service.getThemePreferences(7)).resolves.toEqual(validThemePreferences);
|
|
});
|
|
|
|
it('getDisplayPreferences returns null when repository has no row', async () => {
|
|
await expect(service.getDisplayPreferences(7)).resolves.toBeNull();
|
|
expect(repo.findByCategory).toHaveBeenCalledWith(7, 'display');
|
|
});
|
|
|
|
it('getDisplayPreferences returns saved display settings when row exists', async () => {
|
|
repo.findByCategory.mockResolvedValueOnce({ data: validDisplayPreferences });
|
|
|
|
await expect(service.getDisplayPreferences(7)).resolves.toEqual(validDisplayPreferences);
|
|
});
|
|
|
|
it('getCoverSearchPreferences returns the DuckDuckGo default when no preference exists', async () => {
|
|
await expect(service.getCoverSearchPreferences(7)).resolves.toEqual({ defaultProvider: 'duckduckgo' });
|
|
expect(repo.findByCategory).toHaveBeenCalledWith(7, 'cover-search');
|
|
});
|
|
|
|
it('getCoverSearchPreferences returns a saved provider', async () => {
|
|
repo.findByCategory.mockResolvedValueOnce({ data: validCoverSearchPreferences });
|
|
|
|
await expect(service.getCoverSearchPreferences(7)).resolves.toEqual(validCoverSearchPreferences);
|
|
});
|
|
|
|
it('getCoverSearchPreferences falls back safely when stored data is malformed', async () => {
|
|
repo.findByCategory.mockResolvedValueOnce({ data: { defaultProvider: 'unknown' } } as never);
|
|
|
|
await expect(service.getCoverSearchPreferences(7)).resolves.toEqual({ defaultProvider: 'duckduckgo' });
|
|
});
|
|
|
|
it.each(['duckduckgo', 'itunes', 'all'] as const)('upsertCoverSearchPreferences accepts %s', async (defaultProvider) => {
|
|
await expect(service.upsertCoverSearchPreferences(11, { defaultProvider })).resolves.toBeUndefined();
|
|
expect(repo.upsert).toHaveBeenCalledWith(11, 'cover-search', { defaultProvider });
|
|
});
|
|
|
|
it('upsertCoverSearchPreferences rejects a provider that is not valid for every book', async () => {
|
|
await expect(service.upsertCoverSearchPreferences(11, { defaultProvider: 'audiobookcovers' })).rejects.toBeInstanceOf(BadRequestException);
|
|
expect(repo.upsert).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('upsertCoverSearchPreferences rejects unknown fields', async () => {
|
|
await expect(service.upsertCoverSearchPreferences(11, { ...validCoverSearchPreferences, unexpected: true })).rejects.toBeInstanceOf(
|
|
BadRequestException,
|
|
);
|
|
expect(repo.upsert).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('getReadAloudPreferences returns no language when nothing was saved', async () => {
|
|
await expect(service.getReadAloudPreferences(7)).resolves.toEqual({ defaultLanguage: null });
|
|
expect(repo.findByCategory).toHaveBeenCalledWith(7, 'read-aloud');
|
|
});
|
|
|
|
it('getReadAloudPreferences returns the saved language, and none when the stored row is malformed', async () => {
|
|
repo.findByCategory.mockResolvedValueOnce({ data: { defaultLanguage: 'fr' } } as never);
|
|
await expect(service.getReadAloudPreferences(7)).resolves.toEqual({ defaultLanguage: 'fr' });
|
|
|
|
repo.findByCategory.mockResolvedValueOnce({ data: { defaultLanguage: 'English' } } as never);
|
|
await expect(service.getReadAloudPreferences(7)).resolves.toEqual({ defaultLanguage: null });
|
|
});
|
|
|
|
it('upsertReadAloudPreferences saves and clears the default language', async () => {
|
|
await expect(service.upsertReadAloudPreferences(11, { defaultLanguage: 'de' })).resolves.toBeUndefined();
|
|
expect(repo.upsert).toHaveBeenCalledWith(11, 'read-aloud', { defaultLanguage: 'de' });
|
|
|
|
await expect(service.upsertReadAloudPreferences(11, { defaultLanguage: null })).resolves.toBeUndefined();
|
|
expect(repo.upsert).toHaveBeenLastCalledWith(11, 'read-aloud', { defaultLanguage: null });
|
|
});
|
|
|
|
it('upsertReadAloudPreferences rejects a language name, a full tag and unknown fields', async () => {
|
|
for (const settings of [{ defaultLanguage: 'English' }, { defaultLanguage: 'en-US' }, { defaultLanguage: 'en', unexpected: true }]) {
|
|
await expect(service.upsertReadAloudPreferences(11, settings)).rejects.toBeInstanceOf(BadRequestException);
|
|
}
|
|
expect(repo.upsert).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('getBookRequestPreferences returns no language when nothing was saved', async () => {
|
|
await expect(service.getBookRequestPreferences(7)).resolves.toEqual({ defaultLanguage: null });
|
|
expect(repo.findByCategory).toHaveBeenCalledWith(7, 'book-requests');
|
|
});
|
|
|
|
it('getBookRequestPreferences returns the pinned language', async () => {
|
|
repo.findByCategory.mockResolvedValueOnce({ data: validBookRequestPreferences });
|
|
|
|
await expect(service.getBookRequestPreferences(7)).resolves.toEqual(validBookRequestPreferences);
|
|
});
|
|
|
|
it('getBookRequestPreferences falls back safely when stored data is malformed', async () => {
|
|
repo.findByCategory.mockResolvedValueOnce({ data: { defaultLanguage: 'klingon' } } as never);
|
|
|
|
await expect(service.getBookRequestPreferences(7)).resolves.toEqual({ defaultLanguage: null });
|
|
});
|
|
|
|
it('upsertBookRequestPreferences saves a pinned language', async () => {
|
|
await expect(service.upsertBookRequestPreferences(11, { ...validBookRequestPreferences })).resolves.toBeUndefined();
|
|
expect(repo.upsert).toHaveBeenCalledWith(11, 'book-requests', validBookRequestPreferences);
|
|
});
|
|
|
|
/**
|
|
* Destinations lived in this category under two different shapes before the instance default
|
|
* replaced them. Rows written by either build still exist, and both must keep their language
|
|
* rather than failing strict validation and reverting the whole category to the defaults.
|
|
*/
|
|
it('getBookRequestPreferences drops a destination pinned before the instance default existed', async () => {
|
|
repo.findByCategory.mockResolvedValueOnce({ data: { defaultLibraryId: 4, defaultFolderId: 9, defaultLanguage: 'de' } } as never);
|
|
|
|
await expect(service.getBookRequestPreferences(7)).resolves.toEqual({ defaultLanguage: 'de' });
|
|
});
|
|
|
|
it('getBookRequestPreferences drops a per-medium destination too', async () => {
|
|
repo.findByCategory.mockResolvedValueOnce({
|
|
data: { destinations: { ebook: { libraryId: 4, folderId: 9 } }, defaultLanguage: 'de' },
|
|
} as never);
|
|
|
|
await expect(service.getBookRequestPreferences(7)).resolves.toEqual({ defaultLanguage: 'de' });
|
|
});
|
|
|
|
it('upsertBookRequestPreferences drops a destination a stale client still sends', async () => {
|
|
await expect(service.upsertBookRequestPreferences(11, { defaultLibraryId: 4, defaultLanguage: 'de' })).resolves.toBeUndefined();
|
|
expect(repo.upsert).toHaveBeenCalledWith(11, 'book-requests', { defaultLanguage: 'de' });
|
|
});
|
|
|
|
it('upsertBookRequestPreferences rejects a language no release could be matched against', async () => {
|
|
await expect(service.upsertBookRequestPreferences(11, { ...validBookRequestPreferences, defaultLanguage: 'klingon' })).rejects.toBeInstanceOf(
|
|
BadRequestException,
|
|
);
|
|
expect(repo.upsert).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('upsertBookRequestPreferences rejects unknown fields', async () => {
|
|
await expect(service.upsertBookRequestPreferences(11, { ...validBookRequestPreferences, unexpected: true })).rejects.toBeInstanceOf(
|
|
BadRequestException,
|
|
);
|
|
expect(repo.upsert).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('getLocalePreferences returns null when repository has no row', async () => {
|
|
await expect(service.getLocalePreferences(7)).resolves.toBeNull();
|
|
expect(repo.findByCategory).toHaveBeenCalledWith(7, 'locale');
|
|
});
|
|
|
|
it('getLocalePreferences returns saved locale settings when row exists', async () => {
|
|
repo.findByCategory.mockResolvedValueOnce({ data: validLocalePreferences });
|
|
|
|
await expect(service.getLocalePreferences(7)).resolves.toEqual(validLocalePreferences);
|
|
});
|
|
|
|
it('upsertLocalePreferences persists validated settings', async () => {
|
|
await expect(service.upsertLocalePreferences(11, validLocalePreferences)).resolves.toBeUndefined();
|
|
expect(repo.upsert).toHaveBeenCalledWith(11, 'locale', validLocalePreferences);
|
|
});
|
|
|
|
it.each(SUPPORTED_LOCALES)('upsertLocalePreferences accepts the %s locale', async (locale) => {
|
|
const preferences: LocalePreferences = { locale };
|
|
|
|
await expect(service.upsertLocalePreferences(11, preferences)).resolves.toBeUndefined();
|
|
expect(repo.upsert).toHaveBeenCalledWith(11, 'locale', preferences);
|
|
});
|
|
|
|
it('upsertLocalePreferences rejects unsupported locale', async () => {
|
|
await expect(service.upsertLocalePreferences(11, { locale: 'unsupported' } as never)).rejects.toBeInstanceOf(BadRequestException);
|
|
expect(repo.upsert).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('upsertLocalePreferences rejects extra unknown fields', async () => {
|
|
await expect(
|
|
service.upsertLocalePreferences(11, { ...validLocalePreferences, unexpected: true } as Record<string, unknown>),
|
|
).rejects.toBeInstanceOf(BadRequestException);
|
|
expect(repo.upsert).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('upsertThemePreferences persists validated settings', async () => {
|
|
await expect(service.upsertThemePreferences(11, validThemePreferences)).resolves.toBeUndefined();
|
|
expect(repo.upsert).toHaveBeenCalledWith(11, 'theme', validThemePreferences);
|
|
});
|
|
|
|
it('upsertThemePreferences accepts the system theme', async () => {
|
|
const systemThemePreferences = { ...validThemePreferences, theme: 'system' } as const;
|
|
|
|
await expect(service.upsertThemePreferences(11, systemThemePreferences)).resolves.toBeUndefined();
|
|
expect(repo.upsert).toHaveBeenCalledWith(11, 'theme', systemThemePreferences);
|
|
});
|
|
|
|
it.each(addedAccentIds)('upsertThemePreferences accepts the %s accent', async (accent) => {
|
|
const preferences = { ...validThemePreferences, accent };
|
|
|
|
await expect(service.upsertThemePreferences(11, preferences)).resolves.toBeUndefined();
|
|
expect(repo.upsert).toHaveBeenCalledWith(11, 'theme', preferences);
|
|
});
|
|
|
|
it('upsertThemePreferences accepts a payload without surfaceOpacity', async () => {
|
|
await expect(service.upsertThemePreferences(11, { ...validThemePreferences })).resolves.toBeUndefined();
|
|
expect(repo.upsert).toHaveBeenCalledWith(11, 'theme', validThemePreferences);
|
|
});
|
|
|
|
it.each([80, 92, 100])('upsertThemePreferences accepts surfaceOpacity %i', async (surfaceOpacity) => {
|
|
const preferences = { ...validThemePreferences, surfaceOpacity };
|
|
|
|
await expect(service.upsertThemePreferences(11, preferences)).resolves.toBeUndefined();
|
|
expect(repo.upsert).toHaveBeenCalledWith(11, 'theme', preferences);
|
|
});
|
|
|
|
it.each([79, 101, 50.5])('upsertThemePreferences rejects surfaceOpacity %s', async (surfaceOpacity) => {
|
|
await expect(service.upsertThemePreferences(11, { ...validThemePreferences, surfaceOpacity })).rejects.toBeInstanceOf(BadRequestException);
|
|
expect(repo.upsert).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it.each(STYLE_IDS)('upsertThemePreferences accepts the %s style', async (style) => {
|
|
const preferences = { ...validThemePreferences, style };
|
|
|
|
await expect(service.upsertThemePreferences(11, preferences)).resolves.toBeUndefined();
|
|
expect(repo.upsert).toHaveBeenCalledWith(11, 'theme', preferences);
|
|
});
|
|
|
|
it.each(SURFACE_PALETTE_IDS)('upsertThemePreferences accepts the %s surface palette', async (surfacePalette) => {
|
|
const preferences = { ...validThemePreferences, style: 'nostalgia', surfacePalette } as const;
|
|
|
|
await expect(service.upsertThemePreferences(11, preferences)).resolves.toBeUndefined();
|
|
expect(repo.upsert).toHaveBeenCalledWith(11, 'theme', preferences);
|
|
});
|
|
|
|
it.each([0, 100, 200])('upsertThemePreferences accepts glowReach %i', async (glowReach) => {
|
|
const preferences = { ...validThemePreferences, glowReach };
|
|
|
|
await expect(service.upsertThemePreferences(11, preferences)).resolves.toBeUndefined();
|
|
expect(repo.upsert).toHaveBeenCalledWith(11, 'theme', preferences);
|
|
});
|
|
|
|
it.each([-10, 201, 50.5, 'off'])('upsertThemePreferences rejects glowReach %s', async (glowReach) => {
|
|
await expect(service.upsertThemePreferences(11, { ...validThemePreferences, glowReach } as never)).rejects.toBeInstanceOf(BadRequestException);
|
|
expect(repo.upsert).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('upsertThemePreferences rejects an unknown style', async () => {
|
|
await expect(service.upsertThemePreferences(11, { ...validThemePreferences, style: 'retro' } as never)).rejects.toBeInstanceOf(
|
|
BadRequestException,
|
|
);
|
|
expect(repo.upsert).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('upsertThemePreferences rejects an unknown surface palette', async () => {
|
|
await expect(service.upsertThemePreferences(11, { ...validThemePreferences, surfacePalette: 'teal' } as never)).rejects.toBeInstanceOf(
|
|
BadRequestException,
|
|
);
|
|
expect(repo.upsert).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('upsertThemePreferences rejects invalid theme ids', async () => {
|
|
await expect(service.upsertThemePreferences(11, { ...validThemePreferences, theme: 'sepia' } as never)).rejects.toBeInstanceOf(
|
|
BadRequestException,
|
|
);
|
|
expect(repo.upsert).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('upsertThemePreferences rejects invalid accent ids', async () => {
|
|
await expect(service.upsertThemePreferences(11, { ...validThemePreferences, accent: 'magenta' } as never)).rejects.toBeInstanceOf(
|
|
BadRequestException,
|
|
);
|
|
expect(repo.upsert).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('upsertThemePreferences rejects invalid radius ids', async () => {
|
|
await expect(service.upsertThemePreferences(11, { ...validThemePreferences, radius: 'soft' } as never)).rejects.toBeInstanceOf(
|
|
BadRequestException,
|
|
);
|
|
expect(repo.upsert).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it.each(['stars', 'terminal'])('upsertThemePreferences rejects the unsupported %s background id', async (background) => {
|
|
await expect(service.upsertThemePreferences(11, { ...validThemePreferences, background } as never)).rejects.toBeInstanceOf(BadRequestException);
|
|
expect(repo.upsert).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('upsertThemePreferences rejects brightness below zero', async () => {
|
|
await expect(service.upsertThemePreferences(11, { ...validThemePreferences, brightness: -1 })).rejects.toBeInstanceOf(BadRequestException);
|
|
expect(repo.upsert).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('upsertThemePreferences rejects brightness above one hundred', async () => {
|
|
await expect(service.upsertThemePreferences(11, { ...validThemePreferences, brightness: 101 })).rejects.toBeInstanceOf(BadRequestException);
|
|
expect(repo.upsert).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('upsertThemePreferences rejects extra unknown fields', async () => {
|
|
await expect(
|
|
service.upsertThemePreferences(11, { ...validThemePreferences, unexpected: true } as Record<string, unknown>),
|
|
).rejects.toBeInstanceOf(BadRequestException);
|
|
expect(repo.upsert).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('upsertThemePreferences rejects payloads missing required fields', async () => {
|
|
const { background, ...incomplete } = validThemePreferences;
|
|
void background;
|
|
|
|
await expect(service.upsertThemePreferences(11, incomplete)).rejects.toBeInstanceOf(BadRequestException);
|
|
expect(repo.upsert).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('upsertThemePreferences rethrows repository failures after validation', async () => {
|
|
const err = new Error('database unavailable');
|
|
repo.upsert.mockRejectedValueOnce(err);
|
|
|
|
await expect(service.upsertThemePreferences(11, validThemePreferences)).rejects.toBe(err);
|
|
expect(repo.upsert).toHaveBeenCalledWith(11, 'theme', validThemePreferences);
|
|
});
|
|
|
|
it('upsertThemePreferences logs and rethrows non-Error repository failures', async () => {
|
|
const err = 'database unavailable';
|
|
repo.upsert.mockRejectedValueOnce(err);
|
|
|
|
await expect(service.upsertThemePreferences(11, validThemePreferences)).rejects.toBe(err);
|
|
expect(repo.upsert).toHaveBeenCalledWith(11, 'theme', validThemePreferences);
|
|
});
|
|
|
|
it('upsertDisplayPreferences persists validated settings', async () => {
|
|
await expect(service.upsertDisplayPreferences(11, validDisplayPreferences as unknown as Record<string, unknown>)).resolves.toBeUndefined();
|
|
expect(repo.upsert).toHaveBeenCalledWith(11, 'display', validDisplayPreferences);
|
|
});
|
|
|
|
it('upsertDisplayPreferences accepts all valid thumbnailClickAction values', async () => {
|
|
for (const value of ['reader', 'details']) {
|
|
await expect(
|
|
service.upsertDisplayPreferences(11, { ...validDisplayPreferences, thumbnailClickAction: value } as unknown as Record<string, unknown>),
|
|
).resolves.toBeUndefined();
|
|
}
|
|
});
|
|
|
|
it('upsertDisplayPreferences defaults thumbnailClickAction to reader when omitted', async () => {
|
|
const { thumbnailClickAction, ...withoutAction } = validDisplayPreferences;
|
|
void thumbnailClickAction;
|
|
|
|
await expect(service.upsertDisplayPreferences(11, withoutAction as unknown as Record<string, unknown>)).resolves.toBeUndefined();
|
|
|
|
expect(repo.upsert).toHaveBeenCalledWith(11, 'display', expect.objectContaining({ thumbnailClickAction: 'reader' }));
|
|
});
|
|
|
|
it('upsertDisplayPreferences defaults placeholderCoverStyle to bauhaus when omitted', async () => {
|
|
const { placeholderCoverStyle, ...withoutStyle } = validDisplayPreferences;
|
|
void placeholderCoverStyle;
|
|
await service.upsertDisplayPreferences(11, withoutStyle as unknown as Record<string, unknown>);
|
|
|
|
expect(repo.upsert).toHaveBeenCalledWith(11, 'display', expect.objectContaining({ placeholderCoverStyle: 'bauhaus' }));
|
|
});
|
|
|
|
it('upsertDisplayPreferences keeps a chosen placeholderCoverStyle', async () => {
|
|
await service.upsertDisplayPreferences(11, { ...validDisplayPreferences, placeholderCoverStyle: 'marbled' } as unknown as Record<
|
|
string,
|
|
unknown
|
|
>);
|
|
|
|
expect(repo.upsert).toHaveBeenCalledWith(11, 'display', expect.objectContaining({ placeholderCoverStyle: 'marbled' }));
|
|
});
|
|
|
|
it('upsertDisplayPreferences rejects unknown placeholderCoverStyle values', async () => {
|
|
await expect(
|
|
service.upsertDisplayPreferences(11, { ...validDisplayPreferences, placeholderCoverStyle: 'lattice' } as unknown as Record<string, unknown>),
|
|
).rejects.toThrow(BadRequestException);
|
|
});
|
|
|
|
it('upsertDisplayPreferences rejects invalid thumbnailClickAction values', async () => {
|
|
await expect(
|
|
service.upsertDisplayPreferences(11, { ...validDisplayPreferences, thumbnailClickAction: 'preview' } as unknown as Record<string, unknown>),
|
|
).rejects.toBeInstanceOf(BadRequestException);
|
|
expect(repo.upsert).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('upsertDisplayPreferences accepts all valid seriesCardCoverMode values', async () => {
|
|
for (const value of ['stack', 'mosaic', 'first-volume', 'latest-volume', 'first-unread']) {
|
|
await expect(
|
|
service.upsertDisplayPreferences(11, { ...validDisplayPreferences, seriesCardCoverMode: value } as unknown as Record<string, unknown>),
|
|
).resolves.toBeUndefined();
|
|
}
|
|
});
|
|
|
|
it('upsertDisplayPreferences defaults seriesCardCoverMode to stack when omitted', async () => {
|
|
const { seriesCardCoverMode, ...withoutMode } = validDisplayPreferences;
|
|
void seriesCardCoverMode;
|
|
|
|
await expect(service.upsertDisplayPreferences(11, withoutMode as unknown as Record<string, unknown>)).resolves.toBeUndefined();
|
|
|
|
expect(repo.upsert).toHaveBeenCalledWith(11, 'display', expect.objectContaining({ seriesCardCoverMode: 'stack' }));
|
|
});
|
|
|
|
it('upsertDisplayPreferences accepts both showSpineOnComics values', async () => {
|
|
for (const value of [true, false]) {
|
|
await expect(
|
|
service.upsertDisplayPreferences(11, { ...validDisplayPreferences, showSpineOnComics: value } as unknown as Record<string, unknown>),
|
|
).resolves.toBeUndefined();
|
|
expect(repo.upsert).toHaveBeenCalledWith(11, 'display', expect.objectContaining({ showSpineOnComics: value }));
|
|
}
|
|
});
|
|
|
|
it('upsertDisplayPreferences defaults showSpineOnComics to false when omitted', async () => {
|
|
const { showSpineOnComics, ...withoutFlag } = validDisplayPreferences;
|
|
void showSpineOnComics;
|
|
|
|
await expect(service.upsertDisplayPreferences(11, withoutFlag as unknown as Record<string, unknown>)).resolves.toBeUndefined();
|
|
|
|
expect(repo.upsert).toHaveBeenCalledWith(11, 'display', expect.objectContaining({ showSpineOnComics: false }));
|
|
});
|
|
|
|
it('upsertDisplayPreferences rejects non-boolean showSpineOnComics', async () => {
|
|
await expect(
|
|
service.upsertDisplayPreferences(11, { ...validDisplayPreferences, showSpineOnComics: 'yes' } as unknown as Record<string, unknown>),
|
|
).rejects.toBeInstanceOf(BadRequestException);
|
|
expect(repo.upsert).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('upsertDisplayPreferences accepts both showJumpRails values', async () => {
|
|
for (const value of [true, false]) {
|
|
await expect(
|
|
service.upsertDisplayPreferences(11, { ...validDisplayPreferences, showJumpRails: value } as unknown as Record<string, unknown>),
|
|
).resolves.toBeUndefined();
|
|
expect(repo.upsert).toHaveBeenCalledWith(11, 'display', expect.objectContaining({ showJumpRails: value }));
|
|
}
|
|
});
|
|
|
|
it('upsertDisplayPreferences defaults showJumpRails to true when omitted', async () => {
|
|
const { showJumpRails, ...withoutFlag } = validDisplayPreferences;
|
|
void showJumpRails;
|
|
|
|
await expect(service.upsertDisplayPreferences(11, withoutFlag as unknown as Record<string, unknown>)).resolves.toBeUndefined();
|
|
|
|
expect(repo.upsert).toHaveBeenCalledWith(11, 'display', expect.objectContaining({ showJumpRails: true }));
|
|
});
|
|
|
|
it('upsertDisplayPreferences rejects non-boolean showJumpRails', async () => {
|
|
await expect(
|
|
service.upsertDisplayPreferences(11, { ...validDisplayPreferences, showJumpRails: 'yes' } as unknown as Record<string, unknown>),
|
|
).rejects.toBeInstanceOf(BadRequestException);
|
|
expect(repo.upsert).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('upsertDisplayPreferences accepts and persists author display preferences', async () => {
|
|
const preferences = {
|
|
...validDisplayPreferences,
|
|
authorRowDensity: 'compact',
|
|
authorCoverFallback: true,
|
|
} satisfies DisplayPreferences;
|
|
|
|
await expect(service.upsertDisplayPreferences(11, preferences)).resolves.toBeUndefined();
|
|
|
|
expect(repo.upsert).toHaveBeenCalledWith(11, 'display', preferences);
|
|
});
|
|
|
|
it('upsertDisplayPreferences accepts the complete client payload for fill-crop covers', async () => {
|
|
const preferences = {
|
|
...validDisplayPreferences,
|
|
authorRowDensity: 'comfortable',
|
|
authorCoverFallback: false,
|
|
bookCoverDisplayMode: 'fill-crop',
|
|
} satisfies DisplayPreferences;
|
|
|
|
await expect(service.upsertDisplayPreferences(11, preferences)).resolves.toBeUndefined();
|
|
|
|
expect(repo.upsert).toHaveBeenCalledWith(11, 'display', preferences);
|
|
});
|
|
|
|
it('upsertDisplayPreferences defaults author display preferences omitted by older clients', async () => {
|
|
const { authorRowDensity, authorCoverFallback, ...olderPreferences } = validDisplayPreferences;
|
|
void authorRowDensity;
|
|
void authorCoverFallback;
|
|
|
|
await expect(service.upsertDisplayPreferences(11, olderPreferences)).resolves.toBeUndefined();
|
|
|
|
expect(repo.upsert).toHaveBeenCalledWith(11, 'display', expect.objectContaining({ authorRowDensity: 'comfortable', authorCoverFallback: false }));
|
|
});
|
|
|
|
it.each([
|
|
['authorRowDensity', 'dense'],
|
|
['authorCoverFallback', 'yes'],
|
|
])('upsertDisplayPreferences rejects invalid %s values', async (field, value) => {
|
|
await expect(service.upsertDisplayPreferences(11, { ...validDisplayPreferences, [field]: value })).rejects.toBeInstanceOf(BadRequestException);
|
|
expect(repo.upsert).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('upsertDisplayPreferences rejects invalid cover display modes', async () => {
|
|
await expect(
|
|
service.upsertDisplayPreferences(11, { ...validDisplayPreferences, bookCoverDisplayMode: 'stretched' } as never),
|
|
).rejects.toBeInstanceOf(BadRequestException);
|
|
expect(repo.upsert).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('upsertDisplayPreferences rejects invalid enum values', async () => {
|
|
await expect(service.upsertDisplayPreferences(11, { ...validDisplayPreferences, tableDensity: 'huge' } as never)).rejects.toBeInstanceOf(
|
|
BadRequestException,
|
|
);
|
|
expect(repo.upsert).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('upsertDisplayPreferences rejects cover sizes below bounds', async () => {
|
|
await expect(service.upsertDisplayPreferences(11, { ...validDisplayPreferences, portraitCoverSize: 99 })).rejects.toBeInstanceOf(
|
|
BadRequestException,
|
|
);
|
|
expect(repo.upsert).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('upsertDisplayPreferences rejects grid gaps above bounds', async () => {
|
|
await expect(service.upsertDisplayPreferences(11, { ...validDisplayPreferences, squareGridGap: 81 })).rejects.toBeInstanceOf(BadRequestException);
|
|
expect(repo.upsert).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('upsertDisplayPreferences rejects duplicate card overlays', async () => {
|
|
await expect(service.upsertDisplayPreferences(11, { ...validDisplayPreferences, cardOverlays: ['format', 'format'] })).rejects.toBeInstanceOf(
|
|
BadRequestException,
|
|
);
|
|
expect(repo.upsert).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('upsertDisplayPreferences rejects unknown card overlays', async () => {
|
|
await expect(
|
|
service.upsertDisplayPreferences(11, { ...validDisplayPreferences, cardOverlays: ['format', 'provider'] } as never),
|
|
).rejects.toBeInstanceOf(BadRequestException);
|
|
expect(repo.upsert).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('upsertDisplayPreferences rejects extra unknown fields', async () => {
|
|
await expect(
|
|
service.upsertDisplayPreferences(11, { ...validDisplayPreferences, unexpected: true } as Record<string, unknown>),
|
|
).rejects.toBeInstanceOf(BadRequestException);
|
|
expect(repo.upsert).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('upsertDisplayPreferences rejects payloads missing required fields', async () => {
|
|
const { bookCoverDisplayMode, ...incomplete } = validDisplayPreferences;
|
|
void bookCoverDisplayMode;
|
|
|
|
await expect(service.upsertDisplayPreferences(11, incomplete as unknown as Record<string, unknown>)).rejects.toBeInstanceOf(BadRequestException);
|
|
expect(repo.upsert).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('upsertDisplayPreferences rethrows repository failures after validation', async () => {
|
|
const err = new Error('database unavailable');
|
|
repo.upsert.mockRejectedValueOnce(err);
|
|
|
|
await expect(service.upsertDisplayPreferences(11, validDisplayPreferences as unknown as Record<string, unknown>)).rejects.toBe(err);
|
|
expect(repo.upsert).toHaveBeenCalledWith(11, 'display', validDisplayPreferences);
|
|
});
|
|
|
|
it('upsertDisplayPreferences logs and rethrows non-Error repository failures', async () => {
|
|
const err = 'database unavailable';
|
|
repo.upsert.mockRejectedValueOnce(err);
|
|
|
|
await expect(service.upsertDisplayPreferences(11, validDisplayPreferences as unknown as Record<string, unknown>)).rejects.toBe(err);
|
|
expect(repo.upsert).toHaveBeenCalledWith(11, 'display', validDisplayPreferences);
|
|
});
|
|
|
|
it('upsertDisplayPreferences accepts all valid gridCardPrimaryLabel values', async () => {
|
|
for (const value of ['hidden', 'book-title', 'series-title', 'series-title-position', 'author']) {
|
|
await expect(
|
|
service.upsertDisplayPreferences(11, { ...validDisplayPreferences, gridCardPrimaryLabel: value } as unknown as Record<string, unknown>),
|
|
).resolves.toBeUndefined();
|
|
}
|
|
});
|
|
|
|
it('upsertDisplayPreferences accepts all valid gridCardSecondaryLabel values', async () => {
|
|
for (const value of ['hidden', 'book-title', 'series-title', 'series-title-position', 'author']) {
|
|
await expect(
|
|
service.upsertDisplayPreferences(11, { ...validDisplayPreferences, gridCardSecondaryLabel: value } as unknown as Record<string, unknown>),
|
|
).resolves.toBeUndefined();
|
|
}
|
|
});
|
|
|
|
it('upsertDisplayPreferences rejects invalid gridCardPrimaryLabel value', async () => {
|
|
await expect(
|
|
service.upsertDisplayPreferences(11, { ...validDisplayPreferences, gridCardPrimaryLabel: 'unknown-field' } as unknown as Record<
|
|
string,
|
|
unknown
|
|
>),
|
|
).rejects.toBeInstanceOf(BadRequestException);
|
|
});
|
|
|
|
it('upsertDisplayPreferences rejects invalid gridCardSecondaryLabel value', async () => {
|
|
await expect(
|
|
service.upsertDisplayPreferences(11, { ...validDisplayPreferences, gridCardSecondaryLabel: 'bad-value' } as unknown as Record<string, unknown>),
|
|
).rejects.toBeInstanceOf(BadRequestException);
|
|
});
|
|
|
|
it('upsertDisplayPreferences defaults gridCardPrimaryLabel to hidden when omitted', async () => {
|
|
const { gridCardPrimaryLabel, ...withoutPrimary } = validDisplayPreferences;
|
|
void gridCardPrimaryLabel;
|
|
await expect(service.upsertDisplayPreferences(11, withoutPrimary as unknown as Record<string, unknown>)).resolves.toBeUndefined();
|
|
expect(repo.upsert).toHaveBeenCalledWith(11, 'display', expect.objectContaining({ gridCardPrimaryLabel: 'hidden' }));
|
|
});
|
|
|
|
it('upsertDisplayPreferences defaults gridCardSecondaryLabel to hidden when omitted', async () => {
|
|
const { gridCardSecondaryLabel, ...withoutSecondary } = validDisplayPreferences;
|
|
void gridCardSecondaryLabel;
|
|
await expect(service.upsertDisplayPreferences(11, withoutSecondary as unknown as Record<string, unknown>)).resolves.toBeUndefined();
|
|
expect(repo.upsert).toHaveBeenCalledWith(11, 'display', expect.objectContaining({ gridCardSecondaryLabel: 'hidden' }));
|
|
});
|
|
|
|
it('getWhatsNewPreferences returns defaults when repository has no row', async () => {
|
|
await expect(service.getWhatsNewPreferences(7)).resolves.toEqual({ lastSeenVersion: null, popupEnabled: true });
|
|
expect(repo.findByCategory).toHaveBeenCalledWith(7, 'whats-new');
|
|
});
|
|
|
|
it('getWhatsNewPreferences fills missing fields with defaults', async () => {
|
|
repo.findByCategory.mockResolvedValueOnce({ data: { lastSeenVersion: 'v1.2.0' } } as never);
|
|
await expect(service.getWhatsNewPreferences(7)).resolves.toEqual({ lastSeenVersion: 'v1.2.0', popupEnabled: true });
|
|
});
|
|
|
|
it('upsertWhatsNewPreferences merges a partial update onto existing values', async () => {
|
|
repo.findByCategory.mockResolvedValueOnce({ data: { lastSeenVersion: 'v1.0.0', popupEnabled: true } } as never);
|
|
await expect(service.upsertWhatsNewPreferences(11, { popupEnabled: false })).resolves.toBeUndefined();
|
|
expect(repo.upsert).toHaveBeenCalledWith(11, 'whats-new', { lastSeenVersion: 'v1.0.0', popupEnabled: false });
|
|
});
|
|
|
|
it('upsertWhatsNewPreferences rejects unknown fields', async () => {
|
|
await expect(service.upsertWhatsNewPreferences(11, { lastSeenVersion: 'v1.0.0', nope: true })).rejects.toBeInstanceOf(BadRequestException);
|
|
expect(repo.upsert).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('upsertWhatsNewPreferences rejects a non-boolean popupEnabled', async () => {
|
|
await expect(service.upsertWhatsNewPreferences(11, { popupEnabled: 'yes' })).rejects.toBeInstanceOf(BadRequestException);
|
|
expect(repo.upsert).not.toHaveBeenCalled();
|
|
});
|
|
|
|
describe('server font preferences', () => {
|
|
it('defaults to hiding nothing when the reader has never set them', async () => {
|
|
repo.findByCategory.mockResolvedValue(undefined);
|
|
|
|
await expect(service.getServerFontPreferences(11)).resolves.toEqual({ hiddenFamilies: [] });
|
|
expect(repo.findByCategory).toHaveBeenCalledWith(11, 'server-fonts');
|
|
});
|
|
|
|
it('returns the stored opt-outs', async () => {
|
|
repo.findByCategory.mockResolvedValue({ data: { hiddenFamilies: ['OpenDyslexic'] } });
|
|
|
|
await expect(service.getServerFontPreferences(11)).resolves.toEqual({ hiddenFamilies: ['OpenDyslexic'] });
|
|
});
|
|
|
|
it('tolerates a malformed stored payload rather than failing the reader', async () => {
|
|
repo.findByCategory.mockResolvedValue({ data: { hiddenFamilies: 'OpenDyslexic' } as never });
|
|
|
|
await expect(service.getServerFontPreferences(11)).resolves.toEqual({ hiddenFamilies: [] });
|
|
});
|
|
|
|
it('persists the opt-out list under its own category', async () => {
|
|
await service.upsertServerFontPreferences(11, { hiddenFamilies: ['OpenDyslexic', 'Literata'] });
|
|
|
|
expect(repo.upsert).toHaveBeenCalledWith(11, 'server-fonts', { hiddenFamilies: ['OpenDyslexic', 'Literata'] });
|
|
});
|
|
|
|
it('accepts an empty list, which is how a reader restores everything', async () => {
|
|
await service.upsertServerFontPreferences(11, { hiddenFamilies: [] });
|
|
|
|
expect(repo.upsert).toHaveBeenCalledWith(11, 'server-fonts', { hiddenFamilies: [] });
|
|
});
|
|
|
|
it('deduplicates repeated family names', async () => {
|
|
await service.upsertServerFontPreferences(11, { hiddenFamilies: ['Literata', 'Literata', 'OpenDyslexic'] });
|
|
|
|
expect(repo.upsert).toHaveBeenCalledWith(11, 'server-fonts', { hiddenFamilies: ['Literata', 'OpenDyslexic'] });
|
|
});
|
|
|
|
it('replaces rather than merges, so un-hiding actually removes an entry', async () => {
|
|
repo.findByCategory.mockResolvedValue({ data: { hiddenFamilies: ['Literata', 'OpenDyslexic'] } });
|
|
|
|
await service.upsertServerFontPreferences(11, { hiddenFamilies: ['Literata'] });
|
|
|
|
expect(repo.upsert).toHaveBeenCalledWith(11, 'server-fonts', { hiddenFamilies: ['Literata'] });
|
|
});
|
|
|
|
it('rejects a missing hiddenFamilies key', async () => {
|
|
await expect(service.upsertServerFontPreferences(11, {})).rejects.toBeInstanceOf(BadRequestException);
|
|
expect(repo.upsert).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('rejects non-string entries', async () => {
|
|
await expect(service.upsertServerFontPreferences(11, { hiddenFamilies: [42] })).rejects.toBeInstanceOf(BadRequestException);
|
|
expect(repo.upsert).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('rejects empty family names', async () => {
|
|
await expect(service.upsertServerFontPreferences(11, { hiddenFamilies: [''] })).rejects.toBeInstanceOf(BadRequestException);
|
|
expect(repo.upsert).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('rejects a family name longer than the column allows', async () => {
|
|
await expect(service.upsertServerFontPreferences(11, { hiddenFamilies: ['x'.repeat(201)] })).rejects.toBeInstanceOf(BadRequestException);
|
|
expect(repo.upsert).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('rejects a list longer than the server font cap', async () => {
|
|
const tooMany = Array.from({ length: MAX_SERVER_FONTS + 1 }, (_, i) => `Family ${i}`);
|
|
|
|
await expect(service.upsertServerFontPreferences(11, { hiddenFamilies: tooMany })).rejects.toBeInstanceOf(BadRequestException);
|
|
expect(repo.upsert).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('accepts a list exactly at the cap', async () => {
|
|
const atCap = Array.from({ length: MAX_SERVER_FONTS }, (_, i) => `Family ${i}`);
|
|
|
|
await expect(service.upsertServerFontPreferences(11, { hiddenFamilies: atCap })).resolves.toBeUndefined();
|
|
});
|
|
|
|
it('rejects unknown keys', async () => {
|
|
await expect(service.upsertServerFontPreferences(11, { hiddenFamilies: [], sneaky: true })).rejects.toBeInstanceOf(BadRequestException);
|
|
expect(repo.upsert).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('annotation colour name preferences', () => {
|
|
const hexKey = (i: number) => `#${i.toString(16).padStart(6, '0').toUpperCase()}`;
|
|
|
|
it('defaults to no names when the reader has never set any', async () => {
|
|
repo.findByCategory.mockResolvedValue(undefined);
|
|
|
|
const result = await service.getAnnotationColorNamePreferences(11);
|
|
|
|
expect(result).toEqual({ names: {} });
|
|
expect(repo.findByCategory).toHaveBeenCalledWith(11, 'annotation-colors');
|
|
result.names['#FFFFFF'] = 'mutated';
|
|
expect(ANNOTATION_COLOR_NAME_PREFERENCES_DEFAULTS).toEqual({ names: {} });
|
|
});
|
|
|
|
it('returns the stored names', async () => {
|
|
repo.findByCategory.mockResolvedValue({ data: { names: { '#FACC15': 'Ideas', '#4ADE80': 'Quotes' } } } as never);
|
|
|
|
await expect(service.getAnnotationColorNamePreferences(11)).resolves.toEqual({ names: { '#FACC15': 'Ideas', '#4ADE80': 'Quotes' } });
|
|
});
|
|
|
|
it('falls back to the defaults when the stored payload no longer validates', async () => {
|
|
repo.findByCategory.mockResolvedValue({ data: { names: { yellow: 'Ideas' } } as never });
|
|
|
|
await expect(service.getAnnotationColorNamePreferences(11)).resolves.toEqual({ names: {} });
|
|
});
|
|
|
|
it('normalises keys to uppercase, trims labels and drops blank ones', async () => {
|
|
await service.upsertAnnotationColorNamePreferences(11, { names: { '#facc15': ' Ideas ', '#4ade80': ' ', '#38BDF8': 'Quotes' } });
|
|
|
|
expect(repo.upsert).toHaveBeenCalledWith(11, 'annotation-colors', { names: { '#FACC15': 'Ideas', '#38BDF8': 'Quotes' } });
|
|
});
|
|
|
|
it('stores an empty map, which clears every name', async () => {
|
|
await service.upsertAnnotationColorNamePreferences(11, { names: {} });
|
|
|
|
expect(repo.upsert).toHaveBeenCalledWith(11, 'annotation-colors', { names: {} });
|
|
});
|
|
|
|
it.each([
|
|
['a named colour that is not #RRGGBB', { names: { yellow: 'Ideas' } }],
|
|
['a three-digit hex colour', { names: { '#FC1': 'Ideas' } }],
|
|
['a label longer than the cap', { names: { '#FACC15': 'x'.repeat(41) } }],
|
|
['a non-string label', { names: { '#FACC15': 7 } }],
|
|
['two keys naming the same colour', { names: { '#facc15': 'One', '#FACC15': 'Two' } }],
|
|
['a missing names map', {}],
|
|
['an unknown key', { names: {}, extra: true }],
|
|
])('rejects %s with 400', async (_label, settings) => {
|
|
await expect(service.upsertAnnotationColorNamePreferences(11, settings as Record<string, unknown>)).rejects.toBeInstanceOf(BadRequestException);
|
|
expect(repo.upsert).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('accepts a label exactly at the length cap', async () => {
|
|
await expect(service.upsertAnnotationColorNamePreferences(11, { names: { '#FACC15': 'x'.repeat(40) } })).resolves.toBeUndefined();
|
|
});
|
|
|
|
it('caps the number of named colours, counting only labels that are kept', async () => {
|
|
const atCap = Object.fromEntries(Array.from({ length: ANNOTATION_COLOR_NAME_MAX_ENTRIES }, (_, i) => [hexKey(i), `Name ${i}`]));
|
|
await expect(service.upsertAnnotationColorNamePreferences(11, { names: { ...atCap, '#ABCDEF': ' ' } })).resolves.toBeUndefined();
|
|
|
|
const overCap = { ...atCap, '#ABCDEF': 'One too many' };
|
|
await expect(service.upsertAnnotationColorNamePreferences(11, { names: overCap })).rejects.toBeInstanceOf(BadRequestException);
|
|
});
|
|
});
|
|
|
|
it('getPodcastPlaylistPreferences returns an empty list when nothing is stored', async () => {
|
|
await expect(service.getPodcastPlaylistPreferences(7)).resolves.toEqual({ playlists: [] });
|
|
expect(repo.findByCategory).toHaveBeenCalledWith(7, 'podcast-playlists');
|
|
});
|
|
|
|
it('getPodcastPlaylistPreferences falls back to an empty list when the stored payload no longer validates', async () => {
|
|
repo.findByCategory.mockResolvedValueOnce({ data: { playlists: [{ id: 'a', name: 'Legacy' }] } } as never);
|
|
|
|
await expect(service.getPodcastPlaylistPreferences(7)).resolves.toEqual({ playlists: [] });
|
|
});
|
|
|
|
it('upsertPodcastPlaylistPreferences persists validated playlists', async () => {
|
|
await expect(service.upsertPodcastPlaylistPreferences(11, validPodcastPlaylistPreferences)).resolves.toBeUndefined();
|
|
expect(repo.upsert).toHaveBeenCalledWith(11, 'podcast-playlists', validPodcastPlaylistPreferences);
|
|
});
|
|
|
|
it('upsertPodcastPlaylistPreferences rejects duplicate playlist ids', async () => {
|
|
const [playlist] = validPodcastPlaylistPreferences.playlists;
|
|
|
|
await expect(service.upsertPodcastPlaylistPreferences(11, { playlists: [playlist, playlist] })).rejects.toBeInstanceOf(BadRequestException);
|
|
expect(repo.upsert).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('upsertPodcastPlaylistPreferences rejects an unknown sort and out-of-range durations', async () => {
|
|
const [playlist] = validPodcastPlaylistPreferences.playlists;
|
|
|
|
await expect(
|
|
service.upsertPodcastPlaylistPreferences(11, { playlists: [{ ...playlist, rules: { ...playlist!.rules, sort: 'random' } }] }),
|
|
).rejects.toBeInstanceOf(BadRequestException);
|
|
await expect(
|
|
service.upsertPodcastPlaylistPreferences(11, { playlists: [{ ...playlist, rules: { ...playlist!.rules, maxDurationMinutes: 2000 } }] }),
|
|
).rejects.toBeInstanceOf(BadRequestException);
|
|
expect(repo.upsert).not.toHaveBeenCalled();
|
|
});
|
|
});
|