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);
|
||||
});
|
||||
});
|
||||
@@ -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)
|
||||
|
||||
@@ -297,7 +297,7 @@ describe("Nextcloud integration", () => {
|
||||
// Assert
|
||||
const relevantEvents = events.filter((event) => event.title === testEvent.summary);
|
||||
expect(relevantEvents).toHaveLength(5);
|
||||
});
|
||||
}, 20_000);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user