feat: implement category ID normalization and enhance category operations tests

This commit is contained in:
4gray committed 2026-04-04 21:16:32 +02:00
1 parent a0a084fef7
commit 1d0b4ca1fe
5 files changed
+238 -13

No files matched your search

@@ -116,6 +116,77 @@ test.describe('Electron Xtream Category Management', () => {
await closeElectronApp(app);
}
});
test('persists hidden selections after refreshing from the workspace header', async ({
dataDir,
request,
}) => {
await resetMockServers(request, ['xtream']);
const portalName = 'Header Refresh Xtream';
const app = await launchElectronApp(dataDir);
try {
await addXtreamPortal(app.mainWindow, {
name: portalName,
});
await waitForXtreamWorkspaceReady(app.mainWindow);
await openWorkspaceSection(app.mainWindow, 'Live TV');
await app.mainWindow.waitForURL(
/\/workspace\/xtreams\/[^/]+\/live/
);
const targetCategory = await pickSidebarCategory(app.mainWindow);
const dialog = await openManageCategoriesDialog(app.mainWindow);
await dialog
.getByRole('button', {
name: 'Deselect All',
exact: true,
})
.click();
await expect(
dialog.locator('.category-item mat-checkbox input:checked')
).toHaveCount(0);
await dialog.locator('.search-field input').fill(targetCategory.name);
await toggleManagedCategory(dialog, targetCategory, true);
await dialog.getByRole('button', { name: 'Save', exact: true }).click();
await app.mainWindow.waitForSelector('mat-dialog-container', {
state: 'detached',
});
await expect(
sidebarCategoryById(app.mainWindow, targetCategory.id)
).toBeVisible();
await refreshFromWorkspaceHeader(app.mainWindow);
await waitForXtreamWorkspaceReady(app.mainWindow);
await openWorkspaceSection(app.mainWindow, 'Live TV');
await app.mainWindow.waitForURL(
/\/workspace\/xtreams\/[^/]+\/live/
);
await expectVisibleSidebarCategoryIds(app.mainWindow, [
targetCategory.id,
]);
const restarted = await restartElectronApp(app, dataDir);
app.electronApp = restarted.electronApp;
app.mainWindow = restarted.mainWindow;
await openSources(app.mainWindow);
await sourceRowByTitle(app.mainWindow, portalName).first().click();
await waitForXtreamWorkspaceReady(app.mainWindow);
await openWorkspaceSection(app.mainWindow, 'Live TV');
await app.mainWindow.waitForURL(
/\/workspace\/xtreams\/[^/]+\/live/
);
await expectVisibleSidebarCategoryIds(app.mainWindow, [
targetCategory.id,
]);
} finally {
await closeElectronApp(app);
}
});
});
async function openManageCategoriesDialog(page: Page) {
@@ -134,12 +205,46 @@ async function openManageCategoriesDialog(page: Page) {
return dialog;
}
async function refreshFromWorkspaceHeader(page: Page): Promise<void> {
await page
.getByRole('button', { name: 'Refresh playlist', exact: true })
.click();
const dialog = page.locator('mat-dialog-container');
await expect(dialog).toBeVisible();
await dialog.getByRole('button', { name: 'Yes', exact: true }).click();
await page.waitForSelector('mat-dialog-container', { state: 'detached' });
}
function sidebarCategoryById(page: Page, categoryId: string): Locator {
return page.locator(
`app-workspace-context-panel .category-item[data-category-id="${categoryId}"]`
);
}
async function expectVisibleSidebarCategoryIds(
page: Page,
expectedIds: string[]
): Promise<void> {
const categories = page.locator(
'app-workspace-context-panel .category-item:visible'
);
const actualIds: string[] = [];
const count = await categories.count();
for (let index = 0; index < count; index += 1) {
const categoryId = await categories
.nth(index)
.getAttribute('data-category-id');
if (categoryId) {
actualIds.push(categoryId.trim());
}
}
expect(actualIds).toEqual(expectedIds);
}
async function pickSidebarCategory(
page: Page
): Promise<{ id: string; itemCount: number; name: string }> {
@@ -0,0 +1,98 @@
import type { AppDatabase } from '../database.types';
import { saveCategories } from './category.operations';
function createDbMock(existingCount = 0) {
const where = jest.fn().mockResolvedValue([{ count: existingCount }]);
const from = jest.fn().mockReturnValue({ where });
const select = jest.fn().mockReturnValue({ from });
const onConflictDoNothing = jest.fn().mockResolvedValue(undefined);
const values = jest.fn().mockReturnValue({ onConflictDoNothing });
const insert = jest.fn().mockReturnValue({ values });
return {
db: {
select,
insert,
} as unknown as AppDatabase,
insert,
values,
onConflictDoNothing,
select,
from,
where,
};
}
describe('category.operations', () => {
it('restores hidden categories when Xtream API category IDs are strings', async () => {
const { db, values, insert } = createDbMock();
await saveCategories(
db,
'playlist-1',
[
{ category_name: 'News', category_id: '101' },
{ category_name: 'Sports', category_id: '102' },
],
'live',
[102]
);
expect(insert).toHaveBeenCalled();
expect(values).toHaveBeenCalledWith([
{
playlistId: 'playlist-1',
name: 'News',
type: 'live',
xtreamId: 101,
hidden: false,
},
{
playlistId: 'playlist-1',
name: 'Sports',
type: 'live',
xtreamId: 102,
hidden: true,
},
]);
});
it('skips categories whose Xtream IDs are not numeric', async () => {
const { db, values } = createDbMock();
await saveCategories(
db,
'playlist-1',
[
{ category_name: 'Valid', category_id: '201' },
{ category_name: 'Broken', category_id: 'not-a-number' },
],
'movies',
[201]
);
expect(values).toHaveBeenCalledWith([
{
playlistId: 'playlist-1',
name: 'Valid',
type: 'movies',
xtreamId: 201,
hidden: true,
},
]);
});
it('does not insert categories when all Xtream IDs are invalid', async () => {
const { db, insert } = createDbMock();
await saveCategories(
db,
'playlist-1',
[{ category_name: 'Broken', category_id: 'not-a-number' }],
'series',
[301]
);
expect(insert).not.toHaveBeenCalled();
});
});
@@ -2,6 +2,17 @@ import { and, eq, inArray, sql } from 'drizzle-orm';
import * as schema from 'database-schema';
import type { AppDatabase } from '../database.types';
type XtreamCategoryInput = {
category_name: string;
category_id: string | number;
};
function normalizeXtreamCategoryId(rawCategoryId: string | number): number | null {
const xtreamId = Number.parseInt(String(rawCategoryId), 10);
return Number.isNaN(xtreamId) ? null : xtreamId;
}
export async function hasCategories(
db: AppDatabase,
playlistId: string,
@@ -41,10 +52,7 @@ export async function getCategories(
export async function saveCategories(
db: AppDatabase,
playlistId: string,
categories: Array<{
category_name: string;
category_id: number;
}>,
categories: XtreamCategoryInput[],
type: 'live' | 'movies' | 'series',
hiddenCategoryXtreamIds?: number[]
): Promise<{ success: boolean }> {
@@ -67,13 +75,27 @@ export async function saveCategories(
}
const hiddenSet = new Set(hiddenCategoryXtreamIds || []);
const values = categories.map((category) => ({
playlistId,
name: category.category_name,
type,
xtreamId: category.category_id,
hidden: hiddenSet.has(category.category_id),
}));
const values = categories.flatMap((category) => {
const xtreamId = normalizeXtreamCategoryId(category.category_id);
if (xtreamId === null) {
return [];
}
return [
{
playlistId,
name: category.category_name,
type,
xtreamId,
hidden: hiddenSet.has(xtreamId),
},
];
});
if (values.length === 0) {
return { success: true };
}
await db
.insert(schema.categories)
@@ -27,7 +27,7 @@ handleWorkerRequest(
playlistId: string,
categories: Array<{
category_name: string;
category_id: number;
category_id: string | number;
}>,
type: 'live' | 'movies' | 'series',
hiddenCategoryXtreamIds?: number[]
@@ -289,7 +289,7 @@ async function executeRequest(message: DbWorkerRequestMessage) {
playlistId: string;
categories: Array<{
category_name: string;
category_id: number;
category_id: string | number;
}>;
type: 'live' | 'movies' | 'series';
hiddenCategoryXtreamIds?: number[];