mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
fix(server): reject ambiguous KOReader file hashes
Treat partial-hash matches across distinct books as unresolved so progress cannot be written to an unrelated file. Preserve same-book duplicates and user-scoped manual links as valid disambiguation paths. Fixes #1135
This commit is contained in:
@@ -20,8 +20,10 @@ function makeQueryChain(result: unknown) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function makeDb() {
|
function makeDb() {
|
||||||
|
const select = vi.fn();
|
||||||
return {
|
return {
|
||||||
select: vi.fn(),
|
select,
|
||||||
|
selectDistinctOn: vi.fn((_on, fields) => select(fields)),
|
||||||
insert: vi.fn(),
|
insert: vi.fn(),
|
||||||
update: vi.fn(),
|
update: vi.fn(),
|
||||||
delete: vi.fn(),
|
delete: vi.fn(),
|
||||||
@@ -114,6 +116,42 @@ describe('KoreaderRepository', () => {
|
|||||||
expect(db.select).toHaveBeenCalledTimes(1);
|
expect(db.select).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('returns the oldest file when current hash matches stay within one book', async () => {
|
||||||
|
const firstFile = { id: 10, bookId: 20, libraryId: 1, format: 'epub' };
|
||||||
|
const secondFile = { id: 11, bookId: 20, libraryId: 1, format: 'epub' };
|
||||||
|
db.select.mockReturnValue(makeQueryChain([firstFile, secondFile]));
|
||||||
|
|
||||||
|
await expect(repo.resolveBookFileByHash('abc123', null)).resolves.toEqual(firstFile);
|
||||||
|
expect(db.select).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when a current hash matches different books', async () => {
|
||||||
|
db.select.mockReturnValue(
|
||||||
|
makeQueryChain([
|
||||||
|
{ id: 10, bookId: 20, libraryId: 1, format: 'epub' },
|
||||||
|
{ id: 11, bookId: 21, libraryId: 1, format: 'epub' },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(repo.resolveBookFileByHash('abc123', null)).resolves.toBeNull();
|
||||||
|
expect(db.select).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses a user-scoped manual link when a current hash matches different books', async () => {
|
||||||
|
const manualFile = { id: 11, bookId: 21, libraryId: 1, format: 'epub' };
|
||||||
|
db.select
|
||||||
|
.mockReturnValueOnce(
|
||||||
|
makeQueryChain([
|
||||||
|
{ id: 10, bookId: 20, libraryId: 1, format: 'epub' },
|
||||||
|
{ id: 11, bookId: 21, libraryId: 1, format: 'epub' },
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
.mockReturnValueOnce(makeQueryChain([manualFile]));
|
||||||
|
|
||||||
|
await expect(repo.resolveBookFileByHash('abc123', null, 7)).resolves.toEqual(manualFile);
|
||||||
|
expect(db.select).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
it('falls back to hash history when current hash lookup returns nothing', async () => {
|
it('falls back to hash history when current hash lookup returns nothing', async () => {
|
||||||
const file = { id: 10, bookId: 20, libraryId: 1, format: 'pdf' };
|
const file = { id: 10, bookId: 20, libraryId: 1, format: 'pdf' };
|
||||||
db.select.mockReturnValueOnce(makeQueryChain([])).mockReturnValueOnce(makeQueryChain([file]));
|
db.select.mockReturnValueOnce(makeQueryChain([])).mockReturnValueOnce(makeQueryChain([file]));
|
||||||
@@ -124,6 +162,18 @@ describe('KoreaderRepository', () => {
|
|||||||
expect(db.select).toHaveBeenCalledTimes(2);
|
expect(db.select).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('returns null when a historical hash matches different books', async () => {
|
||||||
|
db.select.mockReturnValueOnce(makeQueryChain([])).mockReturnValueOnce(
|
||||||
|
makeQueryChain([
|
||||||
|
{ id: 10, bookId: 20, libraryId: 1, format: 'epub' },
|
||||||
|
{ id: 11, bookId: 21, libraryId: 1, format: 'epub' },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(repo.resolveBookFileByHash('oldhash', null)).resolves.toBeNull();
|
||||||
|
expect(db.select).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
it('falls back to a user-scoped manual link after direct and history lookups miss', async () => {
|
it('falls back to a user-scoped manual link after direct and history lookups miss', async () => {
|
||||||
const file = { id: 10, bookId: 20, libraryId: 1, format: 'cbz' };
|
const file = { id: 10, bookId: 20, libraryId: 1, format: 'cbz' };
|
||||||
db.select
|
db.select
|
||||||
@@ -200,6 +250,64 @@ describe('KoreaderRepository', () => {
|
|||||||
expect(db.select).toHaveBeenCalledTimes(1);
|
expect(db.select).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('resolves duplicate current hash rows when they belong to one book', async () => {
|
||||||
|
db.select.mockReturnValueOnce(
|
||||||
|
makeQueryChain([
|
||||||
|
{ hash: 'current', bookFileId: 11, bookId: 21, libraryId: 31, format: 'epub' },
|
||||||
|
{ hash: 'current', bookFileId: 12, bookId: 21, libraryId: 31, format: 'epub' },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await repo.resolveBookFilesByHashes(['current'], null);
|
||||||
|
|
||||||
|
expect(result.get('current')).toEqual({ bookFileId: 11, bookId: 21, libraryId: 31, format: 'epub' });
|
||||||
|
expect(db.select).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits a current hash that matches different books', async () => {
|
||||||
|
db.select.mockReturnValueOnce(
|
||||||
|
makeQueryChain([
|
||||||
|
{ hash: 'current', bookFileId: 11, bookId: 21, libraryId: 31, format: 'epub' },
|
||||||
|
{ hash: 'current', bookFileId: 12, bookId: 22, libraryId: 31, format: 'epub' },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await repo.resolveBookFilesByHashes(['current'], null);
|
||||||
|
|
||||||
|
expect(result.has('current')).toBe(false);
|
||||||
|
expect(db.select).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses a user-scoped manual link for an ambiguous current hash', async () => {
|
||||||
|
db.select
|
||||||
|
.mockReturnValueOnce(
|
||||||
|
makeQueryChain([
|
||||||
|
{ hash: 'current', bookFileId: 11, bookId: 21, libraryId: 31, format: 'epub' },
|
||||||
|
{ hash: 'current', bookFileId: 12, bookId: 22, libraryId: 31, format: 'epub' },
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
.mockReturnValueOnce(makeQueryChain([{ hash: 'current', bookFileId: 12, bookId: 22, libraryId: 31, format: 'epub' }]));
|
||||||
|
|
||||||
|
const result = await repo.resolveBookFilesByHashes(['current'], null, 7);
|
||||||
|
|
||||||
|
expect(result.get('current')).toEqual({ bookFileId: 12, bookId: 22, libraryId: 31, format: 'epub' });
|
||||||
|
expect(db.select).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits a historical hash that matches different books', async () => {
|
||||||
|
db.select.mockReturnValueOnce(makeQueryChain([])).mockReturnValueOnce(
|
||||||
|
makeQueryChain([
|
||||||
|
{ hash: 'old', bookFileId: 11, bookId: 21, libraryId: 31, format: 'epub' },
|
||||||
|
{ hash: 'old', bookFileId: 12, bookId: 22, libraryId: 31, format: 'epub' },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await repo.resolveBookFilesByHashes(['old'], null);
|
||||||
|
|
||||||
|
expect(result.has('old')).toBe(false);
|
||||||
|
expect(db.select).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
it('resolves remaining hashes from user-scoped manual links', async () => {
|
it('resolves remaining hashes from user-scoped manual links', async () => {
|
||||||
db.select
|
db.select
|
||||||
.mockReturnValueOnce(makeQueryChain([{ hash: 'current', bookFileId: 11, bookId: 21, libraryId: 31, format: 'epub' }]))
|
.mockReturnValueOnce(makeQueryChain([{ hash: 'current', bookFileId: 11, bookId: 21, libraryId: 31, format: 'epub' }]))
|
||||||
|
|||||||
@@ -24,6 +24,39 @@ type KoreaderHashLinkMetadata = {
|
|||||||
lastOpen?: number | null;
|
lastOpen?: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type HashedResolvedBookFile = ResolvedBookFileByHashes & { hash: string | null };
|
||||||
|
|
||||||
|
function resolveUniqueBook<T extends { bookId: number }>(rows: T[]): T | null {
|
||||||
|
const first = rows[0];
|
||||||
|
if (!first) return null;
|
||||||
|
return rows.every((row) => row.bookId === first.bookId) ? first : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addUnambiguousHashMatches(rows: HashedResolvedBookFile[], result: Map<string, ResolvedBookFileByHashes>): Set<string> {
|
||||||
|
const rowsByHash = new Map<string, HashedResolvedBookFile[]>();
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
if (!row.hash) continue;
|
||||||
|
const matches = rowsByHash.get(row.hash);
|
||||||
|
if (matches) matches.push(row);
|
||||||
|
else rowsByHash.set(row.hash, [row]);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [hash, matches] of rowsByHash) {
|
||||||
|
const resolved = resolveUniqueBook(matches);
|
||||||
|
if (resolved) {
|
||||||
|
result.set(hash, {
|
||||||
|
bookFileId: resolved.bookFileId,
|
||||||
|
bookId: resolved.bookId,
|
||||||
|
libraryId: resolved.libraryId,
|
||||||
|
format: resolved.format,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Set(rowsByHash.keys());
|
||||||
|
}
|
||||||
|
|
||||||
export interface ReadingProgressUpsert {
|
export interface ReadingProgressUpsert {
|
||||||
bookFileId: number;
|
bookFileId: number;
|
||||||
userId: number;
|
userId: number;
|
||||||
@@ -78,49 +111,51 @@ export class KoreaderRepository {
|
|||||||
await this.db.delete(schema.koreaderUsers).where(eq(schema.koreaderUsers.userId, userId));
|
await this.db.delete(schema.koreaderUsers).where(eq(schema.koreaderUsers.userId, userId));
|
||||||
}
|
}
|
||||||
|
|
||||||
// A file hash is not unique: the same content in two places is two book file rows, and one
|
// KOReader's partial hash can collide for different files. Multiple matching files are safe only
|
||||||
// historical hash can belong to several of them. The oldest row wins so a hash always resolves
|
// when they belong to the same book; otherwise a user-scoped manual link must disambiguate them.
|
||||||
// to the same target, both across requests and between this and resolveBookFilesByHashes. An
|
|
||||||
// unordered pick would move a device's sync target under it and strand data on the loser.
|
|
||||||
async resolveBookFileByHash(hash: string, accessibleLibraryIds: number[] | null, userId?: number): Promise<ResolvedBookFileByHash | null> {
|
async resolveBookFileByHash(hash: string, accessibleLibraryIds: number[] | null, userId?: number): Promise<ResolvedBookFileByHash | null> {
|
||||||
if (accessibleLibraryIds !== null && accessibleLibraryIds.length === 0) return null;
|
if (accessibleLibraryIds !== null && accessibleLibraryIds.length === 0) return null;
|
||||||
|
|
||||||
const libraryFilter = accessibleLibraryIds ? inArray(schema.books.libraryId, accessibleLibraryIds) : undefined;
|
const libraryFilter = accessibleLibraryIds ? inArray(schema.books.libraryId, accessibleLibraryIds) : undefined;
|
||||||
|
|
||||||
const [byFileHash] = await this.db
|
const byFileHash = await this.db
|
||||||
.select({ id: schema.bookFiles.id, bookId: schema.bookFiles.bookId, libraryId: schema.books.libraryId, format: schema.bookFiles.format })
|
.selectDistinctOn([schema.bookFiles.bookId], {
|
||||||
|
id: schema.bookFiles.id,
|
||||||
|
bookId: schema.bookFiles.bookId,
|
||||||
|
libraryId: schema.books.libraryId,
|
||||||
|
format: schema.bookFiles.format,
|
||||||
|
})
|
||||||
.from(schema.bookFiles)
|
.from(schema.bookFiles)
|
||||||
.innerJoin(schema.books, eq(schema.books.id, schema.bookFiles.bookId))
|
.innerJoin(schema.books, eq(schema.books.id, schema.bookFiles.bookId))
|
||||||
.where(and(eq(schema.bookFiles.fileHash, hash), libraryFilter))
|
.where(and(eq(schema.bookFiles.fileHash, hash), libraryFilter))
|
||||||
.orderBy(asc(schema.bookFiles.id))
|
.orderBy(asc(schema.bookFiles.bookId), asc(schema.bookFiles.id))
|
||||||
.limit(1);
|
.limit(2);
|
||||||
|
|
||||||
if (byFileHash) return byFileHash;
|
if (byFileHash.length > 0) {
|
||||||
|
return resolveUniqueBook(byFileHash) ?? (userId === undefined ? null : this.resolveManualBookFileByHash(hash, accessibleLibraryIds, userId));
|
||||||
|
}
|
||||||
|
|
||||||
const [byFileHashHistory] = await this.db
|
const byFileHashHistory = await this.db
|
||||||
.select({ id: schema.bookFiles.id, bookId: schema.bookFiles.bookId, libraryId: schema.books.libraryId, format: schema.bookFiles.format })
|
.selectDistinctOn([schema.bookFiles.bookId], {
|
||||||
|
id: schema.bookFiles.id,
|
||||||
|
bookId: schema.bookFiles.bookId,
|
||||||
|
libraryId: schema.books.libraryId,
|
||||||
|
format: schema.bookFiles.format,
|
||||||
|
})
|
||||||
.from(schema.bookFileHashHistory)
|
.from(schema.bookFileHashHistory)
|
||||||
.innerJoin(schema.bookFiles, eq(schema.bookFiles.id, schema.bookFileHashHistory.bookFileId))
|
.innerJoin(schema.bookFiles, eq(schema.bookFiles.id, schema.bookFileHashHistory.bookFileId))
|
||||||
.innerJoin(schema.books, eq(schema.books.id, schema.bookFiles.bookId))
|
.innerJoin(schema.books, eq(schema.books.id, schema.bookFiles.bookId))
|
||||||
.where(and(eq(schema.bookFileHashHistory.fileHash, hash), libraryFilter))
|
.where(and(eq(schema.bookFileHashHistory.fileHash, hash), libraryFilter))
|
||||||
.orderBy(asc(schema.bookFiles.id))
|
.orderBy(asc(schema.bookFiles.bookId), asc(schema.bookFiles.id))
|
||||||
.limit(1);
|
.limit(2);
|
||||||
|
|
||||||
if (byFileHashHistory) return byFileHashHistory;
|
if (byFileHashHistory.length > 0) {
|
||||||
|
return (
|
||||||
if (userId !== undefined) {
|
resolveUniqueBook(byFileHashHistory) ?? (userId === undefined ? null : this.resolveManualBookFileByHash(hash, accessibleLibraryIds, userId))
|
||||||
const [byManualLink] = await this.db
|
);
|
||||||
.select({ id: schema.bookFiles.id, bookId: schema.bookFiles.bookId, libraryId: schema.books.libraryId, format: schema.bookFiles.format })
|
|
||||||
.from(schema.koreaderBookHashLinks)
|
|
||||||
.innerJoin(schema.bookFiles, eq(schema.bookFiles.id, schema.koreaderBookHashLinks.bookFileId))
|
|
||||||
.innerJoin(schema.books, eq(schema.books.id, schema.bookFiles.bookId))
|
|
||||||
.where(and(eq(schema.koreaderBookHashLinks.userId, userId), eq(schema.koreaderBookHashLinks.hash, hash), libraryFilter))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (byManualLink) return byManualLink;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return userId === undefined ? null : this.resolveManualBookFileByHash(hash, accessibleLibraryIds, userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async resolveBookFilesByHashes(
|
async resolveBookFilesByHashes(
|
||||||
@@ -147,36 +182,28 @@ export class KoreaderRepository {
|
|||||||
.where(and(inArray(schema.bookFiles.fileHash, hashes), libraryFilter))
|
.where(and(inArray(schema.bookFiles.fileHash, hashes), libraryFilter))
|
||||||
.orderBy(asc(schema.bookFiles.id));
|
.orderBy(asc(schema.bookFiles.id));
|
||||||
|
|
||||||
for (const row of direct) {
|
const directHashes = addUnambiguousHashMatches(direct, result);
|
||||||
if (row.hash && !result.has(row.hash)) {
|
|
||||||
result.set(row.hash, { bookFileId: row.bookFileId, bookId: row.bookId, libraryId: row.libraryId, format: row.format });
|
const missing = hashes.filter((hash) => !directHashes.has(hash));
|
||||||
}
|
if (missing.length > 0) {
|
||||||
|
const history = await this.db
|
||||||
|
.select({
|
||||||
|
hash: schema.bookFileHashHistory.fileHash,
|
||||||
|
bookFileId: schema.bookFiles.id,
|
||||||
|
bookId: schema.bookFiles.bookId,
|
||||||
|
libraryId: schema.books.libraryId,
|
||||||
|
format: schema.bookFiles.format,
|
||||||
|
})
|
||||||
|
.from(schema.bookFileHashHistory)
|
||||||
|
.innerJoin(schema.bookFiles, eq(schema.bookFiles.id, schema.bookFileHashHistory.bookFileId))
|
||||||
|
.innerJoin(schema.books, eq(schema.books.id, schema.bookFiles.bookId))
|
||||||
|
.where(and(inArray(schema.bookFileHashHistory.fileHash, missing), libraryFilter))
|
||||||
|
.orderBy(asc(schema.bookFiles.id));
|
||||||
|
|
||||||
|
addUnambiguousHashMatches(history, result);
|
||||||
}
|
}
|
||||||
|
|
||||||
const missing = hashes.filter((hash) => !result.has(hash));
|
const stillMissing = hashes.filter((hash) => !result.has(hash));
|
||||||
if (missing.length === 0) return result;
|
|
||||||
|
|
||||||
const history = await this.db
|
|
||||||
.select({
|
|
||||||
hash: schema.bookFileHashHistory.fileHash,
|
|
||||||
bookFileId: schema.bookFiles.id,
|
|
||||||
bookId: schema.bookFiles.bookId,
|
|
||||||
libraryId: schema.books.libraryId,
|
|
||||||
format: schema.bookFiles.format,
|
|
||||||
})
|
|
||||||
.from(schema.bookFileHashHistory)
|
|
||||||
.innerJoin(schema.bookFiles, eq(schema.bookFiles.id, schema.bookFileHashHistory.bookFileId))
|
|
||||||
.innerJoin(schema.books, eq(schema.books.id, schema.bookFiles.bookId))
|
|
||||||
.where(and(inArray(schema.bookFileHashHistory.fileHash, missing), libraryFilter))
|
|
||||||
.orderBy(asc(schema.bookFiles.id));
|
|
||||||
|
|
||||||
for (const row of history) {
|
|
||||||
if (!result.has(row.hash)) {
|
|
||||||
result.set(row.hash, { bookFileId: row.bookFileId, bookId: row.bookId, libraryId: row.libraryId, format: row.format });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const stillMissing = missing.filter((hash) => !result.has(hash));
|
|
||||||
if (stillMissing.length === 0 || userId === undefined) return result;
|
if (stillMissing.length === 0 || userId === undefined) return result;
|
||||||
|
|
||||||
const manualLinks = await this.db
|
const manualLinks = await this.db
|
||||||
@@ -201,6 +228,23 @@ export class KoreaderRepository {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async resolveManualBookFileByHash(
|
||||||
|
hash: string,
|
||||||
|
accessibleLibraryIds: number[] | null,
|
||||||
|
userId: number,
|
||||||
|
): Promise<ResolvedBookFileByHash | null> {
|
||||||
|
const libraryFilter = accessibleLibraryIds ? inArray(schema.books.libraryId, accessibleLibraryIds) : undefined;
|
||||||
|
const [byManualLink] = await this.db
|
||||||
|
.select({ id: schema.bookFiles.id, bookId: schema.bookFiles.bookId, libraryId: schema.books.libraryId, format: schema.bookFiles.format })
|
||||||
|
.from(schema.koreaderBookHashLinks)
|
||||||
|
.innerJoin(schema.bookFiles, eq(schema.bookFiles.id, schema.koreaderBookHashLinks.bookFileId))
|
||||||
|
.innerJoin(schema.books, eq(schema.books.id, schema.bookFiles.bookId))
|
||||||
|
.where(and(eq(schema.koreaderBookHashLinks.userId, userId), eq(schema.koreaderBookHashLinks.hash, hash), libraryFilter))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
return byManualLink ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
async upsertUnmatchedBooks(userId: number, candidates: KoreaderUnmatchedCandidate[], deviceId?: string): Promise<void> {
|
async upsertUnmatchedBooks(userId: number, candidates: KoreaderUnmatchedCandidate[], deviceId?: string): Promise<void> {
|
||||||
if (candidates.length === 0) return;
|
if (candidates.length === 0) return;
|
||||||
|
|
||||||
|
|||||||
@@ -193,4 +193,26 @@ describe('KOReader progress position routing (e2e)', { timeout: 180_000 }, () =>
|
|||||||
expect(stored?.pageNumber).toBeNull();
|
expect(stored?.pageNumber).toBeNull();
|
||||||
expect(stored?.percentage).toBeCloseTo(80, 5);
|
expect(stored?.percentage).toBeCloseTo(80, 5);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not route progress when the document hash matches different books', async () => {
|
||||||
|
const comicProgressBefore = await storedProgress(comic.bookFileId);
|
||||||
|
const epubProgressBefore = await storedProgress(epub.bookFileId);
|
||||||
|
|
||||||
|
await ctx.db.update(schema.bookFiles).set({ fileHash: comicHash }).where(eq(schema.bookFiles.id, epub.bookFileId));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await ctx.app.inject({
|
||||||
|
method: 'PUT',
|
||||||
|
url: '/api/v1/koreader/syncs/progress',
|
||||||
|
headers: deviceHeaders(),
|
||||||
|
payload: { document: comicHash, percentage: 0.99, progress: '999' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(404);
|
||||||
|
await expect(storedProgress(comic.bookFileId)).resolves.toEqual(comicProgressBefore);
|
||||||
|
await expect(storedProgress(epub.bookFileId)).resolves.toEqual(epubProgressBefore);
|
||||||
|
} finally {
|
||||||
|
await ctx.db.update(schema.bookFiles).set({ fileHash: epubHash }).where(eq(schema.bookFiles.id, epub.bookFileId));
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user