merge: stack widget modernization on dnd kit
This commit is contained in:
940 files changed
+104475
-33649
No files matched your search
@@ -0,0 +1,65 @@
|
||||
import { parse } from "superjson";
|
||||
import { expect } from "vitest";
|
||||
|
||||
import { BUNDLED_CUSTOM_WIDGETS, customWidgetDefinitionSchema } from "@homarr/custom-widgets/core";
|
||||
|
||||
import type { Database } from "..";
|
||||
import { eq } from "..";
|
||||
import { seedDataAsync } from "../migrations/seed";
|
||||
import { customWidgetDefinitions, customWidgetSecrets } from "../schema";
|
||||
|
||||
const expectedSeedIds = BUNDLED_CUSTOM_WIDGETS.map(({ id }) => id).toSorted();
|
||||
|
||||
const reconstructWidget = (definition: typeof customWidgetDefinitions.$inferSelect) =>
|
||||
customWidgetDefinitionSchema.parse({
|
||||
$schema: "homarr-custom-widget-v2",
|
||||
name: definition.name,
|
||||
...(definition.description === null ? {} : { description: definition.description }),
|
||||
...(definition.iconUrl === null ? {} : { iconUrl: definition.iconUrl }),
|
||||
sources: parse(definition.sources),
|
||||
requests: parse(definition.requests),
|
||||
options: parse(definition.options),
|
||||
template: definition.template,
|
||||
});
|
||||
|
||||
export const expectBundledCustomWidgetsSeeded = async (db: Database) => {
|
||||
const definitions = await db.query.customWidgetDefinitions.findMany({
|
||||
where: (table, { like }) => like(table.id, "seed-%"),
|
||||
});
|
||||
|
||||
expect(definitions.map(({ id }) => id).toSorted()).toEqual(expectedSeedIds);
|
||||
expect(definitions.every(({ enabled }) => !enabled)).toBe(true);
|
||||
expect(definitions.every(({ creatorId }) => creatorId === null)).toBe(true);
|
||||
expect(await db.$count(customWidgetSecrets)).toBe(0);
|
||||
|
||||
for (const definition of definitions) {
|
||||
const expected = BUNDLED_CUSTOM_WIDGETS.find(({ id }) => id === definition.id);
|
||||
expect(expected).toBeDefined();
|
||||
expect(reconstructWidget(definition)).toEqual(customWidgetDefinitionSchema.parse(expected?.widget));
|
||||
}
|
||||
|
||||
await db
|
||||
.update(customWidgetDefinitions)
|
||||
.set({ name: "User-edited dog facts", enabled: true })
|
||||
.where(eq(customWidgetDefinitions.id, "seed-dog-facts"));
|
||||
await db.delete(customWidgetDefinitions).where(eq(customWidgetDefinitions.id, "seed-currency-exchange"));
|
||||
|
||||
await seedDataAsync(db);
|
||||
|
||||
const reseededDefinitions = await db.query.customWidgetDefinitions.findMany({
|
||||
where: (table, { like }) => like(table.id, "seed-%"),
|
||||
});
|
||||
const editedDefinition = reseededDefinitions.find(({ id }) => id === "seed-dog-facts");
|
||||
const reseededCurrency = reseededDefinitions.find(({ id }) => id === "seed-currency-exchange");
|
||||
|
||||
expect(reseededDefinitions.map(({ id }) => id).toSorted()).toEqual(expectedSeedIds);
|
||||
expect(editedDefinition).toMatchObject({ name: "User-edited dog facts", enabled: true, creatorId: null });
|
||||
expect(reseededDefinitions.filter(({ id }) => id !== "seed-dog-facts").every(({ enabled }) => !enabled)).toBe(true);
|
||||
if (!reseededCurrency) throw new Error("Currency Exchange seed was not restored");
|
||||
expect(reconstructWidget(reseededCurrency)).toEqual(
|
||||
customWidgetDefinitionSchema.parse(
|
||||
BUNDLED_CUSTOM_WIDGETS.find(({ id }) => id === "seed-currency-exchange")?.widget,
|
||||
),
|
||||
);
|
||||
expect(await db.$count(customWidgetSecrets)).toBe(0);
|
||||
};
|
||||
@@ -1,13 +1,28 @@
|
||||
import path from "path";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { MySqlContainer } from "@testcontainers/mysql";
|
||||
import { drizzle } from "drizzle-orm/mysql2";
|
||||
import { migrate } from "drizzle-orm/mysql2/migrator";
|
||||
import mysql from "mysql2";
|
||||
import { describe, test } from "vitest";
|
||||
import SuperJSON from "superjson";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { DB_CASING } from "@homarr/core/infrastructure/db/constants";
|
||||
|
||||
import * as mysqlSchema from "../schema/mysql";
|
||||
import type { Database } from "..";
|
||||
import { seedDataAsync } from "../migrations/seed";
|
||||
import { expectBundledCustomWidgetsSeeded } from "./custom-widget-seed-assertions";
|
||||
|
||||
const applyMigration = async (connection: mysql.Connection, fileName: string) => {
|
||||
const migration = readFileSync(path.join(__dirname, "..", "migrations", "mysql", fileName), "utf8");
|
||||
for (const statement of migration
|
||||
.split("--> statement-breakpoint")
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean)) {
|
||||
await connection.promise().query(statement);
|
||||
}
|
||||
};
|
||||
|
||||
describe("Mysql Migration", () => {
|
||||
test("should add all tables and keys specified in migration files", async () => {
|
||||
@@ -31,11 +46,157 @@ describe("Mysql Migration", () => {
|
||||
await migrate(database, {
|
||||
migrationsFolder: path.join(__dirname, "..", "migrations", "mysql"),
|
||||
});
|
||||
await seedDataAsync(database as unknown as Database);
|
||||
|
||||
// Check if users table exists
|
||||
await database.query.users.findMany();
|
||||
await expectBundledCustomWidgetsSeeded(database as unknown as Database);
|
||||
const [customWidgetTables] = await connection
|
||||
.promise()
|
||||
.query<mysql.RowDataPacket[]>("SHOW TABLES LIKE 'custom_widget%'");
|
||||
expect(customWidgetTables).toHaveLength(4);
|
||||
|
||||
connection.end();
|
||||
await mysqlContainer.stop();
|
||||
}, 120_000);
|
||||
|
||||
test("Custom Widget v2 migration preserves v1 data and board references", async () => {
|
||||
const mysqlContainer = await new MySqlContainer("mysql:latest").start();
|
||||
const connection = mysql.createConnection({
|
||||
host: mysqlContainer.getHost(),
|
||||
database: mysqlContainer.getDatabase(),
|
||||
port: mysqlContainer.getPort(),
|
||||
user: mysqlContainer.getUsername(),
|
||||
password: mysqlContainer.getUserPassword(),
|
||||
multipleStatements: true,
|
||||
});
|
||||
const sql = connection.promise();
|
||||
try {
|
||||
const legacyPlacementOptions = SuperJSON.stringify({ definitionId: "legacy-weather", refreshInterval: 30 });
|
||||
await sql.query(`
|
||||
CREATE TABLE \`user\` (\`id\` varchar(64) NOT NULL PRIMARY KEY);
|
||||
CREATE TABLE \`groupPermission\` (
|
||||
\`group_id\` varchar(64) NOT NULL,
|
||||
\`permission\` text NOT NULL
|
||||
);
|
||||
CREATE TABLE \`item\` (
|
||||
\`id\` varchar(64) NOT NULL PRIMARY KEY,
|
||||
\`kind\` varchar(64) NOT NULL,
|
||||
\`options\` text NOT NULL
|
||||
);
|
||||
CREATE TABLE \`custom_widget_definition\` (
|
||||
\`id\` varchar(64) NOT NULL PRIMARY KEY,
|
||||
\`name\` varchar(256) NOT NULL,
|
||||
\`description\` text,
|
||||
\`icon_url\` text,
|
||||
\`url\` text NOT NULL,
|
||||
\`auth_type\` varchar(32) NOT NULL DEFAULT 'none',
|
||||
\`header_name\` varchar(256),
|
||||
\`method\` varchar(16) NOT NULL DEFAULT 'GET',
|
||||
\`request_body\` text,
|
||||
\`display_type\` varchar(32) NOT NULL DEFAULT 'singleValue',
|
||||
\`display_config\` text NOT NULL,
|
||||
\`enabled\` boolean NOT NULL DEFAULT true,
|
||||
\`created_at\` timestamp NOT NULL,
|
||||
\`updated_at\` timestamp NOT NULL,
|
||||
\`creator_id\` varchar(64),
|
||||
CONSTRAINT \`custom_widget_definition_creator_id_user_id_fk\`
|
||||
FOREIGN KEY (\`creator_id\`) REFERENCES \`user\`(\`id\`) ON DELETE SET NULL
|
||||
);
|
||||
CREATE TABLE \`custom_widget_secret\` (
|
||||
\`kind\` varchar(64) NOT NULL,
|
||||
\`value\` text NOT NULL,
|
||||
\`updated_at\` timestamp NOT NULL,
|
||||
\`definition_id\` varchar(64) NOT NULL,
|
||||
PRIMARY KEY (\`definition_id\`, \`kind\`),
|
||||
CONSTRAINT \`cw_secret_definition_id_cw_definition_id_fk\`
|
||||
FOREIGN KEY (\`definition_id\`) REFERENCES \`custom_widget_definition\`(\`id\`) ON DELETE CASCADE
|
||||
);
|
||||
INSERT INTO \`user\` (\`id\`) VALUES ('owner');
|
||||
INSERT INTO \`custom_widget_definition\` (
|
||||
\`id\`, \`name\`, \`url\`, \`auth_type\`, \`method\`, \`display_type\`, \`display_config\`,
|
||||
\`enabled\`, \`created_at\`, \`updated_at\`, \`creator_id\`
|
||||
) VALUES (
|
||||
'legacy-weather', 'Weather', 'https://example.test/weather', 'bearer', 'GET', 'singleValue',
|
||||
'{"type":"singleValue","jsonPath":"$.temperature"}', true,
|
||||
'2023-11-14 00:00:00', '2023-11-14 00:00:01', 'owner'
|
||||
);
|
||||
INSERT INTO \`custom_widget_secret\` (\`kind\`, \`value\`, \`updated_at\`, \`definition_id\`)
|
||||
VALUES ('apiKey', 'encrypted.value', '2023-11-14 00:00:01', 'legacy-weather');
|
||||
INSERT INTO \`groupPermission\` (\`group_id\`, \`permission\`) VALUES
|
||||
('editors', 'custom-widget-manage'),
|
||||
('editors', 'custom-widget-secret-write'),
|
||||
('editors', 'board-create');
|
||||
`);
|
||||
await sql.execute("INSERT INTO `item` (`id`, `kind`, `options`) VALUES ('weather-item', 'customApi', ?)", [
|
||||
legacyPlacementOptions,
|
||||
]);
|
||||
|
||||
await applyMigration(connection, "0044_custom_widget_v2_tables.sql");
|
||||
await sql.query(`
|
||||
INSERT INTO \`custom_widget_v2_definition\` (
|
||||
\`id\`, \`name\`, \`sources\`, \`requests\`, \`options\`, \`template\`, \`enabled\`,
|
||||
\`created_at\`, \`updated_at\`, \`creator_id\`
|
||||
) VALUES (
|
||||
'legacy-weather', 'Weather v2', '[]', '[]', '[]', '<Text>Weather</Text>', true,
|
||||
'2023-11-14 00:00:02', '2023-11-14 00:00:03', 'owner'
|
||||
);
|
||||
INSERT INTO \`custom_widget_v2_secret\` (
|
||||
\`source_id\`, \`kind\`, \`encrypted_value\`, \`updated_at\`, \`definition_id\`
|
||||
) VALUES ('weather', 'apiKey', 'encrypted.v2-value', '2023-11-14 00:00:03', 'legacy-weather');
|
||||
`);
|
||||
|
||||
const [definitions] = await sql.query<mysql.RowDataPacket[]>(
|
||||
"SELECT id, name, url, enabled, creator_id FROM custom_widget_definition",
|
||||
);
|
||||
const [secrets] = await sql.query<mysql.RowDataPacket[]>(
|
||||
"SELECT definition_id, kind, value FROM custom_widget_secret",
|
||||
);
|
||||
expect(definitions).toEqual([
|
||||
expect.objectContaining({
|
||||
id: "legacy-weather",
|
||||
name: "Weather",
|
||||
url: "https://example.test/weather",
|
||||
enabled: 1,
|
||||
creator_id: "owner",
|
||||
}),
|
||||
]);
|
||||
expect(secrets).toEqual([
|
||||
expect.objectContaining({ definition_id: "legacy-weather", kind: "apiKey", value: "encrypted.value" }),
|
||||
]);
|
||||
const [v2Definitions] = await sql.query<mysql.RowDataPacket[]>(
|
||||
"SELECT id, name, template FROM custom_widget_v2_definition",
|
||||
);
|
||||
const [v2Secrets] = await sql.query<mysql.RowDataPacket[]>(
|
||||
"SELECT definition_id, source_id, kind, encrypted_value FROM custom_widget_v2_secret",
|
||||
);
|
||||
expect(v2Definitions).toEqual([
|
||||
expect.objectContaining({ id: "legacy-weather", name: "Weather v2", template: "<Text>Weather</Text>" }),
|
||||
]);
|
||||
expect(v2Secrets).toEqual([
|
||||
expect.objectContaining({
|
||||
definition_id: "legacy-weather",
|
||||
source_id: "weather",
|
||||
kind: "apiKey",
|
||||
encrypted_value: "encrypted.v2-value",
|
||||
}),
|
||||
]);
|
||||
const [items] = await sql.query<mysql.RowDataPacket[]>("SELECT options FROM item WHERE id = 'weather-item'");
|
||||
expect(items).toEqual([{ options: legacyPlacementOptions }]);
|
||||
const [permissions] = await sql.query<mysql.RowDataPacket[]>(
|
||||
"SELECT permission FROM groupPermission ORDER BY permission",
|
||||
);
|
||||
expect(permissions).toEqual([{ permission: "board-create" }]);
|
||||
const [v1Columns] = await sql.query<mysql.RowDataPacket[]>("SHOW COLUMNS FROM custom_widget_definition");
|
||||
expect(v1Columns.map((column) => column.Field)).toEqual(
|
||||
expect.arrayContaining(["id", "url", "auth_type", "display_type", "display_config"]),
|
||||
);
|
||||
expect(v1Columns.map((column) => column.Field)).not.toContain("sources");
|
||||
const [legacyTables] = await sql.query<mysql.RowDataPacket[]>("SHOW TABLES LIKE 'legacy_custom_widget%'");
|
||||
expect(legacyTables).toEqual([]);
|
||||
} finally {
|
||||
await sql.end();
|
||||
await mysqlContainer.stop();
|
||||
}
|
||||
}, 120_000);
|
||||
});
|
||||
@@ -1,13 +1,28 @@
|
||||
import path from "path";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { PostgreSqlContainer } from "@testcontainers/postgresql";
|
||||
import { drizzle } from "drizzle-orm/node-postgres";
|
||||
import { migrate } from "drizzle-orm/node-postgres/migrator";
|
||||
import { Pool } from "pg";
|
||||
import { describe, test } from "vitest";
|
||||
import SuperJSON from "superjson";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { DB_CASING } from "@homarr/core/infrastructure/db/constants";
|
||||
|
||||
import * as pgSchema from "../schema/postgresql";
|
||||
import type { Database } from "..";
|
||||
import { seedDataAsync } from "../migrations/seed";
|
||||
import { expectBundledCustomWidgetsSeeded } from "./custom-widget-seed-assertions";
|
||||
|
||||
const applyMigration = async (pool: Pool, fileName: string) => {
|
||||
const migration = readFileSync(path.join(__dirname, "..", "migrations", "postgresql", fileName), "utf8");
|
||||
for (const statement of migration
|
||||
.split("--> statement-breakpoint")
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean)) {
|
||||
await pool.query(statement);
|
||||
}
|
||||
};
|
||||
|
||||
describe("PostgreSql Migration", () => {
|
||||
test("should add all tables and keys specified in migration files", async () => {
|
||||
@@ -36,13 +51,159 @@ describe("PostgreSql Migration", () => {
|
||||
await migrate(database, {
|
||||
migrationsFolder: path.join(__dirname, "..", "migrations", "postgresql"),
|
||||
});
|
||||
await seedDataAsync(database as unknown as Database);
|
||||
|
||||
// Check if users table exists
|
||||
await database.query.users.findMany();
|
||||
await expectBundledCustomWidgetsSeeded(database as unknown as Database);
|
||||
expect(
|
||||
(
|
||||
await pool.query(
|
||||
"SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_name LIKE 'custom_widget%'",
|
||||
)
|
||||
).rows,
|
||||
).toHaveLength(4);
|
||||
|
||||
// Close the pool to release resources
|
||||
await pool.end();
|
||||
// Stop the container
|
||||
await postgreSqlContainer.stop();
|
||||
}, 40_000);
|
||||
|
||||
test("Custom Widget v2 migration preserves v1 data and board references", async () => {
|
||||
const postgreSqlContainer = await new PostgreSqlContainer("postgres:latest").start();
|
||||
const pool = new Pool({
|
||||
user: postgreSqlContainer.getUsername(),
|
||||
database: postgreSqlContainer.getDatabase(),
|
||||
password: postgreSqlContainer.getPassword(),
|
||||
port: postgreSqlContainer.getPort(),
|
||||
host: postgreSqlContainer.getHost(),
|
||||
});
|
||||
try {
|
||||
const legacyPlacementOptions = SuperJSON.stringify({ definitionId: "legacy-weather", refreshInterval: 30 });
|
||||
await pool.query(`
|
||||
CREATE TABLE "user" ("id" varchar(64) PRIMARY KEY NOT NULL);
|
||||
CREATE TABLE "groupPermission" (
|
||||
"group_id" varchar(64) NOT NULL,
|
||||
"permission" text NOT NULL
|
||||
);
|
||||
CREATE TABLE "item" (
|
||||
"id" varchar(64) PRIMARY KEY NOT NULL,
|
||||
"kind" varchar(64) NOT NULL,
|
||||
"options" text NOT NULL
|
||||
);
|
||||
CREATE TABLE "custom_widget_definition" (
|
||||
"id" varchar(64) PRIMARY KEY NOT NULL,
|
||||
"name" varchar(256) NOT NULL,
|
||||
"description" text,
|
||||
"icon_url" text,
|
||||
"url" text NOT NULL,
|
||||
"auth_type" varchar(32) DEFAULT 'none' NOT NULL,
|
||||
"header_name" varchar(256),
|
||||
"method" varchar(16) DEFAULT 'GET' NOT NULL,
|
||||
"request_body" text,
|
||||
"display_type" varchar(32) DEFAULT 'singleValue' NOT NULL,
|
||||
"display_config" text NOT NULL,
|
||||
"enabled" boolean DEFAULT true NOT NULL,
|
||||
"created_at" timestamp NOT NULL,
|
||||
"updated_at" timestamp NOT NULL,
|
||||
"creator_id" varchar(64),
|
||||
CONSTRAINT "custom_widget_definition_creator_id_user_id_fk"
|
||||
FOREIGN KEY ("creator_id") REFERENCES "user"("id") ON DELETE SET NULL
|
||||
);
|
||||
CREATE TABLE "custom_widget_secret" (
|
||||
"kind" varchar(64) NOT NULL,
|
||||
"value" text NOT NULL,
|
||||
"updated_at" timestamp NOT NULL,
|
||||
"definition_id" varchar(64) NOT NULL,
|
||||
CONSTRAINT "custom_widget_secret_definition_id_kind_pk" PRIMARY KEY("definition_id", "kind"),
|
||||
CONSTRAINT "custom_widget_secret_definition_id_custom_widget_definition_id_fk"
|
||||
FOREIGN KEY ("definition_id") REFERENCES "custom_widget_definition"("id") ON DELETE CASCADE
|
||||
);
|
||||
INSERT INTO "user" ("id") VALUES ('owner');
|
||||
INSERT INTO "custom_widget_definition" (
|
||||
"id", "name", "url", "auth_type", "method", "display_type", "display_config", "enabled",
|
||||
"created_at", "updated_at", "creator_id"
|
||||
) VALUES (
|
||||
'legacy-weather', 'Weather', 'https://example.test/weather', 'bearer', 'GET', 'singleValue',
|
||||
'{"type":"singleValue","jsonPath":"$.temperature"}', true,
|
||||
'2023-11-14 00:00:00', '2023-11-14 00:00:01', 'owner'
|
||||
);
|
||||
INSERT INTO "custom_widget_secret" ("kind", "value", "updated_at", "definition_id")
|
||||
VALUES ('apiKey', 'encrypted.value', '2023-11-14 00:00:01', 'legacy-weather');
|
||||
INSERT INTO "groupPermission" ("group_id", "permission") VALUES
|
||||
('editors', 'custom-widget-manage'),
|
||||
('editors', 'custom-widget-secret-write'),
|
||||
('editors', 'board-create');
|
||||
`);
|
||||
await pool.query(`INSERT INTO "item" ("id", "kind", "options") VALUES ('weather-item', 'customApi', $1)`, [
|
||||
legacyPlacementOptions,
|
||||
]);
|
||||
|
||||
await applyMigration(pool, "0010_custom_widget_v2_tables.sql");
|
||||
await pool.query(`
|
||||
INSERT INTO "custom_widget_v2_definition" (
|
||||
"id", "name", "sources", "requests", "options", "template", "enabled",
|
||||
"created_at", "updated_at", "creator_id"
|
||||
) VALUES (
|
||||
'legacy-weather', 'Weather v2', '[]', '[]', '[]', '<Text>Weather</Text>', true,
|
||||
'2023-11-14 00:00:02', '2023-11-14 00:00:03', 'owner'
|
||||
);
|
||||
INSERT INTO "custom_widget_v2_secret" (
|
||||
"source_id", "kind", "encrypted_value", "updated_at", "definition_id"
|
||||
) VALUES ('weather', 'apiKey', 'encrypted.v2-value', '2023-11-14 00:00:03', 'legacy-weather');
|
||||
`);
|
||||
|
||||
expect(
|
||||
(await pool.query("SELECT id, name, url, enabled, creator_id FROM custom_widget_definition")).rows,
|
||||
).toEqual([
|
||||
expect.objectContaining({
|
||||
id: "legacy-weather",
|
||||
name: "Weather",
|
||||
url: "https://example.test/weather",
|
||||
enabled: true,
|
||||
creator_id: "owner",
|
||||
}),
|
||||
]);
|
||||
expect((await pool.query("SELECT definition_id, kind, value FROM custom_widget_secret")).rows).toEqual([
|
||||
expect.objectContaining({ definition_id: "legacy-weather", kind: "apiKey", value: "encrypted.value" }),
|
||||
]);
|
||||
expect((await pool.query("SELECT id, name, template FROM custom_widget_v2_definition")).rows).toEqual([
|
||||
expect.objectContaining({ id: "legacy-weather", name: "Weather v2", template: "<Text>Weather</Text>" }),
|
||||
]);
|
||||
expect(
|
||||
(await pool.query("SELECT definition_id, source_id, kind, encrypted_value FROM custom_widget_v2_secret")).rows,
|
||||
).toEqual([
|
||||
expect.objectContaining({
|
||||
definition_id: "legacy-weather",
|
||||
source_id: "weather",
|
||||
kind: "apiKey",
|
||||
encrypted_value: "encrypted.v2-value",
|
||||
}),
|
||||
]);
|
||||
expect((await pool.query("SELECT options FROM item WHERE id = 'weather-item'")).rows).toEqual([
|
||||
{ options: legacyPlacementOptions },
|
||||
]);
|
||||
expect((await pool.query('SELECT "permission" FROM "groupPermission" ORDER BY "permission"')).rows).toEqual([
|
||||
{ permission: "board-create" },
|
||||
]);
|
||||
const v1Columns = (
|
||||
await pool.query(
|
||||
"SELECT column_name FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'custom_widget_definition'",
|
||||
)
|
||||
).rows.map((column: { column_name: string }) => column.column_name);
|
||||
expect(v1Columns).toEqual(expect.arrayContaining(["id", "url", "auth_type", "display_type", "display_config"]));
|
||||
expect(v1Columns).not.toContain("sources");
|
||||
expect(
|
||||
(
|
||||
await pool.query(
|
||||
"SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_name LIKE 'legacy_custom_widget%'",
|
||||
)
|
||||
).rows,
|
||||
).toEqual([]);
|
||||
} finally {
|
||||
await pool.end();
|
||||
await postgreSqlContainer.stop();
|
||||
}
|
||||
}, 40_000);
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
/* eslint-disable import/namespace -- The test compares matching schema tables by their shared keys. */
|
||||
/* eslint-disable import/namespace -- This test compares equivalent schema exports by shared dynamic keys. */
|
||||
import type { Column, InferSelectModel } from "drizzle-orm";
|
||||
import type { ForeignKey as MysqlForeignKey, MySqlTableWithColumns } from "drizzle-orm/mysql-core";
|
||||
import type { PgTableWithColumns, ForeignKey as PostgresqlForeignKey } from "drizzle-orm/pg-core";
|
||||
@@ -24,7 +24,6 @@ type FixedMysqlConfig = {
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
type FixedPostgresqlConfig = {
|
||||
[key in keyof PostgreisqlConfig]: {
|
||||
[column in keyof PostgreisqlConfig[key]]: {
|
||||
@@ -64,6 +63,7 @@ test("schemas should match", () => {
|
||||
if (!("uniqueName" in sqliteColumn)) return;
|
||||
if (!("primary" in sqliteColumn)) return;
|
||||
|
||||
// oxlint-disable-next-line import/namespace -- this test intentionally compares matching dynamic schema keys.
|
||||
const mysqlTable = mysqlSchema[tableName];
|
||||
|
||||
const mysqlColumn = mysqlTable[columnName as keyof typeof mysqlTable] as object;
|
||||
@@ -85,6 +85,7 @@ test("schemas should match", () => {
|
||||
).toEqual(mysqlColumn.primary);
|
||||
});
|
||||
|
||||
// oxlint-disable-next-line import/namespace -- this test intentionally compares matching dynamic schema keys.
|
||||
const mysqlTable = mysqlSchema[tableName];
|
||||
const sqliteForeignKeys = sqliteTable[Symbol.for("drizzle:SQLiteInlineForeignKeys") as keyof typeof sqliteTable] as
|
||||
| SqliteForeignKey[]
|
||||
@@ -145,6 +146,7 @@ test("schemas should match for postgresql", () => {
|
||||
objectEntries(sqliteSchema).forEach(([tableName, sqliteTable]) => {
|
||||
// keys of sqliteSchema and postgresqlSchema are the same, so we can safely use tableName as key
|
||||
// skipcq: JS-E1007
|
||||
// oxlint-disable-next-line import/namespace -- this test intentionally compares matching dynamic schema keys.
|
||||
const postgresqlTable = postgresqlSchema[tableName];
|
||||
Object.entries(sqliteTable).forEach(([columnName, sqliteColumn]: [string, object]) => {
|
||||
if (!("isUnique" in sqliteColumn)) return;
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import path from "path";
|
||||
import { readFileSync } from "node:fs";
|
||||
import BetterSqlite3 from "better-sqlite3";
|
||||
import { drizzle } from "drizzle-orm/better-sqlite3";
|
||||
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
||||
import SuperJSON from "superjson";
|
||||
import { expect, test } from "vitest";
|
||||
|
||||
import { DB_CASING } from "@homarr/core/infrastructure/db/constants";
|
||||
|
||||
import type { Database } from "..";
|
||||
import { seedDataAsync } from "../migrations/seed";
|
||||
import * as sqliteSchema from "../schema/sqlite";
|
||||
import { expectBundledCustomWidgetsSeeded } from "./custom-widget-seed-assertions";
|
||||
|
||||
const applyMigration = (connection: BetterSqlite3.Database, fileName: string) => {
|
||||
const migration = readFileSync(path.join(__dirname, "..", "migrations", "sqlite", fileName), "utf8");
|
||||
for (const statement of migration
|
||||
.split("--> statement-breakpoint")
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean)) {
|
||||
connection.exec(statement);
|
||||
}
|
||||
};
|
||||
|
||||
test("SQLite migrations seed the five disabled bundled custom widgets", async () => {
|
||||
const connection = new BetterSqlite3(":memory:");
|
||||
const database = drizzle(connection, { schema: sqliteSchema, casing: DB_CASING });
|
||||
|
||||
migrate(database, { migrationsFolder: path.join(__dirname, "..", "migrations", "sqlite") });
|
||||
await seedDataAsync(database as unknown as Database);
|
||||
|
||||
await expectBundledCustomWidgetsSeeded(database as unknown as Database);
|
||||
expect(
|
||||
connection
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name LIKE 'custom_widget%' ORDER BY name")
|
||||
.all(),
|
||||
).toEqual([
|
||||
{ name: "custom_widget_definition" },
|
||||
{ name: "custom_widget_secret" },
|
||||
{ name: "custom_widget_v2_definition" },
|
||||
{ name: "custom_widget_v2_secret" },
|
||||
]);
|
||||
|
||||
connection.close();
|
||||
});
|
||||
|
||||
test("Custom Widget v2 migration preserves v1 data and board references", () => {
|
||||
const connection = new BetterSqlite3(":memory:");
|
||||
const legacyPlacementOptions = SuperJSON.stringify({ definitionId: "legacy-weather", refreshInterval: 30 });
|
||||
connection.exec(`
|
||||
CREATE TABLE user (id text PRIMARY KEY NOT NULL);
|
||||
CREATE TABLE groupPermission (
|
||||
group_id text NOT NULL,
|
||||
permission text NOT NULL
|
||||
);
|
||||
CREATE TABLE item (
|
||||
id text PRIMARY KEY NOT NULL,
|
||||
kind text NOT NULL,
|
||||
options text NOT NULL
|
||||
);
|
||||
CREATE TABLE custom_widget_definition (
|
||||
id text PRIMARY KEY NOT NULL,
|
||||
name text NOT NULL,
|
||||
description text,
|
||||
icon_url text,
|
||||
url text NOT NULL,
|
||||
auth_type text DEFAULT 'none' NOT NULL,
|
||||
header_name text,
|
||||
method text DEFAULT 'GET' NOT NULL,
|
||||
request_body text,
|
||||
display_type text DEFAULT 'singleValue' NOT NULL,
|
||||
display_config text DEFAULT '{}' NOT NULL,
|
||||
enabled integer DEFAULT true NOT NULL,
|
||||
created_at integer NOT NULL,
|
||||
updated_at integer NOT NULL,
|
||||
creator_id text REFERENCES user(id) ON DELETE SET NULL
|
||||
);
|
||||
CREATE TABLE custom_widget_secret (
|
||||
kind text NOT NULL,
|
||||
value text NOT NULL,
|
||||
updated_at integer NOT NULL,
|
||||
definition_id text NOT NULL REFERENCES custom_widget_definition(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY(definition_id, kind)
|
||||
);
|
||||
INSERT INTO user (id) VALUES ('owner');
|
||||
INSERT INTO custom_widget_definition (
|
||||
id, name, url, auth_type, method, display_type, display_config, enabled, created_at, updated_at, creator_id
|
||||
) VALUES ('legacy-weather', 'Weather', 'https://example.test/weather', 'bearer', 'GET', 'singleValue',
|
||||
'{"type":"singleValue","jsonPath":"$.temperature"}', true, 1700000000, 1700000001, 'owner');
|
||||
INSERT INTO custom_widget_secret (kind, value, updated_at, definition_id)
|
||||
VALUES ('apiKey', 'encrypted.value', 1700000001, 'legacy-weather');
|
||||
INSERT INTO groupPermission (group_id, permission) VALUES
|
||||
('editors', 'custom-widget-manage'),
|
||||
('editors', 'custom-widget-secret-write'),
|
||||
('editors', 'board-create');
|
||||
`);
|
||||
connection
|
||||
.prepare("INSERT INTO item (id, kind, options) VALUES ('weather-item', 'customApi', ?)")
|
||||
.run(legacyPlacementOptions);
|
||||
|
||||
applyMigration(connection, "0042_custom_widget_v2_tables.sql");
|
||||
connection.exec(`
|
||||
INSERT INTO custom_widget_v2_definition (
|
||||
id, name, sources, requests, options, template, enabled, created_at, updated_at, creator_id
|
||||
) VALUES (
|
||||
'legacy-weather', 'Weather v2', '[]', '[]', '[]', '<Text>Weather</Text>', true,
|
||||
1700000002, 1700000003, 'owner'
|
||||
);
|
||||
INSERT INTO custom_widget_v2_secret (source_id, kind, encrypted_value, updated_at, definition_id)
|
||||
VALUES ('weather', 'apiKey', 'encrypted.v2-value', 1700000003, 'legacy-weather');
|
||||
`);
|
||||
|
||||
expect(connection.prepare("SELECT * FROM custom_widget_definition").get()).toMatchObject({
|
||||
id: "legacy-weather",
|
||||
name: "Weather",
|
||||
url: "https://example.test/weather",
|
||||
enabled: 1,
|
||||
creator_id: "owner",
|
||||
});
|
||||
expect(connection.prepare("SELECT * FROM custom_widget_secret").get()).toMatchObject({
|
||||
definition_id: "legacy-weather",
|
||||
kind: "apiKey",
|
||||
value: "encrypted.value",
|
||||
});
|
||||
expect(connection.prepare("SELECT * FROM custom_widget_v2_definition").get()).toMatchObject({
|
||||
id: "legacy-weather",
|
||||
name: "Weather v2",
|
||||
template: "<Text>Weather</Text>",
|
||||
});
|
||||
expect(connection.prepare("SELECT * FROM custom_widget_v2_secret").get()).toMatchObject({
|
||||
definition_id: "legacy-weather",
|
||||
source_id: "weather",
|
||||
encrypted_value: "encrypted.v2-value",
|
||||
});
|
||||
expect(connection.prepare("SELECT options FROM item WHERE id = 'weather-item'").get()).toEqual({
|
||||
options: legacyPlacementOptions,
|
||||
});
|
||||
expect(connection.prepare("SELECT permission FROM groupPermission ORDER BY permission").all()).toEqual([
|
||||
{ permission: "board-create" },
|
||||
]);
|
||||
expect(
|
||||
connection
|
||||
.prepare(
|
||||
`SELECT id, name, url, auth_type, header_name, method, request_body, display_type, display_config,
|
||||
enabled, created_at, updated_at, creator_id
|
||||
FROM custom_widget_definition`,
|
||||
)
|
||||
.get(),
|
||||
).toMatchObject({
|
||||
id: "legacy-weather",
|
||||
auth_type: "bearer",
|
||||
display_type: "singleValue",
|
||||
});
|
||||
expect(
|
||||
connection
|
||||
.prepare(
|
||||
"SELECT name FROM sqlite_master WHERE type = 'table' AND name LIKE 'legacy_custom_widget%' ORDER BY name",
|
||||
)
|
||||
.all(),
|
||||
).toEqual([]);
|
||||
|
||||
connection.close();
|
||||
});
|
||||
Reference in new issue
Block a user