fix(stats): evaluate achievements for updated KOReader sessions

KOReader page-stat uploads can grow an existing derived session as more page events arrive, but
only newly inserted sessions reached achievement evaluation. A session that crossed a threshold by
being updated showed progress past that threshold while the badge stayed locked.

Carry the updated sessions out of the repository instead of a bare count and emit for them too.
The oversized-upload branch emitted a backfill event that nothing listened for, so subscribe
achievement evaluation to it as well; those runs are serialized per user and a burst collapses into
one trailing re-run, since each backfill re-evaluates the whole catalogue. Awards from that branch
stay silent: an upload that large is a device catching up, and notifying per badge would burst a
toast for every historical unlock.

Fixes #991
This commit is contained in:
neon
2026-08-14 06:09:03 +00:00
parent 0480242a98
commit 5d55b8aa0d
6 changed files with 207 additions and 19 deletions
@@ -4,6 +4,7 @@ import { and, asc, eq, like } from 'drizzle-orm';
import * as schema from '../src/db/schema';
import {
KOREADER_SESSION_GAP_SECONDS,
buildSessionId,
buildSessionIdPrefix,
deriveKoreaderSessions,
type KoreaderPageEvent,
@@ -26,6 +27,8 @@ const KOREADER_PASSWORD = 'PageStatsDevicePass123';
const DEVICE_ID = 'e2e-stats-0001';
const BASE_EPOCH = 1_780_000_000;
const TOTAL_PAGES = 300;
/** Mirrors POWER_HOUR_PAGES in the reading evaluator. */
const POWER_HOUR_PAGES = 75;
/** A contiguous run of one-minute page reads starting at `startEpoch`. */
function cluster(startEpoch: number, pages: number, firstPage: number): KoreaderPageEvent[] {
@@ -98,6 +101,28 @@ describe('KOReader page stats derivation (e2e)', { timeout: 180_000 }, () => {
}));
}
async function hasAchievement(userId: number, key: string): Promise<boolean> {
const rows = await ctx.db
.select({ key: schema.userAchievements.achievementKey })
.from(schema.userAchievements)
.where(and(eq(schema.userAchievements.userId, userId), eq(schema.userAchievements.achievementKey, key)));
return rows.length > 0;
}
/** Achievement evaluation is fire-and-forget off the upload response, so the award lands after it. */
async function waitForAchievement(userId: number, key: string, timeoutMs = 20_000): Promise<boolean> {
const deadline = Date.now() + timeoutMs;
for (;;) {
if (await hasAchievement(userId, key)) return true;
if (Date.now() >= deadline) return false;
await new Promise((resolve) => setTimeout(resolve, 100));
}
}
function pagesFor(progressDelta: number | null): number {
return ((progressDelta ?? 0) / 100) * TOTAL_PAGES;
}
function comparable(rows: Awaited<ReturnType<typeof storedSessions>>) {
return rows.map((row) => ({
sessionId: row.sessionId,
@@ -203,4 +228,37 @@ describe('KOReader page stats derivation (e2e)', { timeout: 180_000 }, () => {
expect(noop.results[0]!.accepted).toBe(0);
expect(await storedSessions()).toEqual(complete);
});
it('unlocks a session achievement when a later batch grows a stored session past its threshold', async () => {
const metadata = await ctx.db
.update(schema.bookMetadata)
.set({ pageCount: TOTAL_PAGES })
.where(eq(schema.bookMetadata.bookId, epub.bookId))
.returning({ bookId: schema.bookMetadata.bookId });
expect(metadata).toHaveLength(1);
const opening = cluster(BASE_EPOCH + 13_000_000, 40, 1);
await uploadPageStats(opening);
const sessionId = buildSessionId(DEVICE_ID, epub.bookFileId, opening[0]!.startTime);
const [session] = await ctx.db
.select({ userId: schema.readingSessions.userId, progressDelta: schema.readingSessions.progressDelta })
.from(schema.readingSessions)
.where(eq(schema.readingSessions.sessionId, sessionId));
expect(session).toBeTruthy();
expect(pagesFor(session!.progressDelta)).toBeLessThan(POWER_HOUR_PAGES);
expect(await hasAchievement(session!.userId, 'power_hour')).toBe(false);
// Resumes within the session gap, so the stored session grows under its existing id instead of
// being inserted again: the update path that previously emitted no achievement event.
const continued = cluster(opening.at(-1)!.startTime + 60, 45, opening.at(-1)!.page + 1);
await uploadPageStats(continued);
const grown = (await storedSessions()).filter((row) => row.startedAt.getTime() >= (BASE_EPOCH + 13_000_000) * 1000);
expect(grown).toHaveLength(1);
expect(grown[0]!.sessionId).toBe(sessionId);
expect(pagesFor(grown[0]!.progressDelta)).toBeGreaterThanOrEqual(POWER_HOUR_PAGES);
expect(await waitForAchievement(session!.userId, 'power_hour')).toBe(true);
});
});