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>
This commit is contained in:
4grayandClaude Fable 5 committed 2026-07-12 11:39:40 +02:00
1 parent 4fa1872964
commit 7ba58592da
3 files changed
+130 -3

No files matched your search

+11 -3
View File
@@ -511,14 +511,22 @@ Always call the synchronous `.run(placeholderValues)` on prepared statements
executed inside a synchronous transaction callback:
```ts
// favorites is playlist-scoped: filter by (contentId, playlistId), otherwise
// a same-contentId favorite in another playlist gets rewritten too.
const stmt = db.update(schema.favorites)
.set({ position: sql<number>`${sql.placeholder('position')}` })
.where(eq(schema.favorites.contentId, sql.placeholder('contentId')))
.where(
and(
eq(schema.favorites.contentId, sql.placeholder('contentId')),
eq(schema.favorites.playlistId, sql.placeholder('playlistId'))
)
)
.prepare();
db.transaction(() => {
for (const { content_id, position } of chunk) {
stmt.run({ position, contentId: content_id }); // NOT .execute()
for (const { content_id, playlist_id, position } of chunk) {
// NOT .execute()
stmt.run({ position, contentId: content_id, playlistId: playlist_id });
}
});
```
@@ -0,0 +1,116 @@
import { TestBed } from '@angular/core/testing';
import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import { DatabaseService, PlaylistsService } from '@iptvnator/services';
import { UnifiedFavoriteChannel } from '@iptvnator/portal/shared/util';
import { GlobalFavoritesService } from './global-favorites.service';
describe('GlobalFavoritesService', () => {
let service: GlobalFavoritesService;
let electronApi: {
dbReorderGlobalFavorites: jest.Mock;
dbSetAppState: jest.Mock;
};
const makeChannel = (
overrides: Partial<UnifiedFavoriteChannel> &
Pick<UnifiedFavoriteChannel, 'uid' | 'sourceType' | 'playlistId'>
): UnifiedFavoriteChannel => ({
name: 'Channel',
logo: null,
playlistName: 'Playlist',
addedAt: new Date(0).toISOString(),
position: 0,
...overrides,
});
beforeEach(() => {
electronApi = {
dbReorderGlobalFavorites: jest
.fn()
.mockResolvedValue({ success: true }),
dbSetAppState: jest.fn().mockResolvedValue({ success: true }),
};
Object.defineProperty(window, 'electron', {
value: electronApi as unknown as Window['electron'],
configurable: true,
});
TestBed.configureTestingModule({
providers: [
GlobalFavoritesService,
{ provide: Store, useValue: { select: jest.fn() } },
{ provide: DatabaseService, useValue: {} },
{ provide: PlaylistsService, useValue: {} },
{
provide: TranslateService,
useValue: { instant: (key: string) => key },
},
],
});
service = TestBed.inject(GlobalFavoritesService);
});
afterEach(() => {
Object.defineProperty(window, 'electron', {
value: undefined,
configurable: true,
});
});
describe('reorder', () => {
it('sends playlist-scoped position updates for Xtream favorites', async () => {
// The backend UPDATE filters by (contentId, playlistId); a payload
// without playlist_id silently matches no rows (PR #1143 review).
const channels: UnifiedFavoriteChannel[] = [
makeChannel({
uid: 'xtream::playlist-b::20',
sourceType: 'xtream',
playlistId: 'playlist-b',
contentId: 202,
}),
makeChannel({
uid: 'm3u::playlist-m::url',
sourceType: 'm3u',
playlistId: 'playlist-m',
}),
makeChannel({
uid: 'xtream::playlist-a::10',
sourceType: 'xtream',
playlistId: 'playlist-a',
contentId: 101,
}),
];
await service.reorder(channels);
expect(electronApi.dbReorderGlobalFavorites).toHaveBeenCalledWith([
{ content_id: 202, playlist_id: 'playlist-b', position: 0 },
{ content_id: 101, playlist_id: 'playlist-a', position: 1 },
]);
});
it('persists the full uid order and skips the DB write without Xtream items', async () => {
const channels: UnifiedFavoriteChannel[] = [
makeChannel({
uid: 'm3u::playlist-m::url',
sourceType: 'm3u',
playlistId: 'playlist-m',
}),
makeChannel({
uid: 'stalker::playlist-s::5',
sourceType: 'stalker',
playlistId: 'playlist-s',
}),
];
await service.reorder(channels);
expect(electronApi.dbReorderGlobalFavorites).not.toHaveBeenCalled();
expect(electronApi.dbSetAppState).toHaveBeenCalledWith(
'global-favorites-channel-order-v1',
JSON.stringify(['m3u::playlist-m::url', 'stalker::playlist-s::5'])
);
});
});
});
@@ -141,6 +141,9 @@ export class GlobalFavoritesService {
)
.map((ch, index) => ({
content_id: ch.contentId,
// The backend UPDATE is scoped by (contentId, playlistId) —
// without the playlist id the write matches no rows.
playlist_id: ch.playlistId,
position: index,
}));