mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -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
781 lines
35 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
});
|