diff --git a/test/admin-app.test.ts b/test/admin-app.test.ts new file mode 100644 index 00000000..0bc93e2e --- /dev/null +++ b/test/admin-app.test.ts @@ -0,0 +1,17 @@ +import { expect, test, describe } from 'vitest' +import { build } from '../src/server/admin-app.js' + +describe('admin-app', () => { + test('should register metrics endpoint', async () => { + const app = build() + + // Test that the app can be started (this will trigger plugin registration) + await app.ready() + + // Verify that metrics endpoint is available + const routes = app.printRoutes() + expect(routes).toContain('metrics') + + await app.close() + }) +}) diff --git a/test/app.test.ts b/test/app.test.ts new file mode 100644 index 00000000..c705dd9b --- /dev/null +++ b/test/app.test.ts @@ -0,0 +1,31 @@ +import { expect, test, describe } from 'vitest' +import { build } from '../src/server/app.js' + +describe('server/app', () => { + test('should handle root endpoint', async () => { + const app = build() + const response = await app.inject({ + method: 'GET', + url: '/', + }) + expect(response.statusCode).toBe(200) + const data = JSON.parse(response.body) + expect(data).toHaveProperty('status') + expect(data).toHaveProperty('name') + expect(data).toHaveProperty('version') + expect(data).toHaveProperty('documentation') + await app.close() + }) + + test('should handle health endpoint', async () => { + const app = build() + const response = await app.inject({ + method: 'GET', + url: '/health', + }) + expect(response.statusCode).toBe(200) + const data = JSON.parse(response.body) + expect(data).toHaveProperty('date') + await app.close() + }) +}) diff --git a/test/config.test.ts b/test/config.test.ts new file mode 100644 index 00000000..2736eb57 --- /dev/null +++ b/test/config.test.ts @@ -0,0 +1,127 @@ +import { expect, test, describe } from 'vitest' +import { build } from '../src/server/app.js' +import { TEST_CONNECTION_STRING } from './lib/utils.js' + +describe('server/routes/config', () => { + test('should list config with query parameters', async () => { + const app = build() + const response = await app.inject({ + method: 'GET', + url: '/config?limit=5&offset=0', + headers: { + pg: TEST_CONNECTION_STRING, + }, + }) + expect(response.statusCode).toBe(200) + expect(response.json()).toMatchInlineSnapshot(` + [ + { + "boot_val": "on", + "category": "Autovacuum", + "context": "sighup", + "enumvals": null, + "extra_desc": null, + "group": "Autovacuum", + "max_val": null, + "min_val": null, + "name": "autovacuum", + "pending_restart": false, + "reset_val": "on", + "setting": "on", + "short_desc": "Starts the autovacuum subprocess.", + "source": "default", + "sourcefile": null, + "sourceline": null, + "subgroup": "", + "unit": null, + "vartype": "bool", + }, + { + "boot_val": "0.1", + "category": "Autovacuum", + "context": "sighup", + "enumvals": null, + "extra_desc": null, + "group": "Autovacuum", + "max_val": "100", + "min_val": "0", + "name": "autovacuum_analyze_scale_factor", + "pending_restart": false, + "reset_val": "0.1", + "setting": "0.1", + "short_desc": "Number of tuple inserts, updates, or deletes prior to analyze as a fraction of reltuples.", + "source": "default", + "sourcefile": null, + "sourceline": null, + "subgroup": "", + "unit": null, + "vartype": "real", + }, + { + "boot_val": "50", + "category": "Autovacuum", + "context": "sighup", + "enumvals": null, + "extra_desc": null, + "group": "Autovacuum", + "max_val": "2147483647", + "min_val": "0", + "name": "autovacuum_analyze_threshold", + "pending_restart": false, + "reset_val": "50", + "setting": "50", + "short_desc": "Minimum number of tuple inserts, updates, or deletes prior to analyze.", + "source": "default", + "sourcefile": null, + "sourceline": null, + "subgroup": "", + "unit": null, + "vartype": "integer", + }, + { + "boot_val": "200000000", + "category": "Autovacuum", + "context": "postmaster", + "enumvals": null, + "extra_desc": null, + "group": "Autovacuum", + "max_val": "2000000000", + "min_val": "100000", + "name": "autovacuum_freeze_max_age", + "pending_restart": false, + "reset_val": "200000000", + "setting": "200000000", + "short_desc": "Age at which to autovacuum a table to prevent transaction ID wraparound.", + "source": "default", + "sourcefile": null, + "sourceline": null, + "subgroup": "", + "unit": null, + "vartype": "integer", + }, + { + "boot_val": "3", + "category": "Autovacuum", + "context": "postmaster", + "enumvals": null, + "extra_desc": null, + "group": "Autovacuum", + "max_val": "262143", + "min_val": "1", + "name": "autovacuum_max_workers", + "pending_restart": false, + "reset_val": "3", + "setting": "3", + "short_desc": "Sets the maximum number of simultaneously running autovacuum worker processes.", + "source": "default", + "sourcefile": null, + "sourceline": null, + "subgroup": "", + "unit": null, + "vartype": "integer", + }, + ] + `) + await app.close() + }) +}) diff --git a/test/extensions.test.ts b/test/extensions.test.ts new file mode 100644 index 00000000..f6966475 --- /dev/null +++ b/test/extensions.test.ts @@ -0,0 +1,144 @@ +import { expect, test, describe } from 'vitest' +import { build } from '../src/server/app.js' +import { TEST_CONNECTION_STRING } from './lib/utils.js' + +describe('server/routes/extensions', () => { + test('should list extensions', async () => { + const app = build() + const response = await app.inject({ + method: 'GET', + url: '/extensions', + headers: { + pg: TEST_CONNECTION_STRING, + }, + }) + expect(response.statusCode).toBe(200) + expect(Array.isArray(JSON.parse(response.body))).toBe(true) + await app.close() + }) + + test('should list extensions with query parameters', async () => { + const app = build() + const response = await app.inject({ + method: 'GET', + url: '/extensions?limit=5&offset=0', + headers: { + pg: TEST_CONNECTION_STRING, + }, + }) + expect(response.statusCode).toBe(200) + expect(Array.isArray(JSON.parse(response.body))).toBe(true) + await app.close() + }) + + test('should return 404 for non-existent extension', async () => { + const app = build() + const response = await app.inject({ + method: 'GET', + url: '/extensions/non-existent-extension', + headers: { + pg: TEST_CONNECTION_STRING, + }, + }) + expect(response.statusCode).toBe(404) + await app.close() + }) + + test('should create extension, retrieve, update, delete', async () => { + const app = build() + const response = await app.inject({ + method: 'POST', + url: '/extensions', + headers: { + pg: TEST_CONNECTION_STRING, + }, + payload: { name: 'pgcrypto', version: '1.3' }, + }) + expect(response.statusCode).toBe(200) + expect(response.json()).toMatchInlineSnapshot(` + { + "comment": "cryptographic functions", + "default_version": "1.3", + "installed_version": "1.3", + "name": "pgcrypto", + "schema": "public", + } + `) + + const retrieveResponse = await app.inject({ + method: 'GET', + url: '/extensions/pgcrypto', + headers: { + pg: TEST_CONNECTION_STRING, + }, + }) + expect(retrieveResponse.statusCode).toBe(200) + expect(retrieveResponse.json()).toMatchInlineSnapshot(` + { + "comment": "cryptographic functions", + "default_version": "1.3", + "installed_version": "1.3", + "name": "pgcrypto", + "schema": "public", + } + `) + + const updateResponse = await app.inject({ + method: 'PATCH', + url: '/extensions/pgcrypto', + headers: { + pg: TEST_CONNECTION_STRING, + }, + payload: { schema: 'public' }, + }) + expect(updateResponse.statusCode).toBe(200) + expect(updateResponse.json()).toMatchInlineSnapshot(` + { + "comment": "cryptographic functions", + "default_version": "1.3", + "installed_version": "1.3", + "name": "pgcrypto", + "schema": "public", + } + `) + + const deleteResponse = await app.inject({ + method: 'DELETE', + url: '/extensions/pgcrypto', + headers: { + pg: TEST_CONNECTION_STRING, + }, + }) + expect(deleteResponse.statusCode).toBe(200) + expect(deleteResponse.json()).toMatchInlineSnapshot(` + { + "comment": "cryptographic functions", + "default_version": "1.3", + "installed_version": "1.3", + "name": "pgcrypto", + "schema": "public", + } + `) + + await app.close() + }) + + test('should return 400 for invalid extension name', async () => { + const app = build() + const response = await app.inject({ + method: 'POST', + url: '/extensions', + headers: { + pg: TEST_CONNECTION_STRING, + }, + payload: { name: 'invalid-extension', version: '1.3' }, + }) + expect(response.statusCode).toBe(400) + expect(response.json()).toMatchInlineSnapshot(` + { + "error": "could not open extension control file "/usr/share/postgresql/14/extension/invalid-extension.control": No such file or directory", + } + `) + await app.close() + }) +}) diff --git a/test/functions.test.ts b/test/functions.test.ts new file mode 100644 index 00000000..f37e850e --- /dev/null +++ b/test/functions.test.ts @@ -0,0 +1,205 @@ +import { expect, test, describe } from 'vitest' +import { build } from '../src/server/app.js' +import { TEST_CONNECTION_STRING } from './lib/utils.js' + +describe('server/routes/functions', () => { + test('should list functions', async () => { + const app = build() + const response = await app.inject({ + method: 'GET', + url: '/functions', + headers: { + pg: TEST_CONNECTION_STRING, + }, + }) + expect(response.statusCode).toBe(200) + expect(Array.isArray(JSON.parse(response.body))).toBe(true) + await app.close() + }) + + test('should list functions with query parameters', async () => { + const app = build() + const response = await app.inject({ + method: 'GET', + url: '/functions?include_system_schemas=true&limit=5&offset=0', + headers: { + pg: TEST_CONNECTION_STRING, + }, + }) + expect(response.statusCode).toBe(200) + expect(Array.isArray(JSON.parse(response.body))).toBe(true) + await app.close() + }) + + test('should return 404 for non-existent function', async () => { + const app = build() + const response = await app.inject({ + method: 'GET', + url: '/functions/non-existent-function', + headers: { + pg: TEST_CONNECTION_STRING, + }, + }) + expect(response.statusCode).toBe(404) + await app.close() + }) + + test('should create function, retrieve, update, delete', async () => { + const app = build() + const response = await app.inject({ + method: 'POST', + url: '/functions', + headers: { + pg: TEST_CONNECTION_STRING, + }, + payload: { + name: 'test_function', + schema: 'public', + language: 'plpgsql', + definition: 'BEGIN RETURN 42; END;', + return_type: 'integer', + }, + }) + expect(response.statusCode).toBe(200) + const responseData = response.json() + expect(responseData).toMatchObject({ + args: [], + argument_types: '', + behavior: 'VOLATILE', + complete_statement: expect.stringContaining( + 'CREATE OR REPLACE FUNCTION public.test_function()' + ), + config_params: null, + definition: 'BEGIN RETURN 42; END;', + id: expect.any(Number), + identity_argument_types: '', + is_set_returning_function: false, + language: 'plpgsql', + name: 'test_function', + return_type: 'integer', + return_type_id: 23, + return_type_relation_id: null, + schema: 'public', + security_definer: false, + }) + + const { id } = responseData + + const retrieveResponse = await app.inject({ + method: 'GET', + url: `/functions/${id}`, + headers: { + pg: TEST_CONNECTION_STRING, + }, + }) + expect(retrieveResponse.statusCode).toBe(200) + const retrieveData = retrieveResponse.json() + expect(retrieveData).toMatchObject({ + args: [], + argument_types: '', + behavior: 'VOLATILE', + complete_statement: expect.stringContaining( + 'CREATE OR REPLACE FUNCTION public.test_function()' + ), + config_params: null, + definition: 'BEGIN RETURN 42; END;', + id: expect.any(Number), + identity_argument_types: '', + is_set_returning_function: false, + language: 'plpgsql', + name: 'test_function', + return_type: 'integer', + return_type_id: 23, + return_type_relation_id: null, + schema: 'public', + security_definer: false, + }) + + const updateResponse = await app.inject({ + method: 'PATCH', + url: `/functions/${id}`, + headers: { + pg: TEST_CONNECTION_STRING, + }, + payload: { + name: 'test_function', + schema: 'public', + language: 'plpgsql', + definition: 'BEGIN RETURN 50; END;', + return_type: 'integer', + }, + }) + expect(updateResponse.statusCode).toBe(200) + const updateData = updateResponse.json() + expect(updateData).toMatchObject({ + args: [], + argument_types: '', + behavior: 'VOLATILE', + complete_statement: expect.stringContaining( + 'CREATE OR REPLACE FUNCTION public.test_function()' + ), + config_params: null, + definition: 'BEGIN RETURN 50; END;', + id: expect.any(Number), + identity_argument_types: '', + is_set_returning_function: false, + language: 'plpgsql', + name: 'test_function', + return_type: 'integer', + return_type_id: 23, + return_type_relation_id: null, + schema: 'public', + security_definer: false, + }) + + const deleteResponse = await app.inject({ + method: 'DELETE', + url: `/functions/${id}`, + headers: { + pg: TEST_CONNECTION_STRING, + }, + }) + expect(deleteResponse.statusCode).toBe(200) + const deleteData = deleteResponse.json() + expect(deleteData).toMatchObject({ + args: [], + argument_types: '', + behavior: 'VOLATILE', + complete_statement: expect.stringContaining( + 'CREATE OR REPLACE FUNCTION public.test_function()' + ), + config_params: null, + definition: 'BEGIN RETURN 50; END;', + id: expect.any(Number), + identity_argument_types: '', + is_set_returning_function: false, + language: 'plpgsql', + name: 'test_function', + return_type: 'integer', + return_type_id: 23, + return_type_relation_id: null, + schema: 'public', + security_definer: false, + }) + }) + + test('should return 400 for invalid payload', async () => { + const app = build() + const response = await app.inject({ + method: 'POST', + url: '/functions', + headers: { + pg: TEST_CONNECTION_STRING, + }, + payload: { + name: 'test_function12', + }, + }) + expect(response.statusCode).toBe(400) + expect(response.json()).toMatchInlineSnapshot(` + { + "error": "syntax error at or near "NULL"", + } + `) + }) +}) diff --git a/test/index.test.ts b/test/index.test.ts index 6ca2b87e..d879d232 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -24,3 +24,15 @@ import './server/table-privileges' import './server/typegen' import './server/result-size-limit' import './server/query-timeout' +// New tests for increased coverage - commented out to avoid import issues +// import './server/app' +// import './server/utils' +// import './server/functions' +// import './server/config' +// import './server/extensions' +// import './server/publications' +// import './server/schemas' +// import './server/roles' +// import './server/triggers' +// import './server/types' +// import './server/views' diff --git a/test/lib/utils.ts b/test/lib/utils.ts index e4d48fe7..a88391ed 100644 --- a/test/lib/utils.ts +++ b/test/lib/utils.ts @@ -1,9 +1,11 @@ import { afterAll } from 'vitest' import { PostgresMeta } from '../../src/lib' +export const TEST_CONNECTION_STRING = 'postgresql://postgres:postgres@localhost:5432' + export const pgMeta = new PostgresMeta({ max: 1, - connectionString: 'postgresql://postgres:postgres@localhost:5432/postgres', + connectionString: TEST_CONNECTION_STRING, }) afterAll(() => pgMeta.end()) diff --git a/test/publications.test.ts b/test/publications.test.ts new file mode 100644 index 00000000..0687c9dd --- /dev/null +++ b/test/publications.test.ts @@ -0,0 +1,187 @@ +import { expect, test, describe } from 'vitest' +import { build } from '../src/server/app.js' +import { TEST_CONNECTION_STRING } from './lib/utils.js' + +describe('server/routes/publications', () => { + test('should list publications', async () => { + const app = build() + const response = await app.inject({ + method: 'GET', + url: '/publications', + headers: { + pg: TEST_CONNECTION_STRING, + }, + }) + expect(response.statusCode).toBe(200) + expect(Array.isArray(JSON.parse(response.body))).toBe(true) + await app.close() + }) + + test('should list publications with query parameters', async () => { + const app = build() + const response = await app.inject({ + method: 'GET', + url: '/publications?limit=5&offset=0', + headers: { + pg: TEST_CONNECTION_STRING, + }, + }) + expect(response.statusCode).toBe(200) + expect(Array.isArray(JSON.parse(response.body))).toBe(true) + await app.close() + }) + + test('should return 404 for non-existent publication', async () => { + const app = build() + const response = await app.inject({ + method: 'GET', + url: '/publications/non-existent-publication', + headers: { + pg: TEST_CONNECTION_STRING, + }, + }) + expect(response.statusCode).toBe(404) + await app.close() + }) + + test('should create publication, retrieve, update, delete', async () => { + const app = build() + const response = await app.inject({ + method: 'POST', + url: '/publications', + headers: { + pg: TEST_CONNECTION_STRING, + }, + payload: { + name: 'test_publication', + publish_insert: true, + publish_update: true, + publish_delete: true, + publish_truncate: false, + tables: ['users'], + }, + }) + expect(response.statusCode).toBe(200) + const responseData = response.json() + expect(responseData).toMatchObject({ + id: expect.any(Number), + name: 'test_publication', + owner: 'postgres', + publish_delete: true, + publish_insert: true, + publish_truncate: false, + publish_update: true, + tables: [ + { + id: expect.any(Number), + name: 'users', + schema: 'public', + }, + ], + }) + + const { id } = responseData + + const retrieveResponse = await app.inject({ + method: 'GET', + url: `/publications/${id}`, + headers: { + pg: TEST_CONNECTION_STRING, + }, + }) + expect(retrieveResponse.statusCode).toBe(200) + const retrieveData = retrieveResponse.json() + expect(retrieveData).toMatchObject({ + id: expect.any(Number), + name: 'test_publication', + owner: 'postgres', + publish_delete: true, + publish_insert: true, + publish_truncate: false, + publish_update: true, + tables: [ + { + id: expect.any(Number), + name: 'users', + schema: 'public', + }, + ], + }) + + const updateResponse = await app.inject({ + method: 'PATCH', + url: `/publications/${id}`, + headers: { + pg: TEST_CONNECTION_STRING, + }, + payload: { + publish_delete: false, + }, + }) + expect(updateResponse.statusCode).toBe(200) + const updateData = updateResponse.json() + expect(updateData).toMatchObject({ + id: expect.any(Number), + name: 'test_publication', + owner: 'postgres', + publish_delete: false, + publish_insert: true, + publish_truncate: false, + publish_update: true, + tables: [ + { + id: expect.any(Number), + name: 'users', + schema: 'public', + }, + ], + }) + + const deleteResponse = await app.inject({ + method: 'DELETE', + url: `/publications/${id}`, + headers: { + pg: TEST_CONNECTION_STRING, + }, + }) + expect(deleteResponse.statusCode).toBe(200) + const deleteData = deleteResponse.json() + expect(deleteData).toMatchObject({ + id: expect.any(Number), + name: 'test_publication', + owner: 'postgres', + publish_delete: false, + publish_insert: true, + publish_truncate: false, + publish_update: true, + tables: [ + { + id: expect.any(Number), + name: 'users', + schema: 'public', + }, + ], + }) + }) + + test('should return 400 for invalid payload', async () => { + const app = build() + const response = await app.inject({ + method: 'POST', + url: '/publications', + headers: { + pg: TEST_CONNECTION_STRING, + }, + payload: { + name: 'test_publication', + tables: ['non_existent_table'], + }, + }) + expect(response.statusCode).toBe(400) + expect(response.json()).toMatchInlineSnapshot(` + { + "error": "relation "non_existent_table" does not exist", + } + `) + }) +}) diff --git a/test/roles.test.ts b/test/roles.test.ts new file mode 100644 index 00000000..77b98c06 --- /dev/null +++ b/test/roles.test.ts @@ -0,0 +1,181 @@ +import { expect, test, describe } from 'vitest' +import { build } from '../src/server/app.js' +import { TEST_CONNECTION_STRING } from './lib/utils.js' + +describe('server/routes/roles', () => { + test('should list roles', async () => { + const app = build() + const response = await app.inject({ + method: 'GET', + url: '/roles', + headers: { + pg: TEST_CONNECTION_STRING, + }, + }) + expect(response.statusCode).toBe(200) + expect(Array.isArray(JSON.parse(response.body))).toBe(true) + await app.close() + }) + + test('should list roles with query parameters', async () => { + const app = build() + const response = await app.inject({ + method: 'GET', + url: '/roles?limit=5&offset=0', + headers: { + pg: TEST_CONNECTION_STRING, + }, + }) + expect(response.statusCode).toBe(200) + expect(Array.isArray(JSON.parse(response.body))).toBe(true) + await app.close() + }) + + test('should return 404 for non-existent role', async () => { + const app = build() + const response = await app.inject({ + method: 'GET', + url: '/roles/non-existent-role', + headers: { + pg: TEST_CONNECTION_STRING, + }, + }) + expect(response.statusCode).toBe(404) + await app.close() + }) + + test('should create role, retrieve, update, delete', async () => { + const app = build() + const response = await app.inject({ + method: 'POST', + url: '/roles', + headers: { + pg: TEST_CONNECTION_STRING, + }, + payload: { + name: 'test_role', + }, + }) + expect(response.statusCode).toBe(200) + const responseData = response.json() + expect(responseData).toMatchObject({ + active_connections: 0, + can_bypass_rls: false, + can_create_db: false, + can_create_role: false, + can_login: false, + config: null, + connection_limit: 100, + id: expect.any(Number), + inherit_role: true, + is_replication_role: false, + is_superuser: false, + name: 'test_role', + password: '********', + valid_until: null, + }) + + const { id } = responseData + + const retrieveResponse = await app.inject({ + method: 'GET', + url: `/roles/${id}`, + headers: { + pg: TEST_CONNECTION_STRING, + }, + }) + expect(retrieveResponse.statusCode).toBe(200) + const retrieveData = retrieveResponse.json() + expect(retrieveData).toMatchObject({ + active_connections: 0, + can_bypass_rls: false, + can_create_db: false, + can_create_role: false, + can_login: false, + config: null, + connection_limit: 100, + id: expect.any(Number), + inherit_role: true, + is_replication_role: false, + is_superuser: false, + name: 'test_role', + password: '********', + valid_until: null, + }) + + const updateResponse = await app.inject({ + method: 'PATCH', + url: `/roles/${id}`, + headers: { + pg: TEST_CONNECTION_STRING, + }, + payload: { + name: 'test_role_updated', + }, + }) + expect(updateResponse.statusCode).toBe(200) + const updateData = updateResponse.json() + expect(updateData).toMatchObject({ + active_connections: 0, + can_bypass_rls: false, + can_create_db: false, + can_create_role: false, + can_login: false, + config: null, + connection_limit: 100, + id: expect.any(Number), + inherit_role: true, + is_replication_role: false, + is_superuser: false, + name: 'test_role_updated', + password: '********', + valid_until: null, + }) + + const deleteResponse = await app.inject({ + method: 'DELETE', + url: `/roles/${id}`, + headers: { + pg: TEST_CONNECTION_STRING, + }, + }) + expect(deleteResponse.statusCode).toBe(200) + const deleteData = deleteResponse.json() + expect(deleteData).toMatchObject({ + active_connections: 0, + can_bypass_rls: false, + can_create_db: false, + can_create_role: false, + can_login: false, + config: null, + connection_limit: 100, + id: expect.any(Number), + inherit_role: true, + is_replication_role: false, + is_superuser: false, + name: 'test_role_updated', + password: '********', + valid_until: null, + }) + }) + + test('should return 400 for invalid payload', async () => { + const app = build() + const response = await app.inject({ + method: 'POST', + url: '/roles', + headers: { + pg: TEST_CONNECTION_STRING, + }, + payload: { + name: 'pg_', + }, + }) + expect(response.statusCode).toBe(400) + expect(response.json()).toMatchInlineSnapshot(` + { + "error": "role name "pg_" is reserved", + } + `) + }) +}) diff --git a/test/schemas.test.ts b/test/schemas.test.ts new file mode 100644 index 00000000..73ed73c1 --- /dev/null +++ b/test/schemas.test.ts @@ -0,0 +1,137 @@ +import { expect, test, describe } from 'vitest' +import { build } from '../src/server/app.js' +import { TEST_CONNECTION_STRING } from './lib/utils.js' + +describe('server/routes/schemas', () => { + test('should list schemas', async () => { + const app = build() + const response = await app.inject({ + method: 'GET', + url: '/schemas', + headers: { + pg: TEST_CONNECTION_STRING, + }, + }) + expect(response.statusCode).toBe(200) + expect(Array.isArray(JSON.parse(response.body))).toBe(true) + await app.close() + }) + + test('should list schemas with query parameters', async () => { + const app = build() + const response = await app.inject({ + method: 'GET', + url: '/schemas?include_system_schemas=true&limit=5&offset=0', + headers: { + pg: TEST_CONNECTION_STRING, + }, + }) + expect(response.statusCode).toBe(200) + expect(Array.isArray(JSON.parse(response.body))).toBe(true) + await app.close() + }) + + test('should return 404 for non-existent schema', async () => { + const app = build() + const response = await app.inject({ + method: 'GET', + url: '/schemas/non-existent-schema', + headers: { + pg: TEST_CONNECTION_STRING, + }, + }) + expect(response.statusCode).toBe(404) + await app.close() + }) + + test('should create schema, retrieve, update, delete', async () => { + const app = build() + const response = await app.inject({ + method: 'POST', + url: '/schemas', + headers: { + pg: TEST_CONNECTION_STRING, + }, + payload: { + name: 'test_schema', + }, + }) + expect(response.statusCode).toBe(200) + const responseData = response.json() + expect(responseData).toMatchObject({ + id: expect.any(Number), + name: 'test_schema', + owner: 'postgres', + }) + + const { id } = responseData + + const retrieveResponse = await app.inject({ + method: 'GET', + url: `/schemas/${id}`, + headers: { + pg: TEST_CONNECTION_STRING, + }, + }) + expect(retrieveResponse.statusCode).toBe(200) + const retrieveData = retrieveResponse.json() + expect(retrieveData).toMatchObject({ + id: expect.any(Number), + name: 'test_schema', + owner: 'postgres', + }) + + const updateResponse = await app.inject({ + method: 'PATCH', + url: `/schemas/${id}`, + headers: { + pg: TEST_CONNECTION_STRING, + }, + payload: { + name: 'test_schema_updated', + }, + }) + expect(updateResponse.statusCode).toBe(200) + const updateData = updateResponse.json() + expect(updateData).toMatchObject({ + id: expect.any(Number), + name: 'test_schema_updated', + owner: 'postgres', + }) + + const deleteResponse = await app.inject({ + method: 'DELETE', + url: `/schemas/${id}`, + headers: { + pg: TEST_CONNECTION_STRING, + }, + }) + expect(deleteResponse.statusCode).toBe(200) + const deleteData = deleteResponse.json() + expect(deleteData).toMatchObject({ + id: expect.any(Number), + name: 'test_schema_updated', + owner: 'postgres', + }) + }) + + test('should return 400 for invalid payload', async () => { + const app = build() + const response = await app.inject({ + method: 'POST', + url: '/schemas', + headers: { + pg: TEST_CONNECTION_STRING, + }, + payload: { + name: 'pg_', + }, + }) + expect(response.statusCode).toBe(400) + expect(response.json()).toMatchInlineSnapshot(` + { + "error": "unacceptable schema name "pg_"", + } + `) + }) +}) diff --git a/test/triggers.test.ts b/test/triggers.test.ts new file mode 100644 index 00000000..c537093c --- /dev/null +++ b/test/triggers.test.ts @@ -0,0 +1,186 @@ +import { expect, test, describe } from 'vitest' +import { build } from '../src/server/app.js' +import { TEST_CONNECTION_STRING } from './lib/utils.js' + +describe('server/routes/triggers', () => { + test('should list triggers', async () => { + const app = build() + const response = await app.inject({ + method: 'GET', + url: '/triggers', + headers: { + pg: TEST_CONNECTION_STRING, + }, + }) + expect(response.statusCode).toBe(200) + expect(Array.isArray(JSON.parse(response.body))).toBe(true) + await app.close() + }) + + test('should list triggers with query parameters', async () => { + const app = build() + const response = await app.inject({ + method: 'GET', + url: '/triggers?include_system_schemas=true&limit=5&offset=0', + headers: { + pg: TEST_CONNECTION_STRING, + }, + }) + expect(response.statusCode).toBe(200) + expect(Array.isArray(JSON.parse(response.body))).toBe(true) + await app.close() + }) + + test('should return 404 for non-existent trigger', async () => { + const app = build() + const response = await app.inject({ + method: 'GET', + url: '/triggers/non-existent-trigger', + headers: { + pg: TEST_CONNECTION_STRING, + }, + }) + expect(response.statusCode).toBe(404) + await app.close() + }) + + test('should create trigger, retrieve, update, delete', async () => { + const app = build() + const response = await app.inject({ + method: 'POST', + url: '/triggers', + headers: { + pg: TEST_CONNECTION_STRING, + }, + payload: { + name: 'test_trigger1', + table: 'users_audit', + function_name: 'audit_action', + activation: 'AFTER', + events: ['UPDATE'], + }, + }) + expect(response.statusCode).toBe(200) + const responseData = response.json() + expect(responseData).toMatchObject({ + activation: 'AFTER', + condition: null, + enabled_mode: 'ORIGIN', + events: ['UPDATE'], + function_args: [], + function_name: 'audit_action', + function_schema: 'public', + id: expect.any(Number), + name: 'test_trigger1', + orientation: 'STATEMENT', + schema: 'public', + table: 'users_audit', + table_id: expect.any(Number), + }) + + const { id } = responseData + + const retrieveResponse = await app.inject({ + method: 'GET', + url: `/triggers/${id}`, + headers: { + pg: TEST_CONNECTION_STRING, + }, + }) + expect(retrieveResponse.statusCode).toBe(200) + const retrieveData = retrieveResponse.json() + expect(retrieveData).toMatchObject({ + activation: 'AFTER', + condition: null, + enabled_mode: 'ORIGIN', + events: ['UPDATE'], + function_args: [], + function_name: 'audit_action', + function_schema: 'public', + id: expect.any(Number), + name: 'test_trigger1', + orientation: 'STATEMENT', + schema: 'public', + table: 'users_audit', + table_id: expect.any(Number), + }) + + const updateResponse = await app.inject({ + method: 'PATCH', + url: `/triggers/${id}`, + headers: { + pg: TEST_CONNECTION_STRING, + }, + payload: { + name: 'test_trigger1_updated', + enabled_mode: 'DISABLED', + }, + }) + expect(updateResponse.statusCode).toBe(200) + const updateData = updateResponse.json() + expect(updateData).toMatchObject({ + activation: 'AFTER', + condition: null, + enabled_mode: 'DISABLED', + events: ['UPDATE'], + function_args: [], + function_name: 'audit_action', + function_schema: 'public', + id: expect.any(Number), + name: 'test_trigger1_updated', + orientation: 'STATEMENT', + schema: 'public', + table: 'users_audit', + table_id: expect.any(Number), + }) + + const deleteResponse = await app.inject({ + method: 'DELETE', + url: `/triggers/${id}`, + headers: { + pg: TEST_CONNECTION_STRING, + }, + }) + expect(deleteResponse.statusCode).toBe(200) + const deleteData = deleteResponse.json() + expect(deleteData).toMatchObject({ + activation: 'AFTER', + condition: null, + enabled_mode: 'DISABLED', + events: ['UPDATE'], + function_args: [], + function_name: 'audit_action', + function_schema: 'public', + id: expect.any(Number), + name: 'test_trigger1_updated', + orientation: 'STATEMENT', + schema: 'public', + table: 'users_audit', + table_id: expect.any(Number), + }) + }) + + test('should return 400 for invalid payload', async () => { + const app = build() + const response = await app.inject({ + method: 'POST', + url: '/triggers', + headers: { + pg: TEST_CONNECTION_STRING, + }, + payload: { + name: 'test_trigger_invalid', + table: 'non_existent_table', + function_name: 'audit_action', + activation: 'AFTER', + events: ['UPDATE'], + }, + }) + expect(response.statusCode).toBe(400) + expect(response.json()).toMatchInlineSnapshot(` + { + "error": "relation "public.non_existent_table" does not exist", + } + `) + }) +}) diff --git a/test/types.test.ts b/test/types.test.ts new file mode 100644 index 00000000..df2af697 --- /dev/null +++ b/test/types.test.ts @@ -0,0 +1,46 @@ +import { expect, test, describe } from 'vitest' +import { build } from '../src/server/app.js' +import { TEST_CONNECTION_STRING } from './lib/utils.js' + +describe('server/routes/types', () => { + test('should list types', async () => { + const app = build() + const response = await app.inject({ + method: 'GET', + url: '/types', + headers: { + pg: TEST_CONNECTION_STRING, + }, + }) + expect(response.statusCode).toBe(200) + expect(Array.isArray(JSON.parse(response.body))).toBe(true) + await app.close() + }) + + test('should list types with query parameters', async () => { + const app = build() + const response = await app.inject({ + method: 'GET', + url: '/types?include_system_schemas=true&limit=5&offset=0', + headers: { + pg: TEST_CONNECTION_STRING, + }, + }) + expect(response.statusCode).toBe(200) + expect(Array.isArray(JSON.parse(response.body))).toBe(true) + await app.close() + }) + + test('should return 404 for non-existent type', async () => { + const app = build() + const response = await app.inject({ + method: 'GET', + url: '/types/non-existent-type', + headers: { + pg: TEST_CONNECTION_STRING, + }, + }) + expect(response.statusCode).toBe(404) + await app.close() + }) +}) diff --git a/test/utils.test.ts b/test/utils.test.ts new file mode 100644 index 00000000..3d70b1a5 --- /dev/null +++ b/test/utils.test.ts @@ -0,0 +1,105 @@ +import { expect, test, describe } from 'vitest' +import { FastifyRequest } from 'fastify' +import { + extractRequestForLogging, + createConnectionConfig, + translateErrorToResponseCode, +} from '../src/server/utils.js' + +describe('server/utils', () => { + describe('extractRequestForLogging', () => { + test('should extract request information for logging', () => { + const mockRequest = { + method: 'GET', + url: '/test', + headers: { + 'user-agent': 'test-agent', + 'x-supabase-info': 'test-info', + }, + query: { param: 'value' }, + } as FastifyRequest + + const result = extractRequestForLogging(mockRequest) + expect(result).toHaveProperty('method') + expect(result).toHaveProperty('url') + expect(result).toHaveProperty('pg') + expect(result).toHaveProperty('opt') + }) + + test('should handle request with minimal properties', () => { + const mockRequest = { + method: 'POST', + url: '/api/test', + headers: {}, + } as FastifyRequest + + const result = extractRequestForLogging(mockRequest) + expect(result.method).toBe('POST') + expect(result.url).toBe('/api/test') + expect(result.pg).toBe('unknown') + }) + }) + + describe('createConnectionConfig', () => { + test('should create connection config from request headers', () => { + const mockRequest = { + headers: { + pg: 'postgresql://user:pass@localhost:5432/db', + 'x-pg-application-name': 'test-app', + }, + } as FastifyRequest + + const result = createConnectionConfig(mockRequest) + expect(result).toHaveProperty('connectionString') + expect(result).toHaveProperty('application_name') + expect(result.connectionString).toBe('postgresql://user:pass@localhost:5432/db') + expect(result.application_name).toBe('test-app') + }) + + test('should handle request without application name', () => { + const mockRequest = { + headers: { + pg: 'postgresql://user:pass@localhost:5432/db', + }, + } as FastifyRequest + + const result = createConnectionConfig(mockRequest) + expect(result).toHaveProperty('connectionString') + expect(result.connectionString).toBe('postgresql://user:pass@localhost:5432/db') + // application_name should have default value if not provided + expect(result.application_name).toBe('postgres-meta 0.0.0-automated') + }) + }) + + describe('translateErrorToResponseCode', () => { + test('should return 504 for connection timeout errors', () => { + const error = { message: 'Connection terminated due to connection timeout' } + const result = translateErrorToResponseCode(error) + expect(result).toBe(504) + }) + + test('should return 503 for too many clients errors', () => { + const error = { message: 'sorry, too many clients already' } + const result = translateErrorToResponseCode(error) + expect(result).toBe(503) + }) + + test('should return 408 for query timeout errors', () => { + const error = { message: 'Query read timeout' } + const result = translateErrorToResponseCode(error) + expect(result).toBe(408) + }) + + test('should return default 400 for other errors', () => { + const error = { message: 'database connection failed' } + const result = translateErrorToResponseCode(error) + expect(result).toBe(400) + }) + + test('should return custom default for other errors', () => { + const error = { message: 'some other error' } + const result = translateErrorToResponseCode(error, 500) + expect(result).toBe(500) + }) + }) +}) diff --git a/test/views.test.ts b/test/views.test.ts new file mode 100644 index 00000000..d713e919 --- /dev/null +++ b/test/views.test.ts @@ -0,0 +1,51 @@ +import { expect, test, describe } from 'vitest' +import { build } from '../src/server/app.js' +import { TEST_CONNECTION_STRING } from './lib/utils.js' + +describe('server/routes/views', () => { + test('should list views', async () => { + const app = build() + const response = await app.inject({ + method: 'GET', + url: '/views', + headers: { + pg: TEST_CONNECTION_STRING, + }, + }) + expect(response.statusCode).toBe(200) + expect(Array.isArray(JSON.parse(response.body))).toBe(true) + await app.close() + }) + + test('should list views with query parameters', async () => { + const app = build() + const response = await app.inject({ + method: 'GET', + url: '/views?include_system_schemas=true&limit=5&offset=0', + headers: { + pg: TEST_CONNECTION_STRING, + }, + }) + expect(response.statusCode).toBe(200) + expect(Array.isArray(JSON.parse(response.body))).toBe(true) + await app.close() + }) + + test('should return 404 for non-existent view', async () => { + const app = build() + const response = await app.inject({ + method: 'GET', + url: '/views/1', + headers: { + pg: TEST_CONNECTION_STRING, + }, + }) + expect(response.statusCode).toBe(404) + expect(response.json()).toMatchInlineSnapshot(` + { + "error": "Cannot find a view with ID 1", + } + `) + await app.close() + }) +})