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

@@ -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(