fix(backup): export and restore hidden Xtream categories by real xtream IDs (#1224)

Category rows crossed the DB-worker IPC boundary with Drizzle's camelCase
property names while the renderer contracts declare snake_case, so backup
export dropped hidden-category IDs and restore degraded to a type-only
match that hid every category. Project category ops to the declared wire
shape, normalize restore state from untrusted sources (dropping entries
without a numeric xtreamId), reject entries with missing user-state
collections, and add full export→import round-trip coverage (unit
manifest-equality + Electron e2e) plus regression tests.

Closes #1017

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5 authored and GitHub committed 2026-07-24 20:32:45 +02:00
1 parent bd07e17857
commit b4c0cce741
16 files changed
+1424 -151

No files matched your search

@@ -0,0 +1,277 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { Locator, Page } from '@playwright/test';
import {
addXtreamPortal,
closeElectronApp,
deleteSource,
expect,
launchElectronApp,
openSettings,
openSources,
openWorkspaceSection,
resetMockServers,
restartElectronApp,
sourceRowByTitle,
test,
waitForXtreamWorkspaceReady,
} from './electron-test-fixtures';
/**
* Full backup round-trip through the real UI, DB worker and IPC stack:
* hide a category, export the backup, delete the source, import the file
* back and verify the restored portal hides the same category again after
* its content is re-imported from the mock server (regression for #1017 —
* exported hidden categories lost their xtream IDs and the restore either
* hid everything or nothing).
*/
test.describe('Electron playlist backup round-trip', () => {
test('exports a backup and re-imports it with hidden categories restored', async ({
dataDir,
request,
}) => {
await resetMockServers(request, ['xtream']);
const portalName = 'Backup Roundtrip Xtream';
const exportPath = join(dataDir, 'roundtrip-backup.json');
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 pickVisibleCategoryWithContent(
app.mainWindow
);
let dialog = await openManageCategoriesDialog(app.mainWindow);
await setManagedCategoryChecked(dialog, targetCategory.name, false);
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)
).toHaveCount(0);
// Export through the real settings flow with the native save
// dialog stubbed to a fixed path inside the test data dir.
await app.electronApp.evaluate(({ dialog: nativeDialog }, path) => {
nativeDialog.showSaveDialog = async () => ({
canceled: false,
filePath: path,
});
}, exportPath);
await openSettings(app.mainWindow);
const backupSection = app.mainWindow.locator('#backup');
await backupSection
.getByRole('button', { name: 'Export', exact: true })
.click();
await expect(
app.mainWindow.getByText('Playlist backup exported.')
).toBeVisible({ timeout: 15000 });
// The exported manifest must reference hidden categories by
// numeric xtream ID — the #1017 regression exported anonymous
// { categoryType } entries.
const manifest = JSON.parse(readFileSync(exportPath, 'utf-8')) as {
playlists: Array<{
portalType: string;
userState?: {
hiddenCategories?: Array<{
categoryType?: string;
xtreamId?: unknown;
}>;
};
}>;
};
const xtreamEntry = manifest.playlists.find(
(entry) => entry.portalType === 'xtream'
);
const hiddenCategories =
xtreamEntry?.userState?.hiddenCategories ?? [];
expect(hiddenCategories.length).toBeGreaterThan(0);
expect(
hiddenCategories.every(
(hiddenCategory) =>
typeof hiddenCategory.xtreamId === 'number'
)
).toBe(true);
await openSources(app.mainWindow);
await deleteSource(app.mainWindow, portalName);
await expect(
sourceRowByTitle(app.mainWindow, portalName)
).toHaveCount(0);
// Import the exported file back through the settings flow; the
// renderer opens a browser file chooser for it.
await openSettings(app.mainWindow);
const fileChooserPromise =
app.mainWindow.waitForEvent('filechooser');
await backupSection
.getByRole('button', { name: 'Import', exact: true })
.click();
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles(exportPath);
await expect(
app.mainWindow.getByText(/Backup import finished: 1 imported/)
).toBeVisible({ timeout: 15000 });
// Restart before opening the restored portal: the root-provided
// XtreamStore still holds the deleted portal's in-memory state
// under the same playlist id and would skip content
// initialization in this session. A restart matches the primary
// restore workflow (fresh install) and forces a real re-import.
const restarted = await restartElectronApp(app, dataDir);
app.electronApp = restarted.electronApp;
app.mainWindow = restarted.mainWindow;
// Opening the restored portal re-imports content from the mock
// server; the pending restore state must hide the same category
// again.
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 expect(
sidebarCategoryById(app.mainWindow, targetCategory.id)
).toHaveCount(0);
// The restored hidden flag must live in the database itself,
// not only in the rendered sidebar state.
const restoredPlaylistId =
app.mainWindow.url().match(/xtreams\/([^/]+)/)?.[1] ?? '';
expect(restoredPlaylistId).not.toEqual('');
const restoredDbRows = await app.mainWindow.evaluate(
(playlistId) =>
(
window as unknown as {
electron: {
dbGetAllCategories: (
id: string,
type: string
) => Promise<
Array<{ name: string; hidden: boolean }>
>;
};
}
).electron.dbGetAllCategories(playlistId, 'live'),
restoredPlaylistId
);
expect(restoredDbRows.length).toBeGreaterThan(0);
expect(
restoredDbRows
.filter((row) => row.hidden)
.map((row) => row.name)
).toEqual([targetCategory.name]);
dialog = await openManageCategoriesDialog(app.mainWindow);
await dialog
.locator('input[type="search"]')
.fill(targetCategory.name);
const restoredRow = dialog.locator('.category-item').first();
await expect(restoredRow).toBeVisible({ timeout: 15000 });
await expect(
restoredRow.locator('mat-checkbox input')
).not.toBeChecked();
} finally {
await closeElectronApp(app);
}
});
});
async function openManageCategoriesDialog(page: Page): Promise<Locator> {
await page.getByRole('button', { name: 'Manage categories' }).click();
const dialog = page.locator('mat-dialog-container').last();
await expect(dialog).toBeVisible();
await expect(dialog.locator('.category-item').first()).toBeVisible({
timeout: 15000,
});
return dialog;
}
async function setManagedCategoryChecked(
dialog: Locator,
categoryName: string,
shouldBeChecked: boolean
): Promise<void> {
await dialog.locator('input[type="search"]').fill(categoryName);
const row = dialog.locator('.category-item').first();
await expect(row).toBeVisible({ timeout: 15000 });
const checkbox = row.locator('mat-checkbox input');
if (shouldBeChecked) {
await checkbox.check();
await expect(checkbox).toBeChecked();
} else {
await checkbox.uncheck();
await expect(checkbox).not.toBeChecked();
}
}
function sidebarCategoryById(page: Page, categoryId: string): Locator {
return page.locator(
`app-workspace-context-panel .category-item[data-category-id="${categoryId}"]`
);
}
async function pickVisibleCategoryWithContent(
page: Page
): Promise<{ id: string; name: string }> {
let picked: { id: string; name: string } | null = null;
await expect
.poll(
async () => {
const categories = page.locator(
'app-workspace-context-panel .category-item:visible'
);
const count = await categories.count();
for (let index = 0; index < count; index += 1) {
const category = categories.nth(index);
const id =
(
await category.getAttribute('data-category-id')
)?.trim() ?? '';
const name =
(
await category
.locator('.nav-item-label')
.textContent()
)?.trim() ?? '';
const countText =
(
await category.locator('.item-count').textContent()
)?.trim() ?? '';
if (id && name && (Number.parseInt(countText, 10) || 0) > 0) {
picked = { id, name };
return true;
}
}
picked = null;
return false;
},
{
message:
'No visible Xtream category with content was found in the sidebar.',
timeout: 15000,
}
)
.toBe(true);
return picked!;
}
@@ -1,6 +1,23 @@
import type { AppDatabase } from '../database.types';
import * as schema from '@iptvnator/shared/database/schema';
import { getCategories, saveCategories } from './category.operations';
import {
getAllCategories,
getCategories,
saveCategories,
} from './category.operations';
// Renderer consumers (XCategoryFromDb/XtreamCategoryFromDb) expect category
// rows in this snake_case wire shape. A bare select() would return Drizzle's
// camelCase property names and silently break the playlist backup
// export/restore (issue #1017).
const categoryWireShape = {
id: schema.categories.id,
playlist_id: schema.categories.playlistId,
name: schema.categories.name,
type: schema.categories.type,
xtream_id: schema.categories.xtreamId,
hidden: schema.categories.hidden,
};
function createDbMock(existingCount = 0) {
const where = jest.fn().mockResolvedValue([{ count: existingCount }]);
@@ -37,6 +54,21 @@ describe('category.operations', () => {
await getCategories(db, 'playlist-1', 'live');
expect(orderBy).toHaveBeenCalledWith(schema.categories.id);
expect(select).toHaveBeenCalledWith(categoryWireShape);
});
it('projects all categories to the snake_case wire shape', async () => {
const orderBy = jest.fn().mockResolvedValue([]);
const where = jest.fn().mockReturnValue({ orderBy });
const from = jest.fn().mockReturnValue({ where });
const select = jest.fn().mockReturnValue({ from });
const db = {
select,
} as unknown as AppDatabase;
await getAllCategories(db, 'playlist-1', 'movies');
expect(select).toHaveBeenCalledWith(categoryWireShape);
});
it('restores hidden categories when Xtream API category IDs are strings', async () => {
@@ -7,6 +7,20 @@ type XtreamCategoryInput = {
category_id: string | number;
};
// Category rows cross the DB-worker IPC boundary in the snake_case wire
// shape declared by XCategoryFromDb/XtreamCategoryFromDb. A bare select()
// would leak Drizzle's camelCase property names (xtreamId, playlistId)
// instead, silently breaking consumers such as the playlist backup
// export/restore (issue #1017).
const categoryWireShape = {
id: schema.categories.id,
playlist_id: schema.categories.playlistId,
name: schema.categories.name,
type: schema.categories.type,
xtream_id: schema.categories.xtreamId,
hidden: schema.categories.hidden,
};
function normalizeXtreamCategoryId(
rawCategoryId: string | number
): number | null {
@@ -43,7 +57,7 @@ export async function getCategories(
// If partial category re-inserts are added later, persist a provider
// sort index instead of relying on the insertion id.
return db
.select()
.select(categoryWireShape)
.from(schema.categories)
.where(
and(
@@ -123,7 +137,7 @@ export async function getAllCategories(
type: 'live' | 'movies' | 'series'
) {
return db
.select()
.select(categoryWireShape)
.from(schema.categories)
.where(
and(