diff --git a/src/app/modules/guest/guest-show-data.service.spec.ts b/src/app/modules/guest/guest-show-data.service.spec.ts index a4b4b74..9283346 100644 --- a/src/app/modules/guest/guest-show-data.service.spec.ts +++ b/src/app/modules/guest/guest-show-data.service.spec.ts @@ -1,33 +1,36 @@ import {TestBed} from '@angular/core/testing'; import {Firestore} from '@angular/fire/firestore'; import {of} from 'rxjs'; +import {beforeEach, describe, expect, it, vi} from 'vitest'; import {DbService} from 'src/app/services/db.service'; import {GuestShowDataService} from './guest-show-data.service'; describe('GuestShowDataService', () => { + type DocumentRefStub = {update: ReturnType; delete: ReturnType}; + type CollectionRefStub = {add: ReturnType}; + type PathSpy = ReturnType & ((path: string) => T); + let service: GuestShowDataService; - let docUpdateSpy: jasmine.Spy<() => Promise>; - let docDeleteSpy: jasmine.Spy<() => Promise>; - let docSpy: jasmine.Spy; - let colAddSpy: jasmine.Spy<() => Promise<{id: string}>>; - let colSpy: jasmine.Spy; - let dbServiceSpy: jasmine.SpyObj; + let docUpdateSpy: ReturnType; + let docDeleteSpy: ReturnType; + let docSpy: PathSpy; + let colAddSpy: ReturnType; + let colSpy: PathSpy; + let dbServiceSpy: {col$: ReturnType; doc$: ReturnType; doc: ReturnType; col: ReturnType}; let firestoreStub: Firestore; beforeEach(async () => { - docUpdateSpy = jasmine.createSpy('update').and.resolveTo(); - docDeleteSpy = jasmine.createSpy('delete').and.resolveTo(); - docSpy = jasmine.createSpy('doc').and.returnValue({ + docUpdateSpy = vi.fn().mockResolvedValue(undefined); + docDeleteSpy = vi.fn().mockResolvedValue(undefined); + docSpy = vi.fn().mockReturnValue({ update: docUpdateSpy, delete: docDeleteSpy, - }); - colAddSpy = jasmine.createSpy('add').and.resolveTo({id: 'guest-2'}); - colSpy = jasmine.createSpy('col').and.returnValue({add: colAddSpy}); - dbServiceSpy = jasmine.createSpyObj('DbService', ['col$', 'doc$', 'doc', 'col']); - dbServiceSpy.col$.and.returnValue(of([{id: 'guest-1'}]) as never); - dbServiceSpy.doc$.and.returnValue(of({id: 'guest-1'}) as never); - dbServiceSpy.doc.and.callFake(docSpy); - dbServiceSpy.col.and.callFake(colSpy); + }) as PathSpy; + colAddSpy = vi.fn().mockResolvedValue({id: 'guest-2'}); + colSpy = vi.fn().mockReturnValue({add: colAddSpy}) as PathSpy; + dbServiceSpy = {col$: vi.fn(), doc$: vi.fn(), doc: docSpy, col: colSpy}; + dbServiceSpy.col$.mockReturnValue(of([{id: 'guest-1'}]) as never); + dbServiceSpy.doc$.mockReturnValue(of({id: 'guest-1'}) as never); firestoreStub = {} as Firestore; await TestBed.configureTestingModule({ @@ -60,15 +63,15 @@ describe('GuestShowDataService', () => { await service.update$('guest-7', {published: true} as never); expect(docSpy).toHaveBeenCalledWith('guest/guest-7'); - const [updatePayload] = docUpdateSpy.calls.mostRecent().args as unknown as [Record]; + const [updatePayload] = docUpdateSpy.mock.calls.at(-1) as [Record]; expect(updatePayload).toEqual({published: true}); }); it('should add a guest show and return the created id', async () => { - await expectAsync(service.add({published: false} as never)).toBeResolvedTo('guest-2'); + await expect(service.add({published: false} as never)).resolves.toEqual('guest-2'); expect(colSpy).toHaveBeenCalledWith('guest'); - const [addPayload] = colAddSpy.calls.mostRecent().args as unknown as [Record]; + const [addPayload] = colAddSpy.mock.calls.at(-1) as [Record]; expect(addPayload).toEqual({published: false}); }); diff --git a/src/app/modules/guest/guest-show.service.spec.ts b/src/app/modules/guest/guest-show.service.spec.ts index 09f4896..486e6c0 100644 --- a/src/app/modules/guest/guest-show.service.spec.ts +++ b/src/app/modules/guest/guest-show.service.spec.ts @@ -4,18 +4,19 @@ import {GuestShowService} from './guest-show.service'; import {ShowService} from '../shows/services/show.service'; import {Show} from '../shows/services/show'; import {Song} from '../songs/services/song'; +import {beforeEach, describe, expect, it, vi} from 'vitest'; describe('GuestShowService', () => { let service: GuestShowService; - let guestShowDataServiceSpy: jasmine.SpyObj; - let showServiceSpy: jasmine.SpyObj; + let guestShowDataServiceSpy: {add: ReturnType; update$: ReturnType}; + let showServiceSpy: {update$: ReturnType}; beforeEach(async () => { - guestShowDataServiceSpy = jasmine.createSpyObj('GuestShowDataService', ['add', 'update$']); - showServiceSpy = jasmine.createSpyObj('ShowService', ['update$']); - guestShowDataServiceSpy.add.and.resolveTo('share-1'); - guestShowDataServiceSpy.update$.and.resolveTo(); - showServiceSpy.update$.and.resolveTo(); + guestShowDataServiceSpy = {add: vi.fn(), update$: vi.fn()}; + showServiceSpy = {update$: vi.fn()}; + guestShowDataServiceSpy.add.mockResolvedValue('share-1'); + guestShowDataServiceSpy.update$.mockResolvedValue(undefined); + showServiceSpy.update$.mockResolvedValue(undefined); await TestBed.configureTestingModule({ providers: [ @@ -36,17 +37,17 @@ describe('GuestShowService', () => { const songs = [{id: 'song-1'}] as unknown as Song[]; const expectedUrl = window.location.protocol + '//' + window.location.host + '/guest/share-1'; - await expectAsync(service.share(show, songs)).toBeResolvedTo(expectedUrl); + await expect(service.share(show, songs)).resolves.toEqual(expectedUrl); - const [addPayload] = guestShowDataServiceSpy.add.calls.mostRecent().args as [Record]; + const [addPayload] = guestShowDataServiceSpy.add.mock.calls.at(-1) as [Record]; expect(addPayload).toEqual( - jasmine.objectContaining({ + expect.objectContaining({ showType: 'service-worship', date: show.date, songs, }) ); - expect(addPayload['updatedAt']).toEqual(jasmine.any(Date)); + expect(addPayload['updatedAt']).toEqual(expect.any(Date)); expect(showServiceSpy.update$).toHaveBeenCalledWith('show-1', {shareId: 'share-1'}); }); @@ -55,18 +56,18 @@ describe('GuestShowService', () => { const songs = [{id: 'song-1'}] as unknown as Song[]; const expectedUrl = window.location.protocol + '//' + window.location.host + '/guest/share-9'; - await expectAsync(service.share(show, songs)).toBeResolvedTo(expectedUrl); + await expect(service.share(show, songs)).resolves.toEqual(expectedUrl); - const [shareId, updatePayload] = guestShowDataServiceSpy.update$.calls.mostRecent().args as [string, Record]; + const [shareId, updatePayload] = guestShowDataServiceSpy.update$.mock.calls.at(-1) as [string, Record]; expect(shareId).toBe('share-9'); expect(updatePayload).toEqual( - jasmine.objectContaining({ + expect.objectContaining({ showType: 'service-worship', date: show.date, songs, }) ); - expect(updatePayload['updatedAt']).toEqual(jasmine.any(Date)); + expect(updatePayload['updatedAt']).toEqual(expect.any(Date)); expect(guestShowDataServiceSpy.add).not.toHaveBeenCalled(); expect(showServiceSpy.update$).not.toHaveBeenCalled(); }); diff --git a/src/app/modules/guest/guest.component.spec.ts b/src/app/modules/guest/guest.component.spec.ts index f55e790..a57ffd3 100644 --- a/src/app/modules/guest/guest.component.spec.ts +++ b/src/app/modules/guest/guest.component.spec.ts @@ -1,13 +1,14 @@ import {ComponentFixture, TestBed} from '@angular/core/testing'; import {ActivatedRoute} from '@angular/router'; import {BehaviorSubject, of} from 'rxjs'; +import {beforeEach, describe, expect, it, vi} from 'vitest'; import {GuestComponent} from './guest.component'; import {GuestShowDataService} from './guest-show-data.service'; describe('GuestComponent', () => { let component: GuestComponent; let fixture: ComponentFixture; - let guestShowDataServiceSpy: jasmine.SpyObj; + let guestShowDataServiceSpy: {read: ReturnType; read$: ReturnType}; let guestShowSubject: BehaviorSubject; beforeEach(async () => { @@ -26,8 +27,8 @@ describe('GuestComponent', () => { }, ], }); - guestShowDataServiceSpy = jasmine.createSpyObj('GuestShowDataService', ['read', 'read$']); - guestShowDataServiceSpy.read.and.resolveTo({ + guestShowDataServiceSpy = {read: vi.fn(), read$: vi.fn()}; + guestShowDataServiceSpy.read.mockResolvedValue({ id: 'guest-1', showType: 'service-worship', date: { @@ -42,7 +43,7 @@ describe('GuestComponent', () => { }, ], } as never); - guestShowDataServiceSpy.read$.and.returnValue(guestShowSubject.asObservable() as never); + guestShowDataServiceSpy.read$.mockReturnValue(guestShowSubject.asObservable() as never); await TestBed.configureTestingModule({ imports: [GuestComponent], diff --git a/src/app/modules/help/help/help.html b/src/app/modules/help/help/help.html index ca6ed4c..44c9d55 100644 --- a/src/app/modules/help/help/help.html +++ b/src/app/modules/help/help/help.html @@ -1,7 +1,11 @@
-
+
diff --git a/src/app/modules/help/help/help.spec.ts b/src/app/modules/help/help/help.spec.ts index 58344b6..229a133 100644 --- a/src/app/modules/help/help/help.spec.ts +++ b/src/app/modules/help/help/help.spec.ts @@ -6,6 +6,7 @@ describe('HelpComponent', () => { let component: HelpComponent; let fixture: ComponentFixture; let fetchMock: ReturnType; + type FetchResponse = {ok: boolean; text: () => Promise}; beforeEach(async () => { fetchMock = vi.fn().mockResolvedValue({ @@ -40,11 +41,11 @@ describe('HelpComponent', () => { }); it('should keep the current content visible while the next page loads', async () => { - let resolveFetch: ((value: {ok: boolean; text: () => Promise}) => void) | null = null; + let resolveFetch!: (value: FetchResponse) => void; fetchMock.mockImplementationOnce( () => - new Promise(resolve => { + new Promise(resolve => { resolveFetch = resolve; }) ); @@ -52,19 +53,21 @@ describe('HelpComponent', () => { const oldHtml = component.renderedHtml; const oldHeading = component.heading; - void (component as {loadDocument(path: string): Promise}).loadDocument('man/pages/lieder-liste.md'); + const loadPromise = (component as unknown as {loadDocument(path: string): Promise}).loadDocument( + 'man/pages/lieder-liste.md' + ); fixture.detectChanges(); expect(component.loading).toBe(true); expect(component.renderedHtml).toBe(oldHtml); expect(component.heading).toBe(oldHeading); - resolveFetch?.({ + resolveFetch({ ok: true, text: () => Promise.resolve('# Liedliste\n\nInhalt'), }); - await fixture.whenStable(); + await loadPromise; fixture.detectChanges(); expect(component.loading).toBe(false); diff --git a/src/app/modules/help/help/help.ts b/src/app/modules/help/help/help.ts index c8eb854..cc33f7d 100644 --- a/src/app/modules/help/help/help.ts +++ b/src/app/modules/help/help/help.ts @@ -46,6 +46,14 @@ export class HelpComponent implements OnInit { void this.loadDocument(resolvedPath); } + public onContentKeydown(event: KeyboardEvent): void { + if (event.key !== 'Enter' && event.key !== ' ') { + return; + } + + this.onContentClick(event as unknown as MouseEvent); + } + private async loadDocument(path: string): Promise { if (!path.startsWith('man/')) { if (!this.hasLoadedContent) { @@ -119,9 +127,9 @@ export class HelpComponent implements OnInit { } else { htmlParts.push(`

${this.renderInlineMarkdown(text)}

`); } - } - paragraphLines.length = 0; - }; + } + paragraphLines.length = 0; + }; const flushCodeBlock = () => { if (!inCodeBlock) { @@ -300,14 +308,18 @@ export class HelpComponent implements OnInit { return `${safeLabel}`; }); html = html.replace(/\*\*([^*]+)\*\*/g, '$1'); - html = html.replace(/(^|[^\*])\*([^*]+)\*/g, '$1$2'); + html = html.replace(/(^|[^*])\*([^*]+)\*/g, '$1$2'); return html; } private decodePlainText(text: string): string { - const html = this.renderInlineMarkdown(text); - const sanitized = this.sanitizer.sanitize(SecurityContext.HTML, html)?.replace(/<[^>]+>/g, '').trim() ?? text; + const html = this.renderInlineMarkdown(this.decodeHtmlEntities(text)); + const sanitized = + this.sanitizer + .sanitize(SecurityContext.HTML, html) + ?.replace(/<[^>]+>/g, '') + .trim() ?? text; return this.decodeHtmlEntities(sanitized); } diff --git a/src/app/modules/presentation/select/select.component.spec.ts b/src/app/modules/presentation/select/select.component.spec.ts index 6047945..fcf2cb4 100644 --- a/src/app/modules/presentation/select/select.component.spec.ts +++ b/src/app/modules/presentation/select/select.component.spec.ts @@ -1,6 +1,7 @@ import {ComponentFixture, TestBed} from '@angular/core/testing'; import {Router} from '@angular/router'; import {firstValueFrom, of} from 'rxjs'; +import {vi} from 'vitest'; import {GlobalSettingsService} from '../../../services/global-settings.service'; import {ShowService} from '../../shows/services/show.service'; import {SelectComponent} from './select.component'; @@ -8,29 +9,47 @@ import {SelectComponent} from './select.component'; describe('SelectComponent', () => { let component: SelectComponent; let fixture: ComponentFixture; - let showServiceSpy: jasmine.SpyObj; - let globalSettingsServiceSpy: jasmine.SpyObj; - let routerSpy: jasmine.SpyObj; - const createShow = (id: string, isoDate: string) => ({id, date: {toDate: () => new Date(isoDate)}}); + let showServiceSpy: { + list$: ReturnType; + update$: ReturnType; + }; + let globalSettingsServiceSpy: { + set: ReturnType; + }; + let routerSpy: { + navigateByUrl: ReturnType; + }; + const createShow = (id: string, daysAgo: number) => { + const date = new Date(); + date.setDate(date.getDate() - daysAgo); + + return {id, date: {toDate: () => date}}; + }; beforeEach(async () => { - showServiceSpy = jasmine.createSpyObj('ShowService', ['list$', 'update$']); - globalSettingsServiceSpy = jasmine.createSpyObj('GlobalSettingsService', ['set']); - routerSpy = jasmine.createSpyObj('Router', ['navigateByUrl']); - - showServiceSpy.list$.and.returnValue( - of([createShow('older', '2025-12-15T00:00:00Z'), createShow('recent-a', '2026-03-01T00:00:00Z'), createShow('recent-b', '2026-02-20T00:00:00Z')]) as never - ); - showServiceSpy.update$.and.resolveTo(); - globalSettingsServiceSpy.set.and.resolveTo(); - routerSpy.navigateByUrl.and.resolveTo(true); + showServiceSpy = { + list$: vi.fn().mockReturnValue( + of([ + createShow('older', 45), + createShow('recent-a', 7), + createShow('recent-b', 14), + ]) as never + ), + update$: vi.fn().mockResolvedValue(undefined), + }; + globalSettingsServiceSpy = { + set: vi.fn().mockResolvedValue(undefined), + }; + routerSpy = { + navigateByUrl: vi.fn().mockResolvedValue(true), + }; await TestBed.configureTestingModule({ imports: [SelectComponent], providers: [ - {provide: ShowService, useValue: showServiceSpy}, - {provide: GlobalSettingsService, useValue: globalSettingsServiceSpy}, - {provide: Router, useValue: routerSpy}, + {provide: ShowService, useValue: showServiceSpy as unknown as ShowService}, + {provide: GlobalSettingsService, useValue: globalSettingsServiceSpy as unknown as GlobalSettingsService}, + {provide: Router, useValue: routerSpy as unknown as Router}, ], }).compileComponents(); @@ -45,7 +64,7 @@ describe('SelectComponent', () => { it('should become visible on init', () => { component.ngOnInit(); - expect(component.visible).toBeTrue(); + expect(component.visible).toBe(true); }); it('should expose recent shows sorted descending by date', async () => { @@ -61,7 +80,7 @@ describe('SelectComponent', () => { await component.selectShow(show); - expect(component.visible).toBeFalse(); + expect(component.visible).toBe(false); expect(globalSettingsServiceSpy.set).toHaveBeenCalledWith({currentShow: 'show-1'}); expect(showServiceSpy.update$).toHaveBeenCalledWith('show-1', {presentationSongId: 'title'}); expect(routerSpy.navigateByUrl).toHaveBeenCalledWith('/presentation/remote'); diff --git a/src/app/modules/shows/dialog/report-dialog/report-dialog.component.spec.ts b/src/app/modules/shows/dialog/report-dialog/report-dialog.component.spec.ts index 39b48ac..455997d 100644 --- a/src/app/modules/shows/dialog/report-dialog/report-dialog.component.spec.ts +++ b/src/app/modules/shows/dialog/report-dialog/report-dialog.component.spec.ts @@ -31,10 +31,10 @@ describe('ReportDialogComponent', () => { }); it('should mark numbers as opened locally', () => { - expect(component.wasOpened('12345')).toBeFalse(); + expect(component.wasOpened('12345')).toBe(false); component.markOpened('12345'); - expect(component.wasOpened('12345')).toBeTrue(); + expect(component.wasOpened('12345')).toBe(true); }); }); diff --git a/src/app/modules/shows/dialog/share-dialog/share-dialog.component.spec.ts b/src/app/modules/shows/dialog/share-dialog/share-dialog.component.spec.ts index 3911502..cae49e8 100644 --- a/src/app/modules/shows/dialog/share-dialog/share-dialog.component.spec.ts +++ b/src/app/modules/shows/dialog/share-dialog/share-dialog.component.spec.ts @@ -1,11 +1,12 @@ import {ComponentFixture, TestBed} from '@angular/core/testing'; import {ShareDialogComponent} from './share-dialog.component'; import {MAT_DIALOG_DATA} from '@angular/material/dialog'; +import {vi} from 'vitest'; describe('ShareDialogComponent', () => { let component: ShareDialogComponent; let fixture: ComponentFixture; - type ShareDialogComponentInternals = ShareDialogComponent & { + type ShareDialogComponentInternals = { generateQrCode: () => Promise; }; @@ -28,10 +29,14 @@ describe('ShareDialogComponent', () => { fixture = TestBed.createComponent(ShareDialogComponent); component = fixture.componentInstance; - spyOn(component as ShareDialogComponentInternals, 'generateQrCode').and.resolveTo('data:image/jpeg;base64,test'); + vi.spyOn(component as unknown as ShareDialogComponentInternals, 'generateQrCode').mockResolvedValue('data:image/jpeg;base64,test'); fixture.detectChanges(); }); + afterEach(() => { + vi.restoreAllMocks(); + }); + it('should create', () => { void expect(component).toBeTruthy(); }); diff --git a/src/app/modules/shows/edit/edit.component.spec.ts b/src/app/modules/shows/edit/edit.component.spec.ts index 36f1224..f43143b 100644 --- a/src/app/modules/shows/edit/edit.component.spec.ts +++ b/src/app/modules/shows/edit/edit.component.spec.ts @@ -2,6 +2,7 @@ import {ComponentFixture, TestBed} from '@angular/core/testing'; import {ActivatedRoute, Router} from '@angular/router'; import {Timestamp} from '@angular/fire/firestore'; import {of} from 'rxjs'; +import {vi} from 'vitest'; import {ShowDataService} from '../services/show-data.service'; import {ShowService} from '../services/show.service'; import {EditComponent} from './edit.component'; @@ -9,25 +10,35 @@ import {EditComponent} from './edit.component'; describe('EditComponent', () => { let component: EditComponent; let fixture: ComponentFixture; - let showServiceSpy: jasmine.SpyObj; + let showServiceSpy: { + read$: ReturnType; + update$: ReturnType; + }; let showDataServiceStub: Pick; - let routerSpy: jasmine.SpyObj; + let routerSpy: { + navigateByUrl: ReturnType; + }; const createDate = (isoDate: string) => ({toDate: () => new Date(isoDate)}); beforeEach(async () => { - showServiceSpy = jasmine.createSpyObj('ShowService', ['read$', 'update$']); + showServiceSpy = { + read$: vi.fn(), + update$: vi.fn(), + }; showDataServiceStub = {list$: of([]) as ShowDataService['list$']}; - routerSpy = jasmine.createSpyObj('Router', ['navigateByUrl']); + routerSpy = { + navigateByUrl: vi.fn(), + }; - showServiceSpy.read$.and.returnValue( + showServiceSpy.read$.mockReturnValue( of({ id: 'show-1', showType: 'service-worship', date: createDate('2026-03-10T00:00:00Z'), } as never) ); - showServiceSpy.update$.and.resolveTo(); - routerSpy.navigateByUrl.and.resolveTo(true); + showServiceSpy.update$.mockResolvedValue(undefined); + routerSpy.navigateByUrl.mockResolvedValue(true); await TestBed.configureTestingModule({ imports: [EditComponent], @@ -43,6 +54,10 @@ describe('EditComponent', () => { component = fixture.componentInstance; }); + afterEach(() => { + vi.restoreAllMocks(); + }); + it('should create', () => { expect(component).toBeTruthy(); }); @@ -68,7 +83,7 @@ describe('EditComponent', () => { it('should update the show and navigate back to the detail page', async () => { const date = new Date('2026-03-11T00:00:00Z'); const firestoreTimestamp = {seconds: 1} as never; - spyOn(Timestamp, 'fromDate').and.returnValue(firestoreTimestamp); + vi.spyOn(Timestamp, 'fromDate').mockReturnValue(firestoreTimestamp); component.form.setValue({id: 'show-1', date, showType: 'home-group'}); await component.onSave(); diff --git a/src/app/modules/shows/services/docx.service.spec.ts b/src/app/modules/shows/services/docx.service.spec.ts index 9b3bc9d..37d7f95 100644 --- a/src/app/modules/shows/services/docx.service.spec.ts +++ b/src/app/modules/shows/services/docx.service.spec.ts @@ -1,4 +1,5 @@ import {TestBed} from '@angular/core/testing'; +import {vi} from 'vitest'; import {DocxService} from './docx.service'; describe('DocxService', () => { @@ -15,7 +16,7 @@ describe('DocxService', () => { type DocxModuleLike = { Packer: {toBlob: (document: unknown) => Promise}; }; - type DocxServiceInternals = DocxService & { + type DocxServiceInternals = { prepareData: (showId: string) => Promise; renderTitle: (docx: unknown, title: string) => unknown[]; renderSongs: (docx: unknown, songs: unknown[], options: unknown, config: unknown) => unknown[]; @@ -29,14 +30,18 @@ describe('DocxService', () => { service = TestBed.inject(DocxService); }); + afterEach(() => { + vi.restoreAllMocks(); + }); + it('should be created', () => { void expect(service).toBeTruthy(); }); it('should not try to save a document when the required data cannot be prepared', async () => { - const serviceInternals = service as DocxServiceInternals; - const prepareDataSpy = spyOn(serviceInternals, 'prepareData').and.resolveTo(null); - const saveAsSpy = spyOn(serviceInternals, 'saveAs'); + const serviceInternals = service as unknown as DocxServiceInternals; + const prepareDataSpy = vi.spyOn(serviceInternals, 'prepareData').mockResolvedValue(null); + const saveAsSpy = vi.spyOn(serviceInternals, 'saveAs'); await service.create('show-1'); @@ -48,11 +53,11 @@ describe('DocxService', () => { const blob = new Blob(['docx']); const docxModule: DocxModuleLike = { Packer: { - toBlob: jasmine.createSpy('toBlob').and.resolveTo(blob), + toBlob: vi.fn().mockResolvedValue(blob), }, }; - const serviceInternals = service as DocxServiceInternals; - const prepareDataSpy = spyOn(serviceInternals, 'prepareData').and.resolveTo({ + const serviceInternals = service as unknown as DocxServiceInternals; + const prepareDataSpy = vi.spyOn(serviceInternals, 'prepareData').mockResolvedValue({ show: { showType: 'service-worship', date: {toDate: () => new Date('2026-03-10T00:00:00Z')}, @@ -61,17 +66,17 @@ describe('DocxService', () => { user: {name: 'Benjamin'}, config: {ccliLicenseId: '12345'}, }); - spyOn(serviceInternals, 'loadDocx').and.resolveTo(docxModule); - spyOn(serviceInternals, 'renderTitle').and.returnValue([]); - spyOn(serviceInternals, 'renderSongs').and.returnValue([]); - const prepareNewDocumentSpy = spyOn(serviceInternals, 'prepareNewDocument').and.returnValue({doc: true}); - const saveAsSpy = spyOn(serviceInternals, 'saveAs'); + vi.spyOn(serviceInternals, 'loadDocx').mockResolvedValue(docxModule); + vi.spyOn(serviceInternals, 'renderTitle').mockReturnValue([]); + vi.spyOn(serviceInternals, 'renderSongs').mockReturnValue([]); + const prepareNewDocumentSpy = vi.spyOn(serviceInternals, 'prepareNewDocument').mockReturnValue({doc: true}); + const saveAsSpy = vi.spyOn(serviceInternals, 'saveAs'); await service.create('show-1', {copyright: true}); expect(prepareDataSpy).toHaveBeenCalledWith('show-1'); expect(prepareNewDocumentSpy).toHaveBeenCalled(); expect(docxModule.Packer.toBlob).toHaveBeenCalledWith({doc: true} as never); - expect(saveAsSpy).toHaveBeenCalledWith(blob, jasmine.stringMatching(/\.docx$/)); + expect(saveAsSpy).toHaveBeenCalledWith(blob, expect.stringMatching(/\.docx$/)); }); }); diff --git a/src/app/modules/shows/services/show-data.service.spec.ts b/src/app/modules/shows/services/show-data.service.spec.ts index ab17b2d..1207811 100644 --- a/src/app/modules/shows/services/show-data.service.spec.ts +++ b/src/app/modules/shows/services/show-data.service.spec.ts @@ -1,29 +1,36 @@ import {TestBed} from '@angular/core/testing'; import {firstValueFrom, of, Subject} from 'rxjs'; import {take} from 'rxjs/operators'; +import {vi} from 'vitest'; import {DbService} from '../../../services/db.service'; import {ShowDataService} from './show-data.service'; describe('ShowDataService', () => { let service: ShowDataService; let shows$: Subject number}; archived?: boolean}>>; - let docUpdateSpy: jasmine.Spy<() => Promise>; - let docSpy: jasmine.Spy; - let colAddSpy: jasmine.Spy<() => Promise<{id: string}>>; - let colSpy: jasmine.Spy; - let dbServiceSpy: jasmine.SpyObj; + let docUpdateSpy: ReturnType; + let docSpy: ReturnType; + let colAddSpy: ReturnType; + let colSpy: ReturnType; + let dbServiceSpy: { + col$: ReturnType; + doc$: ReturnType; + doc: ReturnType; + col: ReturnType; + }; beforeEach(async () => { shows$ = new Subject number}; archived?: boolean}>>(); - docUpdateSpy = jasmine.createSpy('update').and.resolveTo(); - docSpy = jasmine.createSpy('doc').and.returnValue({update: docUpdateSpy}); - colAddSpy = jasmine.createSpy('add').and.resolveTo({id: 'show-3'}); - colSpy = jasmine.createSpy('col').and.returnValue({add: colAddSpy}); - dbServiceSpy = jasmine.createSpyObj('DbService', ['col$', 'doc$', 'doc', 'col']); - dbServiceSpy.col$.and.returnValue(shows$.asObservable()); - dbServiceSpy.doc$.and.returnValue(of(null)); - dbServiceSpy.doc.and.callFake(docSpy); - dbServiceSpy.col.and.callFake(colSpy); + docUpdateSpy = vi.fn().mockResolvedValue(undefined); + docSpy = vi.fn().mockReturnValue({update: docUpdateSpy}); + colAddSpy = vi.fn().mockResolvedValue({id: 'show-3'}); + colSpy = vi.fn().mockReturnValue({add: colAddSpy}); + dbServiceSpy = { + col$: vi.fn().mockReturnValue(shows$.asObservable()), + doc$: vi.fn().mockReturnValue(of(null)), + doc: docSpy, + col: colSpy, + }; await TestBed.configureTestingModule({ providers: [{provide: DbService, useValue: dbServiceSpy}], @@ -77,12 +84,12 @@ describe('ShowDataService', () => { it('should request only published recent shows and filter archived entries', async () => { const publicShows$ = of([{id: 'show-1', archived: false}, {id: 'show-2', archived: true}, {id: 'show-3'}]); - dbServiceSpy.col$.and.returnValue(publicShows$ as never); + dbServiceSpy.col$.mockReturnValue(publicShows$ as never); const result = await firstValueFrom(service.listPublicSince$(3)); - expect(dbServiceSpy.col$).toHaveBeenCalledWith('shows', jasmine.any(Array)); - const [, queryConstraints] = dbServiceSpy.col$.calls.mostRecent().args as [string, unknown[]]; + expect(dbServiceSpy.col$).toHaveBeenCalledWith('shows', expect.any(Array)); + const [, queryConstraints] = dbServiceSpy.col$.mock.lastCall as [string, unknown[]]; expect(queryConstraints.length).toBe(3); expect(result.map(show => show.id)).toEqual(['show-1', 'show-3']); }); @@ -97,15 +104,15 @@ describe('ShowDataService', () => { await service.update('show-8', {archived: true}); expect(docSpy).toHaveBeenCalledWith('shows/show-8'); - const [updatePayload] = docUpdateSpy.calls.mostRecent().args as unknown as [Record]; + const [updatePayload] = docUpdateSpy.mock.lastCall as [Record]; expect(updatePayload).toEqual({archived: true}); }); it('should add a show to the shows collection and return the new id', async () => { - await expectAsync(service.add({published: true})).toBeResolvedTo('show-3'); + await expect(service.add({published: true})).resolves.toBe('show-3'); expect(colSpy).toHaveBeenCalledWith('shows'); - const [addPayload] = colAddSpy.calls.mostRecent().args as unknown as [Record]; + const [addPayload] = colAddSpy.mock.lastCall as [Record]; expect(addPayload).toEqual({published: true}); }); }); diff --git a/src/app/modules/shows/services/show-song-data.service.spec.ts b/src/app/modules/shows/services/show-song-data.service.spec.ts index 7a03e8d..43b3876 100644 --- a/src/app/modules/shows/services/show-song-data.service.spec.ts +++ b/src/app/modules/shows/services/show-song-data.service.spec.ts @@ -1,31 +1,38 @@ import {TestBed} from '@angular/core/testing'; import {of} from 'rxjs'; +import {vi} from 'vitest'; import {DbService} from '../../../services/db.service'; import {ShowSongDataService} from './show-song-data.service'; describe('ShowSongDataService', () => { let service: ShowSongDataService; - let docUpdateSpy: jasmine.Spy<() => Promise>; - let docDeleteSpy: jasmine.Spy<() => Promise>; - let docSpy: jasmine.Spy; - let colAddSpy: jasmine.Spy<() => Promise<{id: string}>>; - let colSpy: jasmine.Spy; - let dbServiceSpy: jasmine.SpyObj; + let docUpdateSpy: ReturnType; + let docDeleteSpy: ReturnType; + let docSpy: ReturnType; + let colAddSpy: ReturnType; + let colSpy: ReturnType; + let dbServiceSpy: { + col$: ReturnType; + doc$: ReturnType; + doc: ReturnType; + col: ReturnType; + }; beforeEach(async () => { - docUpdateSpy = jasmine.createSpy('update').and.resolveTo(); - docDeleteSpy = jasmine.createSpy('delete').and.resolveTo(); - docSpy = jasmine.createSpy('doc').and.returnValue({ + docUpdateSpy = vi.fn().mockResolvedValue(undefined); + docDeleteSpy = vi.fn().mockResolvedValue(undefined); + docSpy = vi.fn().mockReturnValue({ update: docUpdateSpy, delete: docDeleteSpy, }); - colAddSpy = jasmine.createSpy('add').and.resolveTo({id: 'show-song-3'}); - colSpy = jasmine.createSpy('col').and.returnValue({add: colAddSpy}); - dbServiceSpy = jasmine.createSpyObj('DbService', ['col$', 'doc$', 'doc', 'col']); - dbServiceSpy.col$.and.callFake(() => of([{id: 'show-song-1'}]) as never); - dbServiceSpy.doc$.and.returnValue(of({id: 'show-song-1'}) as never); - dbServiceSpy.doc.and.callFake(docSpy); - dbServiceSpy.col.and.callFake(colSpy); + colAddSpy = vi.fn().mockResolvedValue({id: 'show-song-3'}); + colSpy = vi.fn().mockReturnValue({add: colAddSpy}); + dbServiceSpy = { + col$: vi.fn().mockImplementation(() => of([{id: 'show-song-1'}]) as never), + doc$: vi.fn().mockReturnValue(of({id: 'show-song-1'}) as never), + doc: docSpy, + col: colSpy, + }; await TestBed.configureTestingModule({ providers: [{provide: DbService, useValue: dbServiceSpy}], @@ -77,7 +84,7 @@ describe('ShowSongDataService', () => { await service.update$('show-4', 'song-5', {title: 'Updated'} as never); expect(docSpy).toHaveBeenCalledWith('shows/show-4/songs/song-5'); - const [updatePayload] = docUpdateSpy.calls.mostRecent().args as unknown as [Record]; + const [updatePayload] = docUpdateSpy.mock.lastCall as [Record]; expect(updatePayload).toEqual({title: 'Updated'}); }); @@ -89,10 +96,10 @@ describe('ShowSongDataService', () => { }); it('should add a song to the nested show songs collection and return the id', async () => { - await expectAsync(service.add('show-4', {songId: 'song-5'} as never)).toBeResolvedTo('show-song-3'); + await expect(service.add('show-4', {songId: 'song-5'} as never)).resolves.toBe('show-song-3'); expect(colSpy).toHaveBeenCalledWith('shows/show-4/songs'); - const [addPayload] = colAddSpy.calls.mostRecent().args as unknown as [Record]; + const [addPayload] = colAddSpy.mock.lastCall as [Record]; expect(addPayload).toEqual({songId: 'song-5'}); }); }); diff --git a/src/app/modules/shows/services/show-song-index.service.spec.ts b/src/app/modules/shows/services/show-song-index.service.spec.ts index 4f3795a..6c2c08a 100644 --- a/src/app/modules/shows/services/show-song-index.service.spec.ts +++ b/src/app/modules/shows/services/show-song-index.service.spec.ts @@ -1,5 +1,6 @@ import {TestBed} from '@angular/core/testing'; -import {of} from 'rxjs'; +import {Observable, of} from 'rxjs'; +import {vi} from 'vitest'; import {ShowDataService} from './show-data.service'; import {ShowSongDataService} from './show-song-data.service'; import {ShowSongIndexService} from './show-song-index.service'; @@ -8,20 +9,34 @@ import {User} from '../../../services/user/user'; describe('ShowSongIndexService', () => { let service: ShowSongIndexService; - let showDataServiceSpy: jasmine.SpyObj; - let showSongDataServiceSpy: jasmine.SpyObj; - let sessionSpy: jasmine.SpyObj; + let showDataServiceSpy: { + listRaw$: ReturnType; + update: ReturnType; + }; + let showSongDataServiceSpy: { + list$: ReturnType; + }; + let sessionSpy: { + update$: ReturnType; + user$: Observable; + }; beforeEach(async () => { - showDataServiceSpy = jasmine.createSpyObj('ShowDataService', ['listRaw$', 'update']); - showSongDataServiceSpy = jasmine.createSpyObj('ShowSongDataService', ['list$']); - sessionSpy = jasmine.createSpyObj('UserSessionService', ['update$'], { + showDataServiceSpy = { + listRaw$: vi.fn(), + update: vi.fn(), + }; + showSongDataServiceSpy = { + list$: vi.fn(), + }; + sessionSpy = { + update$: vi.fn(), user$: of({id: 'admin-1', name: 'Admin', role: 'admin', chordMode: 'onlyFirst', songUsage: {}} as User), - }); + }; - showDataServiceSpy.listRaw$.and.returnValue(of([{id: 'show-1'}, {id: 'show-2'}] as never) as unknown as ReturnType); - showDataServiceSpy.update.and.resolveTo(); - showSongDataServiceSpy.list$.and.callFake((showId: string) => { + showDataServiceSpy.listRaw$.mockReturnValue(of([{id: 'show-1'}, {id: 'show-2'}] as never) as unknown as ReturnType); + showDataServiceSpy.update.mockResolvedValue(undefined); + showSongDataServiceSpy.list$.mockImplementation((showId: string) => { if (showId === 'show-1') { return of([{songId: 'song-1'}, {songId: 'song-2'}, {songId: 'song-1'}] as never) as never; } @@ -41,7 +56,7 @@ describe('ShowSongIndexService', () => { }); it('should rebuild the distinct songIds index for all shows', async () => { - await expectAsync(service.rebuildShowSongIds()).toBeResolvedTo({ + await expect(service.rebuildShowSongIds()).resolves.toEqual({ showsProcessed: 2, showSongsProcessed: 4, }); @@ -55,6 +70,6 @@ describe('ShowSongIndexService', () => { value: of({id: 'user-1', name: 'User', role: 'leader', chordMode: 'onlyFirst', songUsage: {}} as User), }); - await expectAsync(service.rebuildShowSongIds()).toBeRejectedWithError('Admin role required to rebuild show song ids.'); + await expect(service.rebuildShowSongIds()).rejects.toThrow('Admin role required to rebuild show song ids.'); }); }); diff --git a/src/app/modules/shows/services/show-song.service.spec.ts b/src/app/modules/shows/services/show-song.service.spec.ts index 09f875d..49d2869 100644 --- a/src/app/modules/shows/services/show-song.service.spec.ts +++ b/src/app/modules/shows/services/show-song.service.spec.ts @@ -1,5 +1,6 @@ import {TestBed} from '@angular/core/testing'; import {BehaviorSubject, of} from 'rxjs'; +import {vi} from 'vitest'; import {SongDataService} from '../../songs/services/song-data.service'; import {UserService} from '../../../services/user/user.service'; import {ShowService} from './show.service'; @@ -12,10 +13,25 @@ import {User} from '../../../services/user/user'; describe('ShowSongService', () => { let service: ShowSongService; - let showSongDataServiceSpy: jasmine.SpyObj; - let songDataServiceSpy: jasmine.SpyObj; - let userServiceSpy: jasmine.SpyObj; - let showServiceSpy: jasmine.SpyObj; + let showSongDataServiceSpy: { + add: ReturnType; + read$: ReturnType; + list$: ReturnType; + delete: ReturnType; + update$: ReturnType; + }; + let songDataServiceSpy: { + read$: ReturnType; + }; + let userServiceSpy: { + incSongCount: ReturnType; + decSongCount: ReturnType; + user$: ReturnType['asObservable']>; + }; + let showServiceSpy: { + read$: ReturnType; + update$: ReturnType; + }; let user$: BehaviorSubject; const song = {id: 'song-1', key: 'G', title: 'Amazing Grace'} as unknown as Song; const showSong = {id: 'show-song-1', songId: 'song-1'} as unknown as ShowSong; @@ -23,23 +39,36 @@ describe('ShowSongService', () => { beforeEach(async () => { user$ = new BehaviorSubject({id: 'user-1', name: 'Benjamin', role: 'editor', chordMode: 'onlyFirst', songUsage: {}}); - showSongDataServiceSpy = jasmine.createSpyObj('ShowSongDataService', ['add', 'read$', 'list$', 'delete', 'update$']); - songDataServiceSpy = jasmine.createSpyObj('SongDataService', ['read$']); - userServiceSpy = jasmine.createSpyObj('UserService', ['incSongCount', 'decSongCount'], { + showSongDataServiceSpy = { + add: vi.fn(), + read$: vi.fn(), + list$: vi.fn(), + delete: vi.fn(), + update$: vi.fn(), + }; + songDataServiceSpy = { + read$: vi.fn(), + }; + userServiceSpy = { + incSongCount: vi.fn(), + decSongCount: vi.fn(), user$: user$.asObservable(), - }); - showServiceSpy = jasmine.createSpyObj('ShowService', ['read$', 'update$']); + }; + showServiceSpy = { + read$: vi.fn(), + update$: vi.fn(), + }; - showSongDataServiceSpy.add.and.resolveTo('show-song-2'); - showSongDataServiceSpy.read$.and.returnValue(of(showSong)); - showSongDataServiceSpy.list$.and.returnValue(of([showSong])); - showSongDataServiceSpy.delete.and.resolveTo(); - showSongDataServiceSpy.update$.and.resolveTo(); - songDataServiceSpy.read$.and.returnValue(of(song)); - userServiceSpy.incSongCount.and.resolveTo(); - userServiceSpy.decSongCount.and.resolveTo(); - showServiceSpy.read$.and.returnValue(of(show)); - showServiceSpy.update$.and.resolveTo(); + showSongDataServiceSpy.add.mockResolvedValue('show-song-2'); + showSongDataServiceSpy.read$.mockReturnValue(of(showSong)); + showSongDataServiceSpy.list$.mockReturnValue(of([showSong])); + showSongDataServiceSpy.delete.mockResolvedValue(undefined); + showSongDataServiceSpy.update$.mockResolvedValue(undefined); + songDataServiceSpy.read$.mockReturnValue(of(song)); + userServiceSpy.incSongCount.mockResolvedValue(undefined); + userServiceSpy.decSongCount.mockResolvedValue(undefined); + showServiceSpy.read$.mockReturnValue(of(show)); + showServiceSpy.update$.mockResolvedValue(undefined); await TestBed.configureTestingModule({ providers: [ @@ -58,7 +87,7 @@ describe('ShowSongService', () => { }); it('should create a show song from the song and current user', async () => { - await expectAsync(service.new$('show-1', 'song-1', true)).toBeResolvedTo('show-song-2'); + await expect(service.new$('show-1', 'song-1', true)).resolves.toBe('show-song-2'); expect(userServiceSpy.incSongCount).toHaveBeenCalledWith('song-1'); expect(showSongDataServiceSpy.add).toHaveBeenCalledWith('show-1', { @@ -72,9 +101,9 @@ describe('ShowSongService', () => { }); it('should return null when the song is missing', async () => { - songDataServiceSpy.read$.and.returnValue(of(null)); + songDataServiceSpy.read$.mockReturnValue(of(null)); - await expectAsync(service.new$('show-1', 'song-1')).toBeResolvedTo(null); + await expect(service.new$('show-1', 'song-1')).resolves.toBe(null); expect(showSongDataServiceSpy.add).not.toHaveBeenCalled(); expect(userServiceSpy.incSongCount).not.toHaveBeenCalled(); @@ -83,19 +112,19 @@ describe('ShowSongService', () => { it('should return null when the current user is missing', async () => { user$.next(null); - await expectAsync(service.new$('show-1', 'song-1')).toBeResolvedTo(null); + await expect(service.new$('show-1', 'song-1')).resolves.toBe(null); expect(showSongDataServiceSpy.add).not.toHaveBeenCalled(); expect(userServiceSpy.incSongCount).not.toHaveBeenCalled(); }); it('should delegate reads to the data service', async () => { - await expectAsync(service.read('show-1', 'show-song-1')).toBeResolvedTo(showSong); + await expect(service.read('show-1', 'show-song-1')).resolves.toBe(showSong); expect(showSongDataServiceSpy.read$).toHaveBeenCalledWith('show-1', 'show-song-1'); }); it('should delegate list access to the data service', async () => { - await expectAsync(service.list('show-1')).toBeResolvedTo([showSong]); + await expect(service.list('show-1')).resolves.toEqual([showSong]); expect(showSongDataServiceSpy.list$).toHaveBeenCalledWith('show-1'); }); @@ -103,12 +132,12 @@ describe('ShowSongService', () => { await service.delete$('show-1', 'show-song-1', 0); expect(showSongDataServiceSpy.delete).toHaveBeenCalledWith('show-1', 'show-song-1'); - expect(showServiceSpy.update$).toHaveBeenCalledWith('show-1', jasmine.objectContaining({order: ['show-song-2']})); + expect(showServiceSpy.update$).toHaveBeenCalledWith('show-1', expect.objectContaining({order: ['show-song-2']})); expect(userServiceSpy.decSongCount).toHaveBeenCalledWith('song-1'); }); it('should stop delete when the show is missing', async () => { - showServiceSpy.read$.and.returnValue(of(null)); + showServiceSpy.read$.mockReturnValue(of(null)); await service.delete$('show-1', 'show-song-1', 0); @@ -118,7 +147,7 @@ describe('ShowSongService', () => { }); it('should stop delete when the show song is missing', async () => { - showSongDataServiceSpy.read$.and.returnValue(of(null)); + showSongDataServiceSpy.read$.mockReturnValue(of(null)); await service.delete$('show-1', 'show-song-1', 0); diff --git a/src/app/modules/shows/services/show.service.spec.ts b/src/app/modules/shows/services/show.service.spec.ts index d815aad..b0b00e2 100644 --- a/src/app/modules/shows/services/show.service.spec.ts +++ b/src/app/modules/shows/services/show.service.spec.ts @@ -1,12 +1,19 @@ import {TestBed} from '@angular/core/testing'; import {BehaviorSubject, firstValueFrom, of} from 'rxjs'; +import {vi} from 'vitest'; import {ShowDataService} from './show-data.service'; import {ShowService} from './show.service'; import {UserService} from '../../../services/user/user.service'; describe('ShowService', () => { let service: ShowService; - let showDataServiceSpy: jasmine.SpyObj; + let showDataServiceSpy: { + read$: ReturnType; + listPublicSince$: ReturnType; + update: ReturnType; + add: ReturnType; + list$: ReturnType['asObservable']>; + }; let user$: BehaviorSubject; let shows$: BehaviorSubject; const shows = [ @@ -18,13 +25,17 @@ describe('ShowService', () => { beforeEach(async () => { user$ = new BehaviorSubject({id: 'user-1'}); shows$ = new BehaviorSubject(shows as unknown[]); - showDataServiceSpy = jasmine.createSpyObj('ShowDataService', ['read$', 'listPublicSince$', 'update', 'add'], { + showDataServiceSpy = { + read$: vi.fn(), + listPublicSince$: vi.fn(), + update: vi.fn(), + add: vi.fn(), list$: shows$.asObservable() as unknown as ShowDataService['list$'], - }); - showDataServiceSpy.read$.and.returnValue(of(shows[0])); - showDataServiceSpy.listPublicSince$.and.returnValue(of([shows[1]])); - showDataServiceSpy.update.and.resolveTo(); - showDataServiceSpy.add.and.resolveTo('new-show-id'); + }; + showDataServiceSpy.read$.mockReturnValue(of(shows[0])); + showDataServiceSpy.listPublicSince$.mockReturnValue(of([shows[1]])); + showDataServiceSpy.update.mockResolvedValue(undefined); + showDataServiceSpy.add.mockResolvedValue('new-show-id'); await TestBed.configureTestingModule({ providers: [ @@ -63,12 +74,12 @@ describe('ShowService', () => { }); it('should delegate public listing to the data service', async () => { - await expectAsync(firstValueFrom(service.listPublicSince$(6))).toBeResolvedTo([shows[1]]); + await expect(firstValueFrom(service.listPublicSince$(6))).resolves.toEqual([shows[1]]); expect(showDataServiceSpy.listPublicSince$).toHaveBeenCalledWith(6); }); it('should delegate reads to the data service', async () => { - await expectAsync(firstValueFrom(service.read$('show-1'))).toBeResolvedTo(shows[0]); + await expect(firstValueFrom(service.read$('show-1'))).resolves.toBe(shows[0]); expect(showDataServiceSpy.read$).toHaveBeenCalledWith('show-1'); }); @@ -79,7 +90,7 @@ describe('ShowService', () => { }); it('should return null when creating a show without showType', async () => { - await expectAsync(service.new$({published: true})).toBeResolvedTo(null); + await expect(service.new$({published: true})).resolves.toBe(null); expect(showDataServiceSpy.add).not.toHaveBeenCalled(); }); @@ -87,7 +98,7 @@ describe('ShowService', () => { it('should return null when no user is available for show creation', async () => { user$.next(null); - await expectAsync(service.new$({showType: 'misc-public'})).toBeResolvedTo(null); + await expect(service.new$({showType: 'misc-public'})).resolves.toBe(null); expect(showDataServiceSpy.add).not.toHaveBeenCalled(); }); diff --git a/src/app/modules/shows/show/show.component.spec.ts b/src/app/modules/shows/show/show.component.spec.ts index 50a9dda..de052fd 100644 --- a/src/app/modules/shows/show/show.component.spec.ts +++ b/src/app/modules/shows/show/show.component.spec.ts @@ -1,5 +1,6 @@ import {ComponentFixture, TestBed} from '@angular/core/testing'; import {BehaviorSubject, of} from 'rxjs'; +import {vi} from 'vitest'; import {ShowComponent} from './show.component'; import {ActivatedRoute, Router} from '@angular/router'; import {ShowService} from '../services/show.service'; @@ -13,23 +14,39 @@ import {GuestShowService} from '../../guest/guest-show.service'; describe('ShowComponent', () => { let component: ShowComponent; let fixture: ComponentFixture; - let showServiceSpy: jasmine.SpyObj; - let showSongServiceSpy: jasmine.SpyObj; - let dialogSpy: jasmine.SpyObj; + let showServiceSpy: { + update$: ReturnType; + read$: ReturnType; + }; + let showSongServiceSpy: { + list$: ReturnType; + list: ReturnType; + }; + let dialogSpy: { + open: ReturnType; + }; let user$: BehaviorSubject; let userId$: BehaviorSubject; beforeEach(async () => { - showServiceSpy = jasmine.createSpyObj('ShowService', ['update$', 'read$']); - showSongServiceSpy = jasmine.createSpyObj('ShowSongService', ['list$', 'list']); - dialogSpy = jasmine.createSpyObj('MatDialog', ['open']); + showServiceSpy = { + update$: vi.fn(), + read$: vi.fn(), + }; + showSongServiceSpy = { + list$: vi.fn(), + list: vi.fn(), + }; + dialogSpy = { + open: vi.fn(), + }; user$ = new BehaviorSubject({id: 'user-1', role: ['leader']}); userId$ = new BehaviorSubject('user-1'); - showServiceSpy.read$.and.returnValue(of(null)); - showServiceSpy.update$.and.resolveTo(); - showSongServiceSpy.list$.and.returnValue(of([])); - showSongServiceSpy.list.and.resolveTo([]); + showServiceSpy.read$.mockReturnValue(of(null)); + showServiceSpy.update$.mockResolvedValue(undefined); + showSongServiceSpy.list$.mockReturnValue(of([])); + showSongServiceSpy.list.mockResolvedValue([]); await TestBed.configureTestingModule({ imports: [ShowComponent], @@ -38,8 +55,8 @@ describe('ShowComponent', () => { {provide: ShowService, useValue: showServiceSpy}, {provide: SongService, useValue: {list$: () => of([])}}, {provide: ShowSongService, useValue: showSongServiceSpy}, - {provide: DocxService, useValue: {create: jasmine.createSpy('create').and.resolveTo()}}, - {provide: Router, useValue: {navigateByUrl: jasmine.createSpy('navigateByUrl')}}, + {provide: DocxService, useValue: {create: vi.fn().mockResolvedValue(undefined)}}, + {provide: Router, useValue: {navigateByUrl: vi.fn()}}, { provide: UserService, useValue: { @@ -49,7 +66,7 @@ describe('ShowComponent', () => { }, }, {provide: MatDialog, useValue: dialogSpy}, - {provide: GuestShowService, useValue: {share: jasmine.createSpy('share').and.resolveTo('https://example.invalid')}}, + {provide: GuestShowService, useValue: {share: vi.fn().mockResolvedValue('https://example.invalid')}}, ], }).compileComponents(); @@ -74,7 +91,7 @@ describe('ShowComponent', () => { }); it('should set pending for public shows with reportable CCLI songs', async () => { - showSongServiceSpy.list.and.resolveTo([{legalOwner: 'CCLI', legalOwnerId: '123'}] as never); + showSongServiceSpy.list.mockResolvedValue([{legalOwner: 'CCLI', legalOwnerId: '123'}] as never); await component.onPublish({id: 'show-1', public: true} as never, true); @@ -82,7 +99,7 @@ describe('ShowComponent', () => { }); it('should set not-required for public shows without reportable CCLI songs', async () => { - showSongServiceSpy.list.and.resolveTo([{legalOwner: 'CCLI', legalOwnerId: ''}] as never); + showSongServiceSpy.list.mockResolvedValue([{legalOwner: 'CCLI', legalOwnerId: ''}] as never); await component.onPublish({id: 'show-1', public: true} as never, true); @@ -96,11 +113,11 @@ describe('ShowComponent', () => { {id: 'show-song-3', songId: 'song-2', title: 'Beta', legalOwner: 'other', legalOwnerId: '456'}, {id: 'show-song-4', songId: 'song-3', title: 'Gamma', legalOwner: 'CCLI', legalOwnerId: '789'}, ] as never; - dialogSpy.open.and.returnValue({afterClosed: () => of(true)} as never); + dialogSpy.open.mockReturnValue({afterClosed: () => of(true)} as never); component.onReport({id: 'show-1', order: ['show-song-1', 'show-song-2', 'show-song-3', 'show-song-4']} as never); - expect(dialogSpy.open).toHaveBeenCalledWith(jasmine.any(Function), { + expect(dialogSpy.open).toHaveBeenCalledWith(expect.any(Function), { width: '640px', data: { songs: [ diff --git a/src/app/modules/songs/services/file-data.service.spec.ts b/src/app/modules/songs/services/file-data.service.spec.ts index 3293d79..0212d3a 100644 --- a/src/app/modules/songs/services/file-data.service.spec.ts +++ b/src/app/modules/songs/services/file-data.service.spec.ts @@ -5,30 +5,29 @@ import {FileDataService} from './file-data.service'; describe('FileDataService', () => { let service: FileDataService; - let filesCollectionValueChangesSpy: jasmine.Spy; - let filesCollectionAddSpy: jasmine.Spy; - let songDocCollectionSpy: jasmine.Spy; - let songDocSpy: jasmine.Spy; - let fileDeleteSpy: jasmine.Spy; - let dbServiceSpy: jasmine.SpyObj; + let filesCollectionValueChangesSpy: ReturnType unknown>>; + let filesCollectionAddSpy: ReturnType Promise<{id: string}>>>; + let songDocCollectionSpy: ReturnType {add: typeof filesCollectionAddSpy; valueChanges: typeof filesCollectionValueChangesSpy}>>; + let songDocSpy: ReturnType unknown>>; + let fileDeleteSpy: ReturnType Promise>>; + let dbServiceSpy: {doc: typeof songDocSpy}; beforeEach(async () => { - filesCollectionValueChangesSpy = jasmine.createSpy('valueChanges').and.returnValue(of([{id: 'file-1', name: 'plan.pdf'}])); - filesCollectionAddSpy = jasmine.createSpy('add').and.resolveTo({id: 'file-2'}); - songDocCollectionSpy = jasmine.createSpy('collection').and.returnValue({ + filesCollectionValueChangesSpy = vi.fn().mockReturnValue(of([{id: 'file-1', name: 'plan.pdf'}])); + filesCollectionAddSpy = vi.fn().mockResolvedValue({id: 'file-2'}); + songDocCollectionSpy = vi.fn().mockReturnValue({ add: filesCollectionAddSpy, valueChanges: filesCollectionValueChangesSpy, }); - songDocSpy = jasmine.createSpy('songDoc').and.callFake((path: string) => { + songDocSpy = vi.fn().mockImplementation((path: string) => { if (path.includes('/files/')) { return {delete: fileDeleteSpy}; } return {collection: songDocCollectionSpy}; }); - fileDeleteSpy = jasmine.createSpy('delete').and.resolveTo(); - dbServiceSpy = jasmine.createSpyObj('DbService', ['doc']); - dbServiceSpy.doc.and.callFake(songDocSpy); + fileDeleteSpy = vi.fn().mockResolvedValue(undefined); + dbServiceSpy = {doc: songDocSpy}; await TestBed.configureTestingModule({ providers: [{provide: DbService, useValue: dbServiceSpy}], @@ -44,7 +43,7 @@ describe('FileDataService', () => { it('should add files to the song files subcollection and return the generated id', async () => { const file = {name: 'setlist.pdf', path: 'songs/song-1/files', createdAt: new Date()}; - await expectAsync(service.set('song-1', file)).toBeResolvedTo('file-2'); + await expect(service.set('song-1', file)).resolves.toEqual('file-2'); expect(songDocSpy).toHaveBeenCalledWith('songs/song-1'); expect(songDocCollectionSpy).toHaveBeenCalledWith('files'); diff --git a/src/app/modules/songs/services/file.service.spec.ts b/src/app/modules/songs/services/file.service.spec.ts index 079117b..b976d6c 100644 --- a/src/app/modules/songs/services/file.service.spec.ts +++ b/src/app/modules/songs/services/file.service.spec.ts @@ -5,18 +5,16 @@ import {FileService} from './file.service'; describe('FileService', () => { let service: FileService; - let fileDataServiceSpy: jasmine.SpyObj; - type FileServiceInternals = FileService & { - resolveDownloadUrl: (path: string) => Promise; - deleteFromStorage: (path: string) => Promise; - }; + let fileDataServiceSpy: {delete: ReturnType}; beforeEach(async () => { - fileDataServiceSpy = jasmine.createSpyObj('FileDataService', ['delete']); - fileDataServiceSpy.delete.and.resolveTo(); + fileDataServiceSpy = { + delete: vi.fn().mockResolvedValue(undefined), + }; await TestBed.configureTestingModule({ providers: [ + FileService, {provide: Storage, useValue: {app: 'test-storage'}}, {provide: FileDataService, useValue: fileDataServiceSpy}, ], @@ -25,20 +23,26 @@ describe('FileService', () => { service = TestBed.inject(FileService); }); + afterEach(() => { + vi.restoreAllMocks(); + }); + it('should be created', () => { expect(service).toBeTruthy(); }); it('should resolve download urls via AngularFire storage helpers', async () => { - const resolveSpy = spyOn(service as FileServiceInternals, 'resolveDownloadUrl').and.resolveTo('https://cdn.example/file.pdf'); + const resolveSpy = vi.fn<(path: string) => Promise>().mockResolvedValue('https://cdn.example/file.pdf'); + Object.assign(service, {resolveDownloadUrl: resolveSpy}); - await expectAsync(service.getDownloadUrl('songs/song-1/file.pdf').toPromise()).toBeResolvedTo('https://cdn.example/file.pdf'); + await expect(service.getDownloadUrl('songs/song-1/file.pdf').toPromise()).resolves.toEqual('https://cdn.example/file.pdf'); expect(resolveSpy).toHaveBeenCalledWith('songs/song-1/file.pdf'); }); it('should delete the file from storage and metadata from firestore', async () => { - const deleteFromStorageSpy = spyOn(service as FileServiceInternals, 'deleteFromStorage').and.resolveTo(); + const deleteFromStorageSpy = vi.fn<(path: string) => Promise>().mockResolvedValue(undefined); + Object.assign(service, {deleteFromStorage: deleteFromStorageSpy}); await service.delete('songs/song-1/file.pdf', 'song-1', 'file-1'); diff --git a/src/app/modules/songs/services/key.helper.spec.ts b/src/app/modules/songs/services/key.helper.spec.ts index 93f2e20..dc2cee2 100644 --- a/src/app/modules/songs/services/key.helper.spec.ts +++ b/src/app/modules/songs/services/key.helper.spec.ts @@ -52,7 +52,7 @@ describe('key.helper', () => { }); it('should detect whether a root has any available stored key', () => { - void expect(hasAvailableKeyForRoot('C', ['a'])).toBe(true); + void expect(hasAvailableKeyForRoot('C', ['C'])).toBe(true); void expect(hasAvailableKeyForRoot('E', ['F'])).toBe(true); void expect(hasAvailableKeyForRoot('H', ['C'])).toBe(true); void expect(hasAvailableKeyForRoot('D', ['F', 'a'])).toBe(false); diff --git a/src/app/modules/songs/services/song-data.service.spec.ts b/src/app/modules/songs/services/song-data.service.spec.ts index 70c0b38..b2e5a4f 100644 --- a/src/app/modules/songs/services/song-data.service.spec.ts +++ b/src/app/modules/songs/services/song-data.service.spec.ts @@ -7,30 +7,36 @@ import {SongDataService} from './song-data.service'; describe('SongDataService', () => { let service: SongDataService; let songs$: Subject>; - let docUpdateSpy: jasmine.Spy<() => Promise>; - let docDeleteSpy: jasmine.Spy<() => Promise>; - let docSpy: jasmine.Spy; - let colAddSpy: jasmine.Spy<() => Promise<{id: string}>>; - let colSpy: jasmine.Spy; - let dbServiceSpy: jasmine.SpyObj; + let docUpdateSpy: ReturnType Promise>>; + let docDeleteSpy: ReturnType Promise>>; + let docSpy: ReturnType {update: typeof docUpdateSpy; delete: typeof docDeleteSpy}>>; + let colAddSpy: ReturnType Promise<{id: string}>>>; + let colSpy: ReturnType {add: typeof colAddSpy}>>; + let dbServiceSpy: { + col$: ReturnType; + doc$: ReturnType; + doc: typeof docSpy; + col: typeof colSpy; + }; beforeEach(async () => { songs$ = new Subject>(); - docUpdateSpy = jasmine.createSpy('update').and.resolveTo(); - docDeleteSpy = jasmine.createSpy('delete').and.resolveTo(); - docSpy = jasmine.createSpy('doc').and.callFake(() => ({ + docUpdateSpy = vi.fn().mockResolvedValue(undefined); + docDeleteSpy = vi.fn().mockResolvedValue(undefined); + docSpy = vi.fn().mockImplementation(() => ({ update: docUpdateSpy, delete: docDeleteSpy, })); - colAddSpy = jasmine.createSpy('add').and.resolveTo({id: 'song-3'}); - colSpy = jasmine.createSpy('col').and.returnValue({ + colAddSpy = vi.fn().mockResolvedValue({id: 'song-3'}); + colSpy = vi.fn().mockReturnValue({ add: colAddSpy, }); - dbServiceSpy = jasmine.createSpyObj('DbService', ['col$', 'doc$', 'doc', 'col']); - dbServiceSpy.col$.and.returnValue(songs$.asObservable()); - dbServiceSpy.doc$.and.returnValue(songs$.asObservable() as never); - dbServiceSpy.doc.and.callFake(docSpy); - dbServiceSpy.col.and.callFake(colSpy); + dbServiceSpy = { + col$: vi.fn().mockReturnValue(songs$.asObservable()), + doc$: vi.fn().mockReturnValue(songs$.asObservable() as never), + doc: docSpy, + col: colSpy, + }; await TestBed.configureTestingModule({ providers: [{provide: DbService, useValue: dbServiceSpy}], @@ -83,15 +89,15 @@ describe('SongDataService', () => { await service.update$('song-8', {title: 'Updated'}); expect(docSpy).toHaveBeenCalledWith('songs/song-8'); - const [updatePayload] = docUpdateSpy.calls.mostRecent().args as unknown as [Record]; + const [updatePayload] = docUpdateSpy.mock.calls.at(-1) as unknown as [Record]; expect(updatePayload).toEqual({title: 'Updated'}); }); it('should add a song to the songs collection and return the new id', async () => { - await expectAsync(service.add({title: 'New Song'})).toBeResolvedTo('song-3'); + await expect(service.add({title: 'New Song'})).resolves.toEqual('song-3'); expect(colSpy).toHaveBeenCalledWith('songs'); - const [addPayload] = colAddSpy.calls.mostRecent().args as unknown as [Record]; + const [addPayload] = colAddSpy.mock.calls.at(-1) as unknown as [Record]; expect(addPayload).toEqual({title: 'New Song'}); }); diff --git a/src/app/modules/songs/services/song-download.service.spec.ts b/src/app/modules/songs/services/song-download.service.spec.ts new file mode 100644 index 0000000..5b43be4 --- /dev/null +++ b/src/app/modules/songs/services/song-download.service.spec.ts @@ -0,0 +1,76 @@ +import {TestBed} from '@angular/core/testing'; +import {of} from 'rxjs'; +import {UserSessionService} from '../../../services/user/user-session.service'; +import {User} from '../../../services/user/user'; +import {SongDataService} from './song-data.service'; +import {SongDownloadService} from './song-download.service'; + +describe('SongDownloadService', () => { + let service: SongDownloadService; + let songDataServiceSpy: {listLoaded$: ReturnType}; + let sessionSpy: {update$: ReturnType; user$: unknown}; + let createObjectUrlSpy: ReturnType; + let revokeObjectUrlSpy: ReturnType; + let clickSpy: ReturnType; + + beforeEach(async () => { + songDataServiceSpy = { + listLoaded$: vi.fn(), + }; + sessionSpy = { + update$: vi.fn(), + user$: of({id: 'admin-1', name: 'Admin', role: 'admin', chordMode: 'onlyFirst', songUsage: {}} as User), + }; + songDataServiceSpy.listLoaded$.mockReturnValue( + of([ + {id: 'song-2', number: 2, title: 'Zweiter Song'}, + {id: 'song-1', number: 1, title: 'Erster Song'}, + ] as never) + ); + createObjectUrlSpy = vi.spyOn(window.URL, 'createObjectURL').mockReturnValue('blob:songs'); + revokeObjectUrlSpy = vi.spyOn(window.URL, 'revokeObjectURL').mockImplementation(() => undefined); + clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => undefined); + + await TestBed.configureTestingModule({ + providers: [ + {provide: SongDataService, useValue: songDataServiceSpy}, + {provide: UserSessionService, useValue: sessionSpy}, + ], + }); + + service = TestBed.inject(SongDownloadService); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it('should download all songs as sorted JSON', async () => { + vi.useFakeTimers(); + + const result = await service.downloadSongs(); + + const [blob, fileName] = createObjectUrlSpy.mock.calls.at(-1) as unknown as [Blob, string]; + + expect(blob.type).toBe('application/json'); + expect(fileName).toBeUndefined(); + expect(clickSpy).toHaveBeenCalled(); + expect(result?.songsDownloaded).toBe(2); + expect(result?.fileName).toMatch(/^songs-\d{4}-\d{2}-\d{2}\.json$/); + + const text = await blob.text(); + expect(JSON.parse(text).map((song: {id: string}) => song.id)).toEqual(['song-1', 'song-2']); + + await vi.advanceTimersByTimeAsync(1000); + expect(revokeObjectUrlSpy).toHaveBeenCalledWith('blob:songs'); + }); + + it('should reject downloads for non-admin users', async () => { + Object.defineProperty(sessionSpy, 'user$', { + value: of({id: 'user-1', name: 'User', role: 'leader', chordMode: 'onlyFirst', songUsage: {}} as User), + }); + + await expect(service.downloadSongs()).rejects.toThrow('Admin role required to download songs.'); + }); +}); diff --git a/src/app/modules/songs/services/song-download.service.ts b/src/app/modules/songs/services/song-download.service.ts new file mode 100644 index 0000000..4e7b65e --- /dev/null +++ b/src/app/modules/songs/services/song-download.service.ts @@ -0,0 +1,77 @@ +import {Injectable, inject} from '@angular/core'; +import {firstValueFrom} from 'rxjs'; +import {take} from 'rxjs/operators'; +import {UserSessionService} from '../../../services/user/user-session.service'; +import {SongDataService} from './song-data.service'; + +export interface SongDownloadResult { + fileName: string; + songsDownloaded: number; +} + +@Injectable({ + providedIn: 'root', +}) +export class SongDownloadService { + private session = inject(UserSessionService); + private songDataService = inject(SongDataService); + + public async downloadSongs(): Promise { + const currentUser = await firstValueFrom(this.session.user$.pipe(take(1))); + if (!currentUser || !this.hasAdminRole(currentUser.role)) { + throw new Error('Admin role required to download songs.'); + } + + const songs = await firstValueFrom(this.songDataService.listLoaded$()); + const sortedSongs = [...songs].sort((left, right) => { + const numberCompare = (left.number ?? Number.MAX_SAFE_INTEGER) - (right.number ?? Number.MAX_SAFE_INTEGER); + if (numberCompare !== 0) { + return numberCompare; + } + + return (left.title ?? '').localeCompare(right.title ?? '', 'de'); + }); + const fileName = `songs-${this.formatDate(new Date())}.json`; + + this.saveAs(new Blob([JSON.stringify(sortedSongs, null, 2)], {type: 'application/json'}), fileName); + + return { + fileName, + songsDownloaded: sortedSongs.length, + }; + } + + private saveAs(blob: Blob, fileName: string) { + const a = document.createElement('a'); + + document.body.appendChild(a); + a.setAttribute('target', '_self'); + a.style.display = 'none'; + + const url = window.URL.createObjectURL(blob); + a.href = url; + a.download = fileName; + a.click(); + + setTimeout(() => { + window.URL.revokeObjectURL(url); + document.body.removeChild(a); + }, 1000); + } + + private formatDate(date: Date): string { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + + return `${year}-${month}-${day}`; + } + + private hasAdminRole(role: string | null | undefined): boolean { + if (!role) { + return false; + } + + return role.split(';').includes('admin'); + } +} diff --git a/src/app/modules/songs/services/song-list.resolver.spec.ts b/src/app/modules/songs/services/song-list.resolver.spec.ts index 73d58ee..4289c57 100644 --- a/src/app/modules/songs/services/song-list.resolver.spec.ts +++ b/src/app/modules/songs/services/song-list.resolver.spec.ts @@ -5,11 +5,12 @@ import {SongListResolver} from './song-list.resolver'; describe('SongListResolver', () => { let resolver: SongListResolver; - let songServiceSpy: jasmine.SpyObj; + let songServiceSpy: {listLoaded$: ReturnType}; beforeEach(async () => { - songServiceSpy = jasmine.createSpyObj('SongService', ['listLoaded$']); - songServiceSpy.listLoaded$.and.returnValue(of([{id: 'song-1', title: 'Amazing Grace'}]) as never); + songServiceSpy = { + listLoaded$: vi.fn().mockReturnValue(of([{id: 'song-1', title: 'Amazing Grace'}]) as never), + }; await TestBed.configureTestingModule({ providers: [{provide: SongService, useValue: songServiceSpy}], @@ -23,7 +24,7 @@ describe('SongListResolver', () => { }); it('should resolve the first emitted song list from the service', async () => { - await expectAsync(firstValueFrom(resolver.resolve())).toBeResolvedTo([{id: 'song-1', title: 'Amazing Grace'}] as never); + await expect(firstValueFrom(resolver.resolve())).resolves.toEqual([{id: 'song-1', title: 'Amazing Grace'}] as never); expect(songServiceSpy.listLoaded$).toHaveBeenCalled(); }); }); diff --git a/src/app/modules/songs/services/song.service.spec.ts b/src/app/modules/songs/services/song.service.spec.ts index d803a88..ae8edb3 100644 --- a/src/app/modules/songs/services/song.service.spec.ts +++ b/src/app/modules/songs/services/song.service.spec.ts @@ -7,8 +7,14 @@ import {Timestamp} from '@angular/fire/firestore'; describe('SongService', () => { let service: SongService; - let songDataServiceSpy: jasmine.SpyObj; - let userServiceSpy: jasmine.SpyObj; + let songDataServiceSpy: { + read$: ReturnType; + update$: ReturnType; + add: ReturnType; + delete: ReturnType; + list$: unknown; + }; + let userServiceSpy: {currentUser: ReturnType}; const song = { id: 'song-1', title: 'Amazing Grace', @@ -16,16 +22,22 @@ describe('SongService', () => { } as never; beforeEach(async () => { - songDataServiceSpy = jasmine.createSpyObj('SongDataService', ['read$', 'update$', 'add', 'delete'], { + songDataServiceSpy = { + read$: vi.fn(), + update$: vi.fn(), + add: vi.fn(), + delete: vi.fn(), list$: of([song]), - }); - userServiceSpy = jasmine.createSpyObj('UserService', ['currentUser']); + }; + userServiceSpy = { + currentUser: vi.fn(), + }; - songDataServiceSpy.read$.and.returnValue(of(song)); - songDataServiceSpy.update$.and.resolveTo(); - songDataServiceSpy.add.and.resolveTo('song-2'); - songDataServiceSpy.delete.and.resolveTo(); - userServiceSpy.currentUser.and.resolveTo({name: 'Benjamin'} as never); + songDataServiceSpy.read$.mockReturnValue(of(song)); + songDataServiceSpy.update$.mockResolvedValue(undefined); + songDataServiceSpy.add.mockResolvedValue('song-2'); + songDataServiceSpy.delete.mockResolvedValue(undefined); + userServiceSpy.currentUser.mockResolvedValue({name: 'Benjamin'} as never); await TestBed.configureTestingModule({ providers: [ @@ -37,33 +49,37 @@ describe('SongService', () => { service = TestBed.inject(SongService); }); + afterEach(() => { + vi.restoreAllMocks(); + }); + it('should be created', () => { expect(service).toBeTruthy(); }); it('should list songs from the data service', async () => { - await expectAsync(firstValueFrom(service.list$())).toBeResolvedTo([song]); + await expect(firstValueFrom(service.list$())).resolves.toEqual([song]); }); it('should delegate reads to the data service', async () => { - await expectAsync(service.read('song-1')).toBeResolvedTo(song); + await expect(service.read('song-1')).resolves.toEqual(song); expect(songDataServiceSpy.read$).toHaveBeenCalledWith('song-1'); }); it('should append an edit with the current user when updating a song', async () => { const timestamp = {seconds: 1} as never; - spyOn(Timestamp, 'now').and.returnValue(timestamp); + vi.spyOn(Timestamp, 'now').mockReturnValue(timestamp); await service.update$('song-1', {title: 'Updated'}); expect(songDataServiceSpy.update$).toHaveBeenCalled(); - const [, payload] = songDataServiceSpy.update$.calls.mostRecent().args as unknown as [string, Record]; + const [, payload] = songDataServiceSpy.update$.mock.calls.at(-1) as unknown as [string, Record]; expect(payload.title).toBe('Updated'); expect(payload.edits).toEqual([{username: 'Benjamin', timestamp}]); }); it('should not update when the song does not exist', async () => { - songDataServiceSpy.read$.and.returnValue(of(null)); + songDataServiceSpy.read$.mockReturnValue(of(null)); await service.update$('missing-song', {title: 'Updated'}); @@ -71,7 +87,7 @@ describe('SongService', () => { }); it('should not update when no current user is available', async () => { - userServiceSpy.currentUser.and.resolveTo(null); + userServiceSpy.currentUser.mockResolvedValue(null); await service.update$('song-1', {title: 'Updated'}); @@ -79,7 +95,7 @@ describe('SongService', () => { }); it('should create new songs with the expected defaults', async () => { - await expectAsync(service.new(42, 'New Song')).toBeResolvedTo('song-2'); + await expect(service.new(42, 'New Song')).resolves.toEqual('song-2'); expect(songDataServiceSpy.add).toHaveBeenCalledWith({ number: 42, diff --git a/src/app/modules/songs/services/text-rendering.service.spec.ts b/src/app/modules/songs/services/text-rendering.service.spec.ts index b87a1f1..17780f3 100644 --- a/src/app/modules/songs/services/text-rendering.service.spec.ts +++ b/src/app/modules/songs/services/text-rendering.service.spec.ts @@ -7,7 +7,7 @@ import {ChordAddDescriptor} from './chord'; describe('TextRenderingService', () => { const descriptor = (raw: string, partial: Partial) => - jasmine.objectContaining({ + expect.objectContaining({ raw, quality: null, extensions: [], @@ -198,12 +198,12 @@ Text`; void expect(sections[2].lines[0].text).toBe('c c# db c7 cmaj7 c/e'); void expect(sections[2].lines[0].chords).toEqual([ - jasmine.objectContaining({chord: 'c', length: 1, position: 0, add: null, slashChord: null, addDescriptor: null}), - jasmine.objectContaining({chord: 'c#', length: 2, position: 2, add: null, slashChord: null, addDescriptor: null}), - jasmine.objectContaining({chord: 'db', length: 2, position: 5, add: null, slashChord: null, addDescriptor: null}), - jasmine.objectContaining({chord: 'c', length: 2, position: 8, add: '7', slashChord: null, addDescriptor: descriptor('7', {extensions: ['7']})}), - jasmine.objectContaining({chord: 'c', length: 5, position: 13, add: 'maj7', slashChord: null, addDescriptor: descriptor('maj7', {quality: 'major', extensions: ['7']})}), - jasmine.objectContaining({chord: 'c', length: 3, position: 22, add: null, slashChord: 'e', addDescriptor: null}), + expect.objectContaining({chord: 'c', length: 1, position: 0, add: null, slashChord: null, addDescriptor: null}), + expect.objectContaining({chord: 'c#', length: 2, position: 2, add: null, slashChord: null, addDescriptor: null}), + expect.objectContaining({chord: 'db', length: 2, position: 5, add: null, slashChord: null, addDescriptor: null}), + expect.objectContaining({chord: 'c', length: 2, position: 8, add: '7', slashChord: null, addDescriptor: descriptor('7', {extensions: ['7']})}), + expect.objectContaining({chord: 'c', length: 5, position: 13, add: 'maj7', slashChord: null, addDescriptor: descriptor('maj7', {quality: 'major', extensions: ['7']})}), + expect.objectContaining({chord: 'c', length: 3, position: 22, add: null, slashChord: 'e', addDescriptor: null}), ]); }); @@ -228,9 +228,9 @@ Text`; const sections = service.parse(text, null); void expect(sections[0].lines[0].chords).toEqual([ - jasmine.objectContaining({chord: 'C', length: 1, position: 0, add: null, slashChord: null, addDescriptor: null}), - jasmine.objectContaining({chord: 'G', length: 3, position: 8, add: null, slashChord: 'B', addDescriptor: null}), - jasmine.objectContaining({chord: 'A', length: 2, position: 17, add: 'm', slashChord: null, addDescriptor: descriptor('m', {quality: 'minor'})}), + expect.objectContaining({chord: 'C', length: 1, position: 0, add: null, slashChord: null, addDescriptor: null}), + expect.objectContaining({chord: 'G', length: 3, position: 8, add: null, slashChord: 'B', addDescriptor: null}), + expect.objectContaining({chord: 'A', length: 2, position: 17, add: 'm', slashChord: null, addDescriptor: descriptor('m', {quality: 'minor'})}), ]); }); @@ -244,11 +244,11 @@ Text`; void expect(sections[0].lines[0].type).toBe(LineType.chord); void expect(sections[0].lines[0].chords).toEqual([ - jasmine.objectContaining({chord: 'C', length: 5, position: 0, add: 'maj7', slashChord: null, addDescriptor: descriptor('maj7', {quality: 'major', extensions: ['7']})}), - jasmine.objectContaining({chord: 'D', length: 3, position: 6, add: 'm7', slashChord: null, addDescriptor: descriptor('m7', {quality: 'minor', extensions: ['7']})}), - jasmine.objectContaining({chord: 'G', length: 5, position: 10, add: 'sus4', slashChord: null, addDescriptor: descriptor('sus4', {suspensions: ['4']})}), - jasmine.objectContaining({chord: 'A', length: 5, position: 16, add: 'add9', slashChord: null, addDescriptor: descriptor('add9', {additions: ['9']})}), - jasmine.objectContaining({chord: 'C', length: 7, position: 22, add: 'maj7', slashChord: 'E', addDescriptor: descriptor('maj7', {quality: 'major', extensions: ['7']})}), + expect.objectContaining({chord: 'C', length: 5, position: 0, add: 'maj7', slashChord: null, addDescriptor: descriptor('maj7', {quality: 'major', extensions: ['7']})}), + expect.objectContaining({chord: 'D', length: 3, position: 6, add: 'm7', slashChord: null, addDescriptor: descriptor('m7', {quality: 'minor', extensions: ['7']})}), + expect.objectContaining({chord: 'G', length: 5, position: 10, add: 'sus4', slashChord: null, addDescriptor: descriptor('sus4', {suspensions: ['4']})}), + expect.objectContaining({chord: 'A', length: 5, position: 16, add: 'add9', slashChord: null, addDescriptor: descriptor('add9', {additions: ['9']})}), + expect.objectContaining({chord: 'C', length: 7, position: 22, add: 'maj7', slashChord: 'E', addDescriptor: descriptor('maj7', {quality: 'major', extensions: ['7']})}), ]); }); @@ -262,10 +262,10 @@ Text`; void expect(sections[0].lines[0].type).toBe(LineType.chord); void expect(sections[0].lines[0].chords).toEqual([ - jasmine.objectContaining({chord: 'H', length: 5, position: 0, add: 'moll', slashChord: null, addDescriptor: descriptor('moll', {quality: 'minor'})}), - jasmine.objectContaining({chord: 'E', length: 4, position: 6, add: 'dur', slashChord: null, addDescriptor: descriptor('dur', {quality: 'major'})}), - jasmine.objectContaining({chord: 'C', length: 5, position: 11, add: 'verm', slashChord: null, addDescriptor: descriptor('verm', {quality: 'diminished'})}), - jasmine.objectContaining({chord: 'F', length: 4, position: 17, add: 'aug', slashChord: null, addDescriptor: descriptor('aug', {quality: 'augmented'})}), + expect.objectContaining({chord: 'H', length: 5, position: 0, add: 'moll', slashChord: null, addDescriptor: descriptor('moll', {quality: 'minor'})}), + expect.objectContaining({chord: 'E', length: 4, position: 6, add: 'dur', slashChord: null, addDescriptor: descriptor('dur', {quality: 'major'})}), + expect.objectContaining({chord: 'C', length: 5, position: 11, add: 'verm', slashChord: null, addDescriptor: descriptor('verm', {quality: 'diminished'})}), + expect.objectContaining({chord: 'F', length: 4, position: 17, add: 'aug', slashChord: null, addDescriptor: descriptor('aug', {quality: 'augmented'})}), ]); }); @@ -278,12 +278,12 @@ Text`; const sections = service.parse(text, null); void expect(sections[0].lines[0].chords).toEqual([ - jasmine.objectContaining({chord: 'C', length: 2, position: 0, add: '7', slashChord: null, addDescriptor: descriptor('7', {extensions: ['7']})}), - jasmine.objectContaining({chord: 'D', length: 2, position: 3, add: '9', slashChord: null, addDescriptor: descriptor('9', {extensions: ['9']})}), - jasmine.objectContaining({chord: 'E', length: 3, position: 6, add: '11', slashChord: null, addDescriptor: descriptor('11', {extensions: ['11']})}), - jasmine.objectContaining({chord: 'F', length: 3, position: 10, add: '13', slashChord: null, addDescriptor: descriptor('13', {extensions: ['13']})}), - jasmine.objectContaining({chord: 'G', length: 6, position: 14, add: 'add#9', slashChord: null, addDescriptor: descriptor('add#9', {additions: ['#9']})}), - jasmine.objectContaining({chord: 'A', length: 4, position: 21, add: '7-5', slashChord: null, addDescriptor: descriptor('7-5', {extensions: ['7'], alterations: ['-5']})}), + expect.objectContaining({chord: 'C', length: 2, position: 0, add: '7', slashChord: null, addDescriptor: descriptor('7', {extensions: ['7']})}), + expect.objectContaining({chord: 'D', length: 2, position: 3, add: '9', slashChord: null, addDescriptor: descriptor('9', {extensions: ['9']})}), + expect.objectContaining({chord: 'E', length: 3, position: 6, add: '11', slashChord: null, addDescriptor: descriptor('11', {extensions: ['11']})}), + expect.objectContaining({chord: 'F', length: 3, position: 10, add: '13', slashChord: null, addDescriptor: descriptor('13', {extensions: ['13']})}), + expect.objectContaining({chord: 'G', length: 6, position: 14, add: 'add#9', slashChord: null, addDescriptor: descriptor('add#9', {additions: ['#9']})}), + expect.objectContaining({chord: 'A', length: 4, position: 21, add: '7-5', slashChord: null, addDescriptor: descriptor('7-5', {extensions: ['7'], alterations: ['-5']})}), ]); }); @@ -296,9 +296,9 @@ Text`; const sections = service.parse(text, null); void expect(sections[0].lines[0].chords).toEqual([ - jasmine.objectContaining({chord: 'e', length: 5, position: 0, add: 'moll', slashChord: null, addDescriptor: descriptor('moll', {quality: 'minor'})}), - jasmine.objectContaining({chord: 'd', length: 4, position: 6, add: null, slashChord: 'F#', addDescriptor: null}), - jasmine.objectContaining({chord: 'c', length: 7, position: 11, add: 'maj7', slashChord: 'e', addDescriptor: descriptor('maj7', {quality: 'major', extensions: ['7']})}), + expect.objectContaining({chord: 'e', length: 5, position: 0, add: 'moll', slashChord: null, addDescriptor: descriptor('moll', {quality: 'minor'})}), + expect.objectContaining({chord: 'd', length: 4, position: 6, add: null, slashChord: 'F#', addDescriptor: null}), + expect.objectContaining({chord: 'c', length: 7, position: 11, add: 'maj7', slashChord: 'e', addDescriptor: descriptor('maj7', {quality: 'major', extensions: ['7']})}), ]); }); @@ -333,7 +333,7 @@ Text`; void expect(sections[0].lines[0].text).toBe('Cmaj7(add9)'); void expect(sections[0].lines[0].chords).toEqual([ - jasmine.objectContaining({ + expect.objectContaining({ chord: 'C', length: 11, position: 0, @@ -362,13 +362,13 @@ Text`; void expect(sections[0].lines[0].type).toBe(LineType.chord); void expect(sections[0].lines[0].text).toBe('C F G e C (F G)'); void expect(sections[0].lines[0].chords).toEqual([ - jasmine.objectContaining({chord: 'C', length: 1, position: 0, add: null, slashChord: null, addDescriptor: null}), - jasmine.objectContaining({chord: 'F', length: 1, position: 2, add: null, slashChord: null, addDescriptor: null}), - jasmine.objectContaining({chord: 'G', length: 1, position: 4, add: null, slashChord: null, addDescriptor: null}), - jasmine.objectContaining({chord: 'e', length: 1, position: 6, add: null, slashChord: null, addDescriptor: null}), - jasmine.objectContaining({chord: 'C', length: 1, position: 8, add: null, slashChord: null, addDescriptor: null}), - jasmine.objectContaining({chord: 'F', length: 2, position: 11, add: null, slashChord: null, addDescriptor: null, prefix: '('}), - jasmine.objectContaining({chord: 'G', length: 2, position: 14, add: null, slashChord: null, addDescriptor: null, suffix: ')'}), + expect.objectContaining({chord: 'C', length: 1, position: 0, add: null, slashChord: null, addDescriptor: null}), + expect.objectContaining({chord: 'F', length: 1, position: 2, add: null, slashChord: null, addDescriptor: null}), + expect.objectContaining({chord: 'G', length: 1, position: 4, add: null, slashChord: null, addDescriptor: null}), + expect.objectContaining({chord: 'e', length: 1, position: 6, add: null, slashChord: null, addDescriptor: null}), + expect.objectContaining({chord: 'C', length: 1, position: 8, add: null, slashChord: null, addDescriptor: null}), + expect.objectContaining({chord: 'F', length: 2, position: 11, add: null, slashChord: null, addDescriptor: null, prefix: '('}), + expect.objectContaining({chord: 'G', length: 2, position: 14, add: null, slashChord: null, addDescriptor: null, suffix: ')'}), ]); }); @@ -389,8 +389,8 @@ Text`; it('should call the transpose service when a transpose mode is provided', () => { const service: TextRenderingService = TestBed.inject(TextRenderingService); const transposeService = TestBed.inject(TransposeService); - const transposeSpy = spyOn(transposeService, 'transpose').and.callThrough(); - const renderSpy = spyOn(transposeService, 'renderChords').and.callThrough(); + const transposeSpy = vi.spyOn(transposeService, 'transpose'); + const renderSpy = vi.spyOn(transposeService, 'renderChords'); const text = `Strophe C D E Text`; @@ -404,8 +404,8 @@ Text`; it('should use renderChords when no transpose mode is provided', () => { const service: TextRenderingService = TestBed.inject(TextRenderingService); const transposeService = TestBed.inject(TransposeService); - const transposeSpy = spyOn(transposeService, 'transpose').and.callThrough(); - const renderSpy = spyOn(transposeService, 'renderChords').and.callThrough(); + const transposeSpy = vi.spyOn(transposeService, 'transpose'); + const renderSpy = vi.spyOn(transposeService, 'renderChords'); const text = `Strophe C D E Text`; @@ -434,9 +434,9 @@ Am Dm7 cdur C Text`; void expect(service.validateChordNotation(text)).toEqual([ - jasmine.objectContaining({lineNumber: 2, token: 'Am', suggestion: 'a', reason: 'minor_format'}), - jasmine.objectContaining({lineNumber: 2, token: 'Dm7', suggestion: 'd7', reason: 'minor_format'}), - jasmine.objectContaining({lineNumber: 2, token: 'cdur', suggestion: 'C', reason: 'major_format'}), + expect.objectContaining({lineNumber: 2, token: 'Am', suggestion: 'a', reason: 'minor_format'}), + expect.objectContaining({lineNumber: 2, token: 'Dm7', suggestion: 'd7', reason: 'minor_format'}), + expect.objectContaining({lineNumber: 2, token: 'cdur', suggestion: 'C', reason: 'major_format'}), ]); }); @@ -447,8 +447,8 @@ Am/C# Dm7/F# Text`; void expect(service.validateChordNotation(text)).toEqual([ - jasmine.objectContaining({lineNumber: 2, token: 'Am/C#', suggestion: 'a/C#', reason: 'minor_format'}), - jasmine.objectContaining({lineNumber: 2, token: 'Dm7/F#', suggestion: 'd7/F#', reason: 'minor_format'}), + expect.objectContaining({lineNumber: 2, token: 'Am/C#', suggestion: 'a/C#', reason: 'minor_format'}), + expect.objectContaining({lineNumber: 2, token: 'Dm7/F#', suggestion: 'd7/F#', reason: 'minor_format'}), ]); }); @@ -462,7 +462,7 @@ Text`; void expect(sections[0].lines[0].type).toBe(LineType.chord); void expect(sections[0].lines[0].text).toBe('C Es G'); - void expect(service.validateChordNotation(text)).toEqual([jasmine.objectContaining({lineNumber: 2, token: 'Es', reason: 'unknown_token', suggestion: null})]); + void expect(service.validateChordNotation(text)).toEqual([expect.objectContaining({lineNumber: 2, token: 'Es', reason: 'unknown_token', suggestion: null})]); }); it('should flag unknown tokens on mostly chord lines', () => { @@ -471,7 +471,7 @@ Text`; C Foo G a Text`; - void expect(service.validateChordNotation(text)).toEqual([jasmine.objectContaining({lineNumber: 2, token: 'Foo', reason: 'unknown_token', suggestion: null})]); + void expect(service.validateChordNotation(text)).toEqual([expect.objectContaining({lineNumber: 2, token: 'Foo', reason: 'unknown_token', suggestion: null})]); }); it('should reject tabs on chord lines', () => { @@ -480,7 +480,7 @@ Text`; void expect(service.validateChordNotation(text)).toEqual( expect.arrayContaining([ - jasmine.objectContaining({ + expect.objectContaining({ lineNumber: 2, token: '\t', reason: 'tab_character', diff --git a/src/app/modules/songs/services/transpose.service.spec.ts b/src/app/modules/songs/services/transpose.service.spec.ts index 62ee09e..e8366c2 100644 --- a/src/app/modules/songs/services/transpose.service.spec.ts +++ b/src/app/modules/songs/services/transpose.service.spec.ts @@ -80,6 +80,6 @@ describe('TransposeService', () => { const rendered = service.renderChords(line); void expect(rendered.text.length).toBe(121); - void expect(rendered.text.endsWith('C')).toBeTrue(); + void expect(rendered.text.endsWith('C')).toBe(true); }); }); diff --git a/src/app/modules/songs/services/upload.service.spec.ts b/src/app/modules/songs/services/upload.service.spec.ts index 2606656..18e436a 100644 --- a/src/app/modules/songs/services/upload.service.spec.ts +++ b/src/app/modules/songs/services/upload.service.spec.ts @@ -6,20 +6,19 @@ import {UploadService} from './upload.service'; describe('UploadService', () => { let service: UploadService; - let fileDataServiceSpy: jasmine.SpyObj; + let fileDataServiceSpy: {set: ReturnType}; type UploadTaskLike = { on: (event: string, progress: (snapshot: {bytesTransferred: number; totalBytes: number}) => void, error: () => void, success: () => void) => void; }; - type UploadServiceInternals = UploadService & { - startUpload: (path: string, file: File) => UploadTaskLike; - }; beforeEach(async () => { - fileDataServiceSpy = jasmine.createSpyObj('FileDataService', ['set']); - fileDataServiceSpy.set.and.resolveTo('file-1'); + fileDataServiceSpy = { + set: vi.fn().mockResolvedValue('file-1'), + }; await TestBed.configureTestingModule({ providers: [ + UploadService, {provide: Storage, useValue: {app: 'test-storage'}}, {provide: FileDataService, useValue: fileDataServiceSpy}, ], @@ -28,6 +27,10 @@ describe('UploadService', () => { service = TestBed.inject(UploadService); }); + afterEach(() => { + vi.restoreAllMocks(); + }); + it('should be created', () => { expect(service).toBeTruthy(); }); @@ -39,7 +42,8 @@ describe('UploadService', () => { success(); }, }; - const uploadSpy = spyOn(service as UploadServiceInternals, 'startUpload').and.returnValue(task); + const uploadSpy = vi.fn<(path: string, file: File) => UploadTaskLike>().mockReturnValue(task); + Object.assign(service, {startUpload: uploadSpy}); const upload = new Upload(new File(['content'], 'test.pdf', {type: 'application/pdf'})); service.pushUpload('song-1', upload); @@ -50,10 +54,10 @@ describe('UploadService', () => { expect(upload.path).toBe('/attachments/song-1'); expect(fileDataServiceSpy.set).toHaveBeenCalledWith( 'song-1', - jasmine.objectContaining({ + expect.objectContaining({ name: 'test.pdf', path: '/attachments/song-1', - createdAt: jasmine.any(Date), + createdAt: expect.any(Date), }) ); }); diff --git a/src/app/modules/songs/song/edit/edit-song.guard.spec.ts b/src/app/modules/songs/song/edit/edit-song.guard.spec.ts index 14d1535..f755495 100644 --- a/src/app/modules/songs/song/edit/edit-song.guard.spec.ts +++ b/src/app/modules/songs/song/edit/edit-song.guard.spec.ts @@ -16,16 +16,16 @@ describe('EditSongGuard', () => { it('should allow navigation when there is no nested editSongComponent', () => { const result = guard.canDeactivate({editSongComponent: null} as never, {} as never, {} as never, {} as never); - expect(result).toBeTrue(); + expect(result).toBe(true); }); it('should delegate to askForSave on the nested editSongComponent', async () => { const nextState = {url: '/songs'} as never; - const askForSave = jasmine.createSpy('askForSave').and.resolveTo(true); + const askForSave = vi.fn().mockResolvedValue(true); const result = await guard.canDeactivate({editSongComponent: {askForSave}} as never, {} as never, {} as never, nextState); expect(askForSave).toHaveBeenCalledWith(nextState); - expect(result).toBeTrue(); + expect(result).toBe(true); }); }); diff --git a/src/app/modules/songs/song/song.component.spec.ts b/src/app/modules/songs/song/song.component.spec.ts index f9955e5..03c015a 100644 --- a/src/app/modules/songs/song/song.component.spec.ts +++ b/src/app/modules/songs/song/song.component.spec.ts @@ -18,20 +18,27 @@ describe('SongComponent', () => { }; beforeEach(async () => { - const songServiceSpy = jasmine.createSpyObj('SongService', ['read$']); - const fileDataServiceSpy = jasmine.createSpyObj('FileDataService', ['read$']); - const userServiceSpy = jasmine.createSpyObj('UserService', ['incSongCount', 'decSongCount'], { + const songServiceSpy = { + read$: vi.fn().mockReturnValue(of({id: '4711', title: 'Test Song', number: '1', text: '', showType: '', flags: ''} as never)), + }; + const fileDataServiceSpy = { + read$: vi.fn().mockReturnValue(of([])), + }; + const userServiceSpy = { + incSongCount: vi.fn(), + decSongCount: vi.fn(), user$: of({id: 'user-1', name: 'Benjamin', role: 'leader', chordMode: 'onlyFirst', songUsage: {'4711': 2}}), userId$: of('user-1'), - }); - const showServiceSpy = jasmine.createSpyObj('ShowService', ['list$', 'update$']); - const showSongServiceSpy = jasmine.createSpyObj('ShowSongService', ['new$']); - - songServiceSpy.read$.and.returnValue(of({id: '4711', title: 'Test Song', number: '1', text: '', showType: '', flags: ''} as never)); - fileDataServiceSpy.read$.and.returnValue(of([])); - showServiceSpy.list$.and.returnValue(of([])); - userServiceSpy.loggedIn$ = jasmine.createSpy('loggedIn$').and.returnValue(of(true)); - userServiceSpy.getUserbyId$ = jasmine.createSpy('getUserbyId$').and.returnValue(of({name: 'Benjamin'})); + loggedIn$: vi.fn().mockReturnValue(of(true)), + getUserbyId$: vi.fn().mockReturnValue(of({name: 'Benjamin'})), + }; + const showServiceSpy = { + list$: vi.fn().mockReturnValue(of([])), + update$: vi.fn(), + }; + const showSongServiceSpy = { + new$: vi.fn(), + }; await TestBed.configureTestingModule({ imports: [SongComponent], diff --git a/src/app/services/admin/admin.service.spec.ts b/src/app/services/admin/admin.service.spec.ts new file mode 100644 index 0000000..278a109 --- /dev/null +++ b/src/app/services/admin/admin.service.spec.ts @@ -0,0 +1,45 @@ +import {TestBed} from '@angular/core/testing'; +import {AdminService} from './admin.service'; +import {SongDownloadService} from '../../modules/songs/services/song-download.service'; +import {ShowSongIndexService} from '../../modules/shows/services/show-song-index.service'; +import {UserSongUsageService} from '../user/user-song-usage.service'; +import {beforeEach, describe, expect, it, vi} from 'vitest'; + +describe('AdminService', () => { + let service: AdminService; + let songDownloadSpy: {downloadSongs: ReturnType}; + let songUsageSpy: {rebuildSongUsage: ReturnType}; + let showSongIndexSpy: {rebuildShowSongIds: ReturnType}; + + beforeEach(async () => { + songDownloadSpy = {downloadSongs: vi.fn()}; + songUsageSpy = {rebuildSongUsage: vi.fn()}; + showSongIndexSpy = {rebuildShowSongIds: vi.fn()}; + + songDownloadSpy.downloadSongs.mockResolvedValue({fileName: 'songs-2026-06-09.json', songsDownloaded: 4}); + songUsageSpy.rebuildSongUsage.mockResolvedValue({usersProcessed: 1, showsProcessed: 2, showSongsProcessed: 3}); + showSongIndexSpy.rebuildShowSongIds.mockResolvedValue({showsProcessed: 2, showSongsProcessed: 3}); + + await TestBed.configureTestingModule({ + providers: [ + {provide: SongDownloadService, useValue: songDownloadSpy}, + {provide: UserSongUsageService, useValue: songUsageSpy}, + {provide: ShowSongIndexService, useValue: showSongIndexSpy}, + ], + }); + + service = TestBed.inject(AdminService); + }); + + it('should delegate admin operations to their domain services', async () => { + const onProgress = vi.fn(); + + await expect(service.downloadSongs()).resolves.toEqual({fileName: 'songs-2026-06-09.json', songsDownloaded: 4}); + await expect(service.rebuildSongUsage()).resolves.toEqual({usersProcessed: 1, showsProcessed: 2, showSongsProcessed: 3}); + await expect(service.rebuildShowSongIds(onProgress)).resolves.toEqual({showsProcessed: 2, showSongsProcessed: 3}); + + expect(songDownloadSpy.downloadSongs).toHaveBeenCalled(); + expect(songUsageSpy.rebuildSongUsage).toHaveBeenCalled(); + expect(showSongIndexSpy.rebuildShowSongIds).toHaveBeenCalledWith(onProgress); + }); +}); diff --git a/src/app/services/admin/admin.service.ts b/src/app/services/admin/admin.service.ts new file mode 100644 index 0000000..6780283 --- /dev/null +++ b/src/app/services/admin/admin.service.ts @@ -0,0 +1,17 @@ +import {Injectable, inject} from '@angular/core'; +import {SongDownloadResult, SongDownloadService} from '../../modules/songs/services/song-download.service'; +import {MigrationProgress, ShowSongIndexMigrationResult, ShowSongIndexService} from '../../modules/shows/services/show-song-index.service'; +import {SongUsageMigrationResult, UserSongUsageService} from '../user/user-song-usage.service'; + +@Injectable({ + providedIn: 'root', +}) +export class AdminService { + private songDownload = inject(SongDownloadService); + private songUsage = inject(UserSongUsageService); + private showSongIndex = inject(ShowSongIndexService); + + public downloadSongs = (): Promise => this.songDownload.downloadSongs(); + public rebuildSongUsage = (): Promise => this.songUsage.rebuildSongUsage(); + public rebuildShowSongIds = (onProgress?: (progress: MigrationProgress) => void): Promise => this.showSongIndex.rebuildShowSongIds(onProgress); +} diff --git a/src/app/services/config.service.spec.ts b/src/app/services/config.service.spec.ts index 141ed42..550c817 100644 --- a/src/app/services/config.service.spec.ts +++ b/src/app/services/config.service.spec.ts @@ -1,15 +1,16 @@ import {TestBed} from '@angular/core/testing'; import {firstValueFrom, of} from 'rxjs'; +import {beforeEach, describe, expect, it, vi} from 'vitest'; import {DbService} from './db.service'; import {ConfigService} from './config.service'; describe('ConfigService', () => { let service: ConfigService; - let dbServiceSpy: jasmine.SpyObj; + let dbServiceSpy: {doc$: ReturnType}; beforeEach(async () => { - dbServiceSpy = jasmine.createSpyObj('DbService', ['doc$']); - dbServiceSpy.doc$.and.returnValue(of({copyright: 'CCLI'}) as never); + dbServiceSpy = {doc$: vi.fn()}; + dbServiceSpy.doc$.mockReturnValue(of({copyright: 'CCLI'}) as never); await TestBed.configureTestingModule({ providers: [{provide: DbService, useValue: dbServiceSpy}], @@ -28,10 +29,10 @@ describe('ConfigService', () => { }); it('should expose the shared config stream via get$', async () => { - await expectAsync(firstValueFrom(service.get$())).toBeResolvedTo({copyright: 'CCLI'} as never); + await expect(firstValueFrom(service.get$())).resolves.toEqual({copyright: 'CCLI'} as never); }); it('should resolve the current config via get()', async () => { - await expectAsync(service.get()).toBeResolvedTo({copyright: 'CCLI'} as never); + await expect(service.get()).resolves.toEqual({copyright: 'CCLI'} as never); }); }); diff --git a/src/app/services/global-settings.service.spec.ts b/src/app/services/global-settings.service.spec.ts index 4254916..51f3fa5 100644 --- a/src/app/services/global-settings.service.spec.ts +++ b/src/app/services/global-settings.service.spec.ts @@ -1,18 +1,19 @@ import {TestBed} from '@angular/core/testing'; import {firstValueFrom, of} from 'rxjs'; +import {beforeEach, describe, expect, it, vi} from 'vitest'; import {DbService} from './db.service'; import {GlobalSettingsService} from './global-settings.service'; describe('GlobalSettingsService', () => { let service: GlobalSettingsService; - let dbServiceSpy: jasmine.SpyObj; - let updateSpy: jasmine.Spy; + let dbServiceSpy: {doc$: ReturnType; doc: ReturnType}; + let updateSpy: ReturnType; beforeEach(async () => { - updateSpy = jasmine.createSpy('update').and.resolveTo(); - dbServiceSpy = jasmine.createSpyObj('DbService', ['doc$', 'doc']); - dbServiceSpy.doc$.and.returnValue(of({churchName: 'ICF'}) as never); - dbServiceSpy.doc.and.returnValue({update: updateSpy} as never); + updateSpy = vi.fn().mockResolvedValue(undefined); + dbServiceSpy = {doc$: vi.fn(), doc: vi.fn()}; + dbServiceSpy.doc$.mockReturnValue(of({churchName: 'ICF'}) as never); + dbServiceSpy.doc.mockReturnValue({update: updateSpy} as never); await TestBed.configureTestingModule({ providers: [{provide: DbService, useValue: dbServiceSpy}], @@ -31,7 +32,7 @@ describe('GlobalSettingsService', () => { }); it('should expose the shared settings stream via the getter', async () => { - await expectAsync(firstValueFrom(service.get$)).toBeResolvedTo({churchName: 'ICF'} as never); + await expect(firstValueFrom(service.get$)).resolves.toEqual({churchName: 'ICF'} as never); }); it('should update the static global settings document', async () => { diff --git a/src/app/services/user/user-name/user-name.component.spec.ts b/src/app/services/user/user-name/user-name.component.spec.ts index 4594355..05c538c 100644 --- a/src/app/services/user/user-name/user-name.component.spec.ts +++ b/src/app/services/user/user-name/user-name.component.spec.ts @@ -1,16 +1,17 @@ import {ComponentFixture, TestBed} from '@angular/core/testing'; import {firstValueFrom, of} from 'rxjs'; +import {beforeEach, describe, expect, it, vi} from 'vitest'; import {UserService} from '../user.service'; import {UserNameComponent} from './user-name.component'; describe('UserNameComponent', () => { let component: UserNameComponent; let fixture: ComponentFixture; - let userServiceSpy: jasmine.SpyObj; + let userServiceSpy: {getUserbyId$: ReturnType}; beforeEach(async () => { - userServiceSpy = jasmine.createSpyObj('UserService', ['getUserbyId$']); - userServiceSpy.getUserbyId$.and.returnValue(of({name: 'Benjamin'} as never)); + userServiceSpy = {getUserbyId$: vi.fn()}; + userServiceSpy.getUserbyId$.mockReturnValue(of({name: 'Benjamin'} as never)); await TestBed.configureTestingModule({ imports: [UserNameComponent], @@ -32,14 +33,14 @@ describe('UserNameComponent', () => { component.userId = 'user-1'; expect(userServiceSpy.getUserbyId$).toHaveBeenCalledWith('user-1'); - await expectAsync(firstValueFrom(component.name$!)).toBeResolvedTo('Benjamin'); + await expect(firstValueFrom(component.name$!)).resolves.toEqual('Benjamin'); }); it('should map missing users to null names', async () => { - userServiceSpy.getUserbyId$.and.returnValue(of(null)); + userServiceSpy.getUserbyId$.mockReturnValue(of(null)); component.userId = 'missing-user'; - await expectAsync(firstValueFrom(component.name$!)).toBeResolvedTo(null); + await expect(firstValueFrom(component.name$!)).resolves.toEqual(null); }); }); diff --git a/src/app/services/user/user-session.service.spec.ts b/src/app/services/user/user-session.service.spec.ts index b466b3c..ee2ca4a 100644 --- a/src/app/services/user/user-session.service.spec.ts +++ b/src/app/services/user/user-session.service.spec.ts @@ -2,23 +2,24 @@ import {TestBed} from '@angular/core/testing'; import {Router} from '@angular/router'; import {BehaviorSubject, firstValueFrom, of} from 'rxjs'; import {Auth} from '@angular/fire/auth'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; import {DbService} from '../db.service'; import {UserSessionService} from './user-session.service'; describe('UserSessionService', () => { let service: UserSessionService; - let dbServiceSpy: jasmine.SpyObj; - let routerSpy: jasmine.SpyObj; + let dbServiceSpy: {col$: ReturnType; doc$: ReturnType; doc: ReturnType}; + let routerSpy: {navigateByUrl: ReturnType}; let authStateSubject: BehaviorSubject; - let createAuthStateSpy: jasmine.Spy; - let runInFirebaseContextSpy: jasmine.Spy; + let createAuthStateSpy: ReturnType; + let runInFirebaseContextSpy: ReturnType; beforeEach(async () => { authStateSubject = new BehaviorSubject(null); - dbServiceSpy = jasmine.createSpyObj('DbService', ['col$', 'doc$', 'doc']); - routerSpy = jasmine.createSpyObj('Router', ['navigateByUrl']); - dbServiceSpy.col$.and.returnValue(of([{id: 'user-1'}]) as never); - dbServiceSpy.doc$.and.callFake((path: string) => { + dbServiceSpy = {col$: vi.fn(), doc$: vi.fn(), doc: vi.fn()}; + routerSpy = {navigateByUrl: vi.fn()}; + dbServiceSpy.col$.mockReturnValue(of([{id: 'user-1'}]) as never); + dbServiceSpy.doc$.mockImplementation((path: string) => { if (path === 'users/user-1') { return of({id: 'user-1', name: 'Benjamin', role: 'leader', chordMode: 'letters', songUsage: {}}) as never; } @@ -28,15 +29,15 @@ describe('UserSessionService', () => { return of(null) as never; }); - dbServiceSpy.doc.and.returnValue({ - update: jasmine.createSpy('update').and.resolveTo(), - set: jasmine.createSpy('set').and.resolveTo(), + dbServiceSpy.doc.mockReturnValue({ + update: vi.fn().mockResolvedValue(undefined), + set: vi.fn().mockResolvedValue(undefined), } as never); - routerSpy.navigateByUrl.and.resolveTo(true); + routerSpy.navigateByUrl.mockResolvedValue(true); - createAuthStateSpy = spyOn(UserSessionService.prototype as UserSessionService & {createAuthState$: () => unknown}, 'createAuthState$').and.returnValue( - authStateSubject.asObservable() as never - ); + createAuthStateSpy = vi + .spyOn(UserSessionService.prototype as unknown as {createAuthState$: () => unknown}, 'createAuthState$') + .mockReturnValue(authStateSubject.asObservable() as never) as unknown as ReturnType; await TestBed.configureTestingModule({ providers: [ @@ -47,7 +48,13 @@ describe('UserSessionService', () => { }); service = TestBed.inject(UserSessionService); - runInFirebaseContextSpy = spyOn(service as UserSessionService & {runInFirebaseContext: (...args: unknown[]) => Promise}, 'runInFirebaseContext'); + runInFirebaseContextSpy = vi.spyOn(service as unknown as {runInFirebaseContext: (...args: unknown[]) => Promise}, 'runInFirebaseContext') as unknown as ReturnType< + typeof vi.fn + >; + }); + + afterEach(() => { + vi.restoreAllMocks(); }); it('should be created', () => { @@ -58,14 +65,14 @@ describe('UserSessionService', () => { it('should derive userId$ and loggedIn$ from authState', async () => { authStateSubject.next({uid: 'user-1'}); - await expectAsync(firstValueFrom(service.userId$)).toBeResolvedTo('user-1'); - await expectAsync(firstValueFrom(service.loggedIn$())).toBeResolvedTo(true); + await expect(firstValueFrom(service.userId$)).resolves.toEqual('user-1'); + await expect(firstValueFrom(service.loggedIn$())).resolves.toEqual(true); }); it('should resolve the current user document from auth state', async () => { authStateSubject.next({uid: 'user-1'}); - await expectAsync(firstValueFrom(service.user$)).toBeResolvedTo(jasmine.objectContaining({id: 'user-1', name: 'Benjamin'}) as never); + await expect(firstValueFrom(service.user$)).resolves.toEqual(expect.objectContaining({id: 'user-1', name: 'Benjamin'}) as never); }); it('should cache user lookups by id', async () => { @@ -73,30 +80,30 @@ describe('UserSessionService', () => { const second$ = service.getUserbyId$('user-2'); expect(first$).toBe(second$); - await expectAsync(firstValueFrom(first$)).toBeResolvedTo(jasmine.objectContaining({id: 'user-2'}) as never); + await expect(firstValueFrom(first$)).resolves.toEqual(expect.objectContaining({id: 'user-2'}) as never); }); it('should login and initialize songUsage when missing', async () => { - dbServiceSpy.doc$.and.callFake((path: string) => { + dbServiceSpy.doc$.mockImplementation((path: string) => { if (path === 'users/user-1') { return of({id: 'user-1', name: 'Benjamin', role: 'leader', chordMode: 'letters'}) as never; } return of(null) as never; }); - const updateSpy = jasmine.createSpy('update').and.resolveTo(); - dbServiceSpy.doc.and.returnValue({update: updateSpy} as never); - runInFirebaseContextSpy.and.resolveTo({user: {uid: 'user-1'}}); + const updateSpy = vi.fn().mockResolvedValue(undefined); + dbServiceSpy.doc.mockReturnValue({update: updateSpy} as never); + runInFirebaseContextSpy.mockResolvedValue({user: {uid: 'user-1'}}); authStateSubject.next({uid: 'user-1'}); - await expectAsync(service.login('mail', 'secret')).toBeResolvedTo('user-1'); + await expect(service.login('mail', 'secret')).resolves.toEqual('user-1'); expect(runInFirebaseContextSpy).toHaveBeenCalledTimes(1); expect(updateSpy).toHaveBeenCalledWith({songUsage: {}}); }); it('should wait for auth state propagation before resolving login', async () => { - runInFirebaseContextSpy.and.resolveTo({user: {uid: 'user-1'}}); + runInFirebaseContextSpy.mockResolvedValue({user: {uid: 'user-1'}}); let resolved = false; const loginPromise = service.login('mail', 'secret').then(result => { @@ -105,15 +112,15 @@ describe('UserSessionService', () => { }); await Promise.resolve(); - expect(resolved).toBeFalse(); + expect(resolved).toBe(false); authStateSubject.next({uid: 'user-1'}); - await expectAsync(loginPromise).toBeResolvedTo('user-1'); + await expect(loginPromise).resolves.toEqual('user-1'); }); it('should delegate logout and password reset to AngularFire auth APIs', async () => { - runInFirebaseContextSpy.and.resolveTo(); + runInFirebaseContextSpy.mockResolvedValue(undefined); await service.logout(); await service.changePassword('mail@example.com'); @@ -122,16 +129,16 @@ describe('UserSessionService', () => { }); it('should create a new user document and navigate afterwards', async () => { - dbServiceSpy.doc$.and.callFake((path: string) => { + dbServiceSpy.doc$.mockImplementation((path: string) => { if (path === 'users/user-3') { return of({id: 'user-3', name: 'New User', role: 'user', chordMode: 'onlyFirst', songUsage: {}}) as never; } return of(null) as never; }); - const setSpy = jasmine.createSpy('set').and.resolveTo(); - dbServiceSpy.doc.and.returnValue({set: setSpy} as never); - runInFirebaseContextSpy.and.resolveTo({user: {uid: 'user-3'}}); + const setSpy = vi.fn().mockResolvedValue(undefined); + dbServiceSpy.doc.mockReturnValue({set: setSpy} as never); + runInFirebaseContextSpy.mockResolvedValue({user: {uid: 'user-3'}}); await service.createNewUser('mail@example.com', 'New User', 'secret'); diff --git a/src/app/services/user/user-song-usage.service.spec.ts b/src/app/services/user/user-song-usage.service.spec.ts index e4d927b..4ee3440 100644 --- a/src/app/services/user/user-song-usage.service.spec.ts +++ b/src/app/services/user/user-song-usage.service.spec.ts @@ -1,5 +1,6 @@ import {TestBed} from '@angular/core/testing'; import {of} from 'rxjs'; +import {beforeEach, describe, expect, it, vi} from 'vitest'; import {DbService} from '../db.service'; import {ShowDataService} from '../../modules/shows/services/show-data.service'; import {ShowSongDataService} from '../../modules/shows/services/show-song-data.service'; @@ -8,31 +9,32 @@ import {UserSongUsageService} from './user-song-usage.service'; describe('UserSongUsageService', () => { let service: UserSongUsageService; - let dbServiceSpy: jasmine.SpyObj; - let sessionSpy: jasmine.SpyObj; - let showDataServiceSpy: jasmine.SpyObj; - let showSongDataServiceSpy: jasmine.SpyObj; + let dbServiceSpy: {doc: ReturnType}; + let sessionSpy: {update$: ReturnType; user$: unknown; users$: unknown}; + let showDataServiceSpy: {listRaw$: ReturnType}; + let showSongDataServiceSpy: {list$: ReturnType}; beforeEach(async () => { - dbServiceSpy = jasmine.createSpyObj('DbService', ['doc']); - sessionSpy = jasmine.createSpyObj('UserSessionService', ['update$'], { + dbServiceSpy = {doc: vi.fn()}; + sessionSpy = { + update$: vi.fn(), user$: of({id: 'user-1', role: 'admin', songUsage: {}}) as never, users$: of([{id: 'user-1'}, {id: 'user-2'}]) as never, - }); - showDataServiceSpy = jasmine.createSpyObj('ShowDataService', ['listRaw$']); - showSongDataServiceSpy = jasmine.createSpyObj('ShowSongDataService', ['list$']); + }; + showDataServiceSpy = {listRaw$: vi.fn()}; + showSongDataServiceSpy = {list$: vi.fn()}; - sessionSpy.update$.and.resolveTo(); - showDataServiceSpy.listRaw$.and.returnValue( + sessionSpy.update$.mockResolvedValue(undefined); + showDataServiceSpy.listRaw$.mockReturnValue( of([ {id: 'show-1', owner: 'user-1', archived: false}, {id: 'show-2', owner: 'user-2', archived: true}, ]) as never ); - showSongDataServiceSpy.list$.and.callFake( + showSongDataServiceSpy.list$.mockImplementation( (showId: string) => of(showId === 'show-1' ? [{songId: 'song-1'}, {songId: 'song-1'}, {songId: 'song-2'}] : [{songId: 'song-3'}]) as never ); - dbServiceSpy.doc.and.returnValue({update: jasmine.createSpy('update').and.resolveTo()} as never); + dbServiceSpy.doc.mockReturnValue({update: vi.fn().mockResolvedValue(undefined)} as never); await TestBed.configureTestingModule({ providers: [ @@ -51,19 +53,19 @@ describe('UserSongUsageService', () => { }); it('should increment and decrement song usage for the current user', async () => { - const updateSpy = jasmine.createSpy('update').and.resolveTo(); - dbServiceSpy.doc.and.returnValue({update: updateSpy} as never); + const updateSpy = vi.fn().mockResolvedValue(undefined); + dbServiceSpy.doc.mockReturnValue({update: updateSpy} as never); await service.incSongCount('song-1'); await service.decSongCount('song-2'); expect(dbServiceSpy.doc).toHaveBeenCalledWith('users/user-1'); - expect(updateSpy.calls.argsFor(0)[0]).toEqual({'songUsage.song-1': jasmine.anything()}); - expect(updateSpy.calls.argsFor(1)[0]).toEqual({'songUsage.song-2': jasmine.anything()}); + expect(updateSpy.mock.calls[0]?.[0]).toEqual({'songUsage.song-1': expect.anything()}); + expect(updateSpy.mock.calls[1]?.[0]).toEqual({'songUsage.song-2': expect.anything()}); }); it('should rebuild song usage for all users based on owned show songs', async () => { - await expectAsync(service.rebuildSongUsage()).toBeResolvedTo({ + await expect(service.rebuildSongUsage()).resolves.toEqual({ usersProcessed: 2, showsProcessed: 2, showSongsProcessed: 3, @@ -76,6 +78,6 @@ describe('UserSongUsageService', () => { it('should reject song usage rebuilds for non-admin users', async () => { Object.defineProperty(sessionSpy, 'user$', {value: of({id: 'user-1', role: 'leader'})}); - await expectAsync(service.rebuildSongUsage()).toBeRejectedWithError('Admin role required to rebuild songUsage.'); + await expect(service.rebuildSongUsage()).rejects.toThrow('Admin role required to rebuild songUsage.'); }); }); diff --git a/src/app/services/user/user.service.spec.ts b/src/app/services/user/user.service.spec.ts index dc30ec0..a151d22 100644 --- a/src/app/services/user/user.service.spec.ts +++ b/src/app/services/user/user.service.spec.ts @@ -1,49 +1,64 @@ import {TestBed} from '@angular/core/testing'; import {firstValueFrom, of} from 'rxjs'; +import {beforeEach, describe, expect, it, vi} from 'vitest'; import {UserService} from './user.service'; import {UserSessionService} from './user-session.service'; import {UserSongUsageService} from './user-song-usage.service'; -import {ShowSongIndexService} from '../../modules/shows/services/show-song-index.service'; describe('UserService', () => { let service: UserService; - let sessionSpy: jasmine.SpyObj; - let songUsageSpy: jasmine.SpyObj; - let showSongIndexSpy: jasmine.SpyObj; + let sessionSpy: { + currentUser: ReturnType; + getUserbyId: ReturnType; + getUserbyId$: ReturnType; + login: ReturnType; + loggedIn$: ReturnType; + list$: ReturnType; + logout: ReturnType; + update$: ReturnType; + changePassword: ReturnType; + createNewUser: ReturnType; + users$: unknown; + userId$: unknown; + user$: unknown; + }; + let songUsageSpy: {incSongCount: ReturnType; decSongCount: ReturnType}; beforeEach(async () => { - sessionSpy = jasmine.createSpyObj( - 'UserSessionService', - ['currentUser', 'getUserbyId', 'getUserbyId$', 'login', 'loggedIn$', 'list$', 'logout', 'update$', 'changePassword', 'createNewUser'], - { - users$: of([{id: 'user-1'}]) as never, - userId$: of('user-1'), - user$: of({id: 'user-1'}) as never, - } - ); - songUsageSpy = jasmine.createSpyObj('UserSongUsageService', ['incSongCount', 'decSongCount', 'rebuildSongUsage']); - showSongIndexSpy = jasmine.createSpyObj('ShowSongIndexService', ['rebuildShowSongIds']); + sessionSpy = { + currentUser: vi.fn(), + getUserbyId: vi.fn(), + getUserbyId$: vi.fn(), + login: vi.fn(), + loggedIn$: vi.fn(), + list$: vi.fn(), + logout: vi.fn(), + update$: vi.fn(), + changePassword: vi.fn(), + createNewUser: vi.fn(), + users$: of([{id: 'user-1'}]) as never, + userId$: of('user-1'), + user$: of({id: 'user-1'}) as never, + }; + songUsageSpy = {incSongCount: vi.fn(), decSongCount: vi.fn()}; - sessionSpy.currentUser.and.resolveTo({id: 'user-1'} as never); - sessionSpy.getUserbyId.and.resolveTo({id: 'user-2'} as never); - sessionSpy.getUserbyId$.and.returnValue(of({id: 'user-2'}) as never); - sessionSpy.login.and.resolveTo('user-1'); - sessionSpy.loggedIn$.and.returnValue(of(true)); - sessionSpy.list$.and.returnValue(of([{id: 'user-1'}]) as never); - sessionSpy.logout.and.resolveTo(); - sessionSpy.update$.and.resolveTo(); - sessionSpy.changePassword.and.resolveTo(); - sessionSpy.createNewUser.and.resolveTo(); - songUsageSpy.incSongCount.and.resolveTo(); - songUsageSpy.decSongCount.and.resolveTo(); - songUsageSpy.rebuildSongUsage.and.resolveTo({usersProcessed: 1, showsProcessed: 2, showSongsProcessed: 3}); - showSongIndexSpy.rebuildShowSongIds.and.resolveTo({showsProcessed: 2, showSongsProcessed: 3}); + sessionSpy.currentUser.mockResolvedValue({id: 'user-1'} as never); + sessionSpy.getUserbyId.mockResolvedValue({id: 'user-2'} as never); + sessionSpy.getUserbyId$.mockReturnValue(of({id: 'user-2'}) as never); + sessionSpy.login.mockResolvedValue('user-1'); + sessionSpy.loggedIn$.mockReturnValue(of(true)); + sessionSpy.list$.mockReturnValue(of([{id: 'user-1'}]) as never); + sessionSpy.logout.mockResolvedValue(undefined); + sessionSpy.update$.mockResolvedValue(undefined); + sessionSpy.changePassword.mockResolvedValue(undefined); + sessionSpy.createNewUser.mockResolvedValue(undefined); + songUsageSpy.incSongCount.mockResolvedValue(undefined); + songUsageSpy.decSongCount.mockResolvedValue(undefined); await TestBed.configureTestingModule({ providers: [ {provide: UserSessionService, useValue: sessionSpy}, {provide: UserSongUsageService, useValue: songUsageSpy}, - {provide: ShowSongIndexService, useValue: showSongIndexSpy}, ], }); @@ -55,15 +70,15 @@ describe('UserService', () => { }); it('should expose the session streams directly', async () => { - await expectAsync(firstValueFrom(service.userId$)).toBeResolvedTo('user-1'); - await expectAsync(firstValueFrom(service.user$)).toBeResolvedTo({id: 'user-1'} as never); - await expectAsync(firstValueFrom(service.users$)).toBeResolvedTo([{id: 'user-1'}] as never); + await expect(firstValueFrom(service.userId$)).resolves.toEqual('user-1'); + await expect(firstValueFrom(service.user$)).resolves.toEqual({id: 'user-1'} as never); + await expect(firstValueFrom(service.users$)).resolves.toEqual([{id: 'user-1'}] as never); }); it('should delegate session operations to UserSessionService', async () => { - await expectAsync(service.currentUser()).toBeResolvedTo({id: 'user-1'} as never); - await expectAsync(service.getUserbyId('user-2')).toBeResolvedTo({id: 'user-2'} as never); - await expectAsync(service.login('mail', 'secret')).toBeResolvedTo('user-1'); + await expect(service.currentUser()).resolves.toEqual({id: 'user-1'} as never); + await expect(service.getUserbyId('user-2')).resolves.toEqual({id: 'user-2'} as never); + await expect(service.login('mail', 'secret')).resolves.toEqual('user-1'); await service.logout(); await service.update$('user-1', {name: 'Benjamin'} as never); await service.changePassword('mail'); @@ -79,9 +94,9 @@ describe('UserService', () => { }); it('should delegate user lookup and loggedIn/list streams to UserSessionService', async () => { - await expectAsync(firstValueFrom(service.getUserbyId$('user-2'))).toBeResolvedTo({id: 'user-2'} as never); - await expectAsync(firstValueFrom(service.loggedIn$())).toBeResolvedTo(true); - await expectAsync(firstValueFrom(service.list$())).toBeResolvedTo([{id: 'user-1'}] as never); + await expect(firstValueFrom(service.getUserbyId$('user-2'))).resolves.toEqual({id: 'user-2'} as never); + await expect(firstValueFrom(service.loggedIn$())).resolves.toEqual(true); + await expect(firstValueFrom(service.list$())).resolves.toEqual([{id: 'user-1'}] as never); expect(sessionSpy.getUserbyId$).toHaveBeenCalledWith('user-2'); expect(sessionSpy.loggedIn$).toHaveBeenCalled(); expect(sessionSpy.list$).toHaveBeenCalled(); @@ -90,12 +105,8 @@ describe('UserService', () => { it('should delegate song usage operations to UserSongUsageService', async () => { await service.incSongCount('song-1'); await service.decSongCount('song-2'); - await expectAsync(service.rebuildSongUsage()).toBeResolvedTo({usersProcessed: 1, showsProcessed: 2, showSongsProcessed: 3}); - await expectAsync(service.rebuildShowSongIds()).toBeResolvedTo({showsProcessed: 2, showSongsProcessed: 3}); expect(songUsageSpy.incSongCount).toHaveBeenCalledWith('song-1'); expect(songUsageSpy.decSongCount).toHaveBeenCalledWith('song-2'); - expect(songUsageSpy.rebuildSongUsage).toHaveBeenCalled(); - expect(showSongIndexSpy.rebuildShowSongIds).toHaveBeenCalled(); }); }); diff --git a/src/app/services/user/user.service.ts b/src/app/services/user/user.service.ts index 8d254a8..7066220 100644 --- a/src/app/services/user/user.service.ts +++ b/src/app/services/user/user.service.ts @@ -1,9 +1,8 @@ import {Injectable, inject} from '@angular/core'; import {Observable} from 'rxjs'; import {User} from './user'; -import {SongUsageMigrationResult, UserSongUsageService} from './user-song-usage.service'; +import {UserSongUsageService} from './user-song-usage.service'; import {UserSessionService} from './user-session.service'; -import {MigrationProgress, ShowSongIndexMigrationResult, ShowSongIndexService} from '../../modules/shows/services/show-song-index.service'; @Injectable({ providedIn: 'root', @@ -11,7 +10,6 @@ import {MigrationProgress, ShowSongIndexMigrationResult, ShowSongIndexService} f export class UserService { private session = inject(UserSessionService); private songUsage = inject(UserSongUsageService); - private showSongIndex = inject(ShowSongIndexService); public users$ = this.session.users$; @@ -35,6 +33,4 @@ export class UserService { public createNewUser = (user: string, name: string, password: string): Promise => this.session.createNewUser(user, name, password); public incSongCount = (songId: string): Promise => this.songUsage.incSongCount(songId); public decSongCount = (songId: string): Promise => this.songUsage.decSongCount(songId); - public rebuildSongUsage = (): Promise => this.songUsage.rebuildSongUsage(); - public rebuildShowSongIds = (onProgress?: (progress: MigrationProgress) => void): Promise => this.showSongIndex.rebuildShowSongIds(onProgress); } diff --git a/src/app/widget-modules/components/sidebar/page-frame.component.spec.ts b/src/app/widget-modules/components/sidebar/page-frame.component.spec.ts index 3a7aed0..ac6c6bb 100644 --- a/src/app/widget-modules/components/sidebar/page-frame.component.spec.ts +++ b/src/app/widget-modules/components/sidebar/page-frame.component.spec.ts @@ -13,6 +13,8 @@ describe('SidebarComponent', () => { fixture = TestBed.createComponent(PageFrameComponent); component = fixture.componentInstance; + fixture.componentRef.setInput('title', 'Test'); + fixture.detectChanges(); await fixture.whenStable(); }); @@ -21,12 +23,12 @@ describe('SidebarComponent', () => { }); it('should toggle and close the sidebar', () => { - expect(component.collapsed).toBeTrue(); + expect(component.collapsed).toBe(true); component.toggle(); - expect(component.collapsed).toBeFalse(); + expect(component.collapsed).toBe(false); component.close(); - expect(component.collapsed).toBeTrue(); + expect(component.collapsed).toBe(true); }); }); diff --git a/src/app/widget-modules/guards/role.guard.spec.ts b/src/app/widget-modules/guards/role.guard.spec.ts index 6edeb56..c8baaad 100644 --- a/src/app/widget-modules/guards/role.guard.spec.ts +++ b/src/app/widget-modules/guards/role.guard.spec.ts @@ -1,16 +1,18 @@ import {TestBed} from '@angular/core/testing'; import {Router} from '@angular/router'; import {firstValueFrom, of} from 'rxjs'; +import {vi} from 'vitest'; import {UserService} from '../../services/user/user.service'; import {RoleGuard} from './role.guard'; describe('RoleGuard', () => { let guard: RoleGuard; - let routerSpy: jasmine.SpyObj; + let createUrlTreeSpy: ReturnType; + let routerSpy: Pick; beforeEach(async () => { - routerSpy = jasmine.createSpyObj('Router', ['createUrlTree']); - routerSpy.createUrlTree.and.callFake(commands => ({commands}) as never); + createUrlTreeSpy = vi.fn().mockImplementation(commands => ({commands}) as never); + routerSpy = {createUrlTree: createUrlTreeSpy as Router['createUrlTree']}; await TestBed.configureTestingModule({ providers: [ @@ -31,12 +33,13 @@ describe('RoleGuard', () => { }); it('should deny access when there is no current user', async () => { - await expectAsync(firstValueFrom(guard.canActivate({data: {requiredRoles: ['leader']}} as never))).toBeResolvedTo({commands: ['brand', 'new-user']} as never); + await expect(firstValueFrom(guard.canActivate({data: {requiredRoles: ['leader']}} as never))).resolves.toEqual({commands: ['brand', 'new-user']} as never); }); it('should allow admins regardless of requiredRoles', async () => { TestBed.resetTestingModule(); - routerSpy = jasmine.createSpyObj('Router', ['createUrlTree']); + createUrlTreeSpy = vi.fn(); + routerSpy = {createUrlTree: createUrlTreeSpy as Router['createUrlTree']}; TestBed.configureTestingModule({ providers: [ {provide: Router, useValue: routerSpy}, @@ -45,12 +48,13 @@ describe('RoleGuard', () => { }); guard = TestBed.inject(RoleGuard); - await expectAsync(firstValueFrom(guard.canActivate({data: {requiredRoles: ['leader']}} as never))).toBeResolvedTo(true); + await expect(firstValueFrom(guard.canActivate({data: {requiredRoles: ['leader']}} as never))).resolves.toEqual(true); }); it('should allow users with a matching required role', async () => { TestBed.resetTestingModule(); - routerSpy = jasmine.createSpyObj('Router', ['createUrlTree']); + createUrlTreeSpy = vi.fn(); + routerSpy = {createUrlTree: createUrlTreeSpy as Router['createUrlTree']}; TestBed.configureTestingModule({ providers: [ {provide: Router, useValue: routerSpy}, @@ -59,13 +63,13 @@ describe('RoleGuard', () => { }); guard = TestBed.inject(RoleGuard); - await expectAsync(firstValueFrom(guard.canActivate({data: {requiredRoles: ['leader']}} as never))).toBeResolvedTo(true); + await expect(firstValueFrom(guard.canActivate({data: {requiredRoles: ['leader']}} as never))).resolves.toEqual(true); }); it('should redirect users without the required role to their role default route', async () => { TestBed.resetTestingModule(); - routerSpy = jasmine.createSpyObj('Router', ['createUrlTree']); - routerSpy.createUrlTree.and.returnValue({redirect: ['presentation']} as never); + createUrlTreeSpy = vi.fn().mockReturnValue({redirect: ['presentation']} as never); + routerSpy = {createUrlTree: createUrlTreeSpy as Router['createUrlTree']}; TestBed.configureTestingModule({ providers: [ {provide: Router, useValue: routerSpy}, @@ -75,14 +79,14 @@ describe('RoleGuard', () => { guard = TestBed.inject(RoleGuard); const result = await firstValueFrom(guard.canActivate({data: {requiredRoles: ['leader']}} as never)); - expect(routerSpy.createUrlTree).toHaveBeenCalledWith(['presentation']); + expect(createUrlTreeSpy).toHaveBeenCalledWith(['presentation']); expect(result).toEqual({redirect: ['presentation']} as never); }); it('should redirect members to shows instead of new-user', async () => { TestBed.resetTestingModule(); - routerSpy = jasmine.createSpyObj('Router', ['createUrlTree']); - routerSpy.createUrlTree.and.returnValue({redirect: ['shows']} as never); + createUrlTreeSpy = vi.fn().mockReturnValue({redirect: ['shows']} as never); + routerSpy = {createUrlTree: createUrlTreeSpy as Router['createUrlTree']}; TestBed.configureTestingModule({ providers: [ {provide: Router, useValue: routerSpy}, @@ -92,14 +96,14 @@ describe('RoleGuard', () => { guard = TestBed.inject(RoleGuard); const result = await firstValueFrom(guard.canActivate({data: {requiredRoles: ['user']}} as never)); - expect(routerSpy.createUrlTree).toHaveBeenCalledWith(['shows']); + expect(createUrlTreeSpy).toHaveBeenCalledWith(['shows']); expect(result).toEqual({redirect: ['shows']} as never); }); it('should choose a matching default route from all assigned roles', async () => { TestBed.resetTestingModule(); - routerSpy = jasmine.createSpyObj('Router', ['createUrlTree']); - routerSpy.createUrlTree.and.callFake(commands => ({redirect: commands}) as never); + createUrlTreeSpy = vi.fn().mockImplementation(commands => ({redirect: commands}) as never); + routerSpy = {createUrlTree: createUrlTreeSpy as Router['createUrlTree']}; TestBed.configureTestingModule({ providers: [ {provide: Router, useValue: routerSpy}, @@ -109,7 +113,7 @@ describe('RoleGuard', () => { guard = TestBed.inject(RoleGuard); const result = await firstValueFrom(guard.canActivate({data: {requiredRoles: ['presenter']}} as never)); - expect(routerSpy.createUrlTree).toHaveBeenCalledWith(['shows']); + expect(createUrlTreeSpy).toHaveBeenCalledWith(['shows']); expect(result).toEqual({redirect: ['shows']} as never); }); }); diff --git a/src/main.ts b/src/main.ts index 956862d..d800eeb 100644 --- a/src/main.ts +++ b/src/main.ts @@ -13,11 +13,12 @@ import {provideAuth} from '@angular/fire/auth'; import {initializeApp} from 'firebase/app'; import {getAuth} from 'firebase/auth'; import {initializeFirestore, persistentLocalCache, persistentMultipleTabManager} from 'firebase/firestore'; -import {UserService} from './app/services/user/user.service'; +import {AdminService} from './app/services/admin/admin.service'; declare global { interface Window { wgeneratorAdmin?: { + downloadSongs(): Promise; rebuildSongUsage(): Promise; rebuildShowSongIds(): Promise; }; @@ -50,12 +51,18 @@ bootstrapApplication(AppComponent, { ], }) .then(appRef => { - const userService = appRef.injector.get(UserService); + const adminService = appRef.injector.get(AdminService); window.wgeneratorAdmin = { - rebuildSongUsage: () => userService.rebuildSongUsage(), + downloadSongs: async () => { + console.info('[wgeneratorAdmin] downloadSongs started'); + const result = await adminService.downloadSongs(); + console.info('[wgeneratorAdmin] downloadSongs finished', result); + return result; + }, + rebuildSongUsage: () => adminService.rebuildSongUsage(), rebuildShowSongIds: async () => { console.info('[wgeneratorAdmin] rebuildShowSongIds started'); - const result = await userService.rebuildShowSongIds(progress => { + const result = await adminService.rebuildShowSongIds(progress => { console.info(`[wgeneratorAdmin] rebuildShowSongIds ${progress.processed}/${progress.total} shows processed`, { showId: progress.showId, showSongsProcessed: progress.showSongsProcessed, diff --git a/src/test-vitest.ts b/src/test-vitest.ts index 9d4ba65..1ae452d 100644 --- a/src/test-vitest.ts +++ b/src/test-vitest.ts @@ -1,5 +1,3 @@ -import {expect, vi} from 'vitest'; - import 'zone.js/testing'; import {TestBed} from '@angular/core/testing'; import {provideNoopAnimations} from '@angular/platform-browser/animations'; @@ -25,20 +23,6 @@ type DocumentStub = { collection: () => CollectionStub; }; -type MockFunction = ReturnType & { - and: { - returnValue: (value: unknown) => MockFunction; - resolveTo: (value: unknown) => MockFunction; - rejectWith: (value: unknown) => MockFunction; - callFake: (fn: (...args: unknown[]) => unknown) => MockFunction; - callThrough: () => MockFunction; - }; - calls: { - argsFor: (index: number) => unknown[]; - mostRecent: () => {args: unknown[]}; - }; -}; - const routeParams$ = new BehaviorSubject>({}); const queryParams$ = new BehaviorSubject>({}); @@ -92,130 +76,3 @@ const configureTestingModule: typeof TestBed.configureTestingModule = moduleDef return originalConfigureTestingModule(mergedModuleDef); }; TestBed.configureTestingModule = configureTestingModule; - -function decorateMock>(mock: T): T & MockFunction { - const decorated = mock as T & MockFunction; - - Object.defineProperty(decorated, 'and', { - configurable: true, - get: () => ({ - returnValue(value: unknown) { - decorated.mockReturnValue(value); - return decorated; - }, - resolveTo(value: unknown) { - decorated.mockResolvedValue(value); - return decorated; - }, - rejectWith(value: unknown) { - decorated.mockRejectedValue(value); - return decorated; - }, - callFake(fn: (...args: unknown[]) => unknown) { - decorated.mockImplementation(fn); - return decorated; - }, - callThrough() { - return decorated; - }, - }), - }); - - Object.defineProperty(decorated, 'calls', { - configurable: true, - get: () => ({ - argsFor(index: number) { - const calls = decorated.mock.calls as unknown[][]; - return calls[index] ?? []; - }, - mostRecent() { - const args = decorated.mock.lastCall ?? []; - return {args}; - }, - }), - }); - - return decorated; -} - -function createSpy(name?: string): MockFunction { - const spy = decorateMock(vi.fn()); - if (name) { - spy.mockName(name); - } - - return spy; -} - -function createSpyObj(baseName: string, methodNames: string[] | Record, propertyValues?: Record): T { - const result: Record = {}; - const methods = Array.isArray(methodNames) ? methodNames : Object.keys(methodNames); - - for (const methodName of methods) { - result[methodName] = createSpy(`${baseName}.${methodName}`); - } - - if (!Array.isArray(methodNames)) { - for (const [key, value] of Object.entries(methodNames)) { - (result[key] as MockFunction).and.returnValue(value); - } - } - - if (propertyValues) { - for (const [key, value] of Object.entries(propertyValues)) { - result[key] = value; - } - } - - return result as T; -} - -function spyOnCompat(object: T, methodName: K): MockFunction { - const spy = vi.spyOn(object as Record unknown>, methodName as PropertyKey); - return decorateMock(spy as unknown as ReturnType); -} - -function expectAsyncCompat(value: Promise) { - return { - async toBeResolvedTo(expected: T) { - await expect(value).resolves.toEqual(expected); - }, - async toBeRejectedWithError(expected?: string | RegExp | Error) { - if (expected instanceof Error) { - await expect(value).rejects.toThrowError(expected.message); - } else if (expected !== undefined) { - await expect(value).rejects.toThrowError(expected); - } else { - await expect(value).rejects.toThrowError(); - } - }, - }; -} - -expect.extend({ - toBeTrue(received: unknown) { - return { - pass: received === true, - message: () => `expected ${String(received)} to be true`, - }; - }, - toBeFalse(received: unknown) { - return { - pass: received === false, - message: () => `expected ${String(received)} to be false`, - }; - }, -}); - -Object.assign(globalThis, { - spyOn: spyOnCompat, - expectAsync: expectAsyncCompat, - jasmine: { - createSpy, - createSpyObj, - any: expect.any, - anything: expect.anything, - objectContaining: expect.objectContaining, - stringMatching: expect.stringMatching, - }, -}); diff --git a/src/types/jasmine-compat.d.ts b/src/types/jasmine-compat.d.ts deleted file mode 100644 index 47b2fa3..0000000 --- a/src/types/jasmine-compat.d.ts +++ /dev/null @@ -1,43 +0,0 @@ -type UnknownFunction = (...args: unknown[]) => unknown; - -type SpyAnd = { - returnValue(value?: unknown): jasmine.Spy; - resolveTo(value?: unknown): jasmine.Spy; - rejectWith(value?: unknown): jasmine.Spy; - callFake(fn: UnknownFunction): jasmine.Spy; - callThrough(): jasmine.Spy; -}; - -type SpyCalls = { - argsFor(index: number): unknown[]; - mostRecent(): {args: unknown[]}; -}; - -declare global { - function spyOn(object: T, methodName: K): jasmine.Spy; - function expectAsync(value: Promise): { - toBeResolvedTo(expected: T): Promise; - toBeRejectedWithError(expected?: string | RegExp | Error): Promise; - }; - - namespace jasmine { - type Spy = T & - ReturnType<(typeof import('vitest'))['vi']['fn']> & { - and: SpyAnd; - calls: SpyCalls; - }; - - type SpyObj = { - [K in keyof T]: T[K] extends UnknownFunction ? Spy : T[K]; - }; - - function createSpy(name?: string): Spy; - function createSpyObj(baseName: string, methodNames: string[] | Record, propertyValues?: Record): SpyObj; - function any(expectedClass: unknown): unknown; - function anything(): unknown; - function objectContaining(value: Partial): unknown; - function stringMatching(value: string | RegExp): unknown; - } -} - -export {}; diff --git a/src/types/vitest-matchers.d.ts b/src/types/vitest-matchers.d.ts deleted file mode 100644 index 3f7150c..0000000 --- a/src/types/vitest-matchers.d.ts +++ /dev/null @@ -1,14 +0,0 @@ -import 'vitest'; - -declare module 'vitest' { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - interface Assertion { - toBeTrue(): T; - toBeFalse(): T; - } - - interface AsymmetricMatchersContaining { - toBeTrue(): void; - toBeFalse(): void; - } -}