feat!: release v2 (#6545)

Co-authored-by: Artur <145776561+arturict@users.noreply.github.com>
Co-authored-by: arturict <arturict@users.noreply.github.com>
Co-authored-by: Thomas <thomas@ajnart.fr>
Co-authored-by: Afonso Jorge Ramos <afonsojorgeramos@gmail.com>
Co-authored-by: Danura30082 <danura30082+github@gmail.com>
Co-authored-by: Kushie <122986913+kushiemoon-dev@users.noreply.github.com>
Co-authored-by: ajnart <thomas@ajnart.fr>
Co-authored-by: ajnart <thomas@ajnart.dev>
Co-authored-by: chenjun <chenjun@foxmail.com>
Co-authored-by: Wikibear <wikibear@users.noreply.github.com>
Co-authored-by: Bergasha <bergasha@gmail.com>
Co-authored-by: leafyelin <43306196+leafyelin@users.noreply.github.com>
Co-authored-by: Lucas Araujo <lucas.lra@gmail.com>
Co-authored-by: GreenMonito <86085570+GreenMonito@users.noreply.github.com>
Co-authored-by: Nerdy-Technician <roffo@nerdy-technician.social>
Co-authored-by: Jun <34545896+jcstrong@users.noreply.github.com>
Co-authored-by: Edmond Abraham <54119952+gitedmond@users.noreply.github.com>
Co-authored-by: thrawnx <tight.bulb9384@fastmail.com>
Co-authored-by: Chris <58918656+nordada@users.noreply.github.com>
Co-authored-by: yi111 <153097222+Yi-111-a@users.noreply.github.com>
Co-authored-by: Yanis <naamaneyanis@gmail.com>
Co-authored-by: Wikibear <me@sej.de>
Co-authored-by: alessiottavo <93441886+alessiottavo@users.noreply.github.com>
This commit is contained in:
3286 files changed
+284386 -139176

No files matched your search

@@ -0,0 +1,207 @@
// @vitest-environment node
import { describe, expect, test, vi } from "vitest";
import { createDb } from "@homarr/db/test";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { BinderyIntegration } from "../src/media-organizer/bindery/bindery-integration";
vi.mock("@homarr/db", async (importActual) => {
const actual = await importActual<typeof import("@homarr/db")>();
return { ...actual, db: createDb() };
});
vi.mock("@homarr/core/infrastructure/certificates", async (importActual) => {
const actual = await importActual<typeof import("@homarr/core/infrastructure/certificates")>();
return {
...actual,
getTrustedCertificateHostnamesAsync: vi.fn().mockResolvedValue([]),
};
});
vi.mock("@homarr/core/infrastructure/http", () => ({
fetchWithTrustedCertificatesAsync: vi.fn(),
}));
const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync);
const integrationInput = {
id: "test-bindery",
name: "Bindery",
url: "http://localhost:8787",
decryptedSecrets: [{ kind: "apiKey" as const, value: "test-key" }],
externalUrl: null,
};
describe("BinderyIntegration getMissingAsync", () => {
test("maps Bindery's bare-array response into MissingMediaItem[] with type book", async () => {
// /api/v1/wanted/missing returns a bare array (no {items,total} wrapper),
// unlike Sonarr/Radarr's paginated {totalRecords, records} shape.
mockFetch.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve([
{
id: 14814,
title: "Authority",
releaseDate: "2014-01-01T00:00:00Z",
imageUrl: "https://assets.hardcover.app/edition/30769443/cover.jpg",
author: { authorName: "Jeff VanderMeer" },
},
]),
} as never);
const integration = new BinderyIntegration(integrationInput);
const result = await integration.getMissingAsync(10);
expect(result.totalCount).toBe(1);
expect(result.items[0]).toMatchObject({
id: 14814,
title: "Authority",
type: "book",
year: 2014,
seriesTitle: "Jeff VanderMeer",
imageUrl: "https://assets.hardcover.app/edition/30769443/cover.jpg",
link: "http://localhost:8787/book/14814",
});
});
test("drops a non-absolute imageUrl instead of passing through a Bindery-proxied path", async () => {
// /api/v1/book's imageUrl is a Bindery-proxied, auth-gated relative path
// (/api/v1/images?url=...). If that shape ever leaks into this endpoint's
// response it must not be handed to the browser as a public image URL.
mockFetch.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve([
{
id: 1,
title: "Some Book",
imageUrl: "/api/v1/images?url=https%3A%2F%2Fexample.com%2Fcover.jpg",
},
]),
} as never);
const integration = new BinderyIntegration(integrationInput);
const result = await integration.getMissingAsync(10);
expect(result.items[0]?.imageUrl).toBeNull();
});
test("accepts an explicit null releaseDate, not just an omitted key", async () => {
// Observed live against the real Bindery instance: books with no known
// release date return releaseDate: null (e.g. newly-added, metadata not
// yet fetched), not just an absent field. z.string().optional() alone
// rejects null and threw IntegrationParseError for these rows.
mockFetch.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve([
{
id: 2,
title: "Book With No Release Date Yet",
releaseDate: null,
},
]),
} as never);
const integration = new BinderyIntegration(integrationInput);
const result = await integration.getMissingAsync(10);
expect(result.items[0]).toMatchObject({ id: 2, title: "Book With No Release Date Yet", year: undefined });
});
test("slices client-side to pageSize since the endpoint ignores pagination params", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(Array.from({ length: 5 }, (_, index) => ({ id: index, title: `Book ${index}` }))),
} as never);
const integration = new BinderyIntegration(integrationInput);
const result = await integration.getMissingAsync(2);
expect(result.totalCount).toBe(5);
expect(result.items).toHaveLength(2);
});
});
describe("BinderyIntegration getMediaQueueAsync", () => {
test("excludes already-imported rows from the active queue", async () => {
// Observed live: Bindery's /api/v1/queue returns its entire historical
// log, not just in-flight downloads (522 of 555 rows were "imported").
mockFetch.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve([
{ id: 1, title: "Done Book", status: "imported", percentage: "100.0" },
{ id: 2, title: "Grabbing Book", status: "grabbed", percentage: "40.0", timeLeft: "10m" },
]),
} as never);
const integration = new BinderyIntegration(integrationInput);
const result = await integration.getMediaQueueAsync(10);
expect(result.totalCount).toBe(1);
expect(result.items).toHaveLength(1);
expect(result.items[0]).toMatchObject({ id: 2, status: "grabbed", type: "book" });
});
test("hides timeLeft when percentage is 100 even for a non-imported row", async () => {
// Observed live: an importBlocked item reported percentage "100.0"
// alongside a stale timeLeft ("721h 20m"). A 100%-complete item has no
// meaningful time remaining, so that value must not be surfaced.
mockFetch.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
items: [
{
id: 3,
title: "Stuck Book",
status: "importBlocked",
percentage: "100.0",
timeLeft: "721h 20m",
book: { id: 9990, title: "Stuck Book", authorName: "Some Author" },
},
],
}),
} as never);
const integration = new BinderyIntegration(integrationInput);
const result = await integration.getMediaQueueAsync(10);
expect(result.items[0]).toMatchObject({
status: "importBlocked",
percentComplete: 100,
timeLeft: null,
seriesTitle: "Some Author",
link: "http://localhost:8787/book/9990",
});
});
});
describe("BinderyIntegration testingAsync", () => {
test("hits the authenticated status endpoint and succeeds on a 2xx response", async () => {
const integration = new BinderyIntegration(integrationInput);
const fetchAsync = vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve({ version: "v1.36.1" }) });
// testingAsync is protected; the "test connection" button in the UI is
// the only normal caller. Cast to invoke it directly in isolation from
// testConnectionAsync's TLS/dispatcher setup, which is out of scope here.
const testingAsync = (
integration as unknown as {
testingAsync: (input: { fetchAsync: typeof fetchAsync }) => Promise<{ success: boolean }>;
}
).testingAsync.bind(integration);
const result = await testingAsync({ fetchAsync });
// this.url(...) returns a URL instance, not a string, so compare via
// its serialized form rather than object identity.
const [calledUrl, calledOptions] = fetchAsync.mock.calls[0] as [URL, { headers: Record<string, string> }];
expect(calledUrl.toString()).toBe("http://localhost:8787/api/v1/system/status");
expect(calledOptions).toMatchObject({ headers: { "X-Api-Key": "test-key" } });
expect(result.success).toBe(true);
});
});
+186 -1
View File
@@ -64,7 +64,14 @@ function makeResponse(body: unknown) {
} as never;
}
function mockGlancesFetch(statsBody: unknown, sensorsBody: unknown) {
// Glances answers 400 "Unknown plugin smart" when the SMART plugin is disabled (the default).
const smartDisabledResponse = {
ok: false,
status: 400,
json: () => Promise.resolve({ detail: "Unknown plugin smart (available plugins: ...)" }),
} as never;
function mockGlancesFetch(statsBody: unknown, sensorsBody: unknown, smartBody?: unknown) {
mockFetch.mockImplementation(((url: string | URL) => {
const urlString = url.toString();
if (urlString.endsWith("/api/4/all")) {
@@ -73,10 +80,85 @@ function mockGlancesFetch(statsBody: unknown, sensorsBody: unknown) {
if (urlString.endsWith("/api/4/sensors")) {
return Promise.resolve(makeResponse(sensorsBody));
}
if (urlString.endsWith("/api/4/smart")) {
return Promise.resolve(smartBody === undefined ? smartDisabledResponse : makeResponse(smartBody));
}
return Promise.reject(new Error(`Unexpected fetch URL: ${urlString}`));
}) as typeof fetchWithTrustedCertificatesAsync);
}
// Trimmed /api/4/smart response from Glances 4.5.7 (pySMART) with one SATA disk and one NVMe drive.
const smartStats = [
{
DeviceName: "nvme0 KINGSTON SA2000M81000G",
key: "DeviceName",
"1": {
name: "Number of critical warnings",
key: "criticalWarning",
value: 0,
flags: null,
raw: 0,
worst: null,
threshold: null,
type: null,
updated: null,
when_failed: null,
},
"2": {
name: "Temperature (°C)",
key: "_temperature",
value: 30,
flags: null,
raw: 30,
worst: null,
threshold: null,
type: null,
updated: null,
when_failed: null,
},
"22": {
name: "Errors",
key: "errors",
value: [],
flags: null,
raw: [],
worst: null,
threshold: null,
type: null,
updated: null,
when_failed: null,
},
},
{
DeviceName: "sda WDC WD30EFRX-68EUZN0",
key: "DeviceName",
"5": {
name: "Reallocated_Sector_Ct",
key: "Reallocated_Sector_Ct",
value: "200",
flags: "0x0033",
raw: "0",
worst: "200",
threshold: "140",
type: "Pre-fail",
updated: "Always",
when_failed: "-",
},
"194": {
name: "Temperature_Celsius",
key: "Temperature_Celsius",
value: "115",
flags: "0x0022",
raw: "35 (Min/Max 20/45)",
worst: "106",
threshold: "000",
type: "Old_age",
updated: "Always",
when_failed: "-",
},
},
];
const integrationInput = {
id: "test-glances",
name: "Glances",
@@ -131,6 +213,109 @@ describe("GlancesIntegration schema", () => {
});
});
describe("GlancesIntegration SMART", () => {
beforeEach(() => {
mockFetch.mockReset();
});
test("maps a SATA disk with temperature from Temperature_Celsius", async () => {
mockGlancesFetch(baseStats, [], smartStats);
const integration = new GlancesIntegration(integrationInput);
const result = await integration.getSystemInfoAsync();
expect(result.smart).toContainEqual({
deviceName: "/dev/sda",
temperature: 35,
overallStatus: "PASSED",
healthy: true,
statusReason: { type: "attributesWithinThresholds" },
});
});
test("maps an NVMe drive by controller name with temperature from _temperature", async () => {
mockGlancesFetch(baseStats, [], smartStats);
const integration = new GlancesIntegration(integrationInput);
const result = await integration.getSystemInfoAsync();
expect(result.smart).toContainEqual({
deviceName: "/dev/nvme0",
temperature: 30,
overallStatus: "PASSED",
healthy: true,
statusReason: { type: "noCriticalWarning" },
});
});
test("reports FAILED when an ATA attribute has failed or NVMe has a critical warning", async () => {
const [nvme, sata] = smartStats;
mockGlancesFetch(
baseStats,
[],
[
{ ...nvme, "1": { ...nvme?.["1"], value: 4, raw: 4 } },
{ ...sata, "5": { ...sata?.["5"], when_failed: "FAILING_NOW" } },
],
);
const integration = new GlancesIntegration(integrationInput);
const result = await integration.getSystemInfoAsync();
expect(result.smart).toEqual([
expect.objectContaining({
deviceName: "/dev/nvme0",
overallStatus: "FAILED",
healthy: false,
statusReason: { type: "criticalWarning" },
}),
expect.objectContaining({
deviceName: "/dev/sda",
overallStatus: "FAILED",
healthy: false,
statusReason: { type: "attributesFailed", attributes: ["Reallocated_Sector_Ct"] },
}),
]);
});
test("returns no SMART data when the plugin is disabled, keeping the other stats", async () => {
mockGlancesFetch(baseStats, []);
const integration = new GlancesIntegration(integrationInput);
const result = await integration.getSystemInfoAsync();
expect(result.smart).toEqual([]);
expect(result.cpuUtilization).toBe(10);
expect(result.memUsedInBytes).toBe(4_000_000_000);
expect(result.fileSystem).toHaveLength(1);
expect(result.gpu).toEqual([]);
});
test("omits devices without SMART health data instead of reporting them healthy", async () => {
const [nvme] = smartStats;
mockGlancesFetch(
baseStats,
[],
[
{ DeviceName: "sdc USB Disk", key: "DeviceName" },
{ ...nvme, "1": { ...nvme?.["1"], value: null, raw: null } },
],
);
const integration = new GlancesIntegration(integrationInput);
const result = await integration.getSystemInfoAsync();
expect(result.smart).toEqual([]);
});
test("returns no SMART data when the payload is malformed", async () => {
mockGlancesFetch(baseStats, [], { unexpected: true });
const integration = new GlancesIntegration(integrationInput);
await expect(integration.getSystemInfoAsync()).resolves.toMatchObject({ smart: [] });
});
});
describe("parseGlancesCpuTempFromSensors", () => {
test("prefers CPU label over core sensors", () => {
const result = parseGlancesCpuTempFromSensors([
@@ -41,35 +41,37 @@ describe("Home Assistant integration", () => {
test("Test connection should work", async () => {
// Arrange
const startedContainer = await prepareHomeAssistantContainerAsync();
const homeAssistantIntegration = createHomeAssistantIntegration(startedContainer);
try {
const homeAssistantIntegration = createHomeAssistantIntegration(startedContainer);
// Act
const result = await homeAssistantIntegration.testConnectionAsync();
// Act
const result = await homeAssistantIntegration.testConnectionAsync();
// Assert
expect(result.success).toBe(true);
// Cleanup
await startedContainer.stop();
}, 30_000); // Timeout of 30 seconds
// Assert
expect(result.success).toBe(true);
} finally {
await startedContainer.stop();
}
}, 60_000);
test("Test connection should fail with wrong credentials", async () => {
// Arrange
const startedContainer = await prepareHomeAssistantContainerAsync();
const homeAssistantIntegration = createHomeAssistantIntegration(startedContainer, "wrong-api-key");
try {
const homeAssistantIntegration = createHomeAssistantIntegration(startedContainer, "wrong-api-key");
// Act
const result = await homeAssistantIntegration.testConnectionAsync();
// Act
const result = await homeAssistantIntegration.testConnectionAsync();
// Assert
expect(result.success).toBe(false);
if (result.success) return;
// Assert
expect(result.success).toBe(false);
if (result.success) return;
expect(result.error).toBeInstanceOf(TestConnectionError);
expect(result.error.type).toBe("authorization");
// Cleanup
await startedContainer.stop();
}, 30_000); // Timeout of 30 seconds
expect(result.error).toBeInstanceOf(TestConnectionError);
expect(result.error.type).toBe("authorization");
} finally {
await startedContainer.stop();
}
}, 60_000);
});
const prepareHomeAssistantContainerAsync = async () => {
@@ -77,9 +79,31 @@ const prepareHomeAssistantContainerAsync = async () => {
const startedContainer = await homeAssistantContainer.start();
await startedContainer.exec(["unzip", "-o", "/tmp/config.zip", "-d", "/config"]);
await startedContainer.restart();
await waitForHomeAssistantApiAsync(startedContainer);
return startedContainer;
};
const waitForHomeAssistantApiAsync = async (container: StartedTestContainer) => {
const url = `http://${container.getHost()}:${container.getMappedPort(8123)}/api/config`;
const deadline = Date.now() + 45_000;
while (Date.now() < deadline) {
try {
const response = await fetch(url, {
headers: { Authorization: `Bearer ${DEFAULT_API_KEY}` },
signal: AbortSignal.timeout(1_000),
});
if (response.ok) return;
} catch {
// Home Assistant closes connections while restarting. Retry until the deadline.
}
await new Promise((resolve) => setTimeout(resolve, 250));
}
await container.stop();
throw new Error("Home Assistant API did not become ready after restart");
};
const createHomeAssistantContainer = () => {
return (
new GenericContainer(IMAGE_NAME)
+1 -1
View File
@@ -297,7 +297,7 @@ describe("Nextcloud integration", () => {
// Assert
const relevantEvents = events.filter((event) => event.title === testEvent.summary);
expect(relevantEvents).toHaveLength(5);
});
}, 20_000);
});
});