diff --git a/src/Web/StellaOps.Web/src/app/core/api/approval.client.spec.ts b/src/Web/StellaOps.Web/src/app/core/api/approval.client.spec.ts new file mode 100644 index 000000000..04a631948 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/core/api/approval.client.spec.ts @@ -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'); + }); +}); diff --git a/src/Web/StellaOps.Web/src/app/core/api/approval.client.ts b/src/Web/StellaOps.Web/src/app/core/api/approval.client.ts index 5ba63ff75..3421dd159 100644 --- a/src/Web/StellaOps.Web/src/app/core/api/approval.client.ts +++ b/src/Web/StellaOps.Web/src/app/core/api/approval.client.ts @@ -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 { - if (filter?.urgencies?.length || (filter?.statuses?.length ?? 0) > 1) { - return this.listApprovalsLegacy(filter); - } - const params: Record = {}; - 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(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 { return this.http.get(`${this.detailBaseUrl}/${id}`).pipe( - map(row => this.mapV2ApprovalDetail(row)), - catchError(() => this.http.get(`${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(`${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(`${this.legacyBaseUrl}/${id}/reject`, { comment })) + map(row => this.mapV2ApprovalDetail(row)) ); } batchApprove(ids: string[], comment: string): Observable { - return this.http.post(`${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 { - return this.http.post(`${this.legacyBaseUrl}/batch-reject`, { ids, comment }); - } + if (ids.length === 0) { + return of(undefined); + } - private listApprovalsLegacy(filter?: ApprovalFilter): Observable { - const params: Record = {}; - 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(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