many changes
Traceability Validation / Check Requirement Traces (push) Failing after 1m18s
🏗️ Build and Test JellyTau / Build APK and Run Tests (push) Has been cancelled

This commit is contained in:
2026-02-14 00:09:47 +01:00
parent 6d1c618a3a
commit e3797f32ca
74 changed files with 6718 additions and 771 deletions
+545
View File
@@ -0,0 +1,545 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { invoke } from "@tauri-apps/api/core";
import { RepositoryClient } from "./repository-client";
vi.mock("@tauri-apps/api/core");
/**
* Integration tests documenting Phase 1 & 2 refactoring:
* - Sorting moved to backend (no frontend compareFn)
* - Filtering moved to backend (no frontend iteration/matching)
* - URL construction moved to backend (async Tauri invoke)
* - Search moved to backend (backend search command)
*/
describe("Backend Integration - Refactored Business Logic", () => {
let client: RepositoryClient;
beforeEach(async () => {
client = new RepositoryClient();
(invoke as any).mockResolvedValueOnce("test-handle-123");
await client.create("https://server.com", "user1", "token123", "server1");
});
describe("Sorting Delegated to Backend", () => {
it("should pass sortBy to backend instead of frontend sorting", async () => {
(invoke as any).mockResolvedValueOnce({
items: [
{ id: "item1", name: "Album A" },
{ id: "item2", name: "Album B" },
{ id: "item3", name: "Album C" },
],
totalRecordCount: 3,
});
const result = await client.getItems("library123", {
sortBy: "SortName",
sortOrder: "Ascending",
});
// Backend should have done the sorting
expect(result.items[0].name).toBe("Album A");
// Frontend doesn't have a compareFn
expect(invoke).toHaveBeenCalledWith("repository_get_items", {
handle: "test-handle-123",
parentId: "library123",
options: {
sortBy: "SortName",
sortOrder: "Ascending",
},
});
});
it("should support different sort fields via backend", async () => {
const sortFields = ["SortName", "Artist", "Album", "DatePlayed", "ProductionYear"];
for (const sortField of sortFields) {
vi.clearAllMocks();
(invoke as any).mockResolvedValueOnce("test-handle-123");
await client.create("https://server.com", "user1", "token123", "server1");
(invoke as any).mockResolvedValueOnce({
items: [],
totalRecordCount: 0,
});
await client.getItems("library123", {
sortBy: sortField,
sortOrder: "Ascending",
});
expect(invoke).toHaveBeenCalledWith(
"repository_get_items",
expect.objectContaining({
options: expect.objectContaining({
sortBy: sortField,
}),
})
);
}
});
it("should pass sort order to backend", async () => {
(invoke as any).mockResolvedValueOnce({
items: [],
totalRecordCount: 0,
});
await client.getItems("library123", {
sortBy: "SortName",
sortOrder: "Descending",
});
expect(invoke).toHaveBeenCalledWith(
"repository_get_items",
expect.objectContaining({
options: expect.objectContaining({
sortOrder: "Descending",
}),
})
);
});
it("should NOT include frontend compareFn (removed entirely)", async () => {
// Old code pattern:
// sortOptions: [{
// key: "title",
// label: "Title",
// compareFn: (a, b) => a.name.localeCompare(b.name) // ← REMOVED
// }]
// New code pattern:
// sortOptions: [{
// key: "SortName", // Jellyfin field name
// label: "Title"
// }]
const config = {
sortOptions: [
{ key: "SortName", label: "Title" },
{ key: "Artist", label: "Artist" },
],
};
// Verify no compareFn property exists
for (const option of config.sortOptions) {
expect((option as any).compareFn).toBeUndefined();
}
});
});
describe("Filtering Delegated to Backend", () => {
it("should pass includeItemTypes to backend", async () => {
(invoke as any).mockResolvedValueOnce({
items: [],
totalRecordCount: 0,
});
await client.getItems("library123", {
includeItemTypes: ["Audio", "MusicAlbum"],
});
expect(invoke).toHaveBeenCalledWith(
"repository_get_items",
expect.objectContaining({
options: expect.objectContaining({
includeItemTypes: ["Audio", "MusicAlbum"],
}),
})
);
});
it("should pass genres filter to backend", async () => {
(invoke as any).mockResolvedValueOnce({
items: [],
totalRecordCount: 0,
});
await client.getItems("library123", {
genres: ["Rock", "Jazz"],
});
expect(invoke).toHaveBeenCalledWith(
"repository_get_items",
expect.objectContaining({
options: expect.objectContaining({
genres: ["Rock", "Jazz"],
}),
})
);
});
it("should NOT include frontend filtering logic", async () => {
// Old code pattern:
// let filtered = items.filter(item => {
// return searchFields.some(field => {
// const fieldValue = item[field]?.toLowerCase() ?? "";
// return fieldValue.includes(query.toLowerCase());
// });
// }); // ← REMOVED
// New code pattern:
// Use backend search instead
(invoke as any).mockResolvedValueOnce({
items: [{ id: "item1", name: "Search Result" }],
totalRecordCount: 1,
});
const result = await client.search("query");
expect(result.items.length).toBeGreaterThan(0);
expect(invoke).toHaveBeenCalledWith(
"repository_search",
expect.objectContaining({
query: "query",
})
);
});
it("should support pagination via backend", async () => {
(invoke as any).mockResolvedValueOnce({
items: [],
totalRecordCount: 1000,
});
await client.getItems("library123", {
startIndex: 100,
limit: 50,
});
expect(invoke).toHaveBeenCalledWith(
"repository_get_items",
expect.objectContaining({
options: expect.objectContaining({
startIndex: 100,
limit: 50,
}),
})
);
});
});
describe("Search Delegated to Backend", () => {
it("should use backend search command instead of frontend filtering", async () => {
(invoke as any).mockResolvedValueOnce({
items: [
{ id: "item1", name: "Found Item" },
{ id: "item2", name: "Another Found Item" },
],
totalRecordCount: 2,
});
const result = await client.search("query");
expect(invoke).toHaveBeenCalledWith(
"repository_search",
expect.objectContaining({
query: "query",
})
);
expect(result.items.length).toBe(2);
});
it("should support search with item type filters", async () => {
(invoke as any).mockResolvedValueOnce({
items: [],
totalRecordCount: 0,
});
await client.search("query", {
includeItemTypes: ["Audio"],
});
expect(invoke).toHaveBeenCalledWith(
"repository_search",
expect.objectContaining({
options: expect.objectContaining({
includeItemTypes: ["Audio"],
}),
})
);
});
it("should NOT do client-side search filtering", async () => {
// Old code pattern:
// const query = searchInput.toLowerCase();
// const results = items.filter(item =>
// config.searchFields.some(field =>
// item[field]?.toLowerCase()?.includes(query)
// )
// ); // ← REMOVED
// New code pattern:
// Call backend search directly
(invoke as any).mockResolvedValueOnce({
items: [{ id: "item1" }],
totalRecordCount: 1,
});
const result = await client.search("search term");
// Backend did the filtering
expect(result.items).toBeDefined();
expect(invoke).toHaveBeenCalledWith("repository_search", expect.any(Object));
});
});
describe("URL Construction Delegated to Backend", () => {
it("should get image URLs from backend (not construct in frontend)", async () => {
const backendUrl = "https://server.com/Items/item123/Images/Primary?api_key=token";
(invoke as any).mockResolvedValueOnce(backendUrl);
const url = await client.getImageUrl("item123", "Primary");
// Backend constructed and returned the URL
expect(url).toBe(backendUrl);
expect(invoke).toHaveBeenCalledWith(
"repository_get_image_url",
expect.objectContaining({
itemId: "item123",
imageType: "Primary",
})
);
});
it("should NOT construct image URLs in frontend", async () => {
// Old code pattern:
// return `${serverUrl}/Items/${itemId}/Images/${imageType}?api_key=${token}&maxWidth=${options.maxWidth}`;
// ← REMOVED - NEVER construct URLs in frontend
(invoke as any).mockResolvedValueOnce("https://server.com/image");
const url = await client.getImageUrl("item123", "Primary", { maxWidth: 300 });
// URL came from backend, not constructed in frontend
expect(typeof url).toBe("string");
expect(url).toContain("http");
});
it("should get video stream URLs from backend", async () => {
const backendUrl = "https://server.com/Videos/item123/stream.mp4?api_key=token";
(invoke as any).mockResolvedValueOnce(backendUrl);
const url = await client.getVideoStreamUrl("item123");
expect(url).toBe(backendUrl);
expect(invoke).toHaveBeenCalledWith(
"repository_get_video_stream_url",
expect.any(Object)
);
});
it("should get subtitle URLs from backend", async () => {
const backendUrl = "https://server.com/Videos/item123/Subtitles/0/subtitles.vtt?api_key=token";
(invoke as any).mockResolvedValueOnce(backendUrl);
const url = await client.getSubtitleUrl("item123", "source456", 0);
expect(url).toBe(backendUrl);
expect(invoke).toHaveBeenCalledWith(
"repository_get_subtitle_url",
expect.any(Object)
);
});
it("should get video download URLs from backend", async () => {
const backendUrl = "https://server.com/Videos/item123/stream.mp4?maxWidth=1280&api_key=token";
(invoke as any).mockResolvedValueOnce(backendUrl);
const url = await client.getVideoDownloadUrl("item123", "720p");
expect(url).toBe(backendUrl);
expect(invoke).toHaveBeenCalledWith(
"repository_get_video_download_url",
expect.any(Object)
);
});
it("should never expose access token in frontend code", async () => {
// The access token is NEVER used in frontend URL construction
// It's only stored in backend for secure URL generation
// Frontend code NEVER has direct access to use the token
const client2 = new RepositoryClient();
// client2._accessToken is private and should never be accessed or used
// All token usage is in backend via Tauri commands
expect(invoke).toBeDefined();
});
});
describe("Component Config Simplification", () => {
it("should have simplified MediaListConfig (no searchFields)", () => {
// Old type:
// interface MediaListConfig {
// searchFields: string[]; // ← REMOVED
// compareFn?: (a, b) => number; // ← REMOVED
// }
// New type:
const config = {
itemType: "Audio",
title: "Tracks",
sortOptions: [
{ key: "SortName", label: "Title" },
// No compareFn
],
// No searchFields
};
// Verify no searchFields
expect((config as any).searchFields).toBeUndefined();
});
it("should use Jellyfin field names in sort options", () => {
// Old:
// { key: "title", label: "Title", compareFn: ... }
// New:
// { key: "SortName", label: "Title" }
const sortOptions = [
{ key: "SortName", label: "A-Z" },
{ key: "Artist", label: "Artist" },
{ key: "Album", label: "Album" },
{ key: "DatePlayed", label: "Recent" },
];
for (const option of sortOptions) {
// Should be Jellyfin field names
expect(typeof option.key).toBe("string");
expect(option.key).toMatch(/^[A-Z]/); // Jellyfin fields start with capital
}
});
});
describe("Debounced Search Implementation", () => {
it("should debounce search without frontend filtering", async () => {
vi.useFakeTimers();
const mockSearch = vi.fn().mockResolvedValue({
items: [],
totalRecordCount: 0,
});
(invoke as any).mockImplementation((cmd: string, args: any) => {
if (cmd === "repository_search") {
return mockSearch(args.query);
}
return Promise.resolve({ items: [], totalRecordCount: 0 });
});
// Simulate rapid search queries
await client.search("t");
vi.advanceTimersByTime(100);
await client.search("te");
vi.advanceTimersByTime(100);
await client.search("test");
vi.advanceTimersByTime(300);
// All calls go to backend (debouncing happens in component via $effect)
expect(invoke).toHaveBeenCalled();
vi.useRealTimers();
});
});
describe("End-to-End Data Flow", () => {
it("should support complete flow: load → sort → display", async () => {
(invoke as any).mockResolvedValueOnce({
items: [
{ id: "id1", name: "Album A", sortName: "A" },
{ id: "id2", name: "Album B", sortName: "B" },
],
totalRecordCount: 2,
});
// Frontend requests items with sort
const result = await client.getItems("library123", {
sortBy: "SortName",
sortOrder: "Ascending",
});
// Backend returned pre-sorted items
expect(result.items[0].sortName).toBe("A");
expect(result.items[1].sortName).toBe("B");
// Frontend just displays them
// No compareFn, no local sorting
});
it("should support complete flow: search → load images → display", async () => {
// 1. Frontend calls backend search
(invoke as any).mockResolvedValueOnce({
items: [{ id: "item1", name: "Result", primaryImageTag: "tag1" }],
totalRecordCount: 1,
});
const searchResult = await client.search("query");
expect(searchResult.items.length).toBe(1);
// 2. Frontend loads image URL from backend
(invoke as any).mockResolvedValueOnce("https://server.com/image.jpg");
const imageUrl = await client.getImageUrl("item1", "Primary");
expect(imageUrl).toContain("http");
// 3. Frontend displays search results with images
// No client-side filtering, sorting, or URL construction
});
});
describe("Performance Characteristics", () => {
it("should reduce memory usage by not storing frontend sorting state", async () => {
// Old: Frontend stores items + sorting state + filtered results
// Old: Multiple copies of data (original, filtered, sorted)
// New: Backend returns already-sorted data
// New: Frontend just stores the result
(invoke as any).mockResolvedValueOnce({
items: Array.from({ length: 10000 }, (_, i) => ({
id: `id${i}`,
name: `Item ${i}`,
})),
totalRecordCount: 10000,
});
const result = await client.getItems("library123", {
sortBy: "SortName",
limit: 10000,
});
// Backend handled sorting
expect(result.items.length).toBe(10000);
// Frontend just stores the result array
});
it("should reduce CPU usage by avoiding client-side operations", async () => {
// Old pattern required:
// - Parsing all items into memory
// - Iterating to apply filters
// - Sorting algorithm (O(n log n) comparisons)
// - Updating multiple state variables
// New pattern:
(invoke as any).mockResolvedValueOnce({
items: [], // Backend already filtered/sorted
totalRecordCount: 0,
});
// Frontend just awaits backend result
const result = await client.getItems("library123", {
sortBy: "SortName",
includeItemTypes: ["Audio"],
});
// No client-side work
expect(result).toBeDefined();
});
});
});