From 7170e4a08ecf779b391b23f18c9d7945ba6e0dac Mon Sep 17 00:00:00 2001 From: benjamin Date: Tue, 10 Mar 2026 00:05:08 +0100 Subject: [PATCH] add unit tests --- .../select/select.component.spec.ts | 73 +++++++++++ .../modules/shows/edit/edit.component.spec.ts | 82 ++++++++++++ .../shows/services/docx.service.spec.ts | 35 ++++- .../songs/services/file.service.spec.ts | 32 ++++- .../modules/songs/services/file.service.ts | 14 +- .../songs/services/song-list.resolver.spec.ts | 32 +++++ .../songs/services/upload.service.spec.ts | 60 +++++++-- .../modules/songs/services/upload.service.ts | 12 +- .../songs/song/edit/edit-song.guard.spec.ts | 19 ++- .../songs/song/edit/edit.service.spec.ts | 70 +++++++++- src/app/services/config.service.spec.ts | 30 ++++- .../services/global-settings.service.spec.ts | 36 ++++- .../user-name/user-name.component.spec.ts | 31 ++++- .../user/user-session.service.spec.ts | 124 ++++++++++++++++++ .../user/user-song-usage.service.spec.ts | 76 +++++++++++ src/app/services/user/user.service.spec.ts | 104 ++++++++++++++- .../widget-modules/guards/role.guard.spec.ts | 82 +++++++++++- 17 files changed, 862 insertions(+), 50 deletions(-) create mode 100644 src/app/modules/presentation/select/select.component.spec.ts create mode 100644 src/app/modules/shows/edit/edit.component.spec.ts create mode 100644 src/app/modules/songs/services/song-list.resolver.spec.ts create mode 100644 src/app/services/user/user-session.service.spec.ts create mode 100644 src/app/services/user/user-song-usage.service.spec.ts diff --git a/src/app/modules/presentation/select/select.component.spec.ts b/src/app/modules/presentation/select/select.component.spec.ts new file mode 100644 index 0000000..715199a --- /dev/null +++ b/src/app/modules/presentation/select/select.component.spec.ts @@ -0,0 +1,73 @@ +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {Router} from '@angular/router'; +import {of} from 'rxjs'; +import {GlobalSettingsService} from '../../../services/global-settings.service'; +import {ShowService} from '../../shows/services/show.service'; +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; + + beforeEach(async () => { + showServiceSpy = jasmine.createSpyObj('ShowService', ['list$', 'update$']); + globalSettingsServiceSpy = jasmine.createSpyObj('GlobalSettingsService', ['set']); + routerSpy = jasmine.createSpyObj('Router', ['navigateByUrl']); + + showServiceSpy.list$.and.returnValue( + of([ + {id: 'older', date: {toDate: () => new Date('2025-12-15T00:00:00Z')}}, + {id: 'recent-a', date: {toDate: () => new Date('2026-03-01T00:00:00Z')}}, + {id: 'recent-b', date: {toDate: () => new Date('2026-02-20T00:00:00Z')}}, + ] as never) + ); + showServiceSpy.update$.and.resolveTo(); + globalSettingsServiceSpy.set.and.resolveTo(); + routerSpy.navigateByUrl.and.resolveTo(true); + + await TestBed.configureTestingModule({ + imports: [SelectComponent], + providers: [ + {provide: ShowService, useValue: showServiceSpy}, + {provide: GlobalSettingsService, useValue: globalSettingsServiceSpy}, + {provide: Router, useValue: routerSpy}, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(SelectComponent); + component = fixture.componentInstance; + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should become visible on init', () => { + component.ngOnInit(); + + expect(component.visible).toBeTrue(); + }); + + it('should expose recent shows sorted descending by date', done => { + component.shows$.subscribe(shows => { + expect(showServiceSpy.list$).toHaveBeenCalledWith(true); + expect(shows.map(show => show.id)).toEqual(['recent-a', 'recent-b']); + done(); + }); + }); + + it('should persist the selected show, trigger presentation reset and navigate', async () => { + const show = {id: 'show-1'} as never; + component.visible = true; + + await component.selectShow(show); + + expect(component.visible).toBeFalse(); + 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/edit/edit.component.spec.ts b/src/app/modules/shows/edit/edit.component.spec.ts new file mode 100644 index 0000000..c4fa6aa --- /dev/null +++ b/src/app/modules/shows/edit/edit.component.spec.ts @@ -0,0 +1,82 @@ +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {ActivatedRoute, Router} from '@angular/router'; +import {Timestamp} from '@angular/fire/firestore'; +import {of} from 'rxjs'; +import {ShowDataService} from '../services/show-data.service'; +import {ShowService} from '../services/show.service'; +import {EditComponent} from './edit.component'; + +describe('EditComponent', () => { + let component: EditComponent; + let fixture: ComponentFixture; + let showServiceSpy: jasmine.SpyObj; + let showDataServiceStub: Pick; + let routerSpy: jasmine.SpyObj; + + beforeEach(async () => { + showServiceSpy = jasmine.createSpyObj('ShowService', ['read$', 'update$']); + showDataServiceStub = {list$: of([] as never)}; + routerSpy = jasmine.createSpyObj('Router', ['navigateByUrl']); + + showServiceSpy.read$.and.returnValue( + of({ + id: 'show-1', + showType: 'service-worship', + date: {toDate: () => new Date('2026-03-10T00:00:00Z')}, + } as never) + ); + showServiceSpy.update$.and.resolveTo(); + routerSpy.navigateByUrl.and.resolveTo(true); + + await TestBed.configureTestingModule({ + imports: [EditComponent], + providers: [ + {provide: ShowService, useValue: showServiceSpy}, + {provide: ShowDataService, useValue: showDataServiceStub}, + {provide: Router, useValue: routerSpy}, + {provide: ActivatedRoute, useValue: {params: of({showId: 'show-1'})}}, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(EditComponent); + component = fixture.componentInstance; + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should load the current show into the form on init', () => { + component.ngOnInit(); + + expect(showServiceSpy.read$).toHaveBeenCalledWith('show-1'); + expect(component.form.value.id).toBe('show-1'); + expect(component.form.value.showType).toBe('service-worship'); + expect(component.form.value.date).toEqual(new Date('2026-03-10T00:00:00Z')); + }); + + it('should not save when the form is invalid', async () => { + component.form.setValue({id: null, date: null, showType: null}); + + await component.onSave(); + + expect(showServiceSpy.update$).not.toHaveBeenCalled(); + expect(routerSpy.navigateByUrl).not.toHaveBeenCalled(); + }); + + 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); + component.form.setValue({id: 'show-1', date, showType: 'home-group'}); + + await component.onSave(); + + expect(Timestamp.fromDate).toHaveBeenCalledWith(date); + expect(showServiceSpy.update$).toHaveBeenCalledWith('show-1', { + date: firestoreTimestamp, + showType: 'home-group', + } as never); + expect(routerSpy.navigateByUrl).toHaveBeenCalledWith('/shows/show-1'); + }); +}); diff --git a/src/app/modules/shows/services/docx.service.spec.ts b/src/app/modules/shows/services/docx.service.spec.ts index 1903309..b918668 100644 --- a/src/app/modules/shows/services/docx.service.spec.ts +++ b/src/app/modules/shows/services/docx.service.spec.ts @@ -1,5 +1,5 @@ import {TestBed} from '@angular/core/testing'; - +import {Packer} from 'docx'; import {DocxService} from './docx.service'; describe('DocxService', () => { @@ -13,4 +13,37 @@ describe('DocxService', () => { 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 prepareDataSpy = spyOn(service, 'prepareData').and.resolveTo(null); + const saveAsSpy = spyOn(service, 'saveAs'); + + await service.create('show-1'); + + expect(prepareDataSpy).toHaveBeenCalledWith('show-1'); + expect(saveAsSpy).not.toHaveBeenCalled(); + }); + + it('should build and save a docx file when all data is available', async () => { + const blob = new Blob(['docx']); + const prepareDataSpy = spyOn(service, 'prepareData').and.resolveTo({ + show: { + showType: 'service-worship', + date: {toDate: () => new Date('2026-03-10T00:00:00Z')}, + }, + songs: [], + user: {name: 'Benjamin'}, + config: {ccliLicenseId: '12345'}, + }); + const prepareNewDocumentSpy = spyOn(service, 'prepareNewDocument').and.returnValue({doc: true}); + const saveAsSpy = spyOn(service, 'saveAs'); + spyOn(Packer, 'toBlob').and.resolveTo(blob); + + await service.create('show-1', {copyright: true}); + + expect(prepareDataSpy).toHaveBeenCalledWith('show-1'); + expect(prepareNewDocumentSpy).toHaveBeenCalled(); + expect(Packer.toBlob).toHaveBeenCalledWith({doc: true} as never); + expect(saveAsSpy).toHaveBeenCalledWith(blob, jasmine.stringMatching(/\.docx$/)); + }); }); diff --git a/src/app/modules/songs/services/file.service.spec.ts b/src/app/modules/songs/services/file.service.spec.ts index 07e94a9..a66a8c6 100644 --- a/src/app/modules/songs/services/file.service.spec.ts +++ b/src/app/modules/songs/services/file.service.spec.ts @@ -1,23 +1,45 @@ import {TestBed} from '@angular/core/testing'; import {Storage} from '@angular/fire/storage'; - -import {FileService} from './file.service'; import {FileDataService} from './file-data.service'; +import {FileService} from './file.service'; describe('FileService', () => { let service: FileService; + let fileDataServiceSpy: jasmine.SpyObj; beforeEach(() => { + fileDataServiceSpy = jasmine.createSpyObj('FileDataService', ['delete']); + fileDataServiceSpy.delete.and.resolveTo(); + void TestBed.configureTestingModule({ providers: [ - {provide: Storage, useValue: {}}, - {provide: FileDataService, useValue: {delete: () => Promise.resolve()}}, + {provide: Storage, useValue: {app: 'test-storage'}}, + {provide: FileDataService, useValue: fileDataServiceSpy}, ], }); + service = TestBed.inject(FileService); }); it('should be created', () => { - void expect(service).toBeTruthy(); + expect(service).toBeTruthy(); + }); + + it('should resolve download urls via AngularFire storage helpers', async () => { + const resolveSpy = spyOn(service, 'resolveDownloadUrl').and.resolveTo('https://cdn.example/file.pdf'); + + await expectAsync(service.getDownloadUrl('songs/song-1/file.pdf').toPromise()).toBeResolvedTo('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, 'deleteFromStorage').and.resolveTo(); + + service.delete('songs/song-1/file.pdf', 'song-1', 'file-1'); + await Promise.resolve(); + + expect(deleteFromStorageSpy).toHaveBeenCalledWith('songs/song-1/file.pdf'); + expect(fileDataServiceSpy.delete).toHaveBeenCalledWith('song-1', 'file-1'); }); }); diff --git a/src/app/modules/songs/services/file.service.ts b/src/app/modules/songs/services/file.service.ts index 3a82309..0bc162d 100644 --- a/src/app/modules/songs/services/file.service.ts +++ b/src/app/modules/songs/services/file.service.ts @@ -1,4 +1,4 @@ -import {EnvironmentInjector, Injectable, inject, runInInjectionContext} from '@angular/core'; +import {EnvironmentInjector, inject, Injectable, runInInjectionContext} from '@angular/core'; import {deleteObject, getDownloadURL, ref, Storage} from '@angular/fire/storage'; import {from, Observable} from 'rxjs'; import {FileDataService} from './file-data.service'; @@ -12,11 +12,19 @@ export class FileService { private environmentInjector = inject(EnvironmentInjector); public getDownloadUrl(path: string): Observable { - return from(runInInjectionContext(this.environmentInjector, () => getDownloadURL(ref(this.storage, path)))); + return from(runInInjectionContext(this.environmentInjector, () => this.resolveDownloadUrl(path))); } public delete(path: string, songId: string, fileId: string): void { - void runInInjectionContext(this.environmentInjector, () => deleteObject(ref(this.storage, path))); + void runInInjectionContext(this.environmentInjector, () => this.deleteFromStorage(path)); void this.fileDataService.delete(songId, fileId); } + + private resolveDownloadUrl(path: string): Promise { + return getDownloadURL(ref(this.storage, path)); + } + + private deleteFromStorage(path: string): Promise { + return deleteObject(ref(this.storage, path)); + } } diff --git a/src/app/modules/songs/services/song-list.resolver.spec.ts b/src/app/modules/songs/services/song-list.resolver.spec.ts new file mode 100644 index 0000000..cbd54b3 --- /dev/null +++ b/src/app/modules/songs/services/song-list.resolver.spec.ts @@ -0,0 +1,32 @@ +import {TestBed} from '@angular/core/testing'; +import {of} from 'rxjs'; +import {SongService} from './song.service'; +import {SongListResolver} from './song-list.resolver'; + +describe('SongListResolver', () => { + let resolver: SongListResolver; + let songServiceSpy: jasmine.SpyObj; + + beforeEach(() => { + songServiceSpy = jasmine.createSpyObj('SongService', ['list$']); + songServiceSpy.list$.and.returnValue(of([{id: 'song-1', title: 'Amazing Grace'}] as never)); + + void TestBed.configureTestingModule({ + providers: [{provide: SongService, useValue: songServiceSpy}], + }); + + resolver = TestBed.inject(SongListResolver); + }); + + it('should be created', () => { + expect(resolver).toBeTruthy(); + }); + + it('should resolve the first emitted song list from the service', done => { + resolver.resolve().subscribe(songs => { + expect(songServiceSpy.list$).toHaveBeenCalled(); + expect(songs).toEqual([{id: 'song-1', title: 'Amazing Grace'}] as never); + done(); + }); + }); +}); diff --git a/src/app/modules/songs/services/upload.service.spec.ts b/src/app/modules/songs/services/upload.service.spec.ts index 6662f95..f397560 100644 --- a/src/app/modules/songs/services/upload.service.spec.ts +++ b/src/app/modules/songs/services/upload.service.spec.ts @@ -1,22 +1,54 @@ import {TestBed} from '@angular/core/testing'; import {Storage} from '@angular/fire/storage'; - -import {UploadService} from './upload.service'; import {FileDataService} from './file-data.service'; +import {Upload} from './upload'; +import {UploadService} from './upload.service'; -describe('UploadServiceService', () => { - beforeEach( - () => - void TestBed.configureTestingModule({ - providers: [ - {provide: Storage, useValue: {}}, - {provide: FileDataService, useValue: {set: () => Promise.resolve('')}}, - ], - }) - ); +describe('UploadService', () => { + let service: UploadService; + let fileDataServiceSpy: jasmine.SpyObj; + + beforeEach(() => { + fileDataServiceSpy = jasmine.createSpyObj('FileDataService', ['set']); + fileDataServiceSpy.set.and.resolveTo('file-1'); + + void TestBed.configureTestingModule({ + providers: [ + {provide: Storage, useValue: {app: 'test-storage'}}, + {provide: FileDataService, useValue: fileDataServiceSpy}, + ], + }); + + service = TestBed.inject(UploadService); + }); it('should be created', () => { - const service: UploadService = TestBed.inject(UploadService); - void expect(service).toBeTruthy(); + expect(service).toBeTruthy(); + }); + + it('should upload the file, update progress and persist file metadata on success', async () => { + const task = { + on: (event: string, progress: (snapshot: {bytesTransferred: number; totalBytes: number}) => void, error: () => void, success: () => void) => { + progress({bytesTransferred: 50, totalBytes: 100}); + success(); + }, + }; + const uploadSpy = spyOn(service, 'startUpload').and.returnValue(task as never); + const upload = new Upload(new File(['content'], 'test.pdf', {type: 'application/pdf'})); + + service.pushUpload('song-1', upload); + await Promise.resolve(); + + expect(uploadSpy).toHaveBeenCalledWith('/attachments/song-1/test.pdf', upload.file); + expect(upload.progress).toBe(50); + expect(upload.path).toBe('/attachments/song-1'); + expect(fileDataServiceSpy.set).toHaveBeenCalledWith( + 'song-1', + jasmine.objectContaining({ + name: 'test.pdf', + path: '/attachments/song-1', + createdAt: jasmine.any(Date), + }) + ); }); }); diff --git a/src/app/modules/songs/services/upload.service.ts b/src/app/modules/songs/services/upload.service.ts index df38233..0b1950a 100644 --- a/src/app/modules/songs/services/upload.service.ts +++ b/src/app/modules/songs/services/upload.service.ts @@ -18,12 +18,7 @@ export class UploadService extends FileBase { const filePath = `${directory}/${upload.file.name}`; upload.path = directory; - const {task} = runInInjectionContext(this.environmentInjector, () => { - const storageRef = ref(this.storage, filePath); - return { - task: uploadBytesResumable(storageRef, upload.file), - }; - }); + const task = runInInjectionContext(this.environmentInjector, () => this.startUpload(filePath, upload.file)); task.on( 'state_changed', @@ -45,4 +40,9 @@ export class UploadService extends FileBase { }; await this.fileDataService.set(songId, file); } + + private startUpload(filePath: string, file: File) { + const storageRef = ref(this.storage, filePath); + return uploadBytesResumable(storageRef, file); + } } 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 c703463..d0dc4f2 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 @@ -1,5 +1,4 @@ import {TestBed} from '@angular/core/testing'; - import {EditSongGuard} from './edit-song.guard'; describe('EditSongGuard', () => { @@ -11,6 +10,22 @@ describe('EditSongGuard', () => { }); it('should be created', () => { - void expect(guard).toBeTruthy(); + expect(guard).toBeTruthy(); + }); + + 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(); + }); + + 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 result = await guard.canDeactivate({editSongComponent: {askForSave}} as never, {} as never, {} as never, nextState); + + expect(askForSave).toHaveBeenCalledWith(nextState); + expect(result).toBeTrue(); }); }); diff --git a/src/app/modules/songs/song/edit/edit.service.spec.ts b/src/app/modules/songs/song/edit/edit.service.spec.ts index 496b658..acac970 100644 --- a/src/app/modules/songs/song/edit/edit.service.spec.ts +++ b/src/app/modules/songs/song/edit/edit.service.spec.ts @@ -1,12 +1,74 @@ import {TestBed} from '@angular/core/testing'; - import {EditService} from './edit.service'; describe('EditService', () => { - beforeEach(() => void TestBed.configureTestingModule({})); + let service: EditService; + + beforeEach(() => { + void TestBed.configureTestingModule({}); + service = TestBed.inject(EditService); + }); it('should be created', () => { - const service: EditService = TestBed.inject(EditService); - void expect(service).toBeTruthy(); + expect(service).toBeTruthy(); + }); + + it('should create a form with all editable song fields populated', () => { + const form = service.createSongForm({ + text: 'Line 1', + title: 'Amazing Grace', + comment: 'Comment', + flags: 'fast', + key: 'G', + tempo: 90, + type: 'Praise', + status: 'final', + legalType: 'allowed', + legalOwner: 'CCLI', + legalOwnerId: '123', + artist: 'Artist', + label: 'Label', + termsOfUse: 'Use it', + origin: 'Origin', + } as never); + + expect(form.getRawValue()).toEqual({ + text: 'Line 1', + title: 'Amazing Grace', + comment: 'Comment', + flags: 'fast', + key: 'G', + tempo: 90, + type: 'Praise', + status: 'final', + legalType: 'allowed', + legalOwner: 'CCLI', + legalOwnerId: '123', + artist: 'Artist', + label: 'Label', + termsOfUse: 'Use it', + origin: 'Origin', + }); + }); + + it('should default the status control to draft when the song has no status', () => { + const form = service.createSongForm({ + text: '', + title: 'Untitled', + comment: '', + flags: '', + key: 'C', + tempo: 80, + type: 'Misc', + legalType: 'open', + legalOwner: 'other', + legalOwnerId: '', + artist: '', + label: '', + termsOfUse: '', + origin: '', + } as never); + + expect(form.get('status')?.value).toBe('draft'); }); }); diff --git a/src/app/services/config.service.spec.ts b/src/app/services/config.service.spec.ts index ae1c2f1..6e270aa 100644 --- a/src/app/services/config.service.spec.ts +++ b/src/app/services/config.service.spec.ts @@ -1,16 +1,40 @@ import {TestBed} from '@angular/core/testing'; - +import {of} from 'rxjs'; +import {DbService} from './db.service'; import {ConfigService} from './config.service'; describe('ConfigService', () => { let service: ConfigService; + let dbServiceSpy: jasmine.SpyObj; beforeEach(() => { - void TestBed.configureTestingModule({}); + dbServiceSpy = jasmine.createSpyObj('DbService', ['doc$']); + dbServiceSpy.doc$.and.returnValue(of({copyright: 'CCLI'}) as never); + + void TestBed.configureTestingModule({ + providers: [{provide: DbService, useValue: dbServiceSpy}], + }); + service = TestBed.inject(ConfigService); }); it('should be created', () => { - void expect(service).toBeTruthy(); + expect(service).toBeTruthy(); + }); + + it('should read the global config document once on creation', () => { + expect(dbServiceSpy.doc$).toHaveBeenCalledTimes(1); + expect(dbServiceSpy.doc$).toHaveBeenCalledWith('global/config'); + }); + + it('should expose the shared config stream via get$', done => { + service.get$().subscribe(config => { + expect(config).toEqual({copyright: 'CCLI'} as never); + done(); + }); + }); + + it('should resolve the current config via get()', async () => { + await expectAsync(service.get()).toBeResolvedTo({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 d2a7c94..9796556 100644 --- a/src/app/services/global-settings.service.spec.ts +++ b/src/app/services/global-settings.service.spec.ts @@ -1,16 +1,46 @@ import {TestBed} from '@angular/core/testing'; - +import {of} from 'rxjs'; +import {DbService} from './db.service'; import {GlobalSettingsService} from './global-settings.service'; describe('GlobalSettingsService', () => { let service: GlobalSettingsService; + let dbServiceSpy: jasmine.SpyObj; + let updateSpy: jasmine.Spy; beforeEach(() => { - void TestBed.configureTestingModule({}); + 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); + + void TestBed.configureTestingModule({ + providers: [{provide: DbService, useValue: dbServiceSpy}], + }); + service = TestBed.inject(GlobalSettingsService); }); it('should be created', () => { - void expect(service).toBeTruthy(); + expect(service).toBeTruthy(); + }); + + it('should read the static global settings document once on creation', () => { + expect(dbServiceSpy.doc$).toHaveBeenCalledTimes(1); + expect(dbServiceSpy.doc$).toHaveBeenCalledWith('global/static'); + }); + + it('should expose the shared settings stream via the getter', done => { + service.get$.subscribe(settings => { + expect(settings).toEqual({churchName: 'ICF'} as never); + done(); + }); + }); + + it('should update the static global settings document', async () => { + await service.set({churchName: 'New Name'} as never); + + expect(dbServiceSpy.doc).toHaveBeenCalledWith('global/static'); + expect(updateSpy).toHaveBeenCalledWith({churchName: 'New Name'} as never); }); }); 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 204203d..5501acf 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,14 +1,20 @@ import {ComponentFixture, TestBed, waitForAsync} from '@angular/core/testing'; - +import {of} from 'rxjs'; +import {UserService} from '../user.service'; import {UserNameComponent} from './user-name.component'; describe('UserNameComponent', () => { let component: UserNameComponent; let fixture: ComponentFixture; + let userServiceSpy: jasmine.SpyObj; beforeEach(waitForAsync(() => { + userServiceSpy = jasmine.createSpyObj('UserService', ['getUserbyId$']); + userServiceSpy.getUserbyId$.and.returnValue(of({name: 'Benjamin'} as never)); + void TestBed.configureTestingModule({ imports: [UserNameComponent], + providers: [{provide: UserService, useValue: userServiceSpy}], }).compileComponents(); })); @@ -19,6 +25,27 @@ describe('UserNameComponent', () => { }); it('should create', () => { - void expect(component).toBeTruthy(); + expect(component).toBeTruthy(); + }); + + it('should resolve the user name when the userId input changes', done => { + component.userId = 'user-1'; + + component.name$?.subscribe(name => { + expect(userServiceSpy.getUserbyId$).toHaveBeenCalledWith('user-1'); + expect(name).toBe('Benjamin'); + done(); + }); + }); + + it('should map missing users to null names', done => { + userServiceSpy.getUserbyId$.and.returnValue(of(null)); + + component.userId = 'missing-user'; + + component.name$?.subscribe(name => { + expect(name).toBeNull(); + done(); + }); }); }); diff --git a/src/app/services/user/user-session.service.spec.ts b/src/app/services/user/user-session.service.spec.ts new file mode 100644 index 0000000..66c97a0 --- /dev/null +++ b/src/app/services/user/user-session.service.spec.ts @@ -0,0 +1,124 @@ +import {TestBed} from '@angular/core/testing'; +import {Router} from '@angular/router'; +import {BehaviorSubject, firstValueFrom, of} from 'rxjs'; +import {Auth} from '@angular/fire/auth'; +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 authStateSubject: BehaviorSubject; + let createAuthStateSpy: jasmine.Spy; + let runInFirebaseContextSpy: jasmine.Spy; + + beforeEach(() => { + 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) => { + if (path === 'users/user-1') { + return of({id: 'user-1', name: 'Benjamin', role: 'leader', chordMode: 'letters', songUsage: {}}) as never; + } + if (path === 'users/user-2') { + return of({id: 'user-2', name: 'Paula', role: 'user', chordMode: 'letters', songUsage: {}}) as never; + } + + return of(null) as never; + }); + dbServiceSpy.doc.and.returnValue({ + update: jasmine.createSpy('update').and.resolveTo(), + set: jasmine.createSpy('set').and.resolveTo(), + } as never); + routerSpy.navigateByUrl.and.resolveTo(true); + + createAuthStateSpy = spyOn(UserSessionService.prototype, 'createAuthState$').and.returnValue(authStateSubject.asObservable() as never); + + void TestBed.configureTestingModule({ + providers: [ + {provide: DbService, useValue: dbServiceSpy}, + {provide: Router, useValue: routerSpy}, + {provide: Auth, useValue: {}}, + ], + }); + + service = TestBed.inject(UserSessionService); + runInFirebaseContextSpy = spyOn(service, 'runInFirebaseContext'); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + expect(createAuthStateSpy).toHaveBeenCalled(); + }); + + 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); + }); + + 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 + ); + }); + + it('should cache user lookups by id', async () => { + const first$ = service.getUserbyId$('user-2'); + const second$ = service.getUserbyId$('user-2'); + + expect(first$).toBe(second$); + await expectAsync(firstValueFrom(first$)).toBeResolvedTo(jasmine.objectContaining({id: 'user-2'}) as never); + }); + + it('should login and initialize songUsage when missing', async () => { + dbServiceSpy.doc$.and.callFake((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'}}); + + await expectAsync(service.login('mail', 'secret')).toBeResolvedTo('user-1'); + + expect(runInFirebaseContextSpy).toHaveBeenCalledTimes(1); + expect(updateSpy).toHaveBeenCalledWith({songUsage: {}}); + }); + + it('should delegate logout and password reset to AngularFire auth APIs', async () => { + runInFirebaseContextSpy.and.resolveTo(); + + await service.logout(); + await service.changePassword('mail@example.com'); + + expect(runInFirebaseContextSpy).toHaveBeenCalledTimes(2); + }); + + it('should create a new user document and navigate afterwards', async () => { + dbServiceSpy.doc$.and.callFake((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'}}); + + await service.createNewUser('mail@example.com', 'New User', 'secret'); + + expect(runInFirebaseContextSpy).toHaveBeenCalledTimes(1); + expect(setSpy).toHaveBeenCalledWith({name: 'New User', chordMode: 'onlyFirst', songUsage: {}}); + expect(routerSpy.navigateByUrl).toHaveBeenCalledWith('/brand/new-user'); + }); +}); diff --git a/src/app/services/user/user-song-usage.service.spec.ts b/src/app/services/user/user-song-usage.service.spec.ts new file mode 100644 index 0000000..8583ba0 --- /dev/null +++ b/src/app/services/user/user-song-usage.service.spec.ts @@ -0,0 +1,76 @@ +import {TestBed} from '@angular/core/testing'; +import {of} from 'rxjs'; +import {DbService} from '../db.service'; +import {ShowDataService} from '../../modules/shows/services/show-data.service'; +import {ShowSongDataService} from '../../modules/shows/services/show-song-data.service'; +import {UserSessionService} from './user-session.service'; +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; + + beforeEach(() => { + dbServiceSpy = jasmine.createSpyObj('DbService', ['doc']); + sessionSpy = jasmine.createSpyObj('UserSessionService', ['update$'], { + 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$']); + + sessionSpy.update$.and.resolveTo(); + showDataServiceSpy.listRaw$.and.returnValue(of([{id: 'show-1', owner: 'user-1'}, {id: 'show-2', owner: 'user-2'}] as never)); + showSongDataServiceSpy.list$.and.callFake((showId: string) => + of(showId === 'show-1' ? ([{songId: 'song-1'}, {songId: 'song-1'}, {songId: 'song-2'}] as never) : ([{songId: 'song-3'}] as never)) + ); + dbServiceSpy.doc.and.returnValue({update: jasmine.createSpy('update').and.resolveTo()} as never); + + void TestBed.configureTestingModule({ + providers: [ + {provide: DbService, useValue: dbServiceSpy}, + {provide: UserSessionService, useValue: sessionSpy}, + {provide: ShowDataService, useValue: showDataServiceSpy}, + {provide: ShowSongDataService, useValue: showSongDataServiceSpy}, + ], + }); + + service = TestBed.inject(UserSongUsageService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + 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); + + 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()}); + }); + + it('should rebuild song usage for all users based on owned show songs', async () => { + await expectAsync(service.rebuildSongUsage()).toBeResolvedTo({ + usersProcessed: 2, + showsProcessed: 2, + showSongsProcessed: 4, + }); + + expect(sessionSpy.update$).toHaveBeenCalledWith('user-1', {songUsage: {'song-1': 2, 'song-2': 1}}); + expect(sessionSpy.update$).toHaveBeenCalledWith('user-2', {songUsage: {'song-3': 1}}); + }); + + 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.'); + }); +}); diff --git a/src/app/services/user/user.service.spec.ts b/src/app/services/user/user.service.spec.ts index f97391f..a5629f6 100644 --- a/src/app/services/user/user.service.spec.ts +++ b/src/app/services/user/user.service.spec.ts @@ -1,12 +1,108 @@ import {TestBed} from '@angular/core/testing'; - +import {of} from 'rxjs'; import {UserService} from './user.service'; +import {UserSessionService} from './user-session.service'; +import {UserSongUsageService} from './user-song-usage.service'; describe('UserService', () => { - beforeEach(() => void TestBed.configureTestingModule({})); + let service: UserService; + let sessionSpy: jasmine.SpyObj; + let songUsageSpy: jasmine.SpyObj; + + beforeEach(() => { + 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']); + + 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}); + + void TestBed.configureTestingModule({ + providers: [ + {provide: UserSessionService, useValue: sessionSpy}, + {provide: UserSongUsageService, useValue: songUsageSpy}, + ], + }); + + service = TestBed.inject(UserService); + }); it('should be created', () => { - const service: UserService = TestBed.inject(UserService); - void expect(service).toBeTruthy(); + expect(service).toBeTruthy(); + }); + + it('should expose the session streams directly', done => { + service.userId$.subscribe(userId => { + expect(userId).toBe('user-1'); + service.user$.subscribe(user => { + expect(user).toEqual({id: 'user-1'} as never); + service.users$.subscribe(users => { + expect(users).toEqual([{id: 'user-1'}] as never); + done(); + }); + }); + }); + }); + + 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 service.logout(); + await service.update$('user-1', {name: 'Benjamin'} as never); + await service.changePassword('mail'); + await service.createNewUser('mail', 'Benjamin', 'secret'); + + expect(sessionSpy.currentUser).toHaveBeenCalled(); + expect(sessionSpy.getUserbyId).toHaveBeenCalledWith('user-2'); + expect(sessionSpy.login).toHaveBeenCalledWith('mail', 'secret'); + expect(sessionSpy.logout).toHaveBeenCalled(); + expect(sessionSpy.update$).toHaveBeenCalledWith('user-1', {name: 'Benjamin'} as never); + expect(sessionSpy.changePassword).toHaveBeenCalledWith('mail'); + expect(sessionSpy.createNewUser).toHaveBeenCalledWith('mail', 'Benjamin', 'secret'); + }); + + it('should delegate user lookup and loggedIn/list streams to UserSessionService', done => { + service.getUserbyId$('user-2').subscribe(user => { + expect(user).toEqual({id: 'user-2'} as never); + service.loggedIn$().subscribe(loggedIn => { + expect(loggedIn).toBeTrue(); + service.list$().subscribe(users => { + expect(users).toEqual([{id: 'user-1'}] as never); + expect(sessionSpy.getUserbyId$).toHaveBeenCalledWith('user-2'); + expect(sessionSpy.loggedIn$).toHaveBeenCalled(); + expect(sessionSpy.list$).toHaveBeenCalled(); + done(); + }); + }); + }); + }); + + 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}); + + expect(songUsageSpy.incSongCount).toHaveBeenCalledWith('song-1'); + expect(songUsageSpy.decSongCount).toHaveBeenCalledWith('song-2'); + expect(songUsageSpy.rebuildSongUsage).toHaveBeenCalled(); }); }); diff --git a/src/app/widget-modules/guards/role.guard.spec.ts b/src/app/widget-modules/guards/role.guard.spec.ts index 94ae0c2..05a1f65 100644 --- a/src/app/widget-modules/guards/role.guard.spec.ts +++ b/src/app/widget-modules/guards/role.guard.spec.ts @@ -1,16 +1,92 @@ import {TestBed} from '@angular/core/testing'; - +import {Router} from '@angular/router'; +import {of} from 'rxjs'; +import {UserService} from '../../services/user/user.service'; import {RoleGuard} from './role.guard'; describe('RoleGuard', () => { let guard: RoleGuard; + let routerSpy: jasmine.SpyObj; beforeEach(() => { - void TestBed.configureTestingModule({}); + routerSpy = jasmine.createSpyObj('Router', ['createUrlTree']); + routerSpy.createUrlTree.and.callFake(commands => ({commands}) as never); + + void TestBed.configureTestingModule({ + providers: [ + {provide: Router, useValue: routerSpy}, + {provide: UserService, useValue: {user$: of(null)}}, + ], + }); + guard = TestBed.inject(RoleGuard); }); it('should be created', () => { - void expect(guard).toBeTruthy(); + expect(guard).toBeTruthy(); + }); + + it('should throw when requiredRoles is missing', () => { + expect(() => guard.canActivate({data: {}} as never)).toThrowError('requiredRoles is not defined!'); + }); + + it('should deny access when there is no current user', done => { + guard.canActivate({data: {requiredRoles: ['leader']}} as never).subscribe(result => { + expect(result).toBeFalse(); + done(); + }); + }); + + it('should allow admins regardless of requiredRoles', done => { + TestBed.resetTestingModule(); + routerSpy = jasmine.createSpyObj('Router', ['createUrlTree']); + void TestBed.configureTestingModule({ + providers: [ + {provide: Router, useValue: routerSpy}, + {provide: UserService, useValue: {user$: of({role: 'user;admin'})}}, + ], + }); + guard = TestBed.inject(RoleGuard); + + guard.canActivate({data: {requiredRoles: ['leader']}} as never).subscribe(result => { + expect(result).toBeTrue(); + done(); + }); + }); + + it('should allow users with a matching required role', done => { + TestBed.resetTestingModule(); + routerSpy = jasmine.createSpyObj('Router', ['createUrlTree']); + void TestBed.configureTestingModule({ + providers: [ + {provide: Router, useValue: routerSpy}, + {provide: UserService, useValue: {user$: of({role: 'leader;user'})}}, + ], + }); + guard = TestBed.inject(RoleGuard); + + guard.canActivate({data: {requiredRoles: ['leader']}} as never).subscribe(result => { + expect(result).toBeTrue(); + done(); + }); + }); + + it('should redirect users without the required role to their role default route', done => { + TestBed.resetTestingModule(); + routerSpy = jasmine.createSpyObj('Router', ['createUrlTree']); + routerSpy.createUrlTree.and.returnValue({redirect: ['presentation']} as never); + void TestBed.configureTestingModule({ + providers: [ + {provide: Router, useValue: routerSpy}, + {provide: UserService, useValue: {user$: of({role: 'presenter'})}}, + ], + }); + guard = TestBed.inject(RoleGuard); + + guard.canActivate({data: {requiredRoles: ['leader']}} as never).subscribe(result => { + expect(routerSpy.createUrlTree).toHaveBeenCalledWith(['presentation']); + expect(result).toEqual({redirect: ['presentation']} as never); + done(); + }); }); });