Files
iptvnator/apps/electron-backend/src/app/database/operations/operations.test-helpers.ts
T
4grayandClaude Fable 5 54265755ee fix(favorites): persist custom drag-and-drop order for Xtream favorites (#1143)
* fix(favorites): persist custom drag-and-drop order for Xtream favorites

Prepared-statement writes dispatched via drizzle's `.execute()` on the
better-sqlite3 driver return a promise and defer the write to a microtask.
Inside a synchronous `db.transaction(() => ...)` callback (which cannot
await), the transaction commits before that promise settles, so the write
is a silent no-op — no error, no rows changed.

This bit `reorderGlobalFavorites`: the custom favorites order never
persisted for the per-playlist ("This playlist") Xtream scope, which relies
solely on the `favorites.position` column. The global ("All playlists")
scope masked the bug because it also persists an order to the `appState`
`global-favorites-channel-order-v1` key and re-applies it on read.
`removeRecentItemsBatch` had the same latent bug — batch "clear recent
items" silently did nothing.

Switch both writers to synchronous `.run()`. Add regression coverage that
asserts `.run()` (not `.execute()`) is used and would fail on the old
behavior, and document the gotcha in the DB worker architecture doc.

Verified over CDP against a live Electron instance: reorder writes
positions 0..N, and the order survives navigation and a full reload.

Fixes #1137

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(favorites): scope reorder position writes by playlist

The global favorites reorder wrote the new position filtering only by
content_id, so two Xtream playlists holding a favorite with the same
content_id would clobber each other's persisted order (greptile P1).

Thread playlist_id through the whole reorder path — the renderer builder
(UnifiedCollectionItem already carries playlistId), the IPC contract
(ElectronBridgeFavoriteReorderUpdate + inline payload types), the worker
op — and scope the prepared UPDATE by (contentId, playlistId), matching
the favorites composite unique index.

Tests: favorites.operations.spec asserts the playlistId placeholder and
per-row playlistId payload; preload contract fixture updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(favorites): include playlist_id in workspace global favorites reorder payload

The workspace global-favorites reorder path still sent updates with only
content_id and position. Since the backend UPDATE is now scoped by
(contentId, playlistId), that payload binds an undefined playlist id and
matches no rows — the DB write silently no-ops (flagged by Greptile P1).

Also scope the prepared-statement example in the sqlite-db-worker gotcha
doc by (contentId, playlistId) so it no longer documents the
cross-playlist rewrite this PR fixes (flagged by Codex P3).

Regression spec asserts the reorder payload carries playlist_id per item
(fails on the old payload shape) and that the appState uid order is
still persisted for non-Xtream items.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 13:24:58 +02:00

187 lines
6.0 KiB
TypeScript

import type { AppDatabase } from '../database.types';
/**
* Shared drizzle-orm query-builder mocks for the operations specs.
* Jest keeps a separate module registry per test file, so every spec gets
* its own mock instances. `jest.mock()` calls cannot live here (Jest hoists
* them per file), so each spec registers the module mock itself:
*
* jest.mock('drizzle-orm', () => mockDrizzleOrmModule());
*
* The `mock` prefix on the exports is required so the hoisted factory is
* allowed to reference them.
*/
export const mockDrizzle = {
and: jest.fn((...conditions: unknown[]) => ({
kind: 'and',
conditions,
})),
desc: jest.fn((value: unknown) => ({ kind: 'desc', value })),
eq: jest.fn((left: unknown, right: unknown) => ({
kind: 'eq',
left,
right,
})),
inArray: jest.fn((left: unknown, values: unknown[]) => ({
kind: 'inArray',
left,
values,
})),
sql: Object.assign(
jest.fn((strings: TemplateStringsArray, ...values: unknown[]) => ({
kind: 'sql',
strings: Array.from(strings),
values,
})),
{
placeholder: jest.fn((name: string) => ({
kind: 'placeholder',
name,
})),
}
),
};
export function mockDrizzleOrmModule() {
return {
and: (...conditions: unknown[]) => mockDrizzle.and(...conditions),
desc: (value: unknown) => mockDrizzle.desc(value),
eq: (left: unknown, right: unknown) => mockDrizzle.eq(left, right),
inArray: (left: unknown, values: unknown[]) =>
mockDrizzle.inArray(left, values),
sql: Object.assign(
(strings: TemplateStringsArray, ...values: unknown[]) =>
mockDrizzle.sql(strings, ...values),
{
placeholder: (name: string) =>
mockDrizzle.sql.placeholder(name),
}
),
};
}
export function resetDrizzleMocks() {
mockDrizzle.and.mockClear();
mockDrizzle.desc.mockClear();
mockDrizzle.eq.mockClear();
mockDrizzle.inArray.mockClear();
mockDrizzle.sql.mockClear();
mockDrizzle.sql.placeholder.mockClear();
}
export type QueryMock = {
from: jest.Mock;
innerJoin: jest.Mock;
where: jest.Mock;
orderBy: jest.Mock;
limit: jest.Mock;
then: (
resolve: (value: unknown[]) => void,
reject: (reason: unknown) => void
) => Promise<void>;
};
/**
* Fluent AppDatabase mock. Each `db.select()` call consumes the next entry
* of `selectResultsByCall` as its resolved rows; every builder method
* returns the same chainable/thenable query object.
*/
export function createDbMock(selectResultsByCall: unknown[][] = []) {
let selectIndex = 0;
const queries: QueryMock[] = [];
const select = jest.fn(() => {
const rows = selectResultsByCall[selectIndex] ?? [];
selectIndex += 1;
const query: QueryMock = {
from: jest.fn(),
innerJoin: jest.fn(),
where: jest.fn(),
orderBy: jest.fn(),
limit: jest.fn().mockResolvedValue(rows),
then: (resolve, reject) =>
Promise.resolve(rows).then(resolve, reject),
};
query.from.mockReturnValue(query);
query.innerJoin.mockReturnValue(query);
query.where.mockReturnValue(query);
query.orderBy.mockReturnValue(query);
queries.push(query);
return query;
});
const insertValues = jest.fn().mockResolvedValue(undefined);
const insert = jest.fn().mockReturnValue({ values: insertValues });
// Prepared-statement writes MUST use `.run()` (synchronous) rather than
// `.execute()` inside a synchronous `db.transaction()` callback — the
// better-sqlite3 driver's `.execute()` defers to a promise that never
// settles before COMMIT, silently dropping the write (issue #1137). The
// mock exposes both so specs can assert the correct method is used.
const updateRun = jest.fn();
const updateExecute = jest.fn().mockResolvedValue(undefined);
const updatePrepare = jest
.fn()
.mockReturnValue({ run: updateRun, execute: updateExecute });
const updateWhere = jest.fn().mockReturnValue({
prepare: updatePrepare,
then: (
resolve: (value: unknown) => void,
reject: (reason: unknown) => void
) => Promise.resolve(undefined).then(resolve, reject),
});
const updateSet = jest.fn().mockReturnValue({ where: updateWhere });
const update = jest.fn().mockReturnValue({ set: updateSet });
const deleteRun = jest.fn();
const deleteExecute = jest.fn().mockResolvedValue(undefined);
const deletePrepare = jest
.fn()
.mockReturnValue({ execute: deleteExecute, run: deleteRun });
const deleteResult = {
prepare: deletePrepare,
then: (
resolve: (value: unknown) => void,
reject: (reason: unknown) => void
) => Promise.resolve(undefined).then(resolve, reject),
};
const deleteWhere = jest.fn().mockReturnValue(deleteResult);
const deleteFn = jest.fn().mockReturnValue({
where: deleteWhere,
then: (
resolve: (value: unknown) => void,
reject: (reason: unknown) => void
) => Promise.resolve(undefined).then(resolve, reject),
});
const transaction = jest.fn((callback: () => unknown) => {
const result = callback();
return Promise.resolve(result);
});
return {
db: {
select,
insert,
update,
delete: deleteFn,
transaction,
} as unknown as AppDatabase,
deleteExecute,
deleteFn,
deletePrepare,
deleteRun,
deleteWhere,
insert,
insertValues,
queries,
select,
transaction,
update,
updatePrepare,
updateRun,
updateExecute,
updateSet,
updateWhere,
};
}