From 379e03ab1521c39ef24c2e397ae46d7fe007941b Mon Sep 17 00:00:00 2001 From: Marek Libra Date: Mon, 26 Jan 2026 11:55:22 +0100 Subject: [PATCH 1/7] feat(x2a): paginating of GET /projects result Signed-off-by: Marek Libra --- .../x2a/.changeset/proud-months-smash.md | 7 + .../x2a/plugins/x2a-backend/package.json | 2 +- .../x2a/plugins/x2a-backend/src/constants.ts | 18 + .../x2a/plugins/x2a-backend/src/plugin.ts | 3 + .../plugins/x2a-backend/src/router.test.ts | 260 +++++- .../x2a/plugins/x2a-backend/src/router.ts | 127 ++- .../x2a-backend/src/schema/openapi.yaml | 9 + .../openapi/generated/apis/Api.server.ts | 1 + .../src/schema/openapi/generated/router.ts | 13 + .../src/services/X2ADatabaseService.test.ts | 849 +++++++++++++++++- .../src/services/X2ADatabaseService.ts | 131 ++- .../plugins/x2a-backend/src/utils/delay.ts | 25 + .../plugins/x2a-backend/src/utils/index.ts | 1 + .../openapi/generated/apis/Api.client.ts | 4 +- .../x2a/plugins/x2a-common/package.json | 5 +- .../x2a/plugins/x2a-common/report.api.md | 15 + .../x2a/plugins/x2a-common/src/index.ts | 1 + .../x2a/plugins/x2a-common/src/permissions.ts | 62 ++ workspaces/x2a/yarn.lock | 1 + 19 files changed, 1467 insertions(+), 67 deletions(-) create mode 100644 workspaces/x2a/.changeset/proud-months-smash.md create mode 100644 workspaces/x2a/plugins/x2a-backend/src/constants.ts create mode 100644 workspaces/x2a/plugins/x2a-backend/src/utils/delay.ts create mode 100644 workspaces/x2a/plugins/x2a-common/src/permissions.ts diff --git a/workspaces/x2a/.changeset/proud-months-smash.md b/workspaces/x2a/.changeset/proud-months-smash.md new file mode 100644 index 0000000000..a48787fa13 --- /dev/null +++ b/workspaces/x2a/.changeset/proud-months-smash.md @@ -0,0 +1,7 @@ +--- +'@red-hat-developer-hub/backstage-plugin-x2a-backend': patch +'@red-hat-developer-hub/backstage-plugin-x2a-common': patch +'@red-hat-developer-hub/backstage-plugin-x2a': patch +--- + +Adding pagination and filtering by permissions to the GET|POST /projects and GET /project/[id] endpoints. diff --git a/workspaces/x2a/plugins/x2a-backend/package.json b/workspaces/x2a/plugins/x2a-backend/package.json index af7725bfa5..c956ef5b01 100644 --- a/workspaces/x2a/plugins/x2a-backend/package.json +++ b/workspaces/x2a/plugins/x2a-backend/package.json @@ -40,6 +40,7 @@ "@backstage/catalog-client": "^1.12.1", "@backstage/errors": "^1.2.7", "@backstage/plugin-catalog-node": "^1.20.0", + "@backstage/plugin-permission-common": "^0.9.4", "@backstage/types": "^1.2.2", "@kubernetes/client-node": "^1.4.0", "@red-hat-developer-hub/backstage-plugin-x2a-common": "workspace:*", @@ -51,7 +52,6 @@ "devDependencies": { "@backstage/backend-test-utils": "^1.10.1", "@backstage/cli": "^0.34.5", - "@backstage/plugin-permission-common": "^0.9.4", "@backstage/repo-tools": "^0.16.2", "@types/express": "^4.17.6", "@types/knex": "^0.16.0", diff --git a/workspaces/x2a/plugins/x2a-backend/src/constants.ts b/workspaces/x2a/plugins/x2a-backend/src/constants.ts new file mode 100644 index 0000000000..73581263cf --- /dev/null +++ b/workspaces/x2a/plugins/x2a-backend/src/constants.ts @@ -0,0 +1,18 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export const DEFAULT_PAGE_SIZE = 10; +export const DEFAULT_PAGE_SORT = 'created_at'; +export const DEFAULT_PAGE_ORDER = 'desc'; diff --git a/workspaces/x2a/plugins/x2a-backend/src/plugin.ts b/workspaces/x2a/plugins/x2a-backend/src/plugin.ts index 5968f331c1..e525955a32 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/plugin.ts +++ b/workspaces/x2a/plugins/x2a-backend/src/plugin.ts @@ -36,11 +36,13 @@ export const x2APlugin = createBackendPlugin({ httpRouter: coreServices.httpRouter, database: coreServices.database, logger: coreServices.logger, + permissionsSvc: coreServices.permissions, x2aDatabase: x2aDatabaseServiceRef, kubeService: kubeServiceRef, }, async init({ httpRouter, + permissionsSvc, x2aDatabase, logger, httpAuth, @@ -53,6 +55,7 @@ export const x2APlugin = createBackendPlugin({ await createRouter({ httpAuth, logger, + permissionsSvc, x2aDatabase, kubeService, }), diff --git a/workspaces/x2a/plugins/x2a-backend/src/router.test.ts b/workspaces/x2a/plugins/x2a-backend/src/router.test.ts index 61fa57460f..e62f3c8974 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/router.test.ts +++ b/workspaces/x2a/plugins/x2a-backend/src/router.test.ts @@ -22,6 +22,7 @@ import { } from '@backstage/backend-test-utils'; import express from 'express'; import request from 'supertest'; +import { AuthorizeResult } from '@backstage/plugin-permission-common'; import { createRouter } from './router'; import { X2ADatabaseService } from './services/X2ADatabaseService'; @@ -40,7 +41,11 @@ const mockInputProject: ProjectsPostRequest = { abbreviation: 'MP', }; -async function createApp(client: Knex): Promise { +async function createApp( + client: Knex, + authorizeResult?: AuthorizeResult, + adminWriteResult?: AuthorizeResult, +): Promise { const x2aDatabase = X2ADatabaseService.create({ logger: mockServices.logger.mock(), dbClient: client, @@ -48,6 +53,26 @@ async function createApp(client: Knex): Promise { const router = await createRouter({ httpAuth: mockServices.httpAuth(), logger: mockServices.logger.mock(), + permissionsSvc: mockServices.permissions.mock({ + authorize: async (requests: any[]) => { + // Check which permission is being requested + const permission = requests[0]?.permission; + if ( + permission?.name === 'x2a.admin' && + permission?.attributes?.action === 'update' + ) { + // This is x2aAdminWritePermission + return [ + { + result: + adminWriteResult ?? authorizeResult ?? AuthorizeResult.ALLOW, + }, + ] as any; + } + // Default to the provided authorizeResult or ALLOW + return [{ result: authorizeResult ?? AuthorizeResult.ALLOW }] as any; + }, + }), x2aDatabase, kubeService: { getPods: jest.fn().mockResolvedValue({ items: [] }), @@ -109,7 +134,7 @@ describe('createRouter', () => { ); it.each(databases.eachSupportedId())( - 'should not allow unauthenticated requests to create a migration - %p', + 'should not allow unauthenticated requests to create a project - %p', async databaseId => { const { client } = await createDatabase(databaseId); const app = await createApp(client); @@ -127,6 +152,62 @@ describe('createRouter', () => { }, ); + it.each(databases.eachSupportedId())( + 'should allow users with x2aUserPermission to create projects - %p', + async databaseId => { + const { client } = await createDatabase(databaseId); + const app = await createApp(client, AuthorizeResult.ALLOW); + + const response = await request(app) + .post('/projects') + .send(mockInputProject); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ + ...mockInputProject, + createdBy: 'user:default/mock', + }); + }, + ); + + it.each(databases.eachSupportedId())( + 'should allow users with x2aAdminWritePermission to create projects - %p', + async databaseId => { + const { client } = await createDatabase(databaseId); + const app = await createApp(client, AuthorizeResult.ALLOW); + + const response = await request(app) + .post('/projects') + .send(mockInputProject); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ + ...mockInputProject, + createdBy: 'user:default/mock', + }); + }, + ); + + it.each(databases.eachSupportedId())( + 'should deny users without permissions from creating projects - %p', + async databaseId => { + const { client } = await createDatabase(databaseId); + const app = await createApp(client, AuthorizeResult.DENY); + + const response = await request(app) + .post('/projects') + .send(mockInputProject); + + expect(response.status).toBe(403); + expect(response.body).toMatchObject({ + error: { + name: 'NotAllowedError', + message: 'You are not allowed to create a project', + }, + }); + }, + ); + it.each(databases.eachSupportedId())( 'should get a project by id - %p', async databaseId => { @@ -225,4 +306,179 @@ describe('createRouter', () => { }); }, ); + + it.each(databases.eachSupportedId())( + 'should allow users with admin write permission to delete any project - %p', + async databaseId => { + const { client } = await createDatabase(databaseId); + + // Create a project as user1 + // const user1Credentials = mockCredentials.user('user:default/user1'); + const user1CredentialsHeader = + mockCredentials.user.header('user:default/user1'); + const appWithCreate = await createApp(client, AuthorizeResult.ALLOW); + const createResponse = await request(appWithCreate) + .post('/projects') + .set('Authorization', user1CredentialsHeader) + .send(mockInputProject); + + expect(createResponse.status).toBe(200); + const projectId = createResponse.body.id; + expect(createResponse.body.createdBy).toBe('user:default/user1'); + + // Verify project exists + const getResponse = await request(appWithCreate) + .get(`/projects/${projectId}`) + .set('Authorization', user1CredentialsHeader) + .send(); + expect(getResponse.status).toBe(200); + + // Try to delete as user2 (non-admin) - should fail because they didn't create it + // const user2Credentials = mockCredentials.user('user:default/user2'); + const user2CredentialsHeader = + mockCredentials.user.header('user:default/user2'); + const appNonAdmin = await createApp( + client, + AuthorizeResult.ALLOW, // Can create projects + AuthorizeResult.DENY, // Cannot delete others' projects (no admin write) + ); + const deleteResponseNonAdmin = await request(appNonAdmin) + .delete(`/projects/${projectId}`) + .set('Authorization', user2CredentialsHeader) + .send(); + + // Non-admin user2 cannot delete project created by user1 + // The deleteProject filters by created_by, so deletedCount will be 0 + expect(deleteResponseNonAdmin.status).toBe(404); + expect(deleteResponseNonAdmin.body).toMatchObject({ + error: { name: 'NotFoundError', message: 'Project not found' }, + }); + + // Verify project still exists + const getAfterFailedDelete = await request(appWithCreate) + .get(`/projects/${projectId}`) + .set('Authorization', user1CredentialsHeader) + .send(); + expect(getAfterFailedDelete.status).toBe(200); + expect(getAfterFailedDelete.body.id).toBe(projectId); + + // Now delete as admin user (should succeed even though created by user1) + // const adminCredentials = mockCredentials.user('user:default/admin'); + const adminCredentialsHeader = + mockCredentials.user.header('user:default/admin'); + const appAdmin = await createApp( + client, + AuthorizeResult.ALLOW, + AuthorizeResult.ALLOW, + ); + const deleteResponseAdmin = await request(appAdmin) + .delete(`/projects/${projectId}`) + .set('Authorization', adminCredentialsHeader) + .send(); + + expect(deleteResponseAdmin.status).toBe(200); + expect(deleteResponseAdmin.body.deletedCount).toBe(1); + + // Verify project is deleted + const getAfterDelete = await request(appWithCreate) + .get(`/projects/${projectId}`) + .set('Authorization', user1CredentialsHeader) + .send(); + expect(getAfterDelete.status).toBe(404); + }, + ); + + it.each(databases.eachSupportedId())( + 'should allow users without admin write permission to delete their own project - %p', + async databaseId => { + const { client } = await createDatabase(databaseId); + // User does not have admin write permission, but can create projects + const app = await createApp( + client, + AuthorizeResult.ALLOW, // Can create (has x2aUserPermission) + AuthorizeResult.DENY, // Cannot delete others' projects (no admin write) + ); + + // Create a project (as the default mock user) + const createResponse = await request(app) + .post('/projects') + .send(mockInputProject); + + expect(createResponse.status).toBe(200); + const projectId = createResponse.body.id; + expect(createResponse.body.createdBy).toBe('user:default/mock'); + + // Delete own project (should succeed) + const deleteResponse = await request(app) + .delete(`/projects/${projectId}`) + .send(); + + expect(deleteResponse.status).toBe(200); + expect(deleteResponse.body.deletedCount).toBe(1); + + // Verify project is deleted + const getAfterDeleteResponse = await request(app) + .get(`/projects/${projectId}`) + .send(); + expect(getAfterDeleteResponse.status).toBe(404); + }, + ); + + it.each(databases.eachSupportedId())( + 'should return 404 when deletion fails due to permission filtering - %p', + async databaseId => { + const { client } = await createDatabase(databaseId); + // User does not have admin write permission + const app = await createApp( + client, + AuthorizeResult.ALLOW, // Can create projects + AuthorizeResult.DENY, // Cannot delete others' projects (no admin write) + ); + + // Create a project + const createResponse = await request(app) + .post('/projects') + .send(mockInputProject); + + expect(createResponse.status).toBe(200); + const projectId = createResponse.body.id; + + // Note: The permission filtering happens at the database service level. + // When canWriteAll is false, deleteProject filters by created_by. + // Since the same user created and is deleting, it should succeed. + // Cross-user deletion prevention is tested in X2ADatabaseService tests. + // This test verifies the endpoint integration works correctly. + + const deleteResponse = await request(app) + .delete(`/projects/${projectId}`) + .send(); + + // Same user can delete their own project + expect(deleteResponse.status).toBe(200); + expect(deleteResponse.body.deletedCount).toBe(1); + }, + ); + + it.each(databases.eachSupportedId())( + 'should return 404 when deleting non-existent project even with admin write permission - %p', + async databaseId => { + const { client } = await createDatabase(databaseId); + // User has admin write permission + const app = await createApp( + client, + AuthorizeResult.ALLOW, + AuthorizeResult.ALLOW, + ); + + const nonExistentId = '00000000-0000-0000-0000-000000000000'; + const response = await request(app) + .delete(`/projects/${nonExistentId}`) + .send(); + + expect(response.status).toBe(404); + expect(response.body).toMatchObject({ + error: { name: 'NotFoundError', message: 'Project not found' }, + }); + }, + ); }); diff --git a/workspaces/x2a/plugins/x2a-backend/src/router.ts b/workspaces/x2a/plugins/x2a-backend/src/router.ts index 303323b3a3..05548dc894 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/router.ts +++ b/workspaces/x2a/plugins/x2a-backend/src/router.ts @@ -14,10 +14,24 @@ * limitations under the License. */ -import { HttpAuthService, LoggerService } from '@backstage/backend-plugin-api'; -import { InputError, NotFoundError } from '@backstage/errors'; import { z } from 'zod'; -import express from 'express'; +import express, { Request } from 'express'; +import { + HttpAuthService, + LoggerService, + PermissionsService, +} from '@backstage/backend-plugin-api'; +import { InputError, NotAllowedError, NotFoundError } from '@backstage/errors'; +import { + AuthorizePermissionResponse, + AuthorizeResult, + BasicPermission, +} from '@backstage/plugin-permission-common'; +import { + x2aAdminViewPermission, + x2aAdminWritePermission, + x2aUserPermission, +} from '@red-hat-developer-hub/backstage-plugin-x2a-common'; import { x2aDatabaseServiceRef } from './services/X2ADatabaseService'; import { @@ -27,23 +41,81 @@ import { } from './schema/openapi'; import { kubeServiceRef } from './services/KubeService'; +const isUserOfAdminViewPermission = async ( + request: Request, + permissionsSvc: PermissionsService, + httpAuth: HttpAuthService, +): Promise => { + const credentials = await httpAuth.credentials(request); + const result = await permissionsSvc.authorize( + [{ permission: x2aAdminViewPermission }], + { credentials }, + ); + return result?.[0]?.result === AuthorizeResult.ALLOW; +}; + +const isUserOfAdminWritePermission = async ( + request: Request, + permissionsSvc: PermissionsService, + httpAuth: HttpAuthService, +): Promise => { + const credentials = await httpAuth.credentials(request); + const result = await permissionsSvc.authorize( + [{ permission: x2aAdminWritePermission }], + { credentials }, + ); + return result?.[0]?.result === AuthorizeResult.ALLOW; +}; + +const authorize = async ( + request: Request, + anyOfPermissions: BasicPermission[], + permissionsSvc: PermissionsService, + httpAuth: HttpAuthService, +): Promise => { + const credentials = await httpAuth.credentials(request); + const decisionResponses: AuthorizePermissionResponse[][] = await Promise.all( + anyOfPermissions.map(permission => + permissionsSvc.authorize([{ permission }], { + credentials, + }), + ), + ); + + const decisions: AuthorizePermissionResponse[] = decisionResponses.map( + d => d?.[0] ?? { result: AuthorizeResult.DENY }, + ); + const allow = decisions.find(d => d.result === AuthorizeResult.ALLOW); + return ( + allow || { + result: AuthorizeResult.DENY, + } + ); +}; + export async function createRouter({ httpAuth, x2aDatabase, logger, + permissionsSvc, }: { httpAuth: HttpAuthService; x2aDatabase: typeof x2aDatabaseServiceRef.T; kubeService: typeof kubeServiceRef.T; logger: LoggerService; + permissionsSvc: PermissionsService; }): Promise { const router = await createOpenApiRouter(); router.get('/projects', async (req, res) => { const endpoint = 'GET /projects'; + logger.info(`${endpoint} request received`); + + // parse request query const projectsGetRequestSchema = z.object({ page: z.number().optional(), pageSize: z.number().optional(), + order: z.enum(['asc', 'desc']).optional(), sort: z .enum(['createdAt', 'name', 'description', 'createdBy']) .optional(), @@ -61,7 +133,15 @@ export async function createRouter({ logger.info(`${endpoint} request received: query=${JSON.stringify(query)}`); - const { projects, totalCount } = await x2aDatabase.listProjects(); + // list projects + const { projects, totalCount } = await x2aDatabase.listProjects(query, { + credentials: await httpAuth.credentials(req, { allow: ['user'] }), + canViewAll: await isUserOfAdminViewPermission( + req as unknown as Request, + permissionsSvc, + httpAuth, + ), + }); const response: ProjectsGet['response'] = { totalCount, @@ -72,6 +152,20 @@ export async function createRouter({ router.post('/projects', async (req, res) => { const endpoint = 'POST /projects'; + logger.info(`${endpoint} request received`); + + // authorize request + const decision = await authorize( + req, + [x2aAdminWritePermission, x2aUserPermission], + permissionsSvc, + httpAuth, + ); + if (decision.result === AuthorizeResult.DENY) { + throw new NotAllowedError('You are not allowed to create a project'); + } + + // parse request body const projectCreateRequestSchema = z.object({ name: z.string(), description: z.string(), @@ -86,6 +180,7 @@ export async function createRouter({ } const requestBody: ProjectsPost['body'] = parsedBody.data; + // create project const newProject = await x2aDatabase.createProject(requestBody, { credentials: await httpAuth.credentials(req, { allow: ['user'] }), }); @@ -99,7 +194,17 @@ export async function createRouter({ const projectId = req.params.projectId; logger.info(`${endpoint} request received: projectId=${projectId}`); - const project = await x2aDatabase.getProject({ projectId }); + const project = await x2aDatabase.getProject( + { projectId }, + { + credentials: await httpAuth.credentials(req, { allow: ['user'] }), + canViewAll: await isUserOfAdminViewPermission( + req as unknown as Request, + permissionsSvc, + httpAuth, + ), + }, + ); if (!project) { throw new NotFoundError(`Project not found`); } @@ -110,7 +215,17 @@ export async function createRouter({ const endpoint = 'DELETE /projects/:projectId'; const projectId = req.params.projectId; logger.info(`${endpoint} request received: projectId=${projectId}`); - const deletedCount = await x2aDatabase.deleteProject({ projectId }); + const deletedCount = await x2aDatabase.deleteProject( + { projectId }, + { + credentials: await httpAuth.credentials(req, { allow: ['user'] }), + canWriteAll: await isUserOfAdminWritePermission( + req as unknown as Request, + permissionsSvc, + httpAuth, + ), + }, + ); if (deletedCount === 0) { throw new NotFoundError(`Project not found`); } diff --git a/workspaces/x2a/plugins/x2a-backend/src/schema/openapi.yaml b/workspaces/x2a/plugins/x2a-backend/src/schema/openapi.yaml index 343070a601..c2390eb4ee 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/schema/openapi.yaml +++ b/workspaces/x2a/plugins/x2a-backend/src/schema/openapi.yaml @@ -24,6 +24,15 @@ paths: type: integer required: false description: Page size + - in: query + name: order + schema: + type: string + enum: + - asc + - desc + required: false + description: Sort order, either ascending ("asc") or descending ("desc") - in: query name: sort schema: diff --git a/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/apis/Api.server.ts b/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/apis/Api.server.ts index 3a04fc68e8..21904b05c2 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/apis/Api.server.ts +++ b/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/apis/Api.server.ts @@ -31,6 +31,7 @@ export type ProjectsGet = { query: { page?: number; pageSize?: number; + order?: 'asc' | 'desc'; sort?: 'createdAt' | 'name' | 'description' | 'createdBy'; }; response: ProjectsGet200Response; diff --git a/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/router.ts b/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/router.ts index 2aa3634ebf..4d70768769 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/router.ts +++ b/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/router.ts @@ -56,6 +56,19 @@ export const spec = { "required": false, "description": "Page size" }, + { + "in": "query", + "name": "order", + "schema": { + "type": "string", + "enum": [ + "asc", + "desc" + ] + }, + "required": false, + "description": "Sort order, either ascending (\"asc\") or descending (\"desc\")" + }, { "in": "query", "name": "sort", diff --git a/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService.test.ts b/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService.test.ts index e0ebcbe4dd..1bd52282b9 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService.test.ts +++ b/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService.test.ts @@ -23,7 +23,7 @@ import { import { Knex } from 'knex'; import { X2ADatabaseService } from './X2ADatabaseService'; import { migrate } from './dbMigrate'; -import { toSorted } from '../utils'; +import { delay, toSorted } from '../utils'; const databases = TestDatabases.create({ // TODO: Reenable for 'POSTGRES_18' @@ -150,7 +150,8 @@ describe('X2ADatabaseService', () => { const { client } = await createDatabase(databaseId); const service = createService(client); - const result = await service.listProjects(); + const credentials = mockCredentials.user(); + const result = await service.listProjects({}, { credentials }); expect(result.projects).toEqual([]); expect(result.totalCount).toBe(0); @@ -172,6 +173,7 @@ describe('X2ADatabaseService', () => { }, { credentials }, ); + await delay(10); // To make sure the projects are created in the correct order await service.createProject( { name: 'Project 2', @@ -180,6 +182,7 @@ describe('X2ADatabaseService', () => { }, { credentials }, ); + await delay(10); await service.createProject( { name: 'Project 3', @@ -189,7 +192,10 @@ describe('X2ADatabaseService', () => { { credentials }, ); - const result = await service.listProjects(); + const result = await service.listProjects( + { order: 'desc', sort: 'createdAt' }, + { credentials }, + ); expect(result.totalCount).toBe(3); expect(result.projects).toHaveLength(3); @@ -198,6 +204,428 @@ describe('X2ADatabaseService', () => { expect(result.projects[2].name).toBe('Project 1'); }, ); + + it.each(databases.eachSupportedId())( + 'should paginate results with page and pageSize - %p', + async databaseId => { + const { client } = await createDatabase(databaseId); + const service = createService(client); + + const credentials = mockCredentials.user(); + // Create 5 projects + for (let i = 1; i <= 5; i++) { + await service.createProject( + { + name: `Project ${i}`, + abbreviation: `P${i}`, + description: `Project ${i} description`, + }, + { credentials }, + ); + await delay(10); + } + + // First page with pageSize 2 + const page1 = await service.listProjects( + { page: 0, pageSize: 2, sort: 'createdAt', order: 'desc' }, + { credentials }, + ); + expect(page1.totalCount).toBe(5); + expect(page1.projects).toHaveLength(2); + expect(page1.projects[0].name).toBe('Project 5'); + expect(page1.projects[1].name).toBe('Project 4'); + + // Second page + const page2 = await service.listProjects( + { page: 1, pageSize: 2, sort: 'createdAt', order: 'desc' }, + { credentials }, + ); + expect(page2.totalCount).toBe(5); + expect(page2.projects).toHaveLength(2); + expect(page2.projects[0].name).toBe('Project 3'); + expect(page2.projects[1].name).toBe('Project 2'); + + // Third page (last page with 1 item) + const page3 = await service.listProjects( + { page: 2, pageSize: 2, sort: 'createdAt', order: 'desc' }, + { credentials }, + ); + expect(page3.totalCount).toBe(5); + expect(page3.projects).toHaveLength(1); + expect(page3.projects[0].name).toBe('Project 1'); + }, + ); + + it.each(databases.eachSupportedId())( + 'should use default pageSize when not specified - %p', + async databaseId => { + const { client } = await createDatabase(databaseId); + const service = createService(client); + + const credentials = mockCredentials.user(); + // Create more than DEFAULT_PAGE_SIZE (10) projects + for (let i = 1; i <= 15; i++) { + await service.createProject( + { + name: `Project ${i}`, + abbreviation: `P${i}`, + description: `Project ${i} description`, + }, + { credentials }, + ); + if (i < 15) await delay(10); + } + + const result = await service.listProjects( + { sort: 'createdAt', order: 'desc' }, + { credentials }, + ); + + expect(result.totalCount).toBe(15); + expect(result.projects).toHaveLength(10); // Default page size + }, + ); + + it.each(databases.eachSupportedId())( + 'should sort by name ascending - %p', + async databaseId => { + const { client } = await createDatabase(databaseId); + const service = createService(client); + + const credentials = mockCredentials.user(); + await service.createProject( + { name: 'Zebra Project', abbreviation: 'ZP', description: 'Z' }, + { credentials }, + ); + await delay(10); + await service.createProject( + { name: 'Alpha Project', abbreviation: 'AP', description: 'A' }, + { credentials }, + ); + await delay(10); + await service.createProject( + { name: 'Beta Project', abbreviation: 'BP', description: 'B' }, + { credentials }, + ); + + const result = await service.listProjects( + { sort: 'name', order: 'asc' }, + { credentials }, + ); + + expect(result.projects).toHaveLength(3); + expect(result.projects[0].name).toBe('Alpha Project'); + expect(result.projects[1].name).toBe('Beta Project'); + expect(result.projects[2].name).toBe('Zebra Project'); + }, + ); + + it.each(databases.eachSupportedId())( + 'should sort by name descending - %p', + async databaseId => { + const { client } = await createDatabase(databaseId); + const service = createService(client); + + const credentials = mockCredentials.user(); + await service.createProject( + { name: 'Alpha Project', abbreviation: 'AP', description: 'A' }, + { credentials }, + ); + await delay(10); + await service.createProject( + { name: 'Zebra Project', abbreviation: 'ZP', description: 'Z' }, + { credentials }, + ); + await delay(10); + await service.createProject( + { name: 'Beta Project', abbreviation: 'BP', description: 'B' }, + { credentials }, + ); + + const result = await service.listProjects( + { sort: 'name', order: 'desc' }, + { credentials }, + ); + + expect(result.projects).toHaveLength(3); + expect(result.projects[0].name).toBe('Zebra Project'); + expect(result.projects[1].name).toBe('Beta Project'); + expect(result.projects[2].name).toBe('Alpha Project'); + }, + ); + + it.each(databases.eachSupportedId())( + 'should sort by createdBy - %p', + async databaseId => { + const { client } = await createDatabase(databaseId); + const service = createService(client); + + const credentials1 = mockCredentials.user('user:default/user1'); + const credentials2 = mockCredentials.user('user:default/user2'); + const credentials3 = mockCredentials.user('user:default/user3'); + + await service.createProject( + { name: 'Project 1', abbreviation: 'P1', description: 'D1' }, + { credentials: credentials3 }, + ); + await delay(10); + await service.createProject( + { name: 'Project 2', abbreviation: 'P2', description: 'D2' }, + { credentials: credentials1 }, + ); + await delay(10); + await service.createProject( + { name: 'Project 3', abbreviation: 'P3', description: 'D3' }, + { credentials: credentials2 }, + ); + + const result = await service.listProjects( + { sort: 'createdBy', order: 'asc' }, + { credentials: credentials1, canViewAll: true }, + ); + + expect(result.projects).toHaveLength(3); + // Should be sorted by created_by (userEntityRef) ascending + expect(result.projects[0].createdBy).toBe('user:default/user1'); + expect(result.projects[1].createdBy).toBe('user:default/user2'); + expect(result.projects[2].createdBy).toBe('user:default/user3'); + }, + ); + + it.each(databases.eachSupportedId())( + 'should filter by user when canViewAll is false - %p', + async databaseId => { + const { client } = await createDatabase(databaseId); + const service = createService(client); + + const credentials1 = mockCredentials.user('user:default/user1'); + const credentials2 = mockCredentials.user('user:default/user2'); + + // User1 creates 2 projects + await service.createProject( + { name: 'User1 Project 1', abbreviation: 'U1P1', description: 'D1' }, + { credentials: credentials1 }, + ); + await delay(10); + await service.createProject( + { name: 'User1 Project 2', abbreviation: 'U1P2', description: 'D2' }, + { credentials: credentials1 }, + ); + await delay(10); + + // User2 creates 2 projects + await service.createProject( + { name: 'User2 Project 1', abbreviation: 'U2P1', description: 'D3' }, + { credentials: credentials2 }, + ); + await delay(10); + await service.createProject( + { name: 'User2 Project 2', abbreviation: 'U2P2', description: 'D4' }, + { credentials: credentials2 }, + ); + + // User1 should only see their own projects + const user1Result = await service.listProjects( + { sort: 'createdAt', order: 'desc' }, + { credentials: credentials1, canViewAll: false }, + ); + expect(user1Result.totalCount).toBe(2); + expect(user1Result.projects).toHaveLength(2); + expect( + user1Result.projects.every(p => p.createdBy === 'user:default/user1'), + ).toBe(true); + + // User2 should only see their own projects + const user2Result = await service.listProjects( + { sort: 'createdAt', order: 'desc' }, + { credentials: credentials2, canViewAll: false }, + ); + expect(user2Result.totalCount).toBe(2); + expect(user2Result.projects).toHaveLength(2); + expect( + user2Result.projects.every(p => p.createdBy === 'user:default/user2'), + ).toBe(true); + }, + ); + + it.each(databases.eachSupportedId())( + 'should return all projects when canViewAll is true - %p', + async databaseId => { + const { client } = await createDatabase(databaseId); + const service = createService(client); + + const credentials1 = mockCredentials.user('user:default/user1'); + const credentials2 = mockCredentials.user('user:default/user2'); + const credentials3 = mockCredentials.user('user:default/user3'); + + await service.createProject( + { name: 'Project 1', abbreviation: 'P1', description: 'D1' }, + { credentials: credentials1 }, + ); + await delay(10); + await service.createProject( + { name: 'Project 2', abbreviation: 'P2', description: 'D2' }, + { credentials: credentials2 }, + ); + await delay(10); + await service.createProject( + { name: 'Project 3', abbreviation: 'P3', description: 'D3' }, + { credentials: credentials3 }, + ); + + // User1 with canViewAll should see all projects + const result = await service.listProjects( + { sort: 'createdAt', order: 'desc' }, + { credentials: credentials1, canViewAll: true }, + ); + + expect(result.totalCount).toBe(3); + expect(result.projects).toHaveLength(3); + expect( + result.projects.some(p => p.createdBy === 'user:default/user1'), + ).toBe(true); + expect( + result.projects.some(p => p.createdBy === 'user:default/user2'), + ).toBe(true); + expect( + result.projects.some(p => p.createdBy === 'user:default/user3'), + ).toBe(true); + }, + ); + + it.each(databases.eachSupportedId())( + 'should filter by user when canViewAll is undefined - %p', + async databaseId => { + const { client } = await createDatabase(databaseId); + const service = createService(client); + + const credentials1 = mockCredentials.user('user:default/user1'); + const credentials2 = mockCredentials.user('user:default/user2'); + + await service.createProject( + { name: 'User1 Project', abbreviation: 'U1P', description: 'D1' }, + { credentials: credentials1 }, + ); + await delay(10); + await service.createProject( + { name: 'User2 Project', abbreviation: 'U2P', description: 'D2' }, + { credentials: credentials2 }, + ); + + // When canViewAll is undefined, should default to filtering + const result = await service.listProjects( + { sort: 'createdAt', order: 'desc' }, + { credentials: credentials1 }, + ); + + expect(result.totalCount).toBe(1); + expect(result.projects).toHaveLength(1); + expect(result.projects[0].createdBy).toBe('user:default/user1'); + }, + ); + + it.each(databases.eachSupportedId())( + 'should combine pagination and user filtering - %p', + async databaseId => { + const { client } = await createDatabase(databaseId); + const service = createService(client); + + const credentials1 = mockCredentials.user('user:default/user1'); + const credentials2 = mockCredentials.user('user:default/user2'); + + // User1 creates 5 projects + for (let i = 1; i <= 5; i++) { + await service.createProject( + { + name: `User1 Project ${i}`, + abbreviation: `U1P${i}`, + description: `Description ${i}`, + }, + { credentials: credentials1 }, + ); + await delay(10); + } + + // User2 creates 3 projects + for (let i = 1; i <= 3; i++) { + await service.createProject( + { + name: `User2 Project ${i}`, + abbreviation: `U2P${i}`, + description: `Description ${i}`, + }, + { credentials: credentials2 }, + ); + if (i < 3) await delay(10); + } + + // User1 should only see their 5 projects, paginated + const page1 = await service.listProjects( + { page: 1, pageSize: 2, sort: 'createdAt', order: 'desc' }, + { credentials: credentials1, canViewAll: false }, + ); + expect(page1.totalCount).toBe(5); // Total count should be 5, not 8 + expect(page1.projects).toHaveLength(2); + expect( + page1.projects.every(p => p.createdBy === 'user:default/user1'), + ).toBe(true); + }, + ); + + it.each(databases.eachSupportedId())( + 'should use default sort and order when not specified - %p', + async databaseId => { + const { client } = await createDatabase(databaseId); + const service = createService(client); + + const credentials = mockCredentials.user(); + await service.createProject( + { name: 'Project 1', abbreviation: 'P1', description: 'D1' }, + { credentials }, + ); + await delay(10); + await service.createProject( + { name: 'Project 2', abbreviation: 'P2', description: 'D2' }, + { credentials }, + ); + await delay(10); + await service.createProject( + { name: 'Project 3', abbreviation: 'P3', description: 'D3' }, + { credentials }, + ); + + const result = await service.listProjects({}, { credentials }); + + expect(result.projects).toHaveLength(3); + // Should default to created_at desc (newest first) + expect(result.projects[0].name).toBe('Project 3'); + expect(result.projects[1].name).toBe('Project 2'); + expect(result.projects[2].name).toBe('Project 1'); + }, + ); + + it.each(databases.eachSupportedId())( + 'should handle empty page gracefully - %p', + async databaseId => { + const { client } = await createDatabase(databaseId); + const service = createService(client); + + const credentials = mockCredentials.user(); + await service.createProject( + { name: 'Project 1', abbreviation: 'P1', description: 'D1' }, + { credentials }, + ); + + // Request page 2 which doesn't exist + const result = await service.listProjects( + { page: 2, pageSize: 10 }, + { credentials }, + ); + + expect(result.totalCount).toBe(1); + expect(result.projects).toHaveLength(0); + }, + ); }); describe('getProject', () => { @@ -207,9 +635,13 @@ describe('X2ADatabaseService', () => { const { client } = await createDatabase(databaseId); const service = createService(client); - const project = await service.getProject({ - projectId: 'non-existent-id', - }); + const credentials = mockCredentials.user(); + const project = await service.getProject( + { + projectId: 'non-existent-id', + }, + { credentials }, + ); expect(project).toBeUndefined(); }, @@ -231,9 +663,12 @@ describe('X2ADatabaseService', () => { { credentials }, ); - const retrievedProject = await service.getProject({ - projectId: createdProject.id, - }); + const retrievedProject = await service.getProject( + { + projectId: createdProject.id, + }, + { credentials }, + ); expect(retrievedProject).toBeDefined(); expect(retrievedProject?.id).toBe(createdProject.id); @@ -271,8 +706,14 @@ describe('X2ADatabaseService', () => { { credentials }, ); - const retrieved1 = await service.getProject({ projectId: project1.id }); - const retrieved2 = await service.getProject({ projectId: project2.id }); + const retrieved1 = await service.getProject( + { projectId: project1.id }, + { credentials }, + ); + const retrieved2 = await service.getProject( + { projectId: project2.id }, + { credentials }, + ); expect(retrieved1?.id).toBe(project1.id); expect(retrieved1?.name).toBe('Project 1'); @@ -280,6 +721,132 @@ describe('X2ADatabaseService', () => { expect(retrieved2?.name).toBe('Project 2'); }, ); + + it.each(databases.eachSupportedId())( + 'should return undefined when user tries to access project created by another user - %p', + async databaseId => { + const { client } = await createDatabase(databaseId); + const service = createService(client); + + const credentials1 = mockCredentials.user('user:default/user1'); + const credentials2 = mockCredentials.user('user:default/user2'); + + // User1 creates a project + const project = await service.createProject( + { + name: 'User1 Project', + abbreviation: 'U1P', + description: 'Project created by user1', + }, + { credentials: credentials1 }, + ); + + // User2 tries to access user1's project (should be denied) + const retrievedProject = await service.getProject( + { + projectId: project.id, + }, + { credentials: credentials2, canViewAll: false }, + ); + + expect(retrievedProject).toBeUndefined(); + }, + ); + + it.each(databases.eachSupportedId())( + 'should return project when user accesses their own project - %p', + async databaseId => { + const { client } = await createDatabase(databaseId); + const service = createService(client); + + const credentials = mockCredentials.user('user:default/user1'); + + const project = await service.createProject( + { + name: 'My Project', + abbreviation: 'MP', + description: 'My own project', + }, + { credentials }, + ); + + // User should be able to access their own project + const retrievedProject = await service.getProject( + { + projectId: project.id, + }, + { credentials, canViewAll: false }, + ); + + expect(retrievedProject).toBeDefined(); + expect(retrievedProject?.id).toBe(project.id); + expect(retrievedProject?.createdBy).toBe('user:default/user1'); + }, + ); + + it.each(databases.eachSupportedId())( + 'should return project when canViewAll is true even if created by different user - %p', + async databaseId => { + const { client } = await createDatabase(databaseId); + const service = createService(client); + + const credentials1 = mockCredentials.user('user:default/user1'); + const credentials2 = mockCredentials.user('user:default/user2'); + + // User1 creates a project + const project = await service.createProject( + { + name: 'User1 Project', + abbreviation: 'U1P', + description: 'Project created by user1', + }, + { credentials: credentials1 }, + ); + + // User2 with canViewAll should be able to access user1's project + const retrievedProject = await service.getProject( + { + projectId: project.id, + }, + { credentials: credentials2, canViewAll: true }, + ); + + expect(retrievedProject).toBeDefined(); + expect(retrievedProject?.id).toBe(project.id); + expect(retrievedProject?.createdBy).toBe('user:default/user1'); + }, + ); + + it.each(databases.eachSupportedId())( + 'should filter by user when canViewAll is undefined - %p', + async databaseId => { + const { client } = await createDatabase(databaseId); + const service = createService(client); + + const credentials1 = mockCredentials.user('user:default/user1'); + const credentials2 = mockCredentials.user('user:default/user2'); + + // User1 creates a project + const project = await service.createProject( + { + name: 'User1 Project', + abbreviation: 'U1P', + description: 'Project created by user1', + }, + { credentials: credentials1 }, + ); + + // When canViewAll is undefined, should default to filtering + const retrievedProject = await service.getProject( + { + projectId: project.id, + }, + { credentials: credentials2 }, + ); + + expect(retrievedProject).toBeUndefined(); + }, + ); }); describe('deleteProject', () => { @@ -289,9 +856,13 @@ describe('X2ADatabaseService', () => { const { client } = await createDatabase(databaseId); const service = createService(client); - const deletedCount = await service.deleteProject({ - projectId: 'non-existent-id', - }); + const credentials = mockCredentials.user(); + const deletedCount = await service.deleteProject( + { + projectId: 'non-existent-id', + }, + { credentials }, + ); expect(deletedCount).toBe(0); }, @@ -314,20 +885,29 @@ describe('X2ADatabaseService', () => { ); // Verify it exists - const beforeDelete = await service.getProject({ - projectId: project.id, - }); + const beforeDelete = await service.getProject( + { + projectId: project.id, + }, + { credentials }, + ); expect(beforeDelete).toBeDefined(); // Delete it - const deletedCount = await service.deleteProject({ - projectId: project.id, - }); + const deletedCount = await service.deleteProject( + { + projectId: project.id, + }, + { credentials }, + ); expect(deletedCount).toBe(1); // Verify it's gone - const afterDelete = await service.getProject({ projectId: project.id }); + const afterDelete = await service.getProject( + { projectId: project.id }, + { credentials }, + ); expect(afterDelete).toBeUndefined(); }, ); @@ -356,27 +936,196 @@ describe('X2ADatabaseService', () => { { credentials }, ); - const deletedCount = await service.deleteProject({ - projectId: project1.id, - }); + const deletedCount = await service.deleteProject( + { + projectId: project1.id, + }, + { credentials }, + ); expect(deletedCount).toBe(1); // Verify project1 is deleted - const deleted = await service.getProject({ projectId: project1.id }); + const deleted = await service.getProject( + { projectId: project1.id }, + { credentials }, + ); expect(deleted).toBeUndefined(); // Verify project2 still exists - const remaining = await service.getProject({ projectId: project2.id }); + const remaining = await service.getProject( + { projectId: project2.id }, + { credentials }, + ); expect(remaining).toBeDefined(); expect(remaining?.name).toBe('Project 2'); // Verify total count - const listResult = await service.listProjects(); + const listResult = await service.listProjects({}, { credentials }); expect(listResult.totalCount).toBe(1); expect(listResult.projects[0].id).toBe(project2.id); }, ); + + it.each(databases.eachSupportedId())( + 'should return 0 when user tries to delete project created by another user - %p', + async databaseId => { + const { client } = await createDatabase(databaseId); + const service = createService(client); + + const credentials1 = mockCredentials.user('user:default/user1'); + const credentials2 = mockCredentials.user('user:default/user2'); + + // User1 creates a project + const project = await service.createProject( + { + name: 'User1 Project', + abbreviation: 'U1P', + description: 'Project created by user1', + }, + { credentials: credentials1 }, + ); + + // User2 tries to delete user1's project (should fail - returns 0) + const deletedCount = await service.deleteProject( + { + projectId: project.id, + }, + { credentials: credentials2, canWriteAll: false }, + ); + + expect(deletedCount).toBe(0); + + // Verify project still exists + const projectAfter = await service.getProject( + { + projectId: project.id, + }, + { credentials: credentials1 }, + ); + expect(projectAfter).toBeDefined(); + expect(projectAfter?.id).toBe(project.id); + }, + ); + + it.each(databases.eachSupportedId())( + 'should delete project when user deletes their own project - %p', + async databaseId => { + const { client } = await createDatabase(databaseId); + const service = createService(client); + + const credentials = mockCredentials.user('user:default/user1'); + + const project = await service.createProject( + { + name: 'My Project', + abbreviation: 'MP', + description: 'My own project', + }, + { credentials }, + ); + + // User should be able to delete their own project + const deletedCount = await service.deleteProject( + { + projectId: project.id, + }, + { credentials, canWriteAll: false }, + ); + + expect(deletedCount).toBe(1); + + // Verify project is deleted + const projectAfter = await service.getProject( + { + projectId: project.id, + }, + { credentials }, + ); + expect(projectAfter).toBeUndefined(); + }, + ); + + it.each(databases.eachSupportedId())( + 'should delete project when canWriteAll is true even if created by different user - %p', + async databaseId => { + const { client } = await createDatabase(databaseId); + const service = createService(client); + + const credentials1 = mockCredentials.user('user:default/user1'); + const credentials2 = mockCredentials.user('user:default/user2'); + + // User1 creates a project + const project = await service.createProject( + { + name: 'User1 Project', + abbreviation: 'U1P', + description: 'Project created by user1', + }, + { credentials: credentials1 }, + ); + + // User2 with canWriteAll should be able to delete user1's project + const deletedCount = await service.deleteProject( + { + projectId: project.id, + }, + { credentials: credentials2, canWriteAll: true }, + ); + + expect(deletedCount).toBe(1); + + // Verify project is deleted + const projectAfter = await service.getProject( + { + projectId: project.id, + }, + { credentials: credentials1 }, + ); + expect(projectAfter).toBeUndefined(); + }, + ); + + it.each(databases.eachSupportedId())( + 'should filter by user when canWriteAll is undefined - %p', + async databaseId => { + const { client } = await createDatabase(databaseId); + const service = createService(client); + + const credentials1 = mockCredentials.user('user:default/user1'); + const credentials2 = mockCredentials.user('user:default/user2'); + + // User1 creates a project + const project = await service.createProject( + { + name: 'User1 Project', + abbreviation: 'U1P', + description: 'Project created by user1', + }, + { credentials: credentials1 }, + ); + + // When canWriteAll is undefined, should default to filtering + const deletedCount = await service.deleteProject( + { + projectId: project.id, + }, + { credentials: credentials2 }, + ); + + expect(deletedCount).toBe(0); + + // Verify project still exists + const projectAfter = await service.getProject( + { + projectId: project.id, + }, + { credentials: credentials1 }, + ); + expect(projectAfter).toBeDefined(); + expect(projectAfter?.id).toBe(project.id); + }, + ); }); describe('integration tests', () => { @@ -399,26 +1148,35 @@ describe('X2ADatabaseService', () => { ); // Read - const retrieved = await service.getProject({ projectId: project.id }); + const retrieved = await service.getProject( + { projectId: project.id }, + { credentials }, + ); expect(retrieved).toBeDefined(); expect(retrieved?.name).toBe('Lifecycle Test'); // List - const listResult = await service.listProjects(); + const listResult = await service.listProjects({}, { credentials }); expect(listResult.totalCount).toBe(1); expect(listResult.projects[0].id).toBe(project.id); // Delete - const deletedCount = await service.deleteProject({ - projectId: project.id, - }); + const deletedCount = await service.deleteProject( + { + projectId: project.id, + }, + { credentials }, + ); expect(deletedCount).toBe(1); // Verify deletion - const afterDelete = await service.getProject({ projectId: project.id }); + const afterDelete = await service.getProject( + { projectId: project.id }, + { credentials }, + ); expect(afterDelete).toBeUndefined(); - const finalList = await service.listProjects(); + const finalList = await service.listProjects({}, { credentials }); expect(finalList.totalCount).toBe(0); }, ); @@ -894,15 +1652,21 @@ describe('X2ADatabaseService', () => { expect(modulesBefore).toHaveLength(3); // Delete the project - const deletedCount = await service.deleteProject({ - projectId: project.id, - }); + const deletedCount = await service.deleteProject( + { + projectId: project.id, + }, + { credentials }, + ); expect(deletedCount).toBe(1); // Verify project is deleted - const projectAfter = await service.getProject({ - projectId: project.id, - }); + const projectAfter = await service.getProject( + { + projectId: project.id, + }, + { credentials }, + ); expect(projectAfter).toBeUndefined(); // Verify all modules are cascade deleted @@ -965,7 +1729,10 @@ describe('X2ADatabaseService', () => { }); // Delete project1 - await service.deleteProject({ projectId: project1.id }); + await service.deleteProject( + { projectId: project1.id }, + { credentials }, + ); // Verify project1 modules are cascade deleted expect( diff --git a/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService.ts b/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService.ts index 27597c67e5..094c13e7f2 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService.ts +++ b/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService.ts @@ -26,6 +26,12 @@ import { import { Expand } from '@backstage/types'; import { Project } from '@red-hat-developer-hub/backstage-plugin-x2a-common'; import { Knex } from 'knex'; +import { ProjectsGet } from '../schema/openapi'; +import { + DEFAULT_PAGE_ORDER, + DEFAULT_PAGE_SIZE, + DEFAULT_PAGE_SORT, +} from '../constants'; // TODO: model via openapi schema export interface Module { @@ -74,6 +80,18 @@ export class X2ADatabaseService { }; } + // Map REST sort param to database column name + private mapSortToDatabaseColumn(sort?: string): string | undefined { + const mapping = { + createdAt: 'created_at', + createdBy: 'created_by', + finishedAt: 'finished_at', + startedAt: 'started_at', + }; + + return sort ? mapping[sort as keyof typeof mapping] || sort : undefined; + } + // Map a database row to a Module object private mapRowToModule(row: any): Module { return { @@ -96,6 +114,21 @@ export class X2ADatabaseService { }; } + // Filter query by user permissions + private filterPermissions( + queryBuilder: Knex.QueryBuilder, + canDoAll: boolean | undefined, + userEntityRef: string, + ): void { + if (!canDoAll) { + // Filter by the user who created the project. + // For admins, the WHERE clause is not applied so matching all records. + // If multiple non-admin users are allowed to do the action, we need to + // either pass their full list here or do the permission check outside of the DB + queryBuilder.where('created_by', userEntityRef); + } + } + async createProject( input: { name: string; @@ -135,38 +168,106 @@ export class X2ADatabaseService { return newProject; } - async listProjects(): Promise<{ projects: Project[]; totalCount: number }> { - this.#logger.info('listProjects called'); + async listProjects( + query: ProjectsGet['query'], + options: { + credentials: BackstageCredentials; + canViewAll?: boolean; + }, + ): Promise<{ projects: Project[]; totalCount: number }> { + const calledByUserRef = options.credentials.principal.userEntityRef; + this.#logger.info(`listProjects called by ${calledByUserRef}`); + const pageSize = query.pageSize || DEFAULT_PAGE_SIZE; // Fetch all records from the database const rows = await this.#dbClient('projects') + .limit(pageSize) + .offset((query.page || 0) * pageSize) .select('*') - .orderBy('created_at', 'desc'); + .modify(queryBuilder => + this.filterPermissions( + queryBuilder, + options.canViewAll, + calledByUserRef, + ), + ) + .orderBy( + this.mapSortToDatabaseColumn(query.sort) || DEFAULT_PAGE_SORT, + query.order || DEFAULT_PAGE_ORDER, + ); + + const totalCount = (await this.#dbClient('projects') + .count('*', { as: 'count' }) + .modify(queryBuilder => + this.filterPermissions( + queryBuilder, + options.canViewAll, + calledByUserRef, + ), + ) + .first()) as { count: number }; const projects: Project[] = rows.map(this.mapRowToProject); - const totalCount = projects.length; - this.#logger.debug(`Fetched ${totalCount} projects from database`); + this.#logger.debug( + `Fetched ${projects.length} out of ${totalCount.count} projects from database (permissions applied)`, + ); - return { projects, totalCount }; + return { projects, totalCount: totalCount.count }; } - async getProject({ - projectId, - }: { - projectId: string; - }): Promise { - this.#logger.info(`getProject called for projectId: ${projectId}`); - const row = await this.#dbClient('projects').where('id', projectId).first(); + async getProject( + { + projectId, + }: { + projectId: string; + }, + options: { + credentials: BackstageCredentials; + canViewAll?: boolean; + }, + ): Promise { + const calledByUserRef = options.credentials.principal.userEntityRef; + this.#logger.info( + `getProject called for projectId: ${projectId} by ${calledByUserRef}`, + ); + + const row = await this.#dbClient('projects') + .where('id', projectId) + .modify(queryBuilder => + this.filterPermissions( + queryBuilder, + options.canViewAll, + calledByUserRef, + ), + ) + .first(); + return row ? this.mapRowToProject(row) : undefined; } - async deleteProject({ projectId }: { projectId: string }): Promise { - this.#logger.info(`deleteProject called for projectId: ${projectId}`); + async deleteProject( + { projectId }: { projectId: string }, + options: { + credentials: BackstageCredentials; + canWriteAll?: boolean; + }, + ): Promise { + const calledByUserRef = options.credentials.principal.userEntityRef; + this.#logger.info( + `deleteProject called for projectId: ${projectId} by ${calledByUserRef}`, + ); // Delete from the database const deletedCount = await this.#dbClient('projects') .where('id', projectId) + .modify(queryBuilder => + this.filterPermissions( + queryBuilder, + options.canWriteAll, + calledByUserRef, + ), + ) .delete(); if (deletedCount === 0) { diff --git a/workspaces/x2a/plugins/x2a-backend/src/utils/delay.ts b/workspaces/x2a/plugins/x2a-backend/src/utils/delay.ts new file mode 100644 index 0000000000..d0b905867b --- /dev/null +++ b/workspaces/x2a/plugins/x2a-backend/src/utils/delay.ts @@ -0,0 +1,25 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Creates a promise that resolves after the specified number of milliseconds. + * + * @param ms - The number of milliseconds to wait before resolving + * @returns A promise that resolves after the specified delay + */ +export function delay(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} diff --git a/workspaces/x2a/plugins/x2a-backend/src/utils/index.ts b/workspaces/x2a/plugins/x2a-backend/src/utils/index.ts index 4a17edc5cb..5b4465bcac 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/utils/index.ts +++ b/workspaces/x2a/plugins/x2a-backend/src/utils/index.ts @@ -14,3 +14,4 @@ * limitations under the License. */ export * from './toSorted'; +export * from './delay'; diff --git a/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/apis/Api.client.ts b/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/apis/Api.client.ts index 206d2bb9ef..85a1e36b1c 100644 --- a/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/apis/Api.client.ts +++ b/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/apis/Api.client.ts @@ -51,6 +51,7 @@ export type ProjectsGet = { query: { page?: number; pageSize?: number; + order?: 'asc' | 'desc'; sort?: 'createdAt' | 'name' | 'description' | 'createdBy'; }; }; @@ -96,6 +97,7 @@ export class DefaultApiClient { * Returns a list of projects. * @param page - Page number * @param pageSize - Page size + * @param order - Sort order, either ascending (\"asc\") or descending (\"desc\") * @param sort - Sort by field */ public async projectsGet( @@ -105,7 +107,7 @@ export class DefaultApiClient { ): Promise> { const baseUrl = await this.discoveryApi.getBaseUrl(pluginId); - const uriTemplate = `/projects{?page,pageSize,sort}`; + const uriTemplate = `/projects{?page,pageSize,order,sort}`; const uri = parser.parse(uriTemplate).expand({ ...request.query, diff --git a/workspaces/x2a/plugins/x2a-common/package.json b/workspaces/x2a/plugins/x2a-common/package.json index 3ae68373e9..0ca3e0a8fb 100644 --- a/workspaces/x2a/plugins/x2a-common/package.json +++ b/workspaces/x2a/plugins/x2a-common/package.json @@ -46,5 +46,8 @@ "@mareklibra", "@elai-shalev", "@eloycoto" - ] + ], + "dependencies": { + "@backstage/plugin-permission-common": "^0.9.4" + } } diff --git a/workspaces/x2a/plugins/x2a-common/report.api.md b/workspaces/x2a/plugins/x2a-common/report.api.md index 36c0d7c16b..6ad86177c3 100644 --- a/workspaces/x2a/plugins/x2a-common/report.api.md +++ b/workspaces/x2a/plugins/x2a-common/report.api.md @@ -4,6 +4,8 @@ ```ts +import { BasicPermission } from '@backstage/plugin-permission-common'; + // @public (undocumented) export class DefaultApiClient { constructor(options: { @@ -35,6 +37,7 @@ export type ProjectsGet = { query: { page?: number; pageSize?: number; + order?: 'asc' | 'desc'; sort?: 'createdAt' | 'name' | 'description' | 'createdBy'; }; }; @@ -87,4 +90,16 @@ export type TypedResponse = Omit & { json: () => Promise; }; +// @public +export const x2aAdminViewPermission: BasicPermission; + +// @public +export const x2aAdminWritePermission: BasicPermission; + +// @public +export const x2aPermissions: BasicPermission[]; + +// @public +export const x2aUserPermission: BasicPermission; + ``` diff --git a/workspaces/x2a/plugins/x2a-common/src/index.ts b/workspaces/x2a/plugins/x2a-common/src/index.ts index 3a1b21dcc5..a1072529b3 100644 --- a/workspaces/x2a/plugins/x2a-common/src/index.ts +++ b/workspaces/x2a/plugins/x2a-common/src/index.ts @@ -20,3 +20,4 @@ * @packageDocumentation */ export * from '../client/src/schema/openapi'; +export * from './permissions'; diff --git a/workspaces/x2a/plugins/x2a-common/src/permissions.ts b/workspaces/x2a/plugins/x2a-common/src/permissions.ts new file mode 100644 index 0000000000..122d7c120d --- /dev/null +++ b/workspaces/x2a/plugins/x2a-common/src/permissions.ts @@ -0,0 +1,62 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { createPermission } from '@backstage/plugin-permission-common'; + +/** + * Can view (read-only) all x2a projects and modules. + * + * @public + */ +export const x2aAdminViewPermission = createPermission({ + name: 'x2a.admin', + attributes: { + action: 'read', + }, +}); + +/** + * Can create, update and delete x2a projects and modules. + * + * @public + */ +export const x2aAdminWritePermission = createPermission({ + name: 'x2a.admin', + attributes: { + action: 'update', + }, +}); + +/** + * Can view and manage (read-write) x2a projects created by the user. + * + * @public + */ +export const x2aUserPermission = createPermission({ + name: 'x2a.user', + attributes: {}, +}); + +/** + * All x2a permissions. + * + * @public + */ +export const x2aPermissions = [ + x2aAdminViewPermission, + x2aAdminWritePermission, + x2aUserPermission, +]; diff --git a/workspaces/x2a/yarn.lock b/workspaces/x2a/yarn.lock index a952b7aa1e..3e4bf96157 100644 --- a/workspaces/x2a/yarn.lock +++ b/workspaces/x2a/yarn.lock @@ -7831,6 +7831,7 @@ __metadata: resolution: "@red-hat-developer-hub/backstage-plugin-x2a-common@workspace:plugins/x2a-common" dependencies: "@backstage/cli": ^0.34.5 + "@backstage/plugin-permission-common": ^0.9.4 cross-fetch: ^4.1.0 uri-template: ^2.0.0 languageName: unknown From 8cafda6a41aaedcecfb6229949d941a265f77167 Mon Sep 17 00:00:00 2001 From: Marek Libra Date: Mon, 26 Jan 2026 14:02:04 +0100 Subject: [PATCH 2/7] Add chores to package.json --- workspaces/x2a/package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/workspaces/x2a/package.json b/workspaces/x2a/package.json index a3d7756f76..3f44187d84 100644 --- a/workspaces/x2a/package.json +++ b/workspaces/x2a/package.json @@ -23,6 +23,7 @@ "lint:all": "backstage-cli repo lint", "prettier:check": "prettier --check .", "prettier:fix": "prettier --write .", + "chores": "yarn prettier:fix && yarn lint:all --fix && yarn tsc:full && yarn build:api-reports && yarn test:all", "new": "backstage-cli new --scope @red-hat-developer-hub", "postinstall": "cd ../../ && yarn install" }, From df55ab9ad2cd89b1c76f92efde3fd85d9b4bfe28 Mon Sep 17 00:00:00 2001 From: Marek Libra Date: Mon, 26 Jan 2026 17:07:15 +0100 Subject: [PATCH 3/7] feat(x2a): add sorting and pagination to the ProjectsList Signed-off-by: Marek Libra --- workspaces/x2a/.changeset/eager-rats-jam.md | 7 + .../x2a/plugins/x2a-backend/src/router.ts | 9 +- .../x2a-backend/src/schema/openapi.yaml | 2 + .../openapi/generated/apis/Api.server.ts | 8 +- .../src/schema/openapi/generated/router.ts | 2 + .../src/services/X2ADatabaseService.ts | 8 +- .../openapi/generated/apis/Api.client.ts | 8 +- .../x2a/plugins/x2a-common/report.api.md | 11 +- .../src/constants.ts | 14 + .../x2a/plugins/x2a-common/src/index.ts | 1 + .../ProjectList/ProjectList.test.tsx | 413 +++++++++++++++++- .../components/ProjectList/ProjectList.tsx | 143 +++++- .../x2a/plugins/x2a/src/useSeedTestData.ts | 41 ++ 13 files changed, 634 insertions(+), 33 deletions(-) create mode 100644 workspaces/x2a/.changeset/eager-rats-jam.md rename workspaces/x2a/plugins/{x2a-backend => x2a-common}/src/constants.ts (80%) create mode 100644 workspaces/x2a/plugins/x2a/src/useSeedTestData.ts diff --git a/workspaces/x2a/.changeset/eager-rats-jam.md b/workspaces/x2a/.changeset/eager-rats-jam.md new file mode 100644 index 0000000000..dc96cd4dd4 --- /dev/null +++ b/workspaces/x2a/.changeset/eager-rats-jam.md @@ -0,0 +1,7 @@ +--- +'@red-hat-developer-hub/backstage-plugin-x2a-backend': patch +'@red-hat-developer-hub/backstage-plugin-x2a-common': patch +'@red-hat-developer-hub/backstage-plugin-x2a': patch +--- + +The UI shows projects list with sorting and pagination. diff --git a/workspaces/x2a/plugins/x2a-backend/src/router.ts b/workspaces/x2a/plugins/x2a-backend/src/router.ts index 05548dc894..3f08208b87 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/router.ts +++ b/workspaces/x2a/plugins/x2a-backend/src/router.ts @@ -117,7 +117,14 @@ export async function createRouter({ pageSize: z.number().optional(), order: z.enum(['asc', 'desc']).optional(), sort: z - .enum(['createdAt', 'name', 'description', 'createdBy']) + .enum([ + 'createdAt', + 'name', + 'abbreviation', + 'status', + 'description', + 'createdBy', + ]) .optional(), }); diff --git a/workspaces/x2a/plugins/x2a-backend/src/schema/openapi.yaml b/workspaces/x2a/plugins/x2a-backend/src/schema/openapi.yaml index c2390eb4ee..edf8a9d5a8 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/schema/openapi.yaml +++ b/workspaces/x2a/plugins/x2a-backend/src/schema/openapi.yaml @@ -40,6 +40,8 @@ paths: enum: - createdAt - name + - abbreviation + - status - description - createdBy required: false diff --git a/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/apis/Api.server.ts b/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/apis/Api.server.ts index 21904b05c2..72b291dc40 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/apis/Api.server.ts +++ b/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/apis/Api.server.ts @@ -32,7 +32,13 @@ export type ProjectsGet = { page?: number; pageSize?: number; order?: 'asc' | 'desc'; - sort?: 'createdAt' | 'name' | 'description' | 'createdBy'; + sort?: + | 'createdAt' + | 'name' + | 'abbreviation' + | 'status' + | 'description' + | 'createdBy'; }; response: ProjectsGet200Response; }; diff --git a/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/router.ts b/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/router.ts index 4d70768769..221a0154aa 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/router.ts +++ b/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/router.ts @@ -77,6 +77,8 @@ export const spec = { "enum": [ "createdAt", "name", + "abbreviation", + "status", "description", "createdBy" ] diff --git a/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService.ts b/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService.ts index 094c13e7f2..a921e7d8a8 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService.ts +++ b/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService.ts @@ -24,14 +24,14 @@ import { BackstageUserPrincipal, } from '@backstage/backend-plugin-api'; import { Expand } from '@backstage/types'; -import { Project } from '@red-hat-developer-hub/backstage-plugin-x2a-common'; -import { Knex } from 'knex'; -import { ProjectsGet } from '../schema/openapi'; import { + Project, DEFAULT_PAGE_ORDER, DEFAULT_PAGE_SIZE, DEFAULT_PAGE_SORT, -} from '../constants'; +} from '@red-hat-developer-hub/backstage-plugin-x2a-common'; +import { Knex } from 'knex'; +import { ProjectsGet } from '../schema/openapi'; // TODO: model via openapi schema export interface Module { diff --git a/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/apis/Api.client.ts b/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/apis/Api.client.ts index 85a1e36b1c..2eed5f50f6 100644 --- a/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/apis/Api.client.ts +++ b/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/apis/Api.client.ts @@ -52,7 +52,13 @@ export type ProjectsGet = { page?: number; pageSize?: number; order?: 'asc' | 'desc'; - sort?: 'createdAt' | 'name' | 'description' | 'createdBy'; + sort?: + | 'createdAt' + | 'name' + | 'abbreviation' + | 'status' + | 'description' + | 'createdBy'; }; }; /** diff --git a/workspaces/x2a/plugins/x2a-common/report.api.md b/workspaces/x2a/plugins/x2a-common/report.api.md index 6ad86177c3..52f9831000 100644 --- a/workspaces/x2a/plugins/x2a-common/report.api.md +++ b/workspaces/x2a/plugins/x2a-common/report.api.md @@ -6,6 +6,15 @@ import { BasicPermission } from '@backstage/plugin-permission-common'; +// @public +export const DEFAULT_PAGE_ORDER = "desc"; + +// @public +export const DEFAULT_PAGE_SIZE = 10; + +// @public +export const DEFAULT_PAGE_SORT = "created_at"; + // @public (undocumented) export class DefaultApiClient { constructor(options: { @@ -38,7 +47,7 @@ export type ProjectsGet = { page?: number; pageSize?: number; order?: 'asc' | 'desc'; - sort?: 'createdAt' | 'name' | 'description' | 'createdBy'; + sort?: 'createdAt' | 'name' | 'abbreviation' | 'status' | 'description' | 'createdBy'; }; }; diff --git a/workspaces/x2a/plugins/x2a-backend/src/constants.ts b/workspaces/x2a/plugins/x2a-common/src/constants.ts similarity index 80% rename from workspaces/x2a/plugins/x2a-backend/src/constants.ts rename to workspaces/x2a/plugins/x2a-common/src/constants.ts index 73581263cf..70a73eed18 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/constants.ts +++ b/workspaces/x2a/plugins/x2a-common/src/constants.ts @@ -13,6 +13,20 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +/** + * Default table page size in the UI. + * @public + */ export const DEFAULT_PAGE_SIZE = 10; + +/** + * Default table page sort in the UI. + * @public + */ export const DEFAULT_PAGE_SORT = 'created_at'; + +/** + * Default table page order in the UI. + * @public + */ export const DEFAULT_PAGE_ORDER = 'desc'; diff --git a/workspaces/x2a/plugins/x2a-common/src/index.ts b/workspaces/x2a/plugins/x2a-common/src/index.ts index a1072529b3..8df814da10 100644 --- a/workspaces/x2a/plugins/x2a-common/src/index.ts +++ b/workspaces/x2a/plugins/x2a-common/src/index.ts @@ -21,3 +21,4 @@ */ export * from '../client/src/schema/openapi'; export * from './permissions'; +export * from './constants'; diff --git a/workspaces/x2a/plugins/x2a/src/components/ProjectList/ProjectList.test.tsx b/workspaces/x2a/plugins/x2a/src/components/ProjectList/ProjectList.test.tsx index e8163aa94f..40ce5b2675 100644 --- a/workspaces/x2a/plugins/x2a/src/components/ProjectList/ProjectList.test.tsx +++ b/workspaces/x2a/plugins/x2a/src/components/ProjectList/ProjectList.test.tsx @@ -20,20 +20,64 @@ import { } from '@backstage/test-utils'; import { ProjectList } from './ProjectList'; import { discoveryApiRef, fetchApiRef } from '@backstage/core-plugin-api'; +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { + Project, + ProjectsGet200Response, +} from '@red-hat-developer-hub/backstage-plugin-x2a-common'; + +// Mock useSeedTestData to prevent it from making API calls during tests +jest.mock('../../useSeedTestData', () => ({ + useSeedTestData: jest.fn(), +})); + +const createMockProjects = (count: number, offset: number = 0): Project[] => { + return Array.from({ length: count }, (_, i) => { + const index = offset + i; + return { + id: `project-${index}`, + name: `Project ${index}`, + abbreviation: `P${index}`, + description: `Description ${index}`, + createdAt: new Date( + `2024-01-${String(index + 1).padStart(2, '0')}T00:00:00Z`, + ), + createdBy: `user:default/user${index}`, + }; + }); +}; + +const createMockResponse = ( + items: Project[], + totalCount: number, +): ProjectsGet200Response => ({ + items, + totalCount, +}); describe('ProjectList component', () => { - it('renders the progressbar', async () => { - const discoveryApiMock = mockApis.discovery({ + let fetchApiMock: jest.Mock; + let discoveryApiMock: ReturnType; + + beforeEach(() => { + discoveryApiMock = mockApis.discovery({ baseUrl: 'http://localhost:1234', }); - const fetchApiMock = { - fetch: jest.fn().mockReturnValue(new Promise(() => {})), - }; + fetchApiMock = jest.fn(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('renders the progressbar', async () => { + fetchApiMock.mockReturnValue(new Promise(() => {})); const { findByRole } = await renderInTestApp( @@ -44,13 +88,360 @@ describe('ProjectList component', () => { // Wait for the progressbar to render const progressbar = await findByRole('progressbar'); expect(progressbar).toBeInTheDocument(); + }); + + describe('Columns', () => { + it('renders all expected columns', async () => { + const mockProjects = createMockProjects(5); + const mockResponse = createMockResponse(mockProjects, 5); + + fetchApiMock.mockResolvedValue({ + ok: true, + json: async () => mockResponse, + } as Response); + + await renderInTestApp( + + + , + ); + + await waitFor(() => { + expect(screen.queryByRole('progressbar')).not.toBeInTheDocument(); + }); + + // Check that all column headers are present + expect(screen.getByText('Name')).toBeInTheDocument(); + expect(screen.getByText('Abbreviation')).toBeInTheDocument(); + expect(screen.getByText('Status')).toBeInTheDocument(); + expect(screen.getByText('Description')).toBeInTheDocument(); + expect(screen.getByText('Created At')).toBeInTheDocument(); + }); + + it('displays project data in columns', async () => { + const mockProjects = createMockProjects(2); + const mockResponse = createMockResponse(mockProjects, 2); + + fetchApiMock.mockResolvedValue({ + ok: true, + json: async () => mockResponse, + } as Response); + + await renderInTestApp( + + + , + ); + + await waitFor(() => { + expect(screen.queryByRole('progressbar')).not.toBeInTheDocument(); + }); + + // Check that project data is displayed + expect(screen.getByText('Project 0')).toBeInTheDocument(); + expect(screen.getByText('P0')).toBeInTheDocument(); + expect(screen.getByText('Description 0')).toBeInTheDocument(); + expect(screen.getByText('Project 1')).toBeInTheDocument(); + expect(screen.getByText('P1')).toBeInTheDocument(); + }); + }); + + describe('Sorting', () => { + it('calls API with correct sort parameters when Name column is clicked', async () => { + const user = userEvent.setup(); + const mockProjects = createMockProjects(5); + const mockResponse = createMockResponse(mockProjects, 5); + + fetchApiMock.mockResolvedValue({ + ok: true, + json: async () => mockResponse, + } as Response); + + await renderInTestApp( + + + , + ); + + await waitFor(() => { + expect(screen.queryByRole('progressbar')).not.toBeInTheDocument(); + }); + + // Clear previous calls + fetchApiMock.mockClear(); + + // Click on Name column header to sort + const nameHeader = screen.getByText('Name'); + await user.click(nameHeader); + + await waitFor(() => { + expect(fetchApiMock).toHaveBeenCalled(); + }); + + // Verify the API was called with sort=name + const lastCall = + fetchApiMock.mock.calls[fetchApiMock.mock.calls.length - 1]; + const url = lastCall[0] as string; + expect(url).toContain('sort=name'); + }); + + it('calls API with correct sort parameters when Abbreviation column is clicked', async () => { + const user = userEvent.setup(); + const mockProjects = createMockProjects(5); + const mockResponse = createMockResponse(mockProjects, 5); + + fetchApiMock.mockResolvedValue({ + ok: true, + json: async () => mockResponse, + } as Response); + + await renderInTestApp( + + + , + ); + + await waitFor(() => { + expect(screen.queryByRole('progressbar')).not.toBeInTheDocument(); + }); + + fetchApiMock.mockClear(); + + // Click on Abbreviation column header + const abbreviationHeader = screen.getByText('Abbreviation'); + await user.click(abbreviationHeader); - // TODO: test on mock data + await waitFor(() => { + expect(fetchApiMock).toHaveBeenCalled(); + }); - // const table = await findByRole('table'); + const lastCall = + fetchApiMock.mock.calls[fetchApiMock.mock.calls.length - 1]; + const url = lastCall[0] as string; + expect(url).toContain('sort=abbreviation'); + }); + }); + + describe('Pagination', () => { + it('calls API with default page and pageSize on initial load', async () => { + const mockProjects = createMockProjects(10); + const mockResponse = createMockResponse(mockProjects, 25); + + fetchApiMock.mockResolvedValue({ + ok: true, + json: async () => mockResponse, + } as Response); + + await renderInTestApp( + + + , + ); + + await waitFor(() => { + expect(fetchApiMock).toHaveBeenCalled(); + }); + + // Verify initial call has page=0 and pageSize=10 (DEFAULT_PAGE_SIZE) + const firstCall = fetchApiMock.mock.calls[0]; + const url = firstCall[0] as string; + expect(url).toContain('page=0'); + expect(url).toContain('pageSize=10'); + }); + + it('calls API with updated page when navigating to next page', async () => { + const user = userEvent.setup(); + const mockProjects = createMockProjects(10); + const mockResponse = createMockResponse(mockProjects, 25); + + fetchApiMock.mockResolvedValue({ + ok: true, + json: async () => mockResponse, + } as Response); + + await renderInTestApp( + + + , + ); + + await waitFor(() => { + expect(screen.queryByRole('progressbar')).not.toBeInTheDocument(); + }); + + fetchApiMock.mockClear(); + + // Find and click the "Next Page" button + // Material Table uses aria-label for navigation buttons + const nextPageButton = screen.getByLabelText(/next page/i); + await user.click(nextPageButton); + + await waitFor(() => { + expect(fetchApiMock).toHaveBeenCalled(); + }); + + const lastCall = + fetchApiMock.mock.calls[fetchApiMock.mock.calls.length - 1]; + const url = lastCall[0] as string; + expect(url).toContain('page=1'); + }); + + it('calls API with updated pageSize when changing rows per page', async () => { + const user = userEvent.setup(); + const mockProjects = createMockProjects(10); + const mockResponse = createMockResponse(mockProjects, 25); + + fetchApiMock.mockResolvedValue({ + ok: true, + json: async () => mockResponse, + } as Response); + + await renderInTestApp( + + + , + ); + + await waitFor(() => { + expect(screen.queryByRole('progressbar')).not.toBeInTheDocument(); + }); + + fetchApiMock.mockClear(); - // // Assert that the table contains the expected user data - // expect(table).toBeInTheDocument(); - // expect(getByText('Migration 1')).toBeInTheDocument(); + // Find the rows per page selector and change it + // Material Table typically uses a select element for page size + const rowsPerPageSelect = screen.getByLabelText(/rows per page/i); + await user.click(rowsPerPageSelect); + + const option20 = await screen.findByText('20'); + await user.click(option20); + + await waitFor(() => { + expect(fetchApiMock).toHaveBeenCalled(); + }); + + const lastCall = + fetchApiMock.mock.calls[fetchApiMock.mock.calls.length - 1]; + const url = lastCall[0] as string; + expect(url).toContain('pageSize=20'); + }); + + it('displays correct total count in table title', async () => { + const mockProjects = createMockProjects(10); + const mockResponse = createMockResponse(mockProjects, 20); + + fetchApiMock.mockResolvedValue({ + ok: true, + json: async () => mockResponse, + } as Response); + + await renderInTestApp( + + + , + ); + + await waitFor(() => { + expect(screen.queryByRole('progressbar')).not.toBeInTheDocument(); + }); + + // The table title should show the number of items on current page, not totalCount + expect(screen.getByText(/Projects \(10\)/)).toBeInTheDocument(); + }); + }); + + describe('Combined functionality', () => { + it('maintains sort order when changing pages', async () => { + const user = userEvent.setup(); + const mockProjects = createMockProjects(10); + const mockResponse = createMockResponse(mockProjects, 25); + + fetchApiMock.mockResolvedValue({ + ok: true, + json: async () => mockResponse, + } as Response); + + await renderInTestApp( + + + , + ); + + await waitFor(() => { + expect(screen.queryByRole('progressbar')).not.toBeInTheDocument(); + }); + + fetchApiMock.mockClear(); + + // Sort by name descending + const nameHeader = screen.getByText('Name'); + await user.click(nameHeader); + await waitFor(() => { + expect(fetchApiMock).toHaveBeenCalled(); + }); + + fetchApiMock.mockClear(); + + // Navigate to next page + const nextPageButton = screen.getByLabelText(/next page/i); + await user.click(nextPageButton); + + await waitFor(() => { + expect(fetchApiMock).toHaveBeenCalled(); + }); + + // Verify that sort parameters are still present + const lastCall = + fetchApiMock.mock.calls[fetchApiMock.mock.calls.length - 1]; + const url = lastCall[0] as string; + expect(url).toContain('sort=name'); + expect(url).toContain('order=desc'); + expect(url).toContain('page=1'); + }); }); }); diff --git a/workspaces/x2a/plugins/x2a/src/components/ProjectList/ProjectList.tsx b/workspaces/x2a/plugins/x2a/src/components/ProjectList/ProjectList.tsx index 77cfd3a89e..f40b4948b7 100644 --- a/workspaces/x2a/plugins/x2a/src/components/ProjectList/ProjectList.tsx +++ b/workspaces/x2a/plugins/x2a/src/components/ProjectList/ProjectList.tsx @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import useAsync from 'react-use/lib/useAsync'; import { @@ -25,20 +25,107 @@ import { } from '@backstage/core-components'; import DeleteIcon from '@material-ui/icons/Delete'; +import { Box, Grid } from '@material-ui/core'; import { + DEFAULT_PAGE_SIZE, Project, + ProjectsGet, ProjectsGet200Response, } from '@red-hat-developer-hub/backstage-plugin-x2a-common'; import { useClientService } from '../../ClientService'; -import { Box, Grid } from '@material-ui/core'; + +type OrderDirection = ProjectsGet['query']['order']; + +const useColumns = ( + orderBy: number, + orderDirection: OrderDirection, +): TableColumn[] => + useMemo(() => { + const getDefaultSort = (index: number): OrderDirection => { + if (index === orderBy) { + return orderDirection; + } + return undefined; + }; + + const columns: TableColumn[] = [ + { title: 'Name', field: 'name', defaultSort: getDefaultSort(0) }, + { + title: 'Abbreviation', + field: 'abbreviation', + defaultSort: getDefaultSort(1), + }, + { title: 'Status', field: 'status', defaultSort: getDefaultSort(2) }, + { + title: 'Description', + field: 'description', + defaultSort: getDefaultSort(3), + }, + { + title: 'Created At', + render: (rowData: Project) => { + // TODO: Show human-readable duration instead, make sure sorting still works + return
{rowData.createdAt.toLocaleString()}
; + }, + defaultSort: getDefaultSort(4), + }, + // { + // title: 'Source Repository', + // field: 'sourceRepository', + // defaultSort: getDefaultSort(5), + // }, + ]; + return columns; + }, [orderBy, orderDirection]); + +const mapOrderByToSort = (orderBy: number): ProjectsGet['query']['sort'] => { + const mapping: ProjectsGet['query']['sort'][] = [ + 'name', + 'abbreviation', + 'status', + 'description', + 'createdAt', + ]; + + if (orderBy < 0) { + return mapping[0]; + } + + const result = mapping[orderBy]; + if (!result) { + throw new Error(`Invalid orderBy: ${orderBy}`); + } + return result; +}; type DenseTableProps = { forceRefresh: () => void; projects: Project[]; + totalCount: number; + page: number; + pageSize: number; + onPageChange: (page: number) => void; + onRowsPerPageChange: (pageSize: number) => void; + orderBy: number; + orderDirection: OrderDirection; + setOrderBy: (orderBy: number) => void; + setOrderDirection: (orderDirection: ProjectsGet['query']['order']) => void; }; -export const DenseTable = ({ projects, forceRefresh }: DenseTableProps) => { +export const DenseTable = ({ + projects, + forceRefresh, + totalCount, + page, + pageSize, + onPageChange, + onRowsPerPageChange, + orderBy, + orderDirection, + setOrderBy, + setOrderDirection, +}: DenseTableProps) => { const clientService = useClientService(); const [error, setError] = useState(null); @@ -54,12 +141,12 @@ export const DenseTable = ({ projects, forceRefresh }: DenseTableProps) => { } }; - const columns: TableColumn[] = [ - { title: 'Name', field: 'name' }, - { title: 'Status', field: 'status' }, - { title: 'Source Repository', field: 'sourceRepository' }, - ]; + const handleOrderChange = (sortBy: number, od: OrderDirection) => { + setOrderBy(sortBy); + setOrderDirection(od); + }; + const columns = useColumns(orderBy, orderDirection); const data = projects; const actions = [ @@ -95,14 +182,20 @@ export const DenseTable = ({ projects, forceRefresh }: DenseTableProps) => { title={`Projects (${projects.length})`} options={{ search: false, - paging: false, - actionsColumnIndex: -1, + paging: true, + actionsColumnIndex: -1 /* to the row end */, padding: 'default', + pageSize: pageSize, }} columns={columns} data={data} actions={actions} detailPanel={getDetailPanel} + onOrderChange={handleOrderChange} + page={page} + onPageChange={onPageChange} + onRowsPerPageChange={onRowsPerPageChange} + totalCount={totalCount} /> @@ -110,18 +203,31 @@ export const DenseTable = ({ projects, forceRefresh }: DenseTableProps) => { }; export const ProjectList = () => { - const [refresh, setRefresh] = useState(0); const clientService = useClientService(); + const [refresh, setRefresh] = useState(0); + + const [orderBy, setOrderBy] = useState(0); + const [orderDirection, setOrderDirection] = useState('asc'); + const [page, setPage] = useState(0); + const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE); + + const forceRefresh = useCallback(() => { + setRefresh(refresh + 1); + }, [refresh]); + const { value, loading, error } = useAsync(async (): Promise => { const response = await clientService.projectsGet({ query: { - /* TODO: pagination */ + order: orderDirection || 'asc', + sort: mapOrderByToSort(orderBy), + page, + pageSize, }, }); return await response.json(); - }, [refresh, clientService]); + }, [refresh, clientService, orderBy, orderDirection, page, pageSize]); if (loading) { return ; @@ -132,7 +238,16 @@ export const ProjectList = () => { return ( setRefresh(refresh + 1)} + totalCount={value?.totalCount || 0} + forceRefresh={forceRefresh} + orderBy={orderBy} + orderDirection={orderDirection} + setOrderBy={setOrderBy} + setOrderDirection={setOrderDirection} + page={page} + pageSize={pageSize} + onPageChange={setPage} + onRowsPerPageChange={setPageSize} /> ); }; diff --git a/workspaces/x2a/plugins/x2a/src/useSeedTestData.ts b/workspaces/x2a/plugins/x2a/src/useSeedTestData.ts new file mode 100644 index 0000000000..b5578a894f --- /dev/null +++ b/workspaces/x2a/plugins/x2a/src/useSeedTestData.ts @@ -0,0 +1,41 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { useEffect } from 'react'; +import { useClientService } from './ClientService'; + +/** + * Seed the database with test data. + * + * Never use in production. + */ +export const useSeedTestData = () => { + const clientService = useClientService(); + + useEffect(() => { + const doItAsync = async () => { + for (let i = 0; i < 10; i++) { + await clientService.projectsPost({ + body: { + name: `Test Project ${i}`, + description: `Test Description ${i}`, + abbreviation: `TP${i}`, + }, + }); + } + }; + doItAsync(); + }, [clientService]); +}; From 072b5bd23f64b51cc3e2a14381b16c0b60d6e4cc Mon Sep 17 00:00:00 2001 From: Marek Libra Date: Tue, 27 Jan 2026 13:35:03 +0100 Subject: [PATCH 4/7] Add app-config.production.yaml skeleton Signed-off-by: Marek Libra --- workspaces/x2a/app-config.production.yaml | 79 +++++++++++++++++++ .../plugins/x2a-backend/src/router.test.ts | 7 +- .../src/services/X2ADatabaseService.test.ts | 19 +++-- .../src/services/X2ADatabaseService.ts | 4 +- .../plugins/x2a-backend/src/utils/index.ts | 1 + .../plugins/x2a-backend/src/utils/tests.ts | 16 ++++ 6 files changed, 109 insertions(+), 17 deletions(-) create mode 100644 workspaces/x2a/app-config.production.yaml create mode 100644 workspaces/x2a/plugins/x2a-backend/src/utils/tests.ts diff --git a/workspaces/x2a/app-config.production.yaml b/workspaces/x2a/app-config.production.yaml new file mode 100644 index 0000000000..5a7b022075 --- /dev/null +++ b/workspaces/x2a/app-config.production.yaml @@ -0,0 +1,79 @@ +app: + title: X2Ansible Convertor + baseUrl: http://localhost:3000 + +organization: + name: Red Hat + +backend: + # Used for enabling authentication, secret is shared by all backend plugins + # See https://backstage.io/docs/auth/service-to-service-auth for + # information on the format + # auth: + # keys: + # - secret: ${BACKEND_SECRET} + baseUrl: http://localhost:7007 + listen: + port: 7007 + # Uncomment the following host directive to bind to specific interfaces + # host: 127.0.0.1 + csp: + connect-src: ["'self'", 'http:', 'https:'] + # Content-Security-Policy directives follow the Helmet format: https://helmetjs.github.io/#reference + # Default Helmet Content-Security-Policy values can be removed by setting the key to false + cors: + origin: http://localhost:3000 + methods: [GET, HEAD, PATCH, POST, PUT, DELETE] + credentials: true + database: + client: pg + connection: + host: ${POSTGRES_HOST} + port: ${POSTGRES_PORT} + user: ${POSTGRES_USER} + password: ${POSTGRES_PASSWORD} + +#integrations: +# github: +# - host: github.com +# # This is a Personal Access Token or PAT from GitHub. You can find out how to generate this token, and more information +# about setting up the GitHub integration here: https://backstage.io/docs/integrations/github/locations#configuration +# token: ${GITHUB_TOKEN} +### Example for how to add your GitHub Enterprise instance using the API: +# - host: ghe.example.net +# apiBaseUrl: https://ghe.example.net/api/v3 +# token: ${GHE_TOKEN} + +### Example for how to add a proxy endpoint for the frontend. +### A typical reason to do this is to handle HTTPS and CORS for internal services. +# endpoints: +# '/test': +# target: 'https://example.com' +# changeOrigin: true + +# Reference documentation http://backstage.io/docs/features/techdocs/configuration +# Note: After experimenting with basic setup, use CI/CD to generate docs +# and an external cloud storage when deploying TechDocs for production use-case. +# https://backstage.io/docs/features/techdocs/how-to-guides#how-to-migrate-from-techdocs-basic-to-recommended-deployment-approach +techdocs: + builder: 'local' # Alternatives - 'external' + generator: + runIn: 'docker' # Alternatives - 'local' + publisher: + type: 'local' # Alternatives - 'googleGcs' or 'awsS3'. Read documentation for using alternatives. + +auth: + # see https://backstage.io/docs/auth/ to learn about auth providers + providers: + # See https://backstage.io/docs/auth/guest/provider + guest: {} + +scaffolder: {} + # see https://backstage.io/docs/features/software-templates/configuration for software template options + +catalog: + import: + entityFilename: catalog-info.yaml + pullRequestBranchName: backstage-integration + rules: + - allow: [Component, System, API, Resource, Location] diff --git a/workspaces/x2a/plugins/x2a-backend/src/router.test.ts b/workspaces/x2a/plugins/x2a-backend/src/router.test.ts index e62f3c8974..678992cb2e 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/router.test.ts +++ b/workspaces/x2a/plugins/x2a-backend/src/router.test.ts @@ -29,10 +29,10 @@ import { X2ADatabaseService } from './services/X2ADatabaseService'; import { ProjectsPostRequest } from '@red-hat-developer-hub/backstage-plugin-x2a-common'; import { migrate } from './services/dbMigrate'; import { Knex } from 'knex'; +import { nonExistentId } from './utils'; const databases = TestDatabases.create({ - // TODO: Reenable for 'POSTGRES_18' - ids: ['SQLITE_3'], + ids: ['SQLITE_3', 'POSTGRES_18'], }); const mockInputProject: ProjectsPostRequest = { @@ -241,7 +241,6 @@ describe('createRouter', () => { const { client } = await createDatabase(databaseId); const app = await createApp(client); - const nonExistentId = '00000000-0000-0000-0000-000000000000'; const response = await request(app) .get(`/projects/${nonExistentId}`) .send(); @@ -295,7 +294,6 @@ describe('createRouter', () => { const { client } = await createDatabase(databaseId); const app = await createApp(client); - const nonExistentId = '00000000-0000-0000-0000-000000000000'; const response = await request(app) .delete(`/projects/${nonExistentId}`) .send(); @@ -470,7 +468,6 @@ describe('createRouter', () => { AuthorizeResult.ALLOW, ); - const nonExistentId = '00000000-0000-0000-0000-000000000000'; const response = await request(app) .delete(`/projects/${nonExistentId}`) .send(); diff --git a/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService.test.ts b/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService.test.ts index 1bd52282b9..f411c00d5c 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService.test.ts +++ b/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService.test.ts @@ -23,11 +23,10 @@ import { import { Knex } from 'knex'; import { X2ADatabaseService } from './X2ADatabaseService'; import { migrate } from './dbMigrate'; -import { delay, toSorted } from '../utils'; +import { delay, nonExistentId, toSorted } from '../utils'; const databases = TestDatabases.create({ - // TODO: Reenable for 'POSTGRES_18' - ids: ['SQLITE_3'], + ids: ['SQLITE_3', 'POSTGRES_18'], }); async function createDatabase(databaseId: TestDatabaseId) { @@ -638,7 +637,7 @@ describe('X2ADatabaseService', () => { const credentials = mockCredentials.user(); const project = await service.getProject( { - projectId: 'non-existent-id', + projectId: nonExistentId, }, { credentials }, ); @@ -859,7 +858,7 @@ describe('X2ADatabaseService', () => { const credentials = mockCredentials.user(); const deletedCount = await service.deleteProject( { - projectId: 'non-existent-id', + projectId: nonExistentId, }, { credentials }, ); @@ -1306,7 +1305,7 @@ describe('X2ADatabaseService', () => { const { client } = await createDatabase(databaseId); const service = createService(client); - const module = await service.getModule({ id: 'non-existent-id' }); + const module = await service.getModule({ id: nonExistentId }); expect(module).toBeUndefined(); }, @@ -1514,7 +1513,7 @@ describe('X2ADatabaseService', () => { const service = createService(client); const deletedCount = await service.deleteModule({ - id: 'non-existent-id', + id: nonExistentId, }); expect(deletedCount).toBe(0); @@ -2001,7 +2000,7 @@ describe('X2ADatabaseService', () => { const { client } = await createDatabase(databaseId); const service = createService(client); - const job = await service.getJob({ id: 'non-existent-id' }); + const job = await service.getJob({ id: nonExistentId }); expect(job).toBeUndefined(); }, @@ -2295,7 +2294,7 @@ describe('X2ADatabaseService', () => { const service = createService(client); const updated = await service.updateJob({ - id: 'non-existent-id', + id: nonExistentId, status: 'running', }); @@ -2562,7 +2561,7 @@ describe('X2ADatabaseService', () => { const service = createService(client); const deletedCount = await service.deleteJob({ - id: 'non-existent-id', + id: nonExistentId, }); expect(deletedCount).toBe(0); diff --git a/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService.ts b/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService.ts index a921e7d8a8..31ab85f3c9 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService.ts +++ b/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService.ts @@ -205,7 +205,7 @@ export class X2ADatabaseService { calledByUserRef, ), ) - .first()) as { count: number }; + .first()) as { count: any }; const projects: Project[] = rows.map(this.mapRowToProject); @@ -213,7 +213,7 @@ export class X2ADatabaseService { `Fetched ${projects.length} out of ${totalCount.count} projects from database (permissions applied)`, ); - return { projects, totalCount: totalCount.count }; + return { projects, totalCount: Number.parseInt(totalCount.count, 10) }; } async getProject( diff --git a/workspaces/x2a/plugins/x2a-backend/src/utils/index.ts b/workspaces/x2a/plugins/x2a-backend/src/utils/index.ts index 5b4465bcac..d309dc5b9c 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/utils/index.ts +++ b/workspaces/x2a/plugins/x2a-backend/src/utils/index.ts @@ -15,3 +15,4 @@ */ export * from './toSorted'; export * from './delay'; +export * from './tests'; diff --git a/workspaces/x2a/plugins/x2a-backend/src/utils/tests.ts b/workspaces/x2a/plugins/x2a-backend/src/utils/tests.ts new file mode 100644 index 0000000000..be9e67a5a7 --- /dev/null +++ b/workspaces/x2a/plugins/x2a-backend/src/utils/tests.ts @@ -0,0 +1,16 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export const nonExistentId = '00000000-0000-0000-0000-000000000000'; From 9b2b38361b71e4697beb42d65802644db6b88cf5 Mon Sep 17 00:00:00 2001 From: Marek Libra Date: Tue, 27 Jan 2026 15:13:01 +0100 Subject: [PATCH 5/7] Tear down knex clients in tests --- workspaces/x2a/plugins/x2a-backend/src/router.test.ts | 9 +++++++++ .../x2a-backend/src/services/X2ADatabaseService.test.ts | 9 +++++++++ workspaces/x2a/plugins/x2a-backend/src/utils/delay.ts | 7 ++++++- 3 files changed, 24 insertions(+), 1 deletion(-) diff --git a/workspaces/x2a/plugins/x2a-backend/src/router.test.ts b/workspaces/x2a/plugins/x2a-backend/src/router.test.ts index 678992cb2e..938ae45a95 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/router.test.ts +++ b/workspaces/x2a/plugins/x2a-backend/src/router.test.ts @@ -87,8 +87,17 @@ async function createApp( } describe('createRouter', () => { + const clientsToDestroy: Knex[] = []; + + afterEach(async () => { + await Promise.all( + clientsToDestroy.splice(0).map(client => client.destroy()), + ); + }); + async function createDatabase(databaseId: TestDatabaseId) { const client = await databases.init(databaseId); + clientsToDestroy.push(client); const mockDatabaseService = mockServices.database.mock({ getClient: async () => client, migrations: { skip: false }, diff --git a/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService.test.ts b/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService.test.ts index f411c00d5c..05f852a263 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService.test.ts +++ b/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService.test.ts @@ -29,8 +29,11 @@ const databases = TestDatabases.create({ ids: ['SQLITE_3', 'POSTGRES_18'], }); +const clientsToDestroy: Knex[] = []; + async function createDatabase(databaseId: TestDatabaseId) { const client = await databases.init(databaseId); + clientsToDestroy.push(client); const mockDatabaseService = mockServices.database.mock({ getClient: async () => client, migrations: { skip: false }, @@ -51,6 +54,12 @@ function createService(client: Knex): X2ADatabaseService { } describe('X2ADatabaseService', () => { + afterEach(async () => { + await Promise.all( + clientsToDestroy.splice(0).map(client => client.destroy()), + ); + }); + describe('createProject', () => { it.each(databases.eachSupportedId())( 'should create a project with all required fields - %p', diff --git a/workspaces/x2a/plugins/x2a-backend/src/utils/delay.ts b/workspaces/x2a/plugins/x2a-backend/src/utils/delay.ts index d0b905867b..c7a4391344 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/utils/delay.ts +++ b/workspaces/x2a/plugins/x2a-backend/src/utils/delay.ts @@ -21,5 +21,10 @@ * @returns A promise that resolves after the specified delay */ export function delay(ms: number): Promise { - return new Promise(resolve => setTimeout(resolve, ms)); + return new Promise(resolve => { + const t = setTimeout(resolve, ms); + + // for testing purposes, we need to unref the timeout to avoid the test hanging + t.unref(); + }); } From f942ae81055f10645840732245b760bf2e19148c Mon Sep 17 00:00:00 2001 From: Marek Libra Date: Tue, 27 Jan 2026 15:27:14 +0100 Subject: [PATCH 6/7] Increase timeout for long running tests --- .../plugins/x2a-backend/src/router.test.ts | 33 ++-- .../src/services/X2ADatabaseService.test.ts | 149 +++++++++--------- .../plugins/x2a-backend/src/utils/tests.ts | 1 + 3 files changed, 95 insertions(+), 88 deletions(-) diff --git a/workspaces/x2a/plugins/x2a-backend/src/router.test.ts b/workspaces/x2a/plugins/x2a-backend/src/router.test.ts index 938ae45a95..f2ab8bb1c2 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/router.test.ts +++ b/workspaces/x2a/plugins/x2a-backend/src/router.test.ts @@ -29,11 +29,12 @@ import { X2ADatabaseService } from './services/X2ADatabaseService'; import { ProjectsPostRequest } from '@red-hat-developer-hub/backstage-plugin-x2a-common'; import { migrate } from './services/dbMigrate'; import { Knex } from 'knex'; -import { nonExistentId } from './utils'; +import { LONG_TEST_TIMEOUT, nonExistentId } from './utils'; const databases = TestDatabases.create({ ids: ['SQLITE_3', 'POSTGRES_18'], }); +const supportedDatabaseIds = databases.eachSupportedId(); const mockInputProject: ProjectsPostRequest = { name: 'Mock Project', @@ -110,7 +111,7 @@ describe('createRouter', () => { }; } - it.each(databases.eachSupportedId())( + it.each(supportedDatabaseIds)( 'should query empty project list - %p', async databaseId => { const { client } = await createDatabase(databaseId); @@ -122,9 +123,10 @@ describe('createRouter', () => { expect(response.body.totalCount).toBe(0); expect(response.body.items).toEqual([]); }, + LONG_TEST_TIMEOUT, ); - it.each(databases.eachSupportedId())( + it.each(supportedDatabaseIds)( 'should create a project - %p', async databaseId => { const { client } = await createDatabase(databaseId); @@ -140,9 +142,10 @@ describe('createRouter', () => { createdBy: 'user:default/mock', }); }, + LONG_TEST_TIMEOUT, ); - it.each(databases.eachSupportedId())( + it.each(supportedDatabaseIds)( 'should not allow unauthenticated requests to create a project - %p', async databaseId => { const { client } = await createDatabase(databaseId); @@ -161,7 +164,7 @@ describe('createRouter', () => { }, ); - it.each(databases.eachSupportedId())( + it.each(supportedDatabaseIds)( 'should allow users with x2aUserPermission to create projects - %p', async databaseId => { const { client } = await createDatabase(databaseId); @@ -179,7 +182,7 @@ describe('createRouter', () => { }, ); - it.each(databases.eachSupportedId())( + it.each(supportedDatabaseIds)( 'should allow users with x2aAdminWritePermission to create projects - %p', async databaseId => { const { client } = await createDatabase(databaseId); @@ -197,7 +200,7 @@ describe('createRouter', () => { }, ); - it.each(databases.eachSupportedId())( + it.each(supportedDatabaseIds)( 'should deny users without permissions from creating projects - %p', async databaseId => { const { client } = await createDatabase(databaseId); @@ -217,7 +220,7 @@ describe('createRouter', () => { }, ); - it.each(databases.eachSupportedId())( + it.each(supportedDatabaseIds)( 'should get a project by id - %p', async databaseId => { const { client } = await createDatabase(databaseId); @@ -244,7 +247,7 @@ describe('createRouter', () => { }, ); - it.each(databases.eachSupportedId())( + it.each(supportedDatabaseIds)( 'should fail for non-existent project - %p', async databaseId => { const { client } = await createDatabase(databaseId); @@ -261,7 +264,7 @@ describe('createRouter', () => { }, ); - it.each(databases.eachSupportedId())( + it.each(supportedDatabaseIds)( 'should delete a project by id - %p', async databaseId => { const { client } = await createDatabase(databaseId); @@ -297,7 +300,7 @@ describe('createRouter', () => { }, ); - it.each(databases.eachSupportedId())( + it.each(supportedDatabaseIds)( 'should return 404 when deleting non-existent project - %p', async databaseId => { const { client } = await createDatabase(databaseId); @@ -314,7 +317,7 @@ describe('createRouter', () => { }, ); - it.each(databases.eachSupportedId())( + it.each(supportedDatabaseIds)( 'should allow users with admin write permission to delete any project - %p', async databaseId => { const { client } = await createDatabase(databaseId); @@ -395,7 +398,7 @@ describe('createRouter', () => { }, ); - it.each(databases.eachSupportedId())( + it.each(supportedDatabaseIds)( 'should allow users without admin write permission to delete their own project - %p', async databaseId => { const { client } = await createDatabase(databaseId); @@ -431,7 +434,7 @@ describe('createRouter', () => { }, ); - it.each(databases.eachSupportedId())( + it.each(supportedDatabaseIds)( 'should return 404 when deletion fails due to permission filtering - %p', async databaseId => { const { client } = await createDatabase(databaseId); @@ -466,7 +469,7 @@ describe('createRouter', () => { }, ); - it.each(databases.eachSupportedId())( + it.each(supportedDatabaseIds)( 'should return 404 when deleting non-existent project even with admin write permission - %p', async databaseId => { const { client } = await createDatabase(databaseId); diff --git a/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService.test.ts b/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService.test.ts index 05f852a263..ad2275037a 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService.test.ts +++ b/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService.test.ts @@ -23,11 +23,12 @@ import { import { Knex } from 'knex'; import { X2ADatabaseService } from './X2ADatabaseService'; import { migrate } from './dbMigrate'; -import { delay, nonExistentId, toSorted } from '../utils'; +import { delay, LONG_TEST_TIMEOUT, nonExistentId, toSorted } from '../utils'; const databases = TestDatabases.create({ ids: ['SQLITE_3', 'POSTGRES_18'], }); +const supportedDatabaseIds = databases.eachSupportedId(); const clientsToDestroy: Knex[] = []; @@ -61,7 +62,7 @@ describe('X2ADatabaseService', () => { }); describe('createProject', () => { - it.each(databases.eachSupportedId())( + it.each(supportedDatabaseIds)( 'should create a project with all required fields - %p', async databaseId => { const { client } = await createDatabase(databaseId); @@ -93,9 +94,10 @@ describe('X2ADatabaseService', () => { expect(row.description).toBe(input.description); expect(row.created_by).toBe('user:default/mock'); }, + LONG_TEST_TIMEOUT, ); - it.each(databases.eachSupportedId())( + it.each(supportedDatabaseIds)( 'should create multiple projects with different IDs - %p', async databaseId => { const { client } = await createDatabase(databaseId); @@ -124,9 +126,10 @@ describe('X2ADatabaseService', () => { expect(project1.name).toBe('Project 1'); expect(project2.name).toBe('Project 2'); }, + LONG_TEST_TIMEOUT, ); - it.each(databases.eachSupportedId())( + it.each(supportedDatabaseIds)( 'should use the correct user from credentials - %p', async databaseId => { const { client } = await createDatabase(databaseId); @@ -152,7 +155,7 @@ describe('X2ADatabaseService', () => { }); describe('listProjects', () => { - it.each(databases.eachSupportedId())( + it.each(supportedDatabaseIds)( 'should return empty list when no projects exist - %p', async databaseId => { const { client } = await createDatabase(databaseId); @@ -166,7 +169,7 @@ describe('X2ADatabaseService', () => { }, ); - it.each(databases.eachSupportedId())( + it.each(supportedDatabaseIds)( 'should return all projects with correct totalCount - %p', async databaseId => { const { client } = await createDatabase(databaseId); @@ -213,7 +216,7 @@ describe('X2ADatabaseService', () => { }, ); - it.each(databases.eachSupportedId())( + it.each(supportedDatabaseIds)( 'should paginate results with page and pageSize - %p', async databaseId => { const { client } = await createDatabase(databaseId); @@ -264,7 +267,7 @@ describe('X2ADatabaseService', () => { }, ); - it.each(databases.eachSupportedId())( + it.each(supportedDatabaseIds)( 'should use default pageSize when not specified - %p', async databaseId => { const { client } = await createDatabase(databaseId); @@ -294,7 +297,7 @@ describe('X2ADatabaseService', () => { }, ); - it.each(databases.eachSupportedId())( + it.each(supportedDatabaseIds)( 'should sort by name ascending - %p', async databaseId => { const { client } = await createDatabase(databaseId); @@ -328,7 +331,7 @@ describe('X2ADatabaseService', () => { }, ); - it.each(databases.eachSupportedId())( + it.each(supportedDatabaseIds)( 'should sort by name descending - %p', async databaseId => { const { client } = await createDatabase(databaseId); @@ -362,7 +365,7 @@ describe('X2ADatabaseService', () => { }, ); - it.each(databases.eachSupportedId())( + it.each(supportedDatabaseIds)( 'should sort by createdBy - %p', async databaseId => { const { client } = await createDatabase(databaseId); @@ -400,7 +403,7 @@ describe('X2ADatabaseService', () => { }, ); - it.each(databases.eachSupportedId())( + it.each(supportedDatabaseIds)( 'should filter by user when canViewAll is false - %p', async databaseId => { const { client } = await createDatabase(databaseId); @@ -456,7 +459,7 @@ describe('X2ADatabaseService', () => { }, ); - it.each(databases.eachSupportedId())( + it.each(supportedDatabaseIds)( 'should return all projects when canViewAll is true - %p', async databaseId => { const { client } = await createDatabase(databaseId); @@ -501,7 +504,7 @@ describe('X2ADatabaseService', () => { }, ); - it.each(databases.eachSupportedId())( + it.each(supportedDatabaseIds)( 'should filter by user when canViewAll is undefined - %p', async databaseId => { const { client } = await createDatabase(databaseId); @@ -532,7 +535,7 @@ describe('X2ADatabaseService', () => { }, ); - it.each(databases.eachSupportedId())( + it.each(supportedDatabaseIds)( 'should combine pagination and user filtering - %p', async databaseId => { const { client } = await createDatabase(databaseId); @@ -580,7 +583,7 @@ describe('X2ADatabaseService', () => { }, ); - it.each(databases.eachSupportedId())( + it.each(supportedDatabaseIds)( 'should use default sort and order when not specified - %p', async databaseId => { const { client } = await createDatabase(databaseId); @@ -612,7 +615,7 @@ describe('X2ADatabaseService', () => { }, ); - it.each(databases.eachSupportedId())( + it.each(supportedDatabaseIds)( 'should handle empty page gracefully - %p', async databaseId => { const { client } = await createDatabase(databaseId); @@ -637,7 +640,7 @@ describe('X2ADatabaseService', () => { }); describe('getProject', () => { - it.each(databases.eachSupportedId())( + it.each(supportedDatabaseIds)( 'should return undefined for non-existent project - %p', async databaseId => { const { client } = await createDatabase(databaseId); @@ -655,7 +658,7 @@ describe('X2ADatabaseService', () => { }, ); - it.each(databases.eachSupportedId())( + it.each(supportedDatabaseIds)( 'should return the correct project by ID - %p', async databaseId => { const { client } = await createDatabase(databaseId); @@ -690,7 +693,7 @@ describe('X2ADatabaseService', () => { }, ); - it.each(databases.eachSupportedId())( + it.each(supportedDatabaseIds)( 'should return correct project when multiple projects exist - %p', async databaseId => { const { client } = await createDatabase(databaseId); @@ -730,7 +733,7 @@ describe('X2ADatabaseService', () => { }, ); - it.each(databases.eachSupportedId())( + it.each(supportedDatabaseIds)( 'should return undefined when user tries to access project created by another user - %p', async databaseId => { const { client } = await createDatabase(databaseId); @@ -761,7 +764,7 @@ describe('X2ADatabaseService', () => { }, ); - it.each(databases.eachSupportedId())( + it.each(supportedDatabaseIds)( 'should return project when user accesses their own project - %p', async databaseId => { const { client } = await createDatabase(databaseId); @@ -792,7 +795,7 @@ describe('X2ADatabaseService', () => { }, ); - it.each(databases.eachSupportedId())( + it.each(supportedDatabaseIds)( 'should return project when canViewAll is true even if created by different user - %p', async databaseId => { const { client } = await createDatabase(databaseId); @@ -825,7 +828,7 @@ describe('X2ADatabaseService', () => { }, ); - it.each(databases.eachSupportedId())( + it.each(supportedDatabaseIds)( 'should filter by user when canViewAll is undefined - %p', async databaseId => { const { client } = await createDatabase(databaseId); @@ -858,7 +861,7 @@ describe('X2ADatabaseService', () => { }); describe('deleteProject', () => { - it.each(databases.eachSupportedId())( + it.each(supportedDatabaseIds)( 'should return 0 when deleting non-existent project - %p', async databaseId => { const { client } = await createDatabase(databaseId); @@ -876,7 +879,7 @@ describe('X2ADatabaseService', () => { }, ); - it.each(databases.eachSupportedId())( + it.each(supportedDatabaseIds)( 'should delete a project and return 1 - %p', async databaseId => { const { client } = await createDatabase(databaseId); @@ -920,7 +923,7 @@ describe('X2ADatabaseService', () => { }, ); - it.each(databases.eachSupportedId())( + it.each(supportedDatabaseIds)( 'should only delete the specified project - %p', async databaseId => { const { client } = await createDatabase(databaseId); @@ -975,7 +978,7 @@ describe('X2ADatabaseService', () => { }, ); - it.each(databases.eachSupportedId())( + it.each(supportedDatabaseIds)( 'should return 0 when user tries to delete project created by another user - %p', async databaseId => { const { client } = await createDatabase(databaseId); @@ -1016,7 +1019,7 @@ describe('X2ADatabaseService', () => { }, ); - it.each(databases.eachSupportedId())( + it.each(supportedDatabaseIds)( 'should delete project when user deletes their own project - %p', async databaseId => { const { client } = await createDatabase(databaseId); @@ -1054,7 +1057,7 @@ describe('X2ADatabaseService', () => { }, ); - it.each(databases.eachSupportedId())( + it.each(supportedDatabaseIds)( 'should delete project when canWriteAll is true even if created by different user - %p', async databaseId => { const { client } = await createDatabase(databaseId); @@ -1094,7 +1097,7 @@ describe('X2ADatabaseService', () => { }, ); - it.each(databases.eachSupportedId())( + it.each(supportedDatabaseIds)( 'should filter by user when canWriteAll is undefined - %p', async databaseId => { const { client } = await createDatabase(databaseId); @@ -1137,7 +1140,7 @@ describe('X2ADatabaseService', () => { }); describe('integration tests', () => { - it.each(databases.eachSupportedId())( + it.each(supportedDatabaseIds)( 'should handle full CRUD lifecycle - %p', async databaseId => { const { client } = await createDatabase(databaseId); @@ -1191,7 +1194,7 @@ describe('X2ADatabaseService', () => { }); describe('createModule', () => { - it.each(databases.eachSupportedId())( + it.each(supportedDatabaseIds)( 'should create a module with all required fields - %p', async databaseId => { const { client } = await createDatabase(databaseId); @@ -1231,7 +1234,7 @@ describe('X2ADatabaseService', () => { }, ); - it.each(databases.eachSupportedId())( + it.each(supportedDatabaseIds)( 'should create multiple modules with different IDs - %p', async databaseId => { const { client } = await createDatabase(databaseId); @@ -1265,7 +1268,7 @@ describe('X2ADatabaseService', () => { }, ); - it.each(databases.eachSupportedId())( + it.each(supportedDatabaseIds)( 'should create modules for different projects - %p', async databaseId => { const { client } = await createDatabase(databaseId); @@ -1308,7 +1311,7 @@ describe('X2ADatabaseService', () => { }); describe('getModule', () => { - it.each(databases.eachSupportedId())( + it.each(supportedDatabaseIds)( 'should return undefined for non-existent module - %p', async databaseId => { const { client } = await createDatabase(databaseId); @@ -1320,7 +1323,7 @@ describe('X2ADatabaseService', () => { }, ); - it.each(databases.eachSupportedId())( + it.each(supportedDatabaseIds)( 'should return the correct module by ID - %p', async databaseId => { const { client } = await createDatabase(databaseId); @@ -1354,7 +1357,7 @@ describe('X2ADatabaseService', () => { }, ); - it.each(databases.eachSupportedId())( + it.each(supportedDatabaseIds)( 'should return correct module when multiple modules exist - %p', async databaseId => { const { client } = await createDatabase(databaseId); @@ -1393,7 +1396,7 @@ describe('X2ADatabaseService', () => { }); describe('listModules', () => { - it.each(databases.eachSupportedId())( + it.each(supportedDatabaseIds)( 'should return empty list when no modules exist for project - %p', async databaseId => { const { client } = await createDatabase(databaseId); @@ -1415,7 +1418,7 @@ describe('X2ADatabaseService', () => { }, ); - it.each(databases.eachSupportedId())( + it.each(supportedDatabaseIds)( 'should return all modules for a project - %p', async databaseId => { const { client } = await createDatabase(databaseId); @@ -1457,7 +1460,7 @@ describe('X2ADatabaseService', () => { }, ); - it.each(databases.eachSupportedId())( + it.each(supportedDatabaseIds)( 'should only return modules for the specified project - %p', async databaseId => { const { client } = await createDatabase(databaseId); @@ -1515,7 +1518,7 @@ describe('X2ADatabaseService', () => { }); describe('deleteModule', () => { - it.each(databases.eachSupportedId())( + it.each(supportedDatabaseIds)( 'should return 0 when deleting non-existent module - %p', async databaseId => { const { client } = await createDatabase(databaseId); @@ -1529,7 +1532,7 @@ describe('X2ADatabaseService', () => { }, ); - it.each(databases.eachSupportedId())( + it.each(supportedDatabaseIds)( 'should delete a module and return 1 - %p', async databaseId => { const { client } = await createDatabase(databaseId); @@ -1566,7 +1569,7 @@ describe('X2ADatabaseService', () => { }, ); - it.each(databases.eachSupportedId())( + it.each(supportedDatabaseIds)( 'should only delete the specified module - %p', async databaseId => { const { client } = await createDatabase(databaseId); @@ -1617,7 +1620,7 @@ describe('X2ADatabaseService', () => { }); describe('CASCADE delete', () => { - it.each(databases.eachSupportedId())( + it.each(supportedDatabaseIds)( 'should cascade delete modules when project is deleted - %p', async databaseId => { const { client } = await createDatabase(databaseId); @@ -1696,7 +1699,7 @@ describe('X2ADatabaseService', () => { }, ); - it.each(databases.eachSupportedId())( + it.each(supportedDatabaseIds)( 'should only cascade delete modules for the deleted project - %p', async databaseId => { const { client } = await createDatabase(databaseId); @@ -1768,7 +1771,7 @@ describe('X2ADatabaseService', () => { }); describe('createJob', () => { - it.each(databases.eachSupportedId())( + it.each(supportedDatabaseIds)( 'should create a job with all required fields - %p', async databaseId => { const { client } = await createDatabase(databaseId); @@ -1815,7 +1818,7 @@ describe('X2ADatabaseService', () => { }, ); - it.each(databases.eachSupportedId())( + it.each(supportedDatabaseIds)( 'should create a job with optional fields - %p', async databaseId => { const { client } = await createDatabase(databaseId); @@ -1860,7 +1863,7 @@ describe('X2ADatabaseService', () => { }, ); - it.each(databases.eachSupportedId())( + it.each(supportedDatabaseIds)( 'should create a job with artifacts - %p', async databaseId => { const { client } = await createDatabase(databaseId); @@ -1907,7 +1910,7 @@ describe('X2ADatabaseService', () => { }, ); - it.each(databases.eachSupportedId())( + it.each(supportedDatabaseIds)( 'should create multiple jobs with different IDs - %p', async databaseId => { const { client } = await createDatabase(databaseId); @@ -1938,7 +1941,7 @@ describe('X2ADatabaseService', () => { }, ); - it.each(databases.eachSupportedId())( + it.each(supportedDatabaseIds)( 'should create jobs for different modules - %p', async databaseId => { const { client } = await createDatabase(databaseId); @@ -1973,7 +1976,7 @@ describe('X2ADatabaseService', () => { }, ); - it.each(databases.eachSupportedId())( + it.each(supportedDatabaseIds)( 'should default status to pending when not provided - %p', async databaseId => { const { client } = await createDatabase(databaseId); @@ -2003,7 +2006,7 @@ describe('X2ADatabaseService', () => { }); describe('getJob', () => { - it.each(databases.eachSupportedId())( + it.each(supportedDatabaseIds)( 'should return undefined for non-existent job - %p', async databaseId => { const { client } = await createDatabase(databaseId); @@ -2015,7 +2018,7 @@ describe('X2ADatabaseService', () => { }, ); - it.each(databases.eachSupportedId())( + it.each(supportedDatabaseIds)( 'should return the correct job by ID - %p', async databaseId => { const { client } = await createDatabase(databaseId); @@ -2054,7 +2057,7 @@ describe('X2ADatabaseService', () => { }, ); - it.each(databases.eachSupportedId())( + it.each(supportedDatabaseIds)( 'should return job with artifacts - %p', async databaseId => { const { client } = await createDatabase(databaseId); @@ -2092,7 +2095,7 @@ describe('X2ADatabaseService', () => { }, ); - it.each(databases.eachSupportedId())( + it.each(supportedDatabaseIds)( 'should return correct job when multiple jobs exist - %p', async databaseId => { const { client } = await createDatabase(databaseId); @@ -2135,7 +2138,7 @@ describe('X2ADatabaseService', () => { }); describe('listJobs', () => { - it.each(databases.eachSupportedId())( + it.each(supportedDatabaseIds)( 'should return empty list when no jobs exist for module - %p', async databaseId => { const { client } = await createDatabase(databaseId); @@ -2163,7 +2166,7 @@ describe('X2ADatabaseService', () => { }, ); - it.each(databases.eachSupportedId())( + it.each(supportedDatabaseIds)( 'should return all jobs for a module - %p', async databaseId => { const { client } = await createDatabase(databaseId); @@ -2208,7 +2211,7 @@ describe('X2ADatabaseService', () => { }, ); - it.each(databases.eachSupportedId())( + it.each(supportedDatabaseIds)( 'should return jobs with their artifacts - %p', async databaseId => { const { client } = await createDatabase(databaseId); @@ -2253,7 +2256,7 @@ describe('X2ADatabaseService', () => { }, ); - it.each(databases.eachSupportedId())( + it.each(supportedDatabaseIds)( 'should only return jobs for the specified module - %p', async databaseId => { const { client } = await createDatabase(databaseId); @@ -2296,7 +2299,7 @@ describe('X2ADatabaseService', () => { }); describe('updateJob', () => { - it.each(databases.eachSupportedId())( + it.each(supportedDatabaseIds)( 'should return undefined when updating non-existent job - %p', async databaseId => { const { client } = await createDatabase(databaseId); @@ -2311,7 +2314,7 @@ describe('X2ADatabaseService', () => { }, ); - it.each(databases.eachSupportedId())( + it.each(supportedDatabaseIds)( 'should update job status - %p', async databaseId => { const { client } = await createDatabase(databaseId); @@ -2349,7 +2352,7 @@ describe('X2ADatabaseService', () => { }, ); - it.each(databases.eachSupportedId())( + it.each(supportedDatabaseIds)( 'should update job log - %p', async databaseId => { const { client } = await createDatabase(databaseId); @@ -2386,7 +2389,7 @@ describe('X2ADatabaseService', () => { }, ); - it.each(databases.eachSupportedId())( + it.each(supportedDatabaseIds)( 'should update job finishedAt - %p', async databaseId => { const { client } = await createDatabase(databaseId); @@ -2423,7 +2426,7 @@ describe('X2ADatabaseService', () => { }, ); - it.each(databases.eachSupportedId())( + it.each(supportedDatabaseIds)( 'should update job artifacts - %p', async databaseId => { const { client } = await createDatabase(databaseId); @@ -2473,7 +2476,7 @@ describe('X2ADatabaseService', () => { }, ); - it.each(databases.eachSupportedId())( + it.each(supportedDatabaseIds)( 'should clear artifacts when updating with empty array - %p', async databaseId => { const { client } = await createDatabase(databaseId); @@ -2516,7 +2519,7 @@ describe('X2ADatabaseService', () => { }, ); - it.each(databases.eachSupportedId())( + it.each(supportedDatabaseIds)( 'should update multiple fields at once - %p', async databaseId => { const { client } = await createDatabase(databaseId); @@ -2563,7 +2566,7 @@ describe('X2ADatabaseService', () => { }); describe('deleteJob', () => { - it.each(databases.eachSupportedId())( + it.each(supportedDatabaseIds)( 'should return 0 when deleting non-existent job - %p', async databaseId => { const { client } = await createDatabase(databaseId); @@ -2577,7 +2580,7 @@ describe('X2ADatabaseService', () => { }, ); - it.each(databases.eachSupportedId())( + it.each(supportedDatabaseIds)( 'should delete a job and return 1 - %p', async databaseId => { const { client } = await createDatabase(databaseId); @@ -2618,7 +2621,7 @@ describe('X2ADatabaseService', () => { }, ); - it.each(databases.eachSupportedId())( + it.each(supportedDatabaseIds)( 'should cascade delete artifacts when job is deleted - %p', async databaseId => { const { client } = await createDatabase(databaseId); @@ -2662,7 +2665,7 @@ describe('X2ADatabaseService', () => { }, ); - it.each(databases.eachSupportedId())( + it.each(supportedDatabaseIds)( 'should only delete the specified job - %p', async databaseId => { const { client } = await createDatabase(databaseId); @@ -2709,7 +2712,7 @@ describe('X2ADatabaseService', () => { }); describe('CASCADE delete for jobs', () => { - it.each(databases.eachSupportedId())( + it.each(supportedDatabaseIds)( 'should cascade delete jobs when module is deleted - %p', async databaseId => { const { client } = await createDatabase(databaseId); @@ -2782,7 +2785,7 @@ describe('X2ADatabaseService', () => { }, ); - it.each(databases.eachSupportedId())( + it.each(supportedDatabaseIds)( 'should only cascade delete jobs for the deleted module - %p', async databaseId => { const { client } = await createDatabase(databaseId); diff --git a/workspaces/x2a/plugins/x2a-backend/src/utils/tests.ts b/workspaces/x2a/plugins/x2a-backend/src/utils/tests.ts index be9e67a5a7..0e05da42d3 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/utils/tests.ts +++ b/workspaces/x2a/plugins/x2a-backend/src/utils/tests.ts @@ -13,4 +13,5 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +export const LONG_TEST_TIMEOUT = 60 * 1000; export const nonExistentId = '00000000-0000-0000-0000-000000000000'; From 5a119e45bfeb1ae6c45203d293bdd940b2a854cf Mon Sep 17 00:00:00 2001 From: Marek Libra Date: Tue, 27 Jan 2026 15:41:59 +0100 Subject: [PATCH 7/7] changeset --- workspaces/x2a/.changeset/dirty-lizards-crash.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 workspaces/x2a/.changeset/dirty-lizards-crash.md diff --git a/workspaces/x2a/.changeset/dirty-lizards-crash.md b/workspaces/x2a/.changeset/dirty-lizards-crash.md new file mode 100644 index 0000000000..b84117e91d --- /dev/null +++ b/workspaces/x2a/.changeset/dirty-lizards-crash.md @@ -0,0 +1,7 @@ +--- +'@red-hat-developer-hub/backstage-plugin-x2a-backend': patch +'@red-hat-developer-hub/backstage-plugin-x2a-common': patch +'@red-hat-developer-hub/backstage-plugin-x2a': patch +--- + +Enables tests on both the SQLite and PostgreSQL. Adds app-config.production.yaml skeleton.