mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-15 05:36:17 -08:00
* 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>
187 lines
6.0 KiB
TypeScript
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,
|
|
};
|
|
}
|