feat: test integration connection (#669)
* feat: add test integration for pi-hole * refactor: test integration for pi-hole * fix: multiple secrets of same type could be used for integration creation * fix: remove integration test connection test and add mock for test-connection function * fix: add missing onUpdateFn to mysql integration secrets * fix: format issues * feat: add home assistant test connection * fix: deepsource issues * test: add system integration tests for test connection * fix: add before all for pulling home assistant image * test: add unit tests for handleTestConnectionResponseAsync * test: add unit test for testConnectionAsync * test: add mroe tests to integration-test-connection * fix: deepsource issues * fix: deepsource issue * chore: address pull request feedback
This commit is contained in:
@@ -4,6 +4,7 @@
|
||||
"version": "0.1.0",
|
||||
"exports": {
|
||||
".": "./index.ts",
|
||||
"./client": "./src/client.ts",
|
||||
"./types": "./src/types.ts"
|
||||
},
|
||||
"typesVersions": {
|
||||
@@ -25,7 +26,8 @@
|
||||
"@homarr/definitions": "workspace:^0.1.0",
|
||||
"@homarr/log": "workspace:^0.1.0",
|
||||
"@homarr/validation": "workspace:^0.1.0",
|
||||
"@homarr/common": "workspace:^0.1.0"
|
||||
"@homarr/common": "workspace:^0.1.0",
|
||||
"@homarr/translation": "workspace:^0.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
|
||||
16
packages/integrations/src/base/creator.ts
Normal file
16
packages/integrations/src/base/creator.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { IntegrationKind } from "@homarr/definitions";
|
||||
|
||||
import { HomeAssistantIntegration } from "../homeassistant/homeassistant-integration";
|
||||
import { PiHoleIntegration } from "../pi-hole/pi-hole-integration";
|
||||
import type { IntegrationInput } from "./integration";
|
||||
|
||||
export const integrationCreatorByKind = (kind: IntegrationKind, integration: IntegrationInput) => {
|
||||
switch (kind) {
|
||||
case "piHole":
|
||||
return new PiHoleIntegration(integration);
|
||||
case "homeAssistant":
|
||||
return new HomeAssistantIntegration(integration);
|
||||
default:
|
||||
throw new Error(`Unknown integration kind ${kind}`);
|
||||
}
|
||||
};
|
||||
@@ -1,16 +1,25 @@
|
||||
import { extractErrorMessage } from "@homarr/common";
|
||||
import type { IntegrationSecretKind } from "@homarr/definitions";
|
||||
import { logger } from "@homarr/log";
|
||||
import type { TranslationObject } from "@homarr/translation";
|
||||
import { z } from "@homarr/validation";
|
||||
|
||||
import { IntegrationTestConnectionError } from "./test-connection-error";
|
||||
import type { IntegrationSecret } from "./types";
|
||||
|
||||
const causeSchema = z.object({
|
||||
code: z.string(),
|
||||
});
|
||||
|
||||
export interface IntegrationInput {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
decryptedSecrets: IntegrationSecret[];
|
||||
}
|
||||
|
||||
export abstract class Integration {
|
||||
constructor(
|
||||
protected integration: {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
decryptedSecrets: IntegrationSecret[];
|
||||
},
|
||||
) {}
|
||||
constructor(protected integration: IntegrationInput) {}
|
||||
|
||||
protected getSecretValue(kind: IntegrationSecretKind) {
|
||||
const secret = this.integration.decryptedSecrets.find((secret) => secret.kind === kind);
|
||||
@@ -19,4 +28,87 @@ export abstract class Integration {
|
||||
}
|
||||
return secret.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test the connection to the integration
|
||||
* @throws {IntegrationTestConnectionError} if the connection fails
|
||||
*/
|
||||
public abstract testConnectionAsync(): Promise<void>;
|
||||
|
||||
protected async handleTestConnectionResponseAsync({
|
||||
queryFunctionAsync,
|
||||
handleResponseAsync,
|
||||
}: {
|
||||
queryFunctionAsync: () => Promise<Response>;
|
||||
handleResponseAsync?: (response: Response) => Promise<void>;
|
||||
}): Promise<void> {
|
||||
const response = await queryFunctionAsync().catch((error) => {
|
||||
if (error instanceof Error) {
|
||||
const cause = causeSchema.safeParse(error.cause);
|
||||
if (!cause.success) {
|
||||
logger.error("Failed to test connection", error);
|
||||
throw new IntegrationTestConnectionError("commonError", extractErrorMessage(error));
|
||||
}
|
||||
|
||||
if (cause.data.code === "ENOTFOUND") {
|
||||
logger.error("Failed to test connection: Domain not found");
|
||||
throw new IntegrationTestConnectionError("domainNotFound");
|
||||
}
|
||||
|
||||
if (cause.data.code === "ECONNREFUSED") {
|
||||
logger.error("Failed to test connection: Connection refused");
|
||||
throw new IntegrationTestConnectionError("connectionRefused");
|
||||
}
|
||||
|
||||
if (cause.data.code === "ECONNABORTED") {
|
||||
logger.error("Failed to test connection: Connection aborted");
|
||||
throw new IntegrationTestConnectionError("connectionAborted");
|
||||
}
|
||||
}
|
||||
|
||||
logger.error("Failed to test connection", error);
|
||||
|
||||
throw new IntegrationTestConnectionError("commonError", extractErrorMessage(error));
|
||||
});
|
||||
|
||||
if (response.status >= 400) {
|
||||
logger.error(`Failed to test connection with status code ${response.status}`);
|
||||
|
||||
throwErrorByStatusCode(response.status);
|
||||
}
|
||||
|
||||
await handleResponseAsync?.(response);
|
||||
}
|
||||
}
|
||||
|
||||
export interface TestConnectionError {
|
||||
key: Exclude<keyof TranslationObject["integration"]["testConnection"]["notification"], "success">;
|
||||
message?: string;
|
||||
}
|
||||
export type TestConnectionResult =
|
||||
| {
|
||||
success: false;
|
||||
error: TestConnectionError;
|
||||
}
|
||||
| {
|
||||
success: true;
|
||||
};
|
||||
|
||||
const throwErrorByStatusCode = (statusCode: number) => {
|
||||
switch (statusCode) {
|
||||
case 400:
|
||||
throw new IntegrationTestConnectionError("badRequest");
|
||||
case 401:
|
||||
throw new IntegrationTestConnectionError("unauthorized");
|
||||
case 403:
|
||||
throw new IntegrationTestConnectionError("forbidden");
|
||||
case 404:
|
||||
throw new IntegrationTestConnectionError("notFound");
|
||||
case 500:
|
||||
throw new IntegrationTestConnectionError("internalServerError");
|
||||
case 503:
|
||||
throw new IntegrationTestConnectionError("serviceUnavailable");
|
||||
default:
|
||||
throw new IntegrationTestConnectionError("commonError");
|
||||
}
|
||||
};
|
||||
|
||||
26
packages/integrations/src/base/test-connection-error.ts
Normal file
26
packages/integrations/src/base/test-connection-error.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { FlattenError } from "@homarr/common";
|
||||
import { z } from "@homarr/validation";
|
||||
|
||||
import type { TestConnectionError } from "./integration";
|
||||
|
||||
export class IntegrationTestConnectionError extends FlattenError {
|
||||
constructor(
|
||||
public key: TestConnectionError["key"],
|
||||
public detailMessage?: string,
|
||||
) {
|
||||
super("Checking integration connection failed", { key, message: detailMessage });
|
||||
}
|
||||
}
|
||||
|
||||
const schema = z.object({
|
||||
key: z.custom<TestConnectionError["key"]>((value) => z.string().parse(value)),
|
||||
message: z.string().optional(),
|
||||
});
|
||||
export const convertIntegrationTestConnectionError = (error: unknown) => {
|
||||
const result = schema.safeParse(error);
|
||||
if (!result.success) {
|
||||
return;
|
||||
}
|
||||
|
||||
return result.data;
|
||||
};
|
||||
1
packages/integrations/src/client.ts
Normal file
1
packages/integrations/src/client.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { convertIntegrationTestConnectionError } from "./base/test-connection-error";
|
||||
@@ -5,13 +5,9 @@ import { Integration } from "../base/integration";
|
||||
import { entityStateSchema } from "./homeassistant-types";
|
||||
|
||||
export class HomeAssistantIntegration extends Integration {
|
||||
async getEntityStateAsync(entityId: string) {
|
||||
public async getEntityStateAsync(entityId: string) {
|
||||
try {
|
||||
const response = await fetch(appendPath(this.integration.url, `/states/${entityId}`), {
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.getSecretValue("apiKey")}`,
|
||||
},
|
||||
});
|
||||
const response = await this.getAsync(`/api/states/${entityId}`);
|
||||
const body = (await response.json()) as unknown;
|
||||
if (!response.ok) {
|
||||
logger.warn(`Response did not indicate success`);
|
||||
@@ -29,17 +25,12 @@ export class HomeAssistantIntegration extends Integration {
|
||||
}
|
||||
}
|
||||
|
||||
async triggerAutomationAsync(entityId: string) {
|
||||
public async triggerAutomationAsync(entityId: string) {
|
||||
try {
|
||||
const response = await fetch(appendPath(this.integration.url, "/services/automation/trigger"), {
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.getSecretValue("apiKey")}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
entity_id: entityId,
|
||||
}),
|
||||
method: "POST",
|
||||
const response = await this.postAsync("/api/services/automation/trigger", {
|
||||
entity_id: entityId,
|
||||
});
|
||||
|
||||
return response.ok;
|
||||
} catch (err) {
|
||||
logger.error(`Failed to fetch from '${this.integration.url}': ${err as string}`);
|
||||
@@ -53,21 +44,61 @@ export class HomeAssistantIntegration extends Integration {
|
||||
* @param entityId - The ID of the entity to toggle.
|
||||
* @returns A boolean indicating whether the toggle action was successful.
|
||||
*/
|
||||
async triggerToggleAsync(entityId: string) {
|
||||
public async triggerToggleAsync(entityId: string) {
|
||||
try {
|
||||
const response = await fetch(appendPath(this.integration.url, "/services/homeassistant/toggle"), {
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.getSecretValue("apiKey")}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
entity_id: entityId,
|
||||
}),
|
||||
method: "POST",
|
||||
const response = await this.postAsync("/api/services/homeassistant/toggle", {
|
||||
entity_id: entityId,
|
||||
});
|
||||
|
||||
return response.ok;
|
||||
} catch (err) {
|
||||
logger.error(`Failed to fetch from '${this.integration.url}': ${err as string}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async testConnectionAsync(): Promise<void> {
|
||||
await super.handleTestConnectionResponseAsync({
|
||||
queryFunctionAsync: async () => {
|
||||
return await this.getAsync("/api/config");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes a GET request to the Home Assistant API.
|
||||
* It includes the authorization header with the API key.
|
||||
* @param path full path to the API endpoint
|
||||
* @returns the response from the API
|
||||
*/
|
||||
private async getAsync(path: `/api/${string}`) {
|
||||
return await fetch(appendPath(this.integration.url, path), {
|
||||
headers: this.getAuthHeaders(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes a POST request to the Home Assistant API.
|
||||
* It includes the authorization header with the API key.
|
||||
* @param path full path to the API endpoint
|
||||
* @param body the body of the request
|
||||
* @returns the response from the API
|
||||
*/
|
||||
private async postAsync(path: `/api/${string}`, body: Record<string, string>) {
|
||||
return await fetch(appendPath(this.integration.url, path), {
|
||||
headers: this.getAuthHeaders(),
|
||||
body: JSON.stringify(body),
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the headers required for authorization.
|
||||
* @returns the authorization headers
|
||||
*/
|
||||
private getAuthHeaders() {
|
||||
return {
|
||||
Authorization: `Bearer ${this.getSecretValue("apiKey")}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,2 +1,7 @@
|
||||
// General integrations
|
||||
export { PiHoleIntegration } from "./pi-hole/pi-hole-integration";
|
||||
export { HomeAssistantIntegration } from "./homeassistant/homeassistant-integration";
|
||||
|
||||
// Helpers
|
||||
export { IntegrationTestConnectionError } from "./base/test-connection-error";
|
||||
export { integrationCreatorByKind } from "./base/creator";
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Integration } from "../base/integration";
|
||||
import { IntegrationTestConnectionError } from "../base/test-connection-error";
|
||||
import type { DnsHoleSummaryIntegration } from "../interfaces/dns-hole-summary/dns-hole-summary-integration";
|
||||
import type { DnsHoleSummary } from "../interfaces/dns-hole-summary/dns-hole-summary-types";
|
||||
import { summaryResponseSchema } from "./pi-hole-types";
|
||||
|
||||
export class PiHoleIntegration extends Integration implements DnsHoleSummaryIntegration {
|
||||
async getSummaryAsync(): Promise<DnsHoleSummary> {
|
||||
public async getSummaryAsync(): Promise<DnsHoleSummary> {
|
||||
const apiKey = super.getSecretValue("apiKey");
|
||||
const response = await fetch(`${this.integration.url}/admin/api.php?summaryRaw&auth=${apiKey}`);
|
||||
if (!response.ok) {
|
||||
@@ -28,4 +29,24 @@ export class PiHoleIntegration extends Integration implements DnsHoleSummaryInte
|
||||
dnsQueriesToday: result.data.dns_queries_today,
|
||||
};
|
||||
}
|
||||
|
||||
public async testConnectionAsync(): Promise<void> {
|
||||
const apiKey = super.getSecretValue("apiKey");
|
||||
|
||||
await super.handleTestConnectionResponseAsync({
|
||||
queryFunctionAsync: async () => {
|
||||
return await fetch(`${this.integration.url}/admin/api.php?status&auth=${apiKey}`);
|
||||
},
|
||||
handleResponseAsync: async (response) => {
|
||||
try {
|
||||
const result = (await response.json()) as unknown;
|
||||
if (typeof result === "object" && result !== null && "status" in result) return;
|
||||
} catch (error) {
|
||||
throw new IntegrationTestConnectionError("invalidJson");
|
||||
}
|
||||
|
||||
throw new IntegrationTestConnectionError("invalidCredentials");
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
249
packages/integrations/test/base.spec.ts
Normal file
249
packages/integrations/test/base.spec.ts
Normal file
@@ -0,0 +1,249 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { IntegrationTestConnectionError } from "../src";
|
||||
import { Integration } from "../src/base/integration";
|
||||
|
||||
type HandleResponseProps = Parameters<Integration["handleTestConnectionResponseAsync"]>[0];
|
||||
|
||||
class BaseIntegrationMock extends Integration {
|
||||
public async fakeTestConnectionAsync(props: HandleResponseProps): Promise<void> {
|
||||
await super.handleTestConnectionResponseAsync(props);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
public async testConnectionAsync(): Promise<void> {}
|
||||
}
|
||||
|
||||
describe("Base integration", () => {
|
||||
describe("handleTestConnectionResponseAsync", () => {
|
||||
test("With no cause error should throw IntegrationTestConnectionError with key commonError", async () => {
|
||||
// Arrange
|
||||
const integration = new BaseIntegrationMock({ id: "id", name: "name", url: "url", decryptedSecrets: [] });
|
||||
|
||||
const errorMessage = "The error message";
|
||||
const props: HandleResponseProps = {
|
||||
async queryFunctionAsync() {
|
||||
return await Promise.reject(new Error(errorMessage));
|
||||
},
|
||||
};
|
||||
|
||||
// Act
|
||||
const actPromise = integration.fakeTestConnectionAsync(props);
|
||||
|
||||
// Assert
|
||||
await expect(actPromise).rejects.toHaveProperty("key", "commonError");
|
||||
await expect(actPromise).rejects.toHaveProperty("detailMessage", errorMessage);
|
||||
});
|
||||
|
||||
test("With cause ENOTFOUND should throw IntegrationTestConnectionError with key domainNotFound", async () => {
|
||||
// Arrange
|
||||
const integration = new BaseIntegrationMock({ id: "id", name: "name", url: "url", decryptedSecrets: [] });
|
||||
|
||||
const props: HandleResponseProps = {
|
||||
async queryFunctionAsync() {
|
||||
return await Promise.reject(new Error("Error", { cause: { code: "ENOTFOUND" } }));
|
||||
},
|
||||
};
|
||||
|
||||
// Act
|
||||
const actAsync = async () => await integration.fakeTestConnectionAsync(props);
|
||||
|
||||
// Assert
|
||||
await expect(actAsync()).rejects.toHaveProperty("key", "domainNotFound");
|
||||
});
|
||||
|
||||
test("With cause ENOTFOUND should throw IntegrationTestConnectionError with key connectionRefused", async () => {
|
||||
// Arrange
|
||||
const integration = new BaseIntegrationMock({ id: "id", name: "name", url: "url", decryptedSecrets: [] });
|
||||
|
||||
const props: HandleResponseProps = {
|
||||
async queryFunctionAsync() {
|
||||
return await Promise.reject(new Error("Error", { cause: { code: "ECONNREFUSED" } }));
|
||||
},
|
||||
};
|
||||
|
||||
// Act
|
||||
const actAsync = async () => await integration.fakeTestConnectionAsync(props);
|
||||
|
||||
// Assert
|
||||
await expect(actAsync()).rejects.toHaveProperty("key", "connectionRefused");
|
||||
});
|
||||
|
||||
test("With cause ENOTFOUND should throw IntegrationTestConnectionError with key connectionAborted", async () => {
|
||||
// Arrange
|
||||
const integration = new BaseIntegrationMock({ id: "id", name: "name", url: "url", decryptedSecrets: [] });
|
||||
|
||||
const props: HandleResponseProps = {
|
||||
async queryFunctionAsync() {
|
||||
return await Promise.reject(new Error("Error", { cause: { code: "ECONNABORTED" } }));
|
||||
},
|
||||
};
|
||||
|
||||
// Act
|
||||
const actAsync = async () => await integration.fakeTestConnectionAsync(props);
|
||||
|
||||
// Assert
|
||||
await expect(actAsync()).rejects.toHaveProperty("key", "connectionAborted");
|
||||
});
|
||||
|
||||
test("With not handled cause error should throw IntegrationTestConnectionError with key commonError", async () => {
|
||||
// Arrange
|
||||
const integration = new BaseIntegrationMock({ id: "id", name: "name", url: "url", decryptedSecrets: [] });
|
||||
|
||||
const errorMessage = "The error message";
|
||||
const props: HandleResponseProps = {
|
||||
async queryFunctionAsync() {
|
||||
return await Promise.reject(new Error(errorMessage));
|
||||
},
|
||||
};
|
||||
|
||||
// Act
|
||||
const actPromise = integration.fakeTestConnectionAsync(props);
|
||||
|
||||
// Assert
|
||||
await expect(actPromise).rejects.toHaveProperty("key", "commonError");
|
||||
await expect(actPromise).rejects.toHaveProperty("detailMessage", errorMessage);
|
||||
});
|
||||
|
||||
test("With response status code 400 should throw IntegrationTestConnectionError with key badRequest", async () => {
|
||||
// Arrange
|
||||
const integration = new BaseIntegrationMock({ id: "id", name: "name", url: "url", decryptedSecrets: [] });
|
||||
|
||||
const props: HandleResponseProps = {
|
||||
async queryFunctionAsync() {
|
||||
return await Promise.resolve(new Response(null, { status: 400 }));
|
||||
},
|
||||
};
|
||||
|
||||
// Act
|
||||
const actAsync = async () => await integration.fakeTestConnectionAsync(props);
|
||||
|
||||
// Assert
|
||||
await expect(actAsync()).rejects.toHaveProperty("key", "badRequest");
|
||||
});
|
||||
|
||||
test("With response status code 401 should throw IntegrationTestConnectionError with key unauthorized", async () => {
|
||||
// Arrange
|
||||
const integration = new BaseIntegrationMock({ id: "id", name: "name", url: "url", decryptedSecrets: [] });
|
||||
|
||||
const props: HandleResponseProps = {
|
||||
async queryFunctionAsync() {
|
||||
return await Promise.resolve(new Response(null, { status: 401 }));
|
||||
},
|
||||
};
|
||||
|
||||
// Act
|
||||
const actAsync = async () => await integration.fakeTestConnectionAsync(props);
|
||||
|
||||
// Assert
|
||||
await expect(actAsync()).rejects.toHaveProperty("key", "unauthorized");
|
||||
});
|
||||
|
||||
test("With response status code 403 should throw IntegrationTestConnectionError with key forbidden", async () => {
|
||||
// Arrange
|
||||
const integration = new BaseIntegrationMock({ id: "id", name: "name", url: "url", decryptedSecrets: [] });
|
||||
|
||||
const props: HandleResponseProps = {
|
||||
async queryFunctionAsync() {
|
||||
return await Promise.resolve(new Response(null, { status: 403 }));
|
||||
},
|
||||
};
|
||||
|
||||
// Act
|
||||
const actAsync = async () => await integration.fakeTestConnectionAsync(props);
|
||||
|
||||
// Assert
|
||||
await expect(actAsync()).rejects.toHaveProperty("key", "forbidden");
|
||||
});
|
||||
|
||||
test("With response status code 404 should throw IntegrationTestConnectionError with key notFound", async () => {
|
||||
// Arrange
|
||||
const integration = new BaseIntegrationMock({ id: "id", name: "name", url: "url", decryptedSecrets: [] });
|
||||
|
||||
const props: HandleResponseProps = {
|
||||
async queryFunctionAsync() {
|
||||
return await Promise.resolve(new Response(null, { status: 404 }));
|
||||
},
|
||||
};
|
||||
|
||||
// Act
|
||||
const actAsync = async () => await integration.fakeTestConnectionAsync(props);
|
||||
|
||||
// Assert
|
||||
await expect(actAsync()).rejects.toHaveProperty("key", "notFound");
|
||||
});
|
||||
|
||||
test("With response status code 500 should throw IntegrationTestConnectionError with key internalServerError", async () => {
|
||||
// Arrange
|
||||
const integration = new BaseIntegrationMock({ id: "id", name: "name", url: "url", decryptedSecrets: [] });
|
||||
|
||||
const props: HandleResponseProps = {
|
||||
async queryFunctionAsync() {
|
||||
return await Promise.resolve(new Response(null, { status: 500 }));
|
||||
},
|
||||
};
|
||||
|
||||
// Act
|
||||
const actAsync = async () => await integration.fakeTestConnectionAsync(props);
|
||||
|
||||
// Assert
|
||||
await expect(actAsync()).rejects.toHaveProperty("key", "internalServerError");
|
||||
});
|
||||
|
||||
test("With response status code 503 should throw IntegrationTestConnectionError with key serviceUnavailable", async () => {
|
||||
// Arrange
|
||||
const integration = new BaseIntegrationMock({ id: "id", name: "name", url: "url", decryptedSecrets: [] });
|
||||
|
||||
const props: HandleResponseProps = {
|
||||
async queryFunctionAsync() {
|
||||
return await Promise.resolve(new Response(null, { status: 503 }));
|
||||
},
|
||||
};
|
||||
|
||||
// Act
|
||||
const actAsync = async () => await integration.fakeTestConnectionAsync(props);
|
||||
|
||||
// Assert
|
||||
await expect(actAsync()).rejects.toHaveProperty("key", "serviceUnavailable");
|
||||
});
|
||||
|
||||
test("With response status code 418 (or any other unhandled code) should throw IntegrationTestConnectionError with key commonError", async () => {
|
||||
// Arrange
|
||||
const integration = new BaseIntegrationMock({ id: "id", name: "name", url: "url", decryptedSecrets: [] });
|
||||
|
||||
const props: HandleResponseProps = {
|
||||
async queryFunctionAsync() {
|
||||
return await Promise.resolve(new Response(null, { status: 418 }));
|
||||
},
|
||||
};
|
||||
|
||||
// Act
|
||||
const actAsync = async () => await integration.fakeTestConnectionAsync(props);
|
||||
|
||||
// Assert
|
||||
await expect(actAsync()).rejects.toHaveProperty("key", "commonError");
|
||||
});
|
||||
|
||||
test("Errors from handleResponseAsync should be thrown", async () => {
|
||||
// Arrange
|
||||
const integration = new BaseIntegrationMock({ id: "id", name: "name", url: "url", decryptedSecrets: [] });
|
||||
|
||||
const errorMessage = "The error message";
|
||||
const props: HandleResponseProps = {
|
||||
async queryFunctionAsync() {
|
||||
return await Promise.resolve(new Response(null, { status: 200 }));
|
||||
},
|
||||
async handleResponseAsync() {
|
||||
return await Promise.reject(new IntegrationTestConnectionError("commonError", errorMessage));
|
||||
},
|
||||
};
|
||||
|
||||
// Act
|
||||
const actPromise = integration.fakeTestConnectionAsync(props);
|
||||
|
||||
// Assert
|
||||
await expect(actPromise).rejects.toHaveProperty("key", "commonError");
|
||||
await expect(actPromise).rejects.toHaveProperty("detailMessage", errorMessage);
|
||||
});
|
||||
});
|
||||
});
|
||||
81
packages/integrations/test/home-assistant.spec.ts
Normal file
81
packages/integrations/test/home-assistant.spec.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import type { StartedTestContainer } from "testcontainers";
|
||||
import { GenericContainer, getContainerRuntimeClient, ImageName, Wait } from "testcontainers";
|
||||
import { beforeAll, describe, expect, test } from "vitest";
|
||||
|
||||
import { HomeAssistantIntegration, IntegrationTestConnectionError } from "../src";
|
||||
|
||||
const DEFAULT_API_KEY =
|
||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJkNjQwY2VjNDFjOGU0NGM5YmRlNWQ4ZmFjMjUzYWViZiIsImlhdCI6MTcxODQ3MTE1MSwiZXhwIjoyMDMzODMxMTUxfQ.uQCZ5FZTokipa6N27DtFhLHkwYEXU1LZr0fsVTryL2Q";
|
||||
const IMAGE_NAME = "ghcr.io/home-assistant/home-assistant:stable";
|
||||
|
||||
describe("Home Assistant integration", () => {
|
||||
beforeAll(async () => {
|
||||
const containerRuntimeClient = await getContainerRuntimeClient();
|
||||
await containerRuntimeClient.image.pull(ImageName.fromString(IMAGE_NAME));
|
||||
}, 100_000);
|
||||
|
||||
test("Test connection should work", async () => {
|
||||
// Arrange
|
||||
const startedContainer = await prepareHomeAssistantContainerAsync();
|
||||
const homeAssistantIntegration = createHomeAssistantIntegration(startedContainer);
|
||||
|
||||
// Act
|
||||
const actAsync = async () => await homeAssistantIntegration.testConnectionAsync();
|
||||
|
||||
// Assert
|
||||
await expect(actAsync()).resolves.not.toThrow();
|
||||
|
||||
// Cleanup
|
||||
await startedContainer.stop();
|
||||
}, 20_000); // Timeout of 20 seconds
|
||||
test("Test connection should fail with wrong credentials", async () => {
|
||||
// Arrange
|
||||
const startedContainer = await prepareHomeAssistantContainerAsync();
|
||||
const homeAssistantIntegration = createHomeAssistantIntegration(startedContainer, "wrong-api-key");
|
||||
|
||||
// Act
|
||||
const actAsync = async () => await homeAssistantIntegration.testConnectionAsync();
|
||||
|
||||
// Assert
|
||||
await expect(actAsync()).rejects.toThrow(IntegrationTestConnectionError);
|
||||
|
||||
// Cleanup
|
||||
await startedContainer.stop();
|
||||
}, 20_000); // Timeout of 20 seconds
|
||||
});
|
||||
|
||||
const prepareHomeAssistantContainerAsync = async () => {
|
||||
const homeAssistantContainer = createHomeAssistantContainer();
|
||||
const startedContainer = await homeAssistantContainer.start();
|
||||
|
||||
await startedContainer.exec(["unzip", "-o", "/tmp/config.zip", "-d", "/config"]);
|
||||
await startedContainer.restart();
|
||||
return startedContainer;
|
||||
};
|
||||
|
||||
const createHomeAssistantContainer = () => {
|
||||
return new GenericContainer(IMAGE_NAME)
|
||||
.withCopyFilesToContainer([
|
||||
{
|
||||
source: __dirname + "/volumes/home-assistant-config.zip",
|
||||
target: "/tmp/config.zip",
|
||||
},
|
||||
])
|
||||
.withPrivilegedMode()
|
||||
.withExposedPorts(8123)
|
||||
.withWaitStrategy(Wait.forHttp("/", 8123));
|
||||
};
|
||||
|
||||
const createHomeAssistantIntegration = (container: StartedTestContainer, apiKeyOverride?: string) => {
|
||||
return new HomeAssistantIntegration({
|
||||
id: "1",
|
||||
decryptedSecrets: [
|
||||
{
|
||||
kind: "apiKey",
|
||||
value: apiKeyOverride ?? DEFAULT_API_KEY,
|
||||
},
|
||||
],
|
||||
name: "Home assistant",
|
||||
url: `http://${container.getHost()}:${container.getMappedPort(8123)}`,
|
||||
});
|
||||
};
|
||||
@@ -25,6 +25,36 @@ describe("Pi-hole integration", () => {
|
||||
// Cleanup
|
||||
await piholeContainer.stop();
|
||||
}, 20_000); // Timeout of 20 seconds
|
||||
|
||||
test("testConnectionAsync should not throw", async () => {
|
||||
// Arrange
|
||||
const piholeContainer = await createPiHoleContainer(DEFAULT_PASSWORD).start();
|
||||
const piHoleIntegration = createPiHoleIntegration(piholeContainer, DEFAULT_API_KEY);
|
||||
|
||||
// Act
|
||||
const actAsync = async () => await piHoleIntegration.testConnectionAsync();
|
||||
|
||||
// Assert
|
||||
await expect(actAsync()).resolves.not.toThrow();
|
||||
|
||||
// Cleanup
|
||||
await piholeContainer.stop();
|
||||
}, 20_000); // Timeout of 20 seconds
|
||||
|
||||
test("testConnectionAsync should throw with wrong credentials", async () => {
|
||||
// Arrange
|
||||
const piholeContainer = await createPiHoleContainer(DEFAULT_PASSWORD).start();
|
||||
const piHoleIntegration = createPiHoleIntegration(piholeContainer, "wrong-api-key");
|
||||
|
||||
// Act
|
||||
const actAsync = async () => await piHoleIntegration.testConnectionAsync();
|
||||
|
||||
// Assert
|
||||
await expect(actAsync()).rejects.toThrow();
|
||||
|
||||
// Cleanup
|
||||
await piholeContainer.stop();
|
||||
}, 20_000); // Timeout of 20 seconds
|
||||
});
|
||||
|
||||
const createPiHoleContainer = (password: string) => {
|
||||
|
||||
BIN
packages/integrations/test/volumes/home-assistant-config.zip
Normal file
BIN
packages/integrations/test/volumes/home-assistant-config.zip
Normal file
Binary file not shown.
Reference in New Issue
Block a user