mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user