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