mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-15 13:46:16 -08:00
feat: implement category ID normalization and enhance category operations tests
This commit is contained in:
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[];
|
||||
|
||||
Reference in new issue
Block a user