From 9e75c49e5980cc3e9e6e020a93daac763b060a0b Mon Sep 17 00:00:00 2001 From: master <> Date: Wed, 1 Apr 2026 10:35:38 +0300 Subject: [PATCH] feat(web): advisory-ai conversation resume, hotfix wizard SlicePipe, release-control tests Co-Authored-By: Claude Opus 4.6 (1M context) --- .../advisory-ai/chat/chat.component.ts | 6 +- .../create-hotfix/create-hotfix.component.ts | 3 +- .../create-hotfix.component.spec.ts | 173 ++++++++++++++++ .../create-version.component.spec.ts | 194 ++++++++++++++++++ .../hotfixes-queue.component.spec.ts | 6 +- 5 files changed, 378 insertions(+), 4 deletions(-) create mode 100644 src/Web/StellaOps.Web/src/tests/release-control/create-hotfix.component.spec.ts create mode 100644 src/Web/StellaOps.Web/src/tests/release-control/create-version.component.spec.ts diff --git a/src/Web/StellaOps.Web/src/app/features/advisory-ai/chat/chat.component.ts b/src/Web/StellaOps.Web/src/app/features/advisory-ai/chat/chat.component.ts index 0c867d57d..d39601e1d 100644 --- a/src/Web/StellaOps.Web/src/app/features/advisory-ai/chat/chat.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/advisory-ai/chat/chat.component.ts @@ -596,7 +596,11 @@ export class ChatComponent implements OnInit, OnDestroy { }); // Start or load conversation - if (this.conversationId) { + const existingConv = this.chatService.conversation(); + if (existingConv && this.conversationId && existingConv.conversationId === this.conversationId) { + // Already have this conversation in state — just resume, no API call needed + this.trySendPendingInitialMessage(); + } else if (this.conversationId) { this.chatService.getConversation(this.conversationId).subscribe(() => { this.trySendPendingInitialMessage(); }); diff --git a/src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/create-hotfix/create-hotfix.component.ts b/src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/create-hotfix/create-hotfix.component.ts index b59c76567..33bab80ef 100644 --- a/src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/create-hotfix/create-hotfix.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/create-hotfix/create-hotfix.component.ts @@ -1,3 +1,4 @@ +import { SlicePipe } from '@angular/common'; import { Component, inject, signal, computed, ChangeDetectionStrategy } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { Router, RouterModule } from '@angular/router'; @@ -12,7 +13,7 @@ import { BundleOrganizerApi, type ReleaseControlBundleVersionDetailDto } from '. selector: 'app-create-hotfix', standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, - imports: [FormsModule, RouterModule], + imports: [FormsModule, RouterModule, SlicePipe], template: `
diff --git a/src/Web/StellaOps.Web/src/tests/release-control/create-hotfix.component.spec.ts b/src/Web/StellaOps.Web/src/tests/release-control/create-hotfix.component.spec.ts new file mode 100644 index 000000000..a2271eaf6 --- /dev/null +++ b/src/Web/StellaOps.Web/src/tests/release-control/create-hotfix.component.spec.ts @@ -0,0 +1,173 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { Router, provideRouter } from '@angular/router'; +import { signal } from '@angular/core'; +import { of } from 'rxjs'; +import { vi } from 'vitest'; + +import { type RegistryImage } from '../../app/core/api/release-management.models'; +import { AUTH_SERVICE, type AuthService } from '../../app/core/auth/auth.service'; +import { BundleOrganizerApi, type ReleaseControlBundleDetailDto, type ReleaseControlBundleVersionDetailDto } from '../../app/features/bundles/bundle-organizer.api'; +import { CreateHotfixComponent } from '../../app/features/release-orchestrator/releases/create-hotfix/create-hotfix.component'; +import { ReleaseManagementStore } from '../../app/features/release-orchestrator/releases/release.store'; + +describe('CreateHotfixComponent', () => { + let fixture: ComponentFixture; + let component: CreateHotfixComponent; + let router: Router; + let bundleApi: jasmine.SpyObj; + let auth: jasmine.SpyObj; + + const searchResults = signal([]); + const bundleDetail: ReleaseControlBundleDetailDto = { + id: 'bundle-hotfix-001', + slug: 'checkout-api-hotfix', + name: 'Checkout API Hotfix', + description: 'type=hotfix | pathIntent=hotfix-prod', + totalVersions: 0, + latestVersionNumber: null, + latestVersionId: null, + latestVersionDigest: null, + latestPublishedAt: null, + createdAt: '2026-03-31T09:00:00Z', + updatedAt: '2026-03-31T09:00:00Z', + createdBy: 'admin', + }; + const versionDetail: ReleaseControlBundleVersionDetailDto = { + id: 'version-hotfix-001', + bundleId: 'bundle-hotfix-001', + versionNumber: 1, + digest: 'sha256:9999999999999999999999999999999999999999999999999999999999999999', + status: 'published', + componentsCount: 1, + changelog: 'type=hotfix', + createdAt: '2026-03-31T09:05:00Z', + publishedAt: '2026-03-31T09:05:00Z', + createdBy: 'admin', + components: [ + { + componentVersionId: 'checkout-api@v1.2.4-hf.20260331.1015', + componentName: 'checkout-api', + imageDigest: 'sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + deployOrder: 10, + metadataJson: '{"hotfix":true}', + }, + ], + }; + const selectedImage: RegistryImage = { + name: 'checkout-api', + repository: 'registry.example.local/stella/checkout-api', + tags: ['v1.2.4'], + digests: [ + { + tag: 'v1.2.4', + digest: 'sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + pushedAt: '2026-03-31T09:00:00Z', + }, + ], + lastPushed: '2026-03-31T09:00:00Z', + }; + + beforeEach(async () => { + bundleApi = jasmine.createSpyObj('BundleOrganizerApi', [ + 'createBundle', + 'listBundles', + 'publishBundleVersion', + 'materializeBundleVersion', + ]); + bundleApi.createBundle.and.returnValue(of(bundleDetail)); + bundleApi.listBundles.and.returnValue(of([bundleDetail])); + bundleApi.publishBundleVersion.and.returnValue(of(versionDetail)); + bundleApi.materializeBundleVersion.and.returnValue(of({ + runId: 'run-001', + bundleId: 'bundle-hotfix-001', + versionId: 'version-hotfix-001', + status: 'queued', + targetEnvironment: null, + reason: 'console_hotfix_create', + requestedBy: 'admin', + idempotencyKey: 'hotfix-checkout-api-hotfix-1711872900000', + requestedAt: '2026-03-31T09:06:00Z', + updatedAt: '2026-03-31T09:06:00Z', + })); + + auth = jasmine.createSpyObj('AuthService', ['hasScope']); + auth.hasScope.and.returnValue(true); + + await TestBed.configureTestingModule({ + imports: [CreateHotfixComponent], + providers: [ + provideRouter([]), + { + provide: ReleaseManagementStore, + useValue: { + searchResults, + searchImages: jasmine.createSpy('searchImages'), + clearSearchResults: jasmine.createSpy('clearSearchResults'), + }, + }, + { provide: BundleOrganizerApi, useValue: bundleApi }, + { provide: AUTH_SERVICE, useValue: auth }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(CreateHotfixComponent); + component = fixture.componentInstance; + router = TestBed.inject(Router); + spyOn(router, 'navigate').and.returnValue(Promise.resolve(true)); + fixture.detectChanges(); + }); + + afterEach(() => { + searchResults.set([]); + vi.useRealTimers(); + }); + + it('publishes and materializes the selected hotfix through the bundle lifecycle', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-03-31T10:15:00Z')); + + component.selectImage(selectedImage); + component.pickDigest(selectedImage.digests[0]); + component.description = 'Critical CVE patch'; + component.sealConfirmed = true; + + component.sealAndCreate(); + + expect(bundleApi.createBundle).toHaveBeenCalledWith( + jasmine.objectContaining({ + slug: 'checkout-api-hotfix', + name: 'checkout-api-hotfix', + description: expect.stringContaining('type=hotfix'), + }), + ); + expect(bundleApi.publishBundleVersion).toHaveBeenCalledWith('bundle-hotfix-001', jasmine.objectContaining({ + components: [jasmine.objectContaining({ + componentName: 'checkout-api', + imageDigest: 'sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + })], + })); + expect(bundleApi.materializeBundleVersion).toHaveBeenCalledWith('bundle-hotfix-001', 'version-hotfix-001', jasmine.objectContaining({ + reason: 'console_hotfix_create', + })); + expect(router.navigate).toHaveBeenCalledWith(['/releases/bundles', 'bundle-hotfix-001', 'versions', 'version-hotfix-001'], { + queryParams: { + source: 'hotfix-create', + type: 'hotfix', + returnTo: '/releases/versions', + }, + queryParamsHandling: 'merge', + }); + }); + + it('surfaces missing orch:operate scope instead of attempting publish', () => { + auth.hasScope.and.returnValue(false); + component.selectImage(selectedImage); + component.pickDigest(selectedImage.digests[0]); + component.sealConfirmed = true; + + component.sealAndCreate(); + + expect(bundleApi.createBundle).not.toHaveBeenCalled(); + expect(component.submitError()).toContain('orch:operate'); + }); +}); diff --git a/src/Web/StellaOps.Web/src/tests/release-control/create-version.component.spec.ts b/src/Web/StellaOps.Web/src/tests/release-control/create-version.component.spec.ts new file mode 100644 index 000000000..60bce8150 --- /dev/null +++ b/src/Web/StellaOps.Web/src/tests/release-control/create-version.component.spec.ts @@ -0,0 +1,194 @@ +import { Component, signal } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { Router, provideRouter } from '@angular/router'; +import { of } from 'rxjs'; + +import { type RegistryImage } from '../../app/core/api/release-management.models'; +import { AUTH_SERVICE, type AuthService } from '../../app/core/auth/auth.service'; +import { BundleOrganizerApi, type ReleaseControlBundleDetailDto, type ReleaseControlBundleVersionDetailDto } from '../../app/features/bundles/bundle-organizer.api'; +import { CreateVersionComponent } from '../../app/features/release-orchestrator/releases/create-version/create-version.component'; +import { ReleaseManagementStore } from '../../app/features/release-orchestrator/releases/release.store'; +import { ScriptEditorComponent } from '../../app/shared/components/script-editor/script-editor.component'; + +@Component({ + selector: 'app-script-editor', + standalone: true, + template: '', +}) +class StubScriptEditorComponent { + getContent(): string { + return ''; + } + + getCurrentExtension(): string { + return '.sh'; + } +} + +describe('CreateVersionComponent', () => { + let fixture: ComponentFixture; + let component: CreateVersionComponent; + let router: Router; + let bundleApi: jasmine.SpyObj; + let auth: jasmine.SpyObj; + + const searchResults = signal([]); + const bundleDetail: ReleaseControlBundleDetailDto = { + id: 'bundle-001', + slug: 'checkout-api', + name: 'Checkout API', + description: 'Release version bundle', + totalVersions: 0, + latestVersionNumber: null, + latestVersionId: null, + latestVersionDigest: null, + latestPublishedAt: null, + createdAt: '2026-03-31T09:00:00Z', + updatedAt: '2026-03-31T09:00:00Z', + createdBy: 'admin', + }; + const versionDetail: ReleaseControlBundleVersionDetailDto = { + id: 'version-001', + bundleId: 'bundle-001', + versionNumber: 1, + digest: 'sha256:1111111111111111111111111111111111111111111111111111111111111111', + status: 'published', + componentsCount: 1, + changelog: 'Version v1.2.3', + createdAt: '2026-03-31T09:05:00Z', + publishedAt: '2026-03-31T09:05:00Z', + createdBy: 'admin', + components: [ + { + componentVersionId: 'checkout-api@v1.2.3', + componentName: 'checkout-api', + imageDigest: 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + deployOrder: 10, + metadataJson: '{}', + }, + ], + }; + + beforeEach(async () => { + bundleApi = jasmine.createSpyObj('BundleOrganizerApi', [ + 'createBundle', + 'listBundles', + 'publishBundleVersion', + ]); + bundleApi.createBundle.and.returnValue(of(bundleDetail)); + bundleApi.listBundles.and.returnValue(of([bundleDetail])); + bundleApi.publishBundleVersion.and.returnValue(of(versionDetail)); + + auth = jasmine.createSpyObj('AuthService', ['hasScope']); + auth.hasScope.and.returnValue(true); + + await TestBed.configureTestingModule({ + imports: [CreateVersionComponent], + providers: [ + provideRouter([]), + { + provide: ReleaseManagementStore, + useValue: { + searchResults, + searchImages: jasmine.createSpy('searchImages'), + clearSearchResults: jasmine.createSpy('clearSearchResults'), + }, + }, + { provide: BundleOrganizerApi, useValue: bundleApi }, + { provide: AUTH_SERVICE, useValue: auth }, + ], + }) + .overrideComponent(CreateVersionComponent, { + remove: { imports: [ScriptEditorComponent] }, + add: { imports: [StubScriptEditorComponent] }, + }) + .compileComponents(); + + fixture = TestBed.createComponent(CreateVersionComponent); + component = fixture.componentInstance; + router = TestBed.inject(Router); + spyOn(router, 'navigate').and.returnValue(Promise.resolve(true)); + fixture.detectChanges(); + }); + + afterEach(() => { + searchResults.set([]); + }); + + it('adds script components with inline content and shell metadata', () => { + component.scriptEditor = { + getContent: () => 'echo ok', + getCurrentExtension: () => '.sh', + } as unknown as ScriptEditorComponent; + component.scriptName = 'health-check'; + + component.addScriptComponent(); + + expect(component.components).toHaveLength(1); + expect(component.components[0]).toEqual( + jasmine.objectContaining({ + name: 'health-check', + imageRef: 'script://health-check.sh', + type: 'script', + scriptContent: 'echo ok', + }), + ); + expect(component.components[0].digest).toContain('sha256:'); + }); + + it('publishes through the canonical bundle lifecycle and navigates to the version detail', () => { + component.form.name = 'Checkout API'; + component.form.version = 'v1.2.3'; + component.form.description = 'Release version bundle'; + component.components = [ + { + name: 'checkout-api', + imageRef: 'registry.example.local/stella/checkout-api', + digest: 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + tag: 'v1.2.3', + version: 'v1.2.3', + type: 'container', + }, + ]; + component.sealConfirmed = true; + + component.sealVersion(); + + expect(bundleApi.createBundle).toHaveBeenCalledWith({ + slug: 'checkout-api', + name: 'Checkout API', + description: 'Release version bundle', + }); + expect(bundleApi.publishBundleVersion).toHaveBeenCalledWith('bundle-001', jasmine.objectContaining({ + changelog: 'Release version bundle', + components: [jasmine.objectContaining({ + componentName: 'checkout-api', + componentVersionId: 'checkout-api@v1.2.3', + })], + })); + expect(router.navigate).toHaveBeenCalledWith(['/releases/versions', 'version-001'], { + queryParamsHandling: 'merge', + }); + }); + + it('surfaces missing orch:operate scope instead of attempting publish', () => { + auth.hasScope.and.returnValue(false); + component.form.name = 'Checkout API'; + component.form.version = 'v1.2.3'; + component.components = [ + { + name: 'checkout-api', + imageRef: 'registry.example.local/stella/checkout-api', + digest: 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + version: 'v1.2.3', + type: 'container', + }, + ]; + component.sealConfirmed = true; + + component.sealVersion(); + + expect(bundleApi.createBundle).not.toHaveBeenCalled(); + expect(component.submitError()).toContain('orch:operate'); + }); +}); diff --git a/src/Web/StellaOps.Web/src/tests/release-control/hotfixes-queue.component.spec.ts b/src/Web/StellaOps.Web/src/tests/release-control/hotfixes-queue.component.spec.ts index 8798c0216..3e8900481 100644 --- a/src/Web/StellaOps.Web/src/tests/release-control/hotfixes-queue.component.spec.ts +++ b/src/Web/StellaOps.Web/src/tests/release-control/hotfixes-queue.component.spec.ts @@ -15,10 +15,12 @@ describe('HotfixesQueueComponent', () => { const fixture = TestBed.createComponent(HotfixesQueueComponent); fixture.detectChanges(); - const reviewLink = fixture.nativeElement.querySelector('.action-link') as HTMLAnchorElement | null; + const reviewLink = fixture.nativeElement.querySelector('.row-action') as HTMLAnchorElement | null; + const createLink = fixture.nativeElement.querySelector('.header-actions .btn-primary') as HTMLAnchorElement | null; expect(reviewLink).not.toBeNull(); expect(reviewLink?.getAttribute('href')).toBe('/releases/hotfixes/platform-bundle-1-3-1-hotfix1'); - expect(reviewLink?.textContent?.trim()).toBe('Review'); + expect(createLink).not.toBeNull(); + expect(createLink?.getAttribute('href')).toBe('/releases/hotfixes/new'); }); });