diff --git a/docs/architecture/sqlite-db-worker.md b/docs/architecture/sqlite-db-worker.md index 455caaead..4aaa17c61 100644 --- a/docs/architecture/sqlite-db-worker.md +++ b/docs/architecture/sqlite-db-worker.md @@ -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`${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 }); } }); ``` diff --git a/libs/workspace/shell/feature/src/lib/global-favorites/global-favorites.service.spec.ts b/libs/workspace/shell/feature/src/lib/global-favorites/global-favorites.service.spec.ts new file mode 100644 index 000000000..34ae1087e --- /dev/null +++ b/libs/workspace/shell/feature/src/lib/global-favorites/global-favorites.service.spec.ts @@ -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 & + Pick + ): 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']) + ); + }); + }); +}); diff --git a/libs/workspace/shell/feature/src/lib/global-favorites/global-favorites.service.ts b/libs/workspace/shell/feature/src/lib/global-favorites/global-favorites.service.ts index bfea67259..83506948e 100644 --- a/libs/workspace/shell/feature/src/lib/global-favorites/global-favorites.service.ts +++ b/libs/workspace/shell/feature/src/lib/global-favorites/global-favorites.service.ts @@ -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, }));