Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions workspaces/x2a/.changeset/eight-jokes-invent.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@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
'@red-hat-developer-hub/backstage-plugin-x2a-scaffolder': patch
---

Adding Project Details Page and fixing issues in the Module Details Page.
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,6 @@ export function createProjectAction(
// Output the results
ctx.output('projectId', project.id);
ctx.output('initJobId', initResponseData.jobId);
// TODO: Build proper URL of project detail page once implemented
ctx.output('nextUrl', `/x2a/projects/${project.id}`);
},
});
Expand Down
301 changes: 300 additions & 1 deletion workspaces/x2a/plugins/x2a-backend/src/router/jobs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { mockCredentials, mockServices } from '@backstage/backend-test-utils';
import request from 'supertest';
import { AuthorizeResult } from '@backstage/plugin-permission-common';
import { Knex } from 'knex';
import { Readable } from 'node:stream';

import { X2ADatabaseService } from '../services/X2ADatabaseService';
import {
Expand All @@ -38,6 +39,304 @@ describe('createRouter – jobs (log)', () => {
await tearDownRouters();
});

describe.each(supportedDatabaseIds)(
'GET /projects/:projectId/log (init phase) - %p',
databaseId => {
let client: Knex;
let x2aDatabase: X2ADatabaseService;
let project: { id: string };

beforeEach(async () => {
const dbSetup = await createDatabase(databaseId);
client = dbSetup.client;
x2aDatabase = X2ADatabaseService.create({
logger: mockServices.logger.mock(),
dbClient: client,
});

project = await createTestProject(x2aDatabase);
}, LONG_TEST_TIMEOUT);

it(
'should return logs from database for finished init job with success status',
async () => {
await createTestJob(x2aDatabase, {
projectId: project.id,
moduleId: null,
phase: 'init',
status: 'success',
log: 'Init job logs from database',
});

const app = await createApp(client);

const response = await request(app)
.get(`/projects/${project.id}/log`)
.send();

expect(response.status).toBe(200);
expect(response.type).toBe('text/plain');
expect(response.text).toBe('Init job logs from database');
},
LONG_TEST_TIMEOUT,
);

it(
'should return logs from database for finished init job with error status',
async () => {
await createTestJob(x2aDatabase, {
projectId: project.id,
moduleId: null,
phase: 'init',
status: 'error',
log: 'Init failed: migration plan error',
});

const app = await createApp(client);

const response = await request(app)
.get(`/projects/${project.id}/log`)
.send();

expect(response.status).toBe(200);
expect(response.type).toBe('text/plain');
expect(response.text).toBe('Init failed: migration plan error');
},
LONG_TEST_TIMEOUT,
);

it(
'should return logs from Kubernetes for running init job',
async () => {
await createTestJob(x2aDatabase, {
projectId: project.id,
moduleId: null,
phase: 'init',
status: 'running',
k8sJobName: 'init-k8s-job',
});

const mockGetJobLogs = jest
.fn()
.mockResolvedValue('Init job logs from Kubernetes');
const app = await createApp(client, undefined, undefined, {
getJobLogs: mockGetJobLogs,
});

const response = await request(app)
.get(`/projects/${project.id}/log`)
.send();

expect(response.status).toBe(200);
expect(response.type).toBe('text/plain');
expect(response.text).toBe('Init job logs from Kubernetes');
expect(mockGetJobLogs).toHaveBeenCalledWith('init-k8s-job', false);
},
LONG_TEST_TIMEOUT,
);

it(
'should call getJobLogs with streaming=true when streaming query param is set',
async () => {
await createTestJob(x2aDatabase, {
projectId: project.id,
moduleId: null,
phase: 'init',
status: 'running',
k8sJobName: 'init-k8s-job',
});

const mockGetJobLogs = jest
.fn()
.mockResolvedValue('Streaming init logs');
const app = await createApp(client, undefined, undefined, {
getJobLogs: mockGetJobLogs,
});

const response = await request(app)
.get(`/projects/${project.id}/log?streaming=true`)
.send();

expect(response.status).toBe(200);
expect(mockGetJobLogs).toHaveBeenCalledWith('init-k8s-job', true);
},
LONG_TEST_TIMEOUT,
);

it(
'should handle stream error mid-transfer and send error indicator to client',
async () => {
await createTestJob(x2aDatabase, {
projectId: project.id,
moduleId: null,
phase: 'init',
status: 'running',
k8sJobName: 'init-k8s-job',
});

// Error on second pull so first chunk is delivered before destroy (deterministic)
let firstChunkSent = false;
const failingStream = new Readable({
read() {
if (firstChunkSent) {
this.destroy(new Error('Connection interrupted'));
} else {
firstChunkSent = true;
this.push('Log line 1\n');
}
},
});

const mockGetJobLogs = jest.fn().mockResolvedValue(failingStream);
const app = await createApp(client, undefined, undefined, {
getJobLogs: mockGetJobLogs,
});

const response = await request(app)
.get(`/projects/${project.id}/log?streaming=true`)
.send();

expect(response.status).toBe(200);
expect(response.text).toContain('Log line 1');
expect(response.text).toContain(
'[Log stream error: connection interrupted]',
);
},
LONG_TEST_TIMEOUT,
);

it(
'should return empty logs when init job has no k8sJobName',
async () => {
await createTestJob(x2aDatabase, {
projectId: project.id,
moduleId: null,
phase: 'init',
status: 'pending',
});

const app = await createApp(client);

const response = await request(app)
.get(`/projects/${project.id}/log`)
.send();

expect(response.status).toBe(200);
expect(response.type).toBe('text/plain');
expect(response.text).toBe('');
},
LONG_TEST_TIMEOUT,
);

it(
'should return 404 when no init job found for project',
async () => {
const app = await createApp(client);

const response = await request(app)
.get(`/projects/${project.id}/log`)
.send();

expect(response.status).toBe(404);
expect(response.body).toMatchObject({
error: {
name: 'NotFoundError',
message: expect.stringContaining('No init job found'),
},
});
},
LONG_TEST_TIMEOUT,
);

it(
'should return 404 when project does not exist',
async () => {
const app = await createApp(client);

const response = await request(app)
.get(`/projects/${nonExistentId}/log`)
.send();

expect(response.status).toBe(404);
expect(response.body).toMatchObject({
error: {
name: 'NotFoundError',
message: 'Project not found for the "user:default/mock" user.',
},
});
},
LONG_TEST_TIMEOUT,
);

it(
'should return latest init job logs when multiple init jobs exist',
async () => {
await createTestJob(x2aDatabase, {
projectId: project.id,
moduleId: null,
phase: 'init',
status: 'success',
log: 'Old init logs',
});

await new Promise(resolve => setTimeout(resolve, 10));

await createTestJob(x2aDatabase, {
projectId: project.id,
moduleId: null,
phase: 'init',
status: 'success',
log: 'New init logs',
});

const app = await createApp(client);

const response = await request(app)
.get(`/projects/${project.id}/log`)
.send();

expect(response.status).toBe(200);
expect(response.text).toBe('New init logs');
},
LONG_TEST_TIMEOUT,
);

it(
'should return 403 when user has neither x2a.user nor x2a admin permissions',
async () => {
await createTestJob(x2aDatabase, {
projectId: project.id,
moduleId: null,
phase: 'init',
status: 'success',
log: 'Init logs',
});

const app = await createApp(
client,
AuthorizeResult.DENY,
undefined,
undefined,
AuthorizeResult.DENY,
);

const response = await request(app)
.get(`/projects/${project.id}/log`)
.send();

expect(response.status).toBe(403);
expect(response.body).toMatchObject({
error: {
name: 'NotAllowedError',
message: 'The user is not allowed to read projects.',
},
});
},
LONG_TEST_TIMEOUT,
);
},
);

describe.each(supportedDatabaseIds)(
'GET /projects/:projectId/modules/:moduleId/log - %p',
databaseId => {
Expand All @@ -56,7 +355,7 @@ describe('createRouter – jobs (log)', () => {

project = await createTestProject(x2aDatabase);
module = await createTestModule(x2aDatabase, project.id);
});
}, LONG_TEST_TIMEOUT);

it(
'should return logs from database for finished job with success status',
Expand Down
Loading