Files
bookorbit/server/src/modules/achievement/achievement.service.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

781 lines
35 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { primeServerTextCatalog, resetServerTextCache } from '../../common/i18n/server-text';
import { AchievementService } from './achievement.service';
import { AchievementEventsService, ACHIEVEMENT_EVENT_BACKFILL, ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED } from './achievement-events.service';
function makeRepo() {
return {
findAllAchievements: vi.fn().mockResolvedValue([]),
findAchievementByKey: vi.fn().mockResolvedValue(null),
findUserAchievements: vi.fn().mockResolvedValue([]),
findUserEarnedKeys: vi.fn().mockResolvedValue(new Set()),
findUserIsSuperuser: vi.fn().mockResolvedValue(false),
findUserTimeZone: vi.fn().mockResolvedValue('UTC'),
award: vi.fn().mockResolvedValue({ id: 1, achievementKey: 'test', userId: 1, awardedAt: new Date(), contextJson: null }),
upsertCatalogue: vi.fn().mockResolvedValue(undefined),
backfillExistingCelebrations: vi.fn().mockResolvedValue(0),
claimNextCelebration: vi.fn().mockResolvedValue(null),
acknowledgeCelebration: vi.fn().mockResolvedValue('acknowledged'),
findAccessibleBookIds: vi.fn().mockResolvedValue(new Set()),
countFinishedBooks: vi.fn().mockResolvedValue(0),
sumPagesRead: vi.fn().mockResolvedValue(0),
sumReadingHours: vi.fn().mockResolvedValue(0),
countAccessibleBooks: vi.fn().mockResolvedValue(0),
countAnnotations: vi.fn().mockResolvedValue(0),
countDistinctGenresRead: vi.fn().mockResolvedValue(0),
countDistinctLanguagesRead: vi.fn().mockResolvedValue(0),
getCurrentStreak: vi.fn().mockResolvedValue(0),
getMaxFinishedBookPageCount: vi.fn().mockResolvedValue(0),
getMaxSessionMinutes: vi.fn().mockResolvedValue(0),
countBooksFinishedInDateRange: vi.fn().mockResolvedValue(0),
countCollections: vi.fn().mockResolvedValue(0),
maxBooksPerAuthor: vi.fn().mockResolvedValue(0),
countDistinctFormats: vi.fn().mockResolvedValue(0),
countDistinctCenturiesRead: vi.fn().mockResolvedValue(0),
countDistinctDecadesRead: vi.fn().mockResolvedValue(0),
countFinishedBooksByMaxPageCount: vi.fn().mockResolvedValue(0),
maxBooksPerGenre: vi.fn().mockResolvedValue(0),
countAnnotationsWithNotes: vi.fn().mockResolvedValue(0),
countAnnotationsOnDay: vi.fn().mockResolvedValue(0),
countBooksFinishedInMonth: vi.fn().mockResolvedValue(0),
countBooksFinishedInYear: vi.fn().mockResolvedValue(0),
countDistinctEarnedCategories: vi.fn().mockResolvedValue(0),
countDistinctSeasonsWithReading: vi.fn().mockResolvedValue(0),
countDistinctMonthsWithReading: vi.fn().mockResolvedValue(0),
};
}
function makeRegistry(awards: { key: string; context: Record<string, unknown> | null }[] = []) {
return {
evaluate: vi.fn().mockResolvedValue(awards),
};
}
function makeNotificationService() {
return {
notify: vi.fn().mockResolvedValue(undefined),
};
}
function makeUserService() {
return {
isAchievementEnabled: vi.fn().mockResolvedValue(true),
};
}
describe('AchievementService', () => {
let service: AchievementService;
let repo: ReturnType<typeof makeRepo>;
let events: AchievementEventsService;
let registry: ReturnType<typeof makeRegistry>;
let notificationService: ReturnType<typeof makeNotificationService>;
let userService: ReturnType<typeof makeUserService>;
beforeEach(() => {
vi.clearAllMocks();
repo = makeRepo();
events = new AchievementEventsService();
registry = makeRegistry();
notificationService = makeNotificationService();
userService = makeUserService();
service = new AchievementService(repo as never, events, registry as never, notificationService as never, userService as never);
});
describe('onModuleInit', () => {
it('seeds the catalogue', async () => {
await service.onModuleInit();
expect(repo.upsertCatalogue).toHaveBeenCalledOnce();
});
it('subscribes achievement evaluation to backfill events', async () => {
const handleEvent = vi.spyOn(service, 'handleEvent').mockResolvedValue(undefined);
await service.onModuleInit();
events.emit(ACHIEVEMENT_EVENT_BACKFILL, { userId: 7 });
expect(handleEvent).toHaveBeenCalledWith(ACHIEVEMENT_EVENT_BACKFILL, { userId: 7 });
});
it('collapses a burst of backfill events for one user into a single trailing re-run', async () => {
const pending: (() => void)[] = [];
const handleEvent = vi.spyOn(service, 'handleEvent').mockImplementation(() => new Promise<void>((resolve) => pending.push(resolve)));
await service.onModuleInit();
events.emit(ACHIEVEMENT_EVENT_BACKFILL, { userId: 7 });
events.emit(ACHIEVEMENT_EVENT_BACKFILL, { userId: 7 });
events.emit(ACHIEVEMENT_EVENT_BACKFILL, { userId: 7 });
expect(handleEvent).toHaveBeenCalledOnce();
pending[0]!();
await vi.waitFor(() => expect(handleEvent).toHaveBeenCalledTimes(2));
pending[1]!();
await new Promise((resolve) => setTimeout(resolve, 0));
expect(handleEvent).toHaveBeenCalledTimes(2);
});
it('does not serialize backfill runs across users', async () => {
const handleEvent = vi.spyOn(service, 'handleEvent').mockImplementation(() => new Promise<void>(() => undefined));
await service.onModuleInit();
events.emit(ACHIEVEMENT_EVENT_BACKFILL, { userId: 7 });
events.emit(ACHIEVEMENT_EVENT_BACKFILL, { userId: 8 });
expect(handleEvent).toHaveBeenCalledTimes(2);
expect(handleEvent).toHaveBeenCalledWith(ACHIEVEMENT_EVENT_BACKFILL, { userId: 8 });
});
});
describe('getCatalogue', () => {
it('returns categorized achievements with progress', async () => {
repo.findAllAchievements.mockResolvedValue([
{
key: 'books_finished_1',
groupKey: 'books_finished',
tier: 1,
category: 'reading',
name: 'Ink Initiate',
description: 'Finish 1 book',
iconName: 'book-open',
rarity: 'common',
threshold: 1,
hidden: false,
sortOrder: 1,
},
]);
repo.findUserAchievements.mockResolvedValue([]);
repo.countFinishedBooks.mockResolvedValue(0);
const result = await service.getCatalogue({ id: 1, isSuperuser: false } as never);
expect(result.categories).toHaveLength(5);
expect(result.totalAvailable).toBe(1);
expect(result.totalEarned).toBe(0);
const readingCategory = result.categories.find((c) => c.key === 'reading');
expect(readingCategory?.achievements).toHaveLength(1);
expect(readingCategory?.achievements[0]?.earned).toBe(false);
});
it('marks earned achievements correctly', async () => {
repo.findAllAchievements.mockResolvedValue([
{
key: 'books_finished_1',
groupKey: 'books_finished',
tier: 1,
category: 'reading',
name: 'Ink Initiate',
description: 'Finish 1',
iconName: 'book-open',
rarity: 'common',
threshold: 1,
hidden: false,
sortOrder: 1,
},
]);
repo.findUserAchievements.mockResolvedValue([
{ achievementKey: 'books_finished_1', awardedAt: new Date('2026-01-01'), contextJson: { bookId: 1 } },
]);
const result = await service.getCatalogue({ id: 1, isSuperuser: false } as never);
const item = result.categories.find((c) => c.key === 'reading')?.achievements[0];
expect(item?.earned).toBe(true);
expect(item?.awardedAt).toBeTruthy();
});
it('hides name and description for hidden unearned achievements', async () => {
repo.findAllAchievements.mockResolvedValue([
{
key: 'secret_1',
groupKey: null,
tier: null,
category: 'reading',
name: 'Secret',
description: 'Hidden thing',
iconName: 'gift',
rarity: 'epic',
threshold: null,
hidden: true,
sortOrder: 99,
},
]);
repo.findUserAchievements.mockResolvedValue([]);
const result = await service.getCatalogue({ id: 1, isSuperuser: false } as never);
const item = result.categories.find((c) => c.key === 'reading')?.achievements[0];
expect(item?.name).toBe('Secret Achievement');
expect(item?.description).toBe('Keep reading to reveal this achievement.');
expect(item?.iconName).toBe('lock');
expect(item?.threshold).toBeNull();
expect(item?.context).toBeNull();
});
describe('in another language', () => {
const visible = {
key: 'books_finished_1',
groupKey: 'books_finished',
tier: 1,
category: 'reading',
name: 'Ink Initiate',
description: 'Finish your first book',
iconName: 'book-open',
rarity: 'common',
threshold: 1,
hidden: false,
sortOrder: 1,
};
const secret = { ...visible, key: 'secret_1', groupKey: null, tier: null, name: 'Secret', description: 'Hidden thing', hidden: true };
beforeEach(() => {
primeServerTextCatalog('de', {
achievements: { books_finished_1: { name: 'Tinten-Neuling', description: 'Beende dein erstes Buch' } },
achievementCategories: { reading: 'Lesen' },
achievementSecret: { name: 'Geheimer Erfolg', description: 'Lies weiter, um ihn zu enthüllen.' },
});
repo.findUserAchievements.mockResolvedValue([]);
});
afterEach(() => resetServerTextCache());
it('translates names, descriptions, category labels and the secret placeholder', async () => {
repo.findAllAchievements.mockResolvedValue([visible, secret]);
const result = await service.getCatalogue({ id: 1, isSuperuser: false } as never, 'de');
const reading = result.categories.find((c) => c.key === 'reading');
expect(reading?.label).toBe('Lesen');
expect(reading?.achievements.map((item) => [item.name, item.description])).toEqual([
['Tinten-Neuling', 'Beende dein erstes Buch'],
['Geheimer Erfolg', 'Lies weiter, um ihn zu enthüllen.'],
]);
expect(result.categories.find((c) => c.key === 'library')?.label).toBe('Library');
});
it('keeps the stored English when a request names no language', async () => {
repo.findAllAchievements.mockResolvedValue([visible]);
const result = await service.getCatalogue({ id: 1, isSuperuser: false } as never);
const reading = result.categories.find((c) => c.key === 'reading');
expect(reading?.label).toBe('Reading');
expect(reading?.achievements[0]?.name).toBe('Ink Initiate');
});
});
});
describe('celebrations', () => {
const achievement = {
key: 'books_finished_1',
groupKey: 'books_finished',
tier: 1,
category: 'reading',
name: 'Ink Initiate',
description: 'Finish one book',
iconName: 'book-open',
rarity: 'common',
threshold: 1,
hidden: false,
sortOrder: 1,
};
it('does not claim when achievements are disabled', async () => {
userService.isAchievementEnabled.mockResolvedValue(false);
await expect(service.claimCelebration({ id: 1, isSuperuser: false } as never)).resolves.toBeNull();
expect(repo.claimNextCelebration).not.toHaveBeenCalled();
});
it('returns a typed claim with accessible related-book context', async () => {
repo.claimNextCelebration.mockResolvedValue({
achievement,
award: {
id: 3,
userId: 1,
achievementKey: achievement.key,
awardedAt: new Date('2026-07-22T12:00:00Z'),
contextJson: { bookId: 42, bookTitle: 'The Long Orbit' },
},
});
repo.findAccessibleBookIds.mockResolvedValue(new Set([42]));
const claim = await service.claimCelebration({ id: 1, isSuperuser: false } as never);
expect(claim?.claimId).toMatch(/^[0-9a-f-]{36}$/);
expect(claim?.achievement.contextBookId).toBe(42);
expect(claim?.achievement.contextBookTitle).toBe('The Long Orbit');
expect(repo.claimNextCelebration).toHaveBeenCalledOnce();
});
it('treats missing acknowledgement as idempotent and rejects foreign claims', async () => {
repo.acknowledgeCelebration.mockResolvedValueOnce('missing').mockResolvedValueOnce('foreign');
await expect(service.acknowledgeCelebration({ id: 1 } as never, 'claim')).resolves.toBeUndefined();
await expect(service.acknowledgeCelebration({ id: 1 } as never, 'claim')).rejects.toThrow('not found');
});
});
describe('getCatalogue - single badge progress', () => {
const marathonerRow = {
key: 'marathoner',
groupKey: null,
tier: null,
category: 'reading',
name: 'Marathoner',
description: 'Complete a reading session of 90 minutes or more',
iconName: 'timer',
rarity: 'rare',
threshold: 90,
hidden: false,
sortOrder: 13,
};
it('returns progress for marathoner based on max session minutes', async () => {
repo.findAllAchievements.mockResolvedValue([marathonerRow]);
repo.findUserAchievements.mockResolvedValue([]);
repo.getMaxSessionMinutes.mockResolvedValue(45);
const result = await service.getCatalogue({ id: 1, isSuperuser: false } as never);
const item = result.categories.find((c) => c.key === 'reading')?.achievements.find((a) => a.key === 'marathoner');
expect(item?.currentProgress).toBe(45);
expect(repo.getMaxSessionMinutes).toHaveBeenCalledWith(1);
});
it('skips progress computation for earned single-badge achievements', async () => {
repo.findAllAchievements.mockResolvedValue([marathonerRow]);
repo.findUserAchievements.mockResolvedValue([{ achievementKey: 'marathoner', awardedAt: new Date('2026-01-01'), contextJson: null }]);
repo.getMaxSessionMinutes.mockResolvedValue(120);
const result = await service.getCatalogue({ id: 1, isSuperuser: false } as never);
const item = result.categories.find((c) => c.key === 'reading')?.achievements.find((a) => a.key === 'marathoner');
expect(item?.currentProgress).toBeNull();
expect(repo.getMaxSessionMinutes).not.toHaveBeenCalled();
});
it('returns null progress for badges with no trackable progress query', async () => {
const sprintRow = {
key: 'sprint',
groupKey: null,
tier: null,
category: 'reading',
name: 'Sprint',
description: 'Finish a book in a single day',
iconName: 'zap',
rarity: 'epic',
threshold: null,
hidden: false,
sortOrder: 18,
};
repo.findAllAchievements.mockResolvedValue([sprintRow]);
repo.findUserAchievements.mockResolvedValue([]);
const result = await service.getCatalogue({ id: 1, isSuperuser: false } as never);
const item = result.categories.find((c) => c.key === 'reading')?.achievements.find((a) => a.key === 'sprint');
expect(item?.currentProgress).toBeNull();
});
it('returns curator progress from collection count', async () => {
const curatorRow = {
key: 'curator',
groupKey: null,
tier: null,
category: 'library',
name: 'Curator',
description: 'Create 10 or more collections',
iconName: 'folder-open',
rarity: 'rare',
threshold: 10,
hidden: false,
sortOrder: 10,
};
repo.findAllAchievements.mockResolvedValue([curatorRow]);
repo.findUserAchievements.mockResolvedValue([]);
repo.countCollections.mockResolvedValue(6);
const result = await service.getCatalogue({ id: 1, isSuperuser: false } as never);
const item = result.categories.find((c) => c.key === 'library')?.achievements.find((a) => a.key === 'curator');
expect(item?.currentProgress).toBe(6);
expect(repo.countCollections).toHaveBeenCalledWith(1);
});
it('returns author_deep_dive progress from max books per author', async () => {
const authorRow = {
key: 'author_deep_dive',
groupKey: null,
tier: null,
category: 'exploration',
name: 'Author Deep Dive',
description: 'Read 5 or more books by the same author',
iconName: 'user',
rarity: 'rare',
threshold: 5,
hidden: false,
sortOrder: 13,
};
repo.findAllAchievements.mockResolvedValue([authorRow]);
repo.findUserAchievements.mockResolvedValue([]);
repo.maxBooksPerAuthor.mockResolvedValue(3);
const result = await service.getCatalogue({ id: 1, isSuperuser: false } as never);
const item = result.categories.find((c) => c.key === 'exploration')?.achievements.find((a) => a.key === 'author_deep_dive');
expect(item?.currentProgress).toBe(3);
});
function makeSingleBadgeRow(key: string, category: string) {
return {
key,
groupKey: null,
tier: null,
category,
name: key,
description: key,
iconName: 'star',
rarity: 'common',
threshold: 1,
hidden: false,
sortOrder: 1,
};
}
it('returns multi_format progress', async () => {
repo.findAllAchievements.mockResolvedValue([makeSingleBadgeRow('multi_format', 'library')]);
repo.findUserAchievements.mockResolvedValue([]);
repo.countDistinctFormats.mockResolvedValue(2);
const result = await service.getCatalogue({ id: 1, isSuperuser: false } as never);
const item = result.categories.find((c) => c.key === 'library')?.achievements.find((a) => a.key === 'multi_format');
expect(item?.currentProgress).toBe(2);
});
it('returns century_span progress', async () => {
repo.findAllAchievements.mockResolvedValue([makeSingleBadgeRow('century_span', 'exploration')]);
repo.findUserAchievements.mockResolvedValue([]);
repo.countDistinctCenturiesRead.mockResolvedValue(3);
const result = await service.getCatalogue({ id: 1, isSuperuser: false } as never);
const item = result.categories.find((c) => c.key === 'exploration')?.achievements.find((a) => a.key === 'century_span');
expect(item?.currentProgress).toBe(3);
});
it('returns decade_sampler progress', async () => {
repo.findAllAchievements.mockResolvedValue([makeSingleBadgeRow('decade_sampler', 'exploration')]);
repo.findUserAchievements.mockResolvedValue([]);
repo.countDistinctDecadesRead.mockResolvedValue(4);
const result = await service.getCatalogue({ id: 1, isSuperuser: false } as never);
const item = result.categories.find((c) => c.key === 'exploration')?.achievements.find((a) => a.key === 'decade_sampler');
expect(item?.currentProgress).toBe(4);
});
it('returns short_story_fan progress', async () => {
repo.findAllAchievements.mockResolvedValue([makeSingleBadgeRow('short_story_fan', 'exploration')]);
repo.findUserAchievements.mockResolvedValue([]);
repo.countFinishedBooksByMaxPageCount.mockResolvedValue(3);
const result = await service.getCatalogue({ id: 1, isSuperuser: false } as never);
const item = result.categories.find((c) => c.key === 'exploration')?.achievements.find((a) => a.key === 'short_story_fan');
expect(item?.currentProgress).toBe(3);
});
it('returns genre_devotee progress', async () => {
repo.findAllAchievements.mockResolvedValue([makeSingleBadgeRow('genre_devotee', 'exploration')]);
repo.findUserAchievements.mockResolvedValue([]);
repo.maxBooksPerGenre.mockResolvedValue(7);
const result = await service.getCatalogue({ id: 1, isSuperuser: false } as never);
const item = result.categories.find((c) => c.key === 'exploration')?.achievements.find((a) => a.key === 'genre_devotee');
expect(item?.currentProgress).toBe(7);
});
it('returns note_keeper progress', async () => {
repo.findAllAchievements.mockResolvedValue([makeSingleBadgeRow('note_keeper', 'library')]);
repo.findUserAchievements.mockResolvedValue([]);
repo.countAnnotationsWithNotes.mockResolvedValue(15);
const result = await service.getCatalogue({ id: 1, isSuperuser: false } as never);
const item = result.categories.find((c) => c.key === 'library')?.achievements.find((a) => a.key === 'note_keeper');
expect(item?.currentProgress).toBe(15);
});
it('returns deep_dive_session progress', async () => {
repo.findAllAchievements.mockResolvedValue([makeSingleBadgeRow('deep_dive_session', 'library')]);
repo.findUserAchievements.mockResolvedValue([]);
repo.countAnnotationsOnDay.mockResolvedValue(5);
const result = await service.getCatalogue({ id: 1, isSuperuser: false } as never);
const item = result.categories.find((c) => c.key === 'library')?.achievements.find((a) => a.key === 'deep_dive_session');
expect(item?.currentProgress).toBe(5);
});
it('returns monthly_reader_2 progress', async () => {
repo.findAllAchievements.mockResolvedValue([makeSingleBadgeRow('monthly_reader_2', 'reading')]);
repo.findUserAchievements.mockResolvedValue([]);
repo.countBooksFinishedInMonth.mockResolvedValue(8);
const result = await service.getCatalogue({ id: 1, isSuperuser: false } as never);
const item = result.categories.find((c) => c.key === 'reading')?.achievements.find((a) => a.key === 'monthly_reader_2');
expect(item?.currentProgress).toBe(8);
});
it('returns yearly_finisher_12 progress', async () => {
repo.findAllAchievements.mockResolvedValue([makeSingleBadgeRow('yearly_finisher_12', 'dedication')]);
repo.findUserAchievements.mockResolvedValue([]);
repo.countBooksFinishedInYear.mockResolvedValue(9);
const result = await service.getCatalogue({ id: 1, isSuperuser: false } as never);
const item = result.categories.find((c) => c.key === 'dedication')?.achievements.find((a) => a.key === 'yearly_finisher_12');
expect(item?.currentProgress).toBe(9);
});
it('returns category_sweeper progress', async () => {
repo.findAllAchievements.mockResolvedValue([makeSingleBadgeRow('category_sweeper', 'dedication')]);
repo.findUserAchievements.mockResolvedValue([]);
repo.countDistinctEarnedCategories.mockResolvedValue(3);
const result = await service.getCatalogue({ id: 1, isSuperuser: false } as never);
const item = result.categories.find((c) => c.key === 'dedication')?.achievements.find((a) => a.key === 'category_sweeper');
expect(item?.currentProgress).toBe(3);
});
it('returns seasonal_reader progress', async () => {
repo.findAllAchievements.mockResolvedValue([makeSingleBadgeRow('seasonal_reader', 'dedication')]);
repo.findUserAchievements.mockResolvedValue([]);
repo.countDistinctSeasonsWithReading.mockResolvedValue(2);
const result = await service.getCatalogue({ id: 1, isSuperuser: false } as never);
const item = result.categories.find((c) => c.key === 'dedication')?.achievements.find((a) => a.key === 'seasonal_reader');
expect(item?.currentProgress).toBe(2);
});
it('returns year_round_reader progress as months read this year', async () => {
repo.findAllAchievements.mockResolvedValue([makeSingleBadgeRow('year_round_reader', 'dedication')]);
repo.findUserAchievements.mockResolvedValue([]);
repo.countDistinctMonthsWithReading.mockResolvedValue(10);
const result = await service.getCatalogue({ id: 1, isSuperuser: false } as never);
const item = result.categories.find((c) => c.key === 'dedication')?.achievements.find((a) => a.key === 'year_round_reader');
expect(item?.currentProgress).toBe(10);
expect(repo.countDistinctMonthsWithReading).toHaveBeenCalledWith(1, new Date().getUTCFullYear());
});
it('returns null progress for consistent_reader', async () => {
repo.findAllAchievements.mockResolvedValue([makeSingleBadgeRow('consistent_reader', 'dedication')]);
repo.findUserAchievements.mockResolvedValue([]);
const result = await service.getCatalogue({ id: 1, isSuperuser: false } as never);
const item = result.categories.find((c) => c.key === 'dedication')?.achievements.find((a) => a.key === 'consistent_reader');
expect(item?.currentProgress).toBeNull();
});
it('returns null progress for weekend_rhythm', async () => {
repo.findAllAchievements.mockResolvedValue([makeSingleBadgeRow('weekend_rhythm', 'dedication')]);
repo.findUserAchievements.mockResolvedValue([]);
const result = await service.getCatalogue({ id: 1, isSuperuser: false } as never);
const item = result.categories.find((c) => c.key === 'dedication')?.achievements.find((a) => a.key === 'weekend_rhythm');
expect(item?.currentProgress).toBeNull();
});
it('returns null progress for unknown badge key', async () => {
repo.findAllAchievements.mockResolvedValue([makeSingleBadgeRow('totally_unknown_badge', 'dedication')]);
repo.findUserAchievements.mockResolvedValue([]);
const result = await service.getCatalogue({ id: 1, isSuperuser: false } as never);
const item = result.categories.find((c) => c.key === 'dedication')?.achievements.find((a) => a.key === 'totally_unknown_badge');
expect(item?.currentProgress).toBeNull();
});
});
describe('getCatalogue - tiered group progress', () => {
function makeTieredRow(key: string, groupKey: string, tier: number, category: string, threshold: number) {
return {
key,
groupKey,
tier,
category,
name: key,
description: key,
iconName: 'star',
rarity: 'common',
threshold,
hidden: false,
sortOrder: tier,
};
}
it('returns books_finished group progress', async () => {
repo.findAllAchievements.mockResolvedValue([makeTieredRow('books_finished_1', 'books_finished', 1, 'reading', 1)]);
repo.findUserAchievements.mockResolvedValue([]);
repo.countFinishedBooks.mockResolvedValue(5);
const result = await service.getCatalogue({ id: 1, isSuperuser: false } as never);
const item = result.categories.find((c) => c.key === 'reading')?.achievements.find((a) => a.key === 'books_finished_1');
expect(item?.currentProgress).toBe(5);
});
it('returns pages_read group progress', async () => {
repo.findAllAchievements.mockResolvedValue([makeTieredRow('pages_read_1', 'pages_read', 1, 'reading', 1000)]);
repo.findUserAchievements.mockResolvedValue([]);
repo.sumPagesRead.mockResolvedValue(750);
const result = await service.getCatalogue({ id: 1, isSuperuser: false } as never);
const item = result.categories.find((c) => c.key === 'reading')?.achievements.find((a) => a.key === 'pages_read_1');
expect(item?.currentProgress).toBe(750);
});
it('returns long_book group progress', async () => {
repo.findAllAchievements.mockResolvedValue([makeTieredRow('long_book_1', 'long_book', 1, 'reading', 300)]);
repo.findUserAchievements.mockResolvedValue([]);
repo.getMaxFinishedBookPageCount.mockResolvedValue(540);
const result = await service.getCatalogue({ id: 1, isSuperuser: false } as never);
const item = result.categories.find((c) => c.key === 'reading')?.achievements.find((a) => a.key === 'long_book_1');
expect(item?.currentProgress).toBe(540);
});
it('returns hours_read group progress', async () => {
repo.findAllAchievements.mockResolvedValue([makeTieredRow('hours_read_1', 'hours_read', 1, 'reading', 10)]);
repo.findUserAchievements.mockResolvedValue([]);
repo.sumReadingHours.mockResolvedValue(7);
const result = await service.getCatalogue({ id: 1, isSuperuser: false } as never);
const item = result.categories.find((c) => c.key === 'reading')?.achievements.find((a) => a.key === 'hours_read_1');
expect(item?.currentProgress).toBe(7);
});
it('returns library_builder group progress', async () => {
repo.findAllAchievements.mockResolvedValue([makeTieredRow('library_builder_1', 'library_builder', 1, 'library', 50)]);
repo.findUserAchievements.mockResolvedValue([]);
repo.countAccessibleBooks.mockResolvedValue(30);
const result = await service.getCatalogue({ id: 1, isSuperuser: false } as never);
const item = result.categories.find((c) => c.key === 'library')?.achievements.find((a) => a.key === 'library_builder_1');
expect(item?.currentProgress).toBe(30);
});
it('returns annotator group progress', async () => {
repo.findAllAchievements.mockResolvedValue([makeTieredRow('annotator_1', 'annotator', 1, 'library', 10)]);
repo.findUserAchievements.mockResolvedValue([]);
repo.countAnnotations.mockResolvedValue(4);
const result = await service.getCatalogue({ id: 1, isSuperuser: false } as never);
const item = result.categories.find((c) => c.key === 'library')?.achievements.find((a) => a.key === 'annotator_1');
expect(item?.currentProgress).toBe(4);
});
it('returns genre_explorer group progress', async () => {
repo.findAllAchievements.mockResolvedValue([makeTieredRow('genre_explorer_1', 'genre_explorer', 1, 'exploration', 5)]);
repo.findUserAchievements.mockResolvedValue([]);
repo.countDistinctGenresRead.mockResolvedValue(3);
const result = await service.getCatalogue({ id: 1, isSuperuser: false } as never);
const item = result.categories.find((c) => c.key === 'exploration')?.achievements.find((a) => a.key === 'genre_explorer_1');
expect(item?.currentProgress).toBe(3);
});
it('returns polyglot group progress', async () => {
repo.findAllAchievements.mockResolvedValue([makeTieredRow('polyglot_1', 'polyglot', 1, 'exploration', 3)]);
repo.findUserAchievements.mockResolvedValue([]);
repo.countDistinctLanguagesRead.mockResolvedValue(2);
const result = await service.getCatalogue({ id: 1, isSuperuser: false } as never);
const item = result.categories.find((c) => c.key === 'exploration')?.achievements.find((a) => a.key === 'polyglot_1');
expect(item?.currentProgress).toBe(2);
});
it('returns streak group progress', async () => {
repo.findAllAchievements.mockResolvedValue([makeTieredRow('streak_1', 'streak', 1, 'dedication', 7)]);
repo.findUserAchievements.mockResolvedValue([]);
repo.getCurrentStreak.mockResolvedValue(5);
const result = await service.getCatalogue({ id: 1, isSuperuser: false } as never);
const item = result.categories.find((c) => c.key === 'dedication')?.achievements.find((a) => a.key === 'streak_1');
expect(item?.currentProgress).toBe(5);
});
it('returns null progress for unknown group key', async () => {
repo.findAllAchievements.mockResolvedValue([makeTieredRow('unknown_group_badge', 'totally_unknown_group', 1, 'reading', 100)]);
repo.findUserAchievements.mockResolvedValue([]);
const result = await service.getCatalogue({ id: 1, isSuperuser: false } as never);
const item = result.categories.find((c) => c.key === 'reading')?.achievements.find((a) => a.key === 'unknown_group_badge');
expect(item?.currentProgress).toBeNull();
});
it('skips progress for all earned tiers in a group', async () => {
repo.findAllAchievements.mockResolvedValue([
makeTieredRow('books_finished_1', 'books_finished', 1, 'reading', 1),
makeTieredRow('books_finished_2', 'books_finished', 2, 'reading', 10),
]);
repo.findUserAchievements.mockResolvedValue([
{ achievementKey: 'books_finished_1', awardedAt: new Date(), contextJson: null },
{ achievementKey: 'books_finished_2', awardedAt: new Date(), contextJson: null },
]);
await service.getCatalogue({ id: 1, isSuperuser: false } as never);
expect(repo.countFinishedBooks).not.toHaveBeenCalled();
});
});
describe('handleEvent', () => {
it('skips evaluation when achievements are disabled', async () => {
userService.isAchievementEnabled.mockResolvedValue(false);
await service.handleEvent(ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED, { userId: 1, bookId: 5, newStatus: 'read' });
expect(registry.evaluate).not.toHaveBeenCalled();
expect(repo.award).not.toHaveBeenCalled();
expect(notificationService.notify).not.toHaveBeenCalled();
});
it('evaluates and awards achievements', async () => {
registry.evaluate.mockResolvedValueOnce([{ key: 'books_finished_1', context: { count: 1 } }]).mockResolvedValueOnce([]);
repo.findAchievementByKey.mockResolvedValue({ key: 'books_finished_1', name: 'Ink Initiate', rarity: 'common', iconName: 'book-open' });
await service.handleEvent(ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED, { userId: 1, bookId: 5, newStatus: 'read' });
expect(registry.evaluate).toHaveBeenCalledTimes(2);
expect(repo.award).toHaveBeenCalledWith(1, 'books_finished_1', { count: 1 });
expect(notificationService.notify).toHaveBeenCalledOnce();
expect(notificationService.notify).toHaveBeenCalledWith(
expect.objectContaining({
message: 'Ink Initiate',
meta: expect.objectContaining({
achievementKey: 'books_finished_1',
achievementName: 'Ink Initiate',
rarity: 'common',
}),
}),
);
});
it('does not call evaluateMetaBadges when no achievement is awarded', async () => {
registry.evaluate.mockResolvedValue([]);
await service.handleEvent(ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED, { userId: 1, bookId: 5, newStatus: 'read' });
expect(registry.evaluate).toHaveBeenCalledOnce();
expect(repo.award).not.toHaveBeenCalled();
});
it('skips already earned achievements', async () => {
repo.findUserEarnedKeys.mockResolvedValue(new Set(['books_finished_1']));
registry.evaluate.mockResolvedValue([{ key: 'books_finished_1', context: { count: 1 } }]);
await service.handleEvent(ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED, { userId: 1, bookId: 5, newStatus: 'read' });
expect(repo.award).not.toHaveBeenCalled();
expect(notificationService.notify).not.toHaveBeenCalled();
});
it('does nothing when no userId in payload', async () => {
await service.handleEvent(ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED, { bookId: 5 });
expect(registry.evaluate).not.toHaveBeenCalled();
});
it('awards backfill achievements without notifying', async () => {
registry.evaluate.mockResolvedValueOnce([{ key: 'power_hour', context: { pages: 112 } }]).mockResolvedValueOnce([]);
repo.findAchievementByKey.mockResolvedValue({ key: 'power_hour', name: 'Power Hour', rarity: 'rare', iconName: 'zap' });
await service.handleEvent(ACHIEVEMENT_EVENT_BACKFILL, { userId: 1 });
expect(repo.award).toHaveBeenCalledWith(1, 'power_hour', { pages: 112 });
expect(notificationService.notify).not.toHaveBeenCalled();
});
it('handles achievement preference lookup errors gracefully', async () => {
userService.isAchievementEnabled.mockRejectedValue(new Error('Settings lookup failed'));
await expect(service.handleEvent(ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED, { userId: 1, bookId: 5, newStatus: 'read' })).resolves.toBeUndefined();
expect(registry.evaluate).not.toHaveBeenCalled();
});
it('handles errors gracefully', async () => {
registry.evaluate.mockRejectedValue(new Error('DB timeout'));
await expect(service.handleEvent(ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED, { userId: 1, bookId: 5, newStatus: 'read' })).resolves.toBeUndefined();
});
});
describe('onModuleDestroy', () => {
it('removes all registered event listeners', async () => {
await service.onModuleInit();
const removeListenerSpy = vi.spyOn(events, 'removeListener');
service.onModuleDestroy();
expect(removeListenerSpy).toHaveBeenCalledTimes(8);
});
});
});