Keep approval queue on live canonical contracts

This commit is contained in:
master
2026-03-10 01:38:21 +02:00
parent 4a13601207
commit 7be7295597
2 changed files with 162 additions and 25 deletions

View File

@@ -0,0 +1,121 @@
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { ApprovalHttpClient } from './approval.client';
import type { ApprovalFilter } from './approval.models';
describe('ApprovalHttpClient', () => {
let httpMock: HttpTestingController;
let client: ApprovalHttpClient;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
ApprovalHttpClient,
provideHttpClient(withInterceptorsFromDi()),
provideHttpClientTesting(),
],
});
httpMock = TestBed.inject(HttpTestingController);
client = TestBed.inject(ApprovalHttpClient);
});
afterEach(() => {
httpMock.verify();
});
it('filters multi-status and urgency queries against the v2 approvals queue without using the legacy list route', () => {
const filter: ApprovalFilter = {
statuses: ['pending', 'approved'],
urgencies: ['high'],
environment: 'prod',
};
let resultIds: string[] | null = null;
client.listApprovals(filter).subscribe((items) => {
resultIds = items.map((item) => item.id);
});
const req = httpMock.expectOne(
(request) =>
request.method === 'GET' &&
request.url === '/api/v2/releases/approvals' &&
request.params.get('environment') === 'prod' &&
!request.params.has('status')
);
req.flush({
items: [
{
approvalId: 'apr-001',
releaseId: 'rel-001',
releaseName: 'API',
requestedBy: 'alice',
requestedAt: '2026-03-09T08:00:00Z',
targetEnvironment: 'prod',
urgency: 'high',
status: 'pending',
currentApprovals: 1,
requiredApprovals: 2,
},
{
approvalId: 'apr-002',
releaseId: 'rel-002',
releaseName: 'Worker',
requestedBy: 'bob',
requestedAt: '2026-03-09T08:30:00Z',
targetEnvironment: 'prod',
urgency: 'normal',
status: 'approved',
currentApprovals: 2,
requiredApprovals: 2,
},
],
});
expect(resultIds).toEqual(['apr-001']);
httpMock.expectNone((request) => request.url.includes('/api/v1/release-orchestrator/approvals'));
});
it('fans out batch approval decisions through the canonical approval decision endpoint', () => {
let completed = false;
client.batchApprove(['apr-001', 'apr-002'], 'Ship it').subscribe(() => {
completed = true;
});
const requests = httpMock.match((request) =>
request.method === 'POST' &&
(request.url === '/api/v1/approvals/apr-001/decision' ||
request.url === '/api/v1/approvals/apr-002/decision')
);
expect(requests.length).toBe(2);
expect(requests.every((request) => request.request.body.action === 'approve')).toBeTrue();
expect(requests.every((request) => request.request.body.comment === 'Ship it')).toBeTrue();
for (const request of requests) {
request.flush({
id: request.request.url.includes('apr-001') ? 'apr-001' : 'apr-002',
releaseId: 'rel-001',
releaseName: 'API',
requestedBy: 'alice',
requestedAt: '2026-03-09T08:00:00Z',
targetEnvironment: 'prod',
sourceEnvironment: 'stage',
urgency: 'high',
status: 'approved',
currentApprovals: 2,
requiredApprovals: 2,
gateResults: [],
actions: [],
approvers: [],
releaseComponents: [],
});
}
expect(completed).toBeTrue();
httpMock.expectNone('/api/v1/release-orchestrator/approvals/batch-approve');
});
});

View File

@@ -4,7 +4,7 @@
*/
import { Injectable, InjectionToken, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, catchError, delay, map, of } from 'rxjs';
import { Observable, delay, forkJoin, map, of } from 'rxjs';
import type {
ApprovalRequest,
ApprovalDetail,
@@ -34,30 +34,26 @@ export class ApprovalHttpClient implements ApprovalApi {
private readonly http = inject(HttpClient);
private readonly queueBaseUrl = '/api/v2/releases/approvals';
private readonly detailBaseUrl = '/api/v1/approvals';
private readonly legacyBaseUrl = '/api/v1/release-orchestrator/approvals';
listApprovals(filter?: ApprovalFilter): Observable<ApprovalRequest[]> {
if (filter?.urgencies?.length || (filter?.statuses?.length ?? 0) > 1) {
return this.listApprovalsLegacy(filter);
}
const params: Record<string, string> = {};
if (filter?.statuses?.length) params['status'] = filter.statuses[0];
if ((filter?.statuses?.length ?? 0) === 1) params['status'] = filter!.statuses![0];
if (filter?.environment) params['environment'] = filter.environment;
return this.http.get<any>(this.queueBaseUrl, { params }).pipe(
map((rows) => {
const items = Array.isArray(rows) ? rows : (rows?.items ?? []);
return items.map((row: any) => this.mapV2ApprovalSummary(row));
}),
catchError(() => this.listApprovalsLegacy(filter))
return this.filterApprovals(
items.map((row: any) => this.mapV2ApprovalSummary(row)),
filter
);
})
);
}
getApproval(id: string): Observable<ApprovalDetail> {
return this.http.get<any>(`${this.detailBaseUrl}/${id}`).pipe(
map(row => this.mapV2ApprovalDetail(row)),
catchError(() => this.http.get<ApprovalDetail>(`${this.legacyBaseUrl}/${id}`))
map(row => this.mapV2ApprovalDetail(row))
);
}
@@ -87,8 +83,7 @@ export class ApprovalHttpClient implements ApprovalApi {
comment,
actor: 'ui-operator',
}).pipe(
map(row => this.mapV2ApprovalDetail(row)),
catchError(() => this.http.post<ApprovalDetail>(`${this.legacyBaseUrl}/${id}/approve`, { comment }))
map(row => this.mapV2ApprovalDetail(row))
);
}
@@ -98,25 +93,28 @@ export class ApprovalHttpClient implements ApprovalApi {
comment,
actor: 'ui-operator',
}).pipe(
map(row => this.mapV2ApprovalDetail(row)),
catchError(() => this.http.post<ApprovalDetail>(`${this.legacyBaseUrl}/${id}/reject`, { comment }))
map(row => this.mapV2ApprovalDetail(row))
);
}
batchApprove(ids: string[], comment: string): Observable<void> {
return this.http.post<void>(`${this.legacyBaseUrl}/batch-approve`, { ids, comment });
if (ids.length === 0) {
return of(undefined);
}
return forkJoin(ids.map((id) => this.approve(id, comment))).pipe(
map(() => undefined)
);
}
batchReject(ids: string[], comment: string): Observable<void> {
return this.http.post<void>(`${this.legacyBaseUrl}/batch-reject`, { ids, comment });
}
if (ids.length === 0) {
return of(undefined);
}
private listApprovalsLegacy(filter?: ApprovalFilter): Observable<ApprovalRequest[]> {
const params: Record<string, string> = {};
if (filter?.statuses?.length) params['statuses'] = filter.statuses.join(',');
if (filter?.urgencies?.length) params['urgencies'] = filter.urgencies.join(',');
if (filter?.environment) params['environment'] = filter.environment;
return this.http.get<ApprovalRequest[]>(this.legacyBaseUrl, { params });
return forkJoin(ids.map((id) => this.reject(id, comment))).pipe(
map(() => undefined)
);
}
private mapV2ApprovalSummary(row: any): ApprovalRequest {
@@ -174,6 +172,24 @@ export class ApprovalHttpClient implements ApprovalApi {
})),
};
}
private filterApprovals(items: ApprovalRequest[], filter?: ApprovalFilter): ApprovalRequest[] {
return items.filter((item) => {
if (filter?.statuses?.length && !filter.statuses.includes(item.status)) {
return false;
}
if (filter?.urgencies?.length && !filter.urgencies.includes(item.urgency)) {
return false;
}
if (filter?.environment && item.targetEnvironment !== filter.environment) {
return false;
}
return true;
});
}
}
// Mock Client Implementation