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
28 changes: 21 additions & 7 deletions src/app/api/v2/builds/[uuid]/services/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { NextRequest } from 'next/server';
import { createApiHandler } from 'server/lib/createApiHandler';
import { errorResponse, successResponse } from 'server/lib/response';
import OverrideService, {
ServiceOverrideNotEditableError,
ServiceOverrideNotFoundError,
type ServiceOverridePatchInput,
} from 'server/services/override';
Expand All @@ -40,12 +41,12 @@ function validateServiceOverride(value: unknown, index: number): ServiceOverride
return new Error(`serviceOverrides[${index}] must be an object`);
}

const serviceName = value.serviceName;
const name = value.name;
const hasActive = hasOwn(value, 'active');
const hasBranchOrExternalUrl = hasOwn(value, 'branchOrExternalUrl');

if (typeof serviceName !== 'string' || serviceName.length === 0) {
return new Error(`serviceOverrides[${index}].serviceName must be a non-empty string`);
if (typeof name !== 'string' || name.length === 0) {
return new Error(`serviceOverrides[${index}].name must be a non-empty string`);
}

if (!hasActive && !hasBranchOrExternalUrl) {
Expand All @@ -61,7 +62,7 @@ function validateServiceOverride(value: unknown, index: number): ServiceOverride
}

return {
serviceName,
name,
...(hasActive ? { active: value.active as boolean } : {}),
...(hasBranchOrExternalUrl ? { branchOrExternalUrl: value.branchOrExternalUrl as string } : {}),
};
Expand Down Expand Up @@ -95,7 +96,7 @@ function validateServiceOverride(value: unknown, index: number): ServiceOverride
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/BuildOverrideUpdateSuccessResponse'
* $ref: '#/components/schemas/UpdateBuildServiceOverridesSuccessResponse'
* '400':
* description: Invalid request body.
* content:
Expand Down Expand Up @@ -136,7 +137,7 @@ const patchHandler = async (req: NextRequest, { params }: { params: { uuid: stri
const override = new OverrideService();
const build = await override.db.models.Build.query()
.findOne({ uuid: params.uuid })
.withGraphFetched('[pullRequest, deploys.[service, deployable]]');
.withGraphFetched('[pullRequest, environment.[defaultServices, optionalServices], deploys.[service, deployable]]');

if (!build) {
return errorResponse(new Error(`Build with UUID ${params.uuid} not found`), { status: 404 }, req);
Expand All @@ -150,13 +151,26 @@ const patchHandler = async (req: NextRequest, { params }: { params: { uuid: stri
serviceOverrides,
runUuid: nanoid(),
});
const updatedBuild = await override.db.models.Build.query()
.findOne({ uuid: params.uuid })
.withGraphFetched('[environment.[defaultServices, optionalServices], deploys.[service, deployable]]');

return successResponse(result, { status: 200 }, req);
if (!updatedBuild) {
return errorResponse(new Error(`Build with UUID ${params.uuid} not found`), { status: 404 }, req);
}

const updatedServiceOverrides = await override.getServiceOverrideStates(updatedBuild, updatedBuild.deploys || []);

return successResponse({ serviceOverrides: updatedServiceOverrides, queued: result.queued }, { status: 200 }, req);
} catch (error) {
if (error instanceof ServiceOverrideNotFoundError) {
return errorResponse(error, { status: 404 }, req);
}

if (error instanceof ServiceOverrideNotEditableError) {
return errorResponse(error, { status: 400 }, req);
}

throw error;
}
};
Expand Down
97 changes: 97 additions & 0 deletions src/server/services/__tests__/build.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ const mockIsFeatureEnabled = jest.fn();
const mockQueueAdd = jest.fn();
const mockCleanupDeploy = jest.fn();
const mockDeleteServiceRows = jest.fn();
const mockGetServiceOverrideStates = jest.fn();

jest.mock('server/lib/dependencies', () => ({
defaultDb: {},
Expand Down Expand Up @@ -142,6 +143,13 @@ jest.mock('server/services/webhook', () => ({
})),
}));

jest.mock('server/services/override', () => ({
__esModule: true,
default: jest.fn().mockImplementation(() => ({
getServiceOverrideStates: (...args: any[]) => mockGetServiceOverrideStates(...args),
})),
}));

jest.mock('server/lib/fastly', () =>
jest.fn().mockImplementation(() => ({
getServiceDashboardUrl: jest.fn(),
Expand Down Expand Up @@ -245,6 +253,95 @@ describe('BuildService build response queries', () => {
expect(query.findOne).toHaveBeenCalledWith({ uuid: 'sample-build' });
expect(query.select.mock.calls[0]).toEqual(expect.arrayContaining(['commentRuntimeEnv', 'commentInitEnv']));
});

test('attaches service override edit state to deploys when loading a build by UUID', async () => {
const build = {
id: 10,
uuid: 'sample-build',
deploys: [
{
uuid: 'api-sample-build',
deployable: { name: 'api' },
},
{
uuid: 'internal-sample-build',
deployable: { name: 'internal' },
},
],
};
const buildForServiceOverrides = {
id: 10,
uuid: 'sample-build',
deploys: [{ uuid: 'api-sample-build' }],
};
const query: any = {
findOne: jest.fn(() => query),
select: jest.fn(() => query),
withGraphFetched: jest.fn(() => query),
modifyGraph: jest.fn(() => query),
then: (resolve: (value: any) => void, reject: (reason: unknown) => void) =>
Promise.resolve(build).then(resolve, reject),
};
const serviceOverrideQuery: any = {
findOne: jest.fn(() => serviceOverrideQuery),
select: jest.fn(() => serviceOverrideQuery),
withGraphFetched: jest.fn(() => serviceOverrideQuery),
then: (resolve: (value: any) => void, reject: (reason: unknown) => void) =>
Promise.resolve(buildForServiceOverrides).then(resolve, reject),
};
const buildService = new BuildService(
{
models: {
Build: {
query: jest.fn().mockReturnValueOnce(query).mockReturnValueOnce(serviceOverrideQuery),
},
},
} as any,
{} as any,
{} as any,
createQueueManager() as any
);
mockGetServiceOverrideStates.mockResolvedValueOnce([
{
name: 'api',
active: true,
branchOrExternalUrl: 'feature/api',
status: 'deployed',
statusMessage: null,
updatedAt: '2026-05-08T12:00:00.000Z',
group: 'default',
editable: true,
},
]);

await expect(buildService.getBuildByUUID('sample-build')).resolves.toBe(build);

expect(serviceOverrideQuery.findOne).toHaveBeenCalledWith({ id: 10 });
expect(serviceOverrideQuery.withGraphFetched).toHaveBeenCalledWith(
'[environment.[defaultServices, optionalServices], deploys.[service, deployable]]'
);
expect(mockGetServiceOverrideStates).toHaveBeenCalledWith(
buildForServiceOverrides,
buildForServiceOverrides.deploys
);
expect(build.deploys).toEqual([
{
uuid: 'api-sample-build',
deployable: { name: 'api' },
serviceOverride: {
name: 'api',
branchOrExternalUrl: 'feature/api',
group: 'default',
editable: true,
},
},
{
uuid: 'internal-sample-build',
deployable: { name: 'internal' },
serviceOverride: null,
},
]);
});
});

describe('BuildService failure boundaries', () => {
Expand Down
Loading
Loading