Keep approval queue on live canonical contracts
This commit is contained in:
121
src/Web/StellaOps.Web/src/app/core/api/approval.client.spec.ts
Normal file
121
src/Web/StellaOps.Web/src/app/core/api/approval.client.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user