diff --git a/apps/server/package.json b/apps/server/package.json index c0a9ec88d..f47fa7f5a 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -24,6 +24,7 @@ "migration:codegen": "kysely-codegen --dialect=postgres --camel-case --env-file=../../.env --out-file=./src/database/types/db.d.ts", "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", "test": "jest", + "test:integration": "jest --config test/jest-integration.json --runInBand", "test:watch": "jest --watch", "test:cov": "jest --coverage", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", diff --git a/apps/server/test/docker-compose.integration.yml b/apps/server/test/docker-compose.integration.yml new file mode 100644 index 000000000..7f5dac89a --- /dev/null +++ b/apps/server/test/docker-compose.integration.yml @@ -0,0 +1,18 @@ +name: docmost-cycle-test + +services: + postgres: + image: postgres:18 + environment: + POSTGRES_DB: docmost_cycle_test + POSTGRES_USER: docmost + POSTGRES_PASSWORD: docmost + ports: + - '127.0.0.1:55432:5432' + tmpfs: + - /var/lib/postgresql + healthcheck: + test: ['CMD-SHELL', 'pg_isready -U docmost -d docmost_cycle_test'] + interval: 2s + timeout: 2s + retries: 15 diff --git a/apps/server/test/jest-integration.json b/apps/server/test/jest-integration.json new file mode 100644 index 000000000..a3a95fb8a --- /dev/null +++ b/apps/server/test/jest-integration.json @@ -0,0 +1,26 @@ +{ + "moduleFileExtensions": ["js", "json", "ts"], + "rootDir": ".", + "testEnvironment": "node", + "testRegex": ".*\\.integration-spec\\.ts$", + "transform": { + "happy-dom.+\\.js$": [ + "babel-jest", + { + "presets": [["@babel/preset-env", { "targets": { "node": "current" } }]] + } + ], + "^.+\\.(t|j)s$": "ts-jest" + }, + "transformIgnorePatterns": [ + "/node_modules/(?!(\\.pnpm/)?(nanoid|uuid|image-dimensions|marked|happy-dom)(@|/))" + ], + "moduleNameMapper": { + "^@docmost/db/(.*)$": "/../src/database/$1", + "^@docmost/transactional/(.*)$": "/../src/integrations/transactional/$1", + "^@docmost/ee/(.*)$": "/../src/ee/$1", + "^src/(.*)$": "/../src/$1", + "^@docmost/base-formula/server$": "/../../../packages/base-formula/src/index.server.ts", + "^@docmost/base-formula/client$": "/../../../packages/base-formula/src/index.client.ts" + } +} diff --git a/apps/server/test/page-hierarchy-cycle.integration-spec.ts b/apps/server/test/page-hierarchy-cycle.integration-spec.ts new file mode 100644 index 000000000..5ecdb76de --- /dev/null +++ b/apps/server/test/page-hierarchy-cycle.integration-spec.ts @@ -0,0 +1,21 @@ +import { db } from './support/database'; +import { seedAcyclicPageChain } from './support/page-hierarchy-fixtures'; + +describe('page hierarchy cycle integration harness', () => { + it('inserts and reads an acyclic page chain through the test Kysely instance', async () => { + const { root, child, grandchild } = await seedAcyclicPageChain(); + + const pages = await db + .selectFrom('pages') + .select(['id', 'parentPageId', 'title']) + .where('id', 'in', [root.id, child.id, grandchild.id]) + .orderBy('title') + .execute(); + + expect(pages).toEqual([ + { id: child.id, parentPageId: root.id, title: 'Child' }, + { id: grandchild.id, parentPageId: child.id, title: 'Grandchild' }, + { id: root.id, parentPageId: null, title: 'Root' }, + ]); + }); +}); diff --git a/apps/server/test/support/database.ts b/apps/server/test/support/database.ts new file mode 100644 index 000000000..ed872e766 --- /dev/null +++ b/apps/server/test/support/database.ts @@ -0,0 +1,43 @@ +import { CamelCasePlugin, Kysely, sql } from 'kysely'; +import { PostgresJSDialect } from 'kysely-postgres-js'; +import * as postgres from 'postgres'; +import { DbInterface } from '../../src/database/types/db.interface'; + +const databaseUrl = process.env.TEST_DATABASE_URL; + +if (!databaseUrl) { + throw new Error('TEST_DATABASE_URL must be set for integration tests'); +} + +const postgresPool = postgres(databaseUrl, { max: 1, onnotice: () => {} }); + +export const db = new Kysely({ + dialect: new PostgresJSDialect({ postgres: postgresPool }), + plugins: [new CamelCasePlugin()], +}); + +export async function withStatementTimeout( + callback: (connection: Kysely) => Promise, +): Promise { + return db.connection().execute(async (connection) => { + await sql`SET statement_timeout = '500ms'`.execute(connection); + + try { + return await callback(connection); + } finally { + await sql`SET statement_timeout = DEFAULT`.execute(connection); + } + }); +} + +export async function truncateFixtureTables(): Promise { + await sql`TRUNCATE TABLE pages, spaces, workspaces CASCADE`.execute(db); +} + +afterEach(async () => { + await truncateFixtureTables(); +}); + +afterAll(async () => { + await postgresPool.end(); +}); diff --git a/apps/server/test/support/page-hierarchy-fixtures.ts b/apps/server/test/support/page-hierarchy-fixtures.ts new file mode 100644 index 000000000..c02b644ec --- /dev/null +++ b/apps/server/test/support/page-hierarchy-fixtures.ts @@ -0,0 +1,114 @@ +import { randomUUID } from 'node:crypto'; +import { sql } from 'kysely'; +import { db } from './database'; + +export type PageFixture = { + id: string; + parentPageId: string | null; + title: string; +}; + +type HierarchyContext = { + workspaceId: string; + spaceId: string; +}; + +async function seedWorkspaceAndSpace(): Promise { + const workspace = await db + .insertInto('workspaces') + .values({ + hostname: `cycle-test-${randomUUID()}`, + name: 'Page hierarchy integration workspace', + }) + .returning('id') + .executeTakeFirstOrThrow(); + + const space = await db + .insertInto('spaces') + .values({ + name: 'Page hierarchy integration space', + slug: `cycle-test-${randomUUID()}`, + workspaceId: workspace.id, + }) + .returning('id') + .executeTakeFirstOrThrow(); + + return { spaceId: space.id, workspaceId: workspace.id }; +} + +async function insertPage( + context: HierarchyContext, + title: string, + parentPageId: string | null = null, +): Promise { + const page = await db + .insertInto('pages') + .values({ + parentPageId, + slugId: randomUUID(), + spaceId: context.spaceId, + title, + workspaceId: context.workspaceId, + }) + .returning(['id', 'parentPageId', 'title']) + .executeTakeFirstOrThrow(); + + return { id: page.id, parentPageId: page.parentPageId, title }; +} + +export async function seedAcyclicPageChain(): Promise<{ + root: PageFixture; + child: PageFixture; + grandchild: PageFixture; +}> { + const context = await seedWorkspaceAndSpace(); + const root = await insertPage(context, 'Root'); + const child = await insertPage(context, 'Child', root.id); + const grandchild = await insertPage(context, 'Grandchild', child.id); + + return { root, child, grandchild }; +} + +export async function seedSelfCycle(): Promise<{ self: PageFixture }> { + const context = await seedWorkspaceAndSpace(); + const self = await insertPage(context, 'Self'); + + await sql`UPDATE pages SET parent_page_id = ${self.id} WHERE id = ${self.id}`.execute( + db, + ); + + return { self: { ...self, parentPageId: self.id } }; +} + +export async function seedTwoPageCycle(): Promise<{ + a: PageFixture; + b: PageFixture; +}> { + const context = await seedWorkspaceAndSpace(); + const a = await insertPage(context, 'A'); + const b = await insertPage(context, 'B', a.id); + + await sql`UPDATE pages SET parent_page_id = ${b.id} WHERE id = ${a.id}`.execute( + db, + ); + + return { + a: { ...a, parentPageId: b.id }, + b, + }; +} + +export async function seedBranchingDescendantTree(): Promise<{ + root: PageFixture; + firstChild: PageFixture; + secondChild: PageFixture; + grandchild: PageFixture; +}> { + const context = await seedWorkspaceAndSpace(); + const root = await insertPage(context, 'Root'); + const firstChild = await insertPage(context, 'First child', root.id); + const secondChild = await insertPage(context, 'Second child', root.id); + const grandchild = await insertPage(context, 'Grandchild', firstChild.id); + + return { root, firstChild, secondChild, grandchild }; +}