From 0d0873730ad9dfee9acf84babf97da1d03e6d486 Mon Sep 17 00:00:00 2001 From: benjamin Date: Mon, 9 Mar 2026 23:25:11 +0100 Subject: [PATCH] migrate angular 21 tests --- .../guest/guest-show-data.service.spec.ts | 75 +++++++++++ .../modules/guest/guest-show.service.spec.ts | 62 +++++++++ src/app/modules/guest/guest-show.service.ts | 5 +- .../share-dialog.component.spec.ts | 3 + .../shows/services/show-data.service.spec.ts | 111 +++++++++++++++- .../shows/services/show-data.service.ts | 2 +- .../services/show-song-data.service.spec.ts | 88 ++++++++++++- .../shows/services/show-song.service.spec.ts | 121 +++++++++++++++++- .../shows/services/show.service.spec.ts | 112 +++++++++++++--- .../songs/services/file-data.service.spec.ts | 64 ++++++++- .../modules/songs/services/file.service.ts | 7 +- .../songs/services/song-data.service.spec.ts | 111 +++++++++++++--- .../songs/services/song.service.spec.ts | 112 ++++++++++++---- .../services/text-rendering.service.spec.ts | 4 +- .../songs/services/transpose.service.spec.ts | 2 +- .../songs/services/transpose.service.ts | 22 ++++ .../modules/songs/services/upload.service.ts | 11 +- .../song-list/song-list.component.spec.ts | 2 +- .../save-dialog/save-dialog.component.spec.ts | 2 +- .../modules/songs/song/file/file.component.ts | 5 +- src/app/services/db.service.ts | 24 ++-- src/app/services/user/user.service.ts | 17 ++- src/app/widget-modules/guards/auth.guard.ts | 5 +- src/test.ts | 66 +++++++++- 24 files changed, 924 insertions(+), 109 deletions(-) create mode 100644 src/app/modules/guest/guest-show-data.service.spec.ts create mode 100644 src/app/modules/guest/guest-show.service.spec.ts diff --git a/src/app/modules/guest/guest-show-data.service.spec.ts b/src/app/modules/guest/guest-show-data.service.spec.ts new file mode 100644 index 0000000..6db7021 --- /dev/null +++ b/src/app/modules/guest/guest-show-data.service.spec.ts @@ -0,0 +1,75 @@ +import {TestBed} from '@angular/core/testing'; +import {of} from 'rxjs'; +import {DbService} from 'src/app/services/db.service'; +import {GuestShowDataService} from './guest-show-data.service'; + +describe('GuestShowDataService', () => { + 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; + + beforeEach(() => { + docUpdateSpy = jasmine.createSpy('update').and.resolveTo(); + docDeleteSpy = jasmine.createSpy('delete').and.resolveTo(); + docSpy = jasmine.createSpy('doc').and.returnValue({ + 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); + + void TestBed.configureTestingModule({ + providers: [{provide: DbService, useValue: dbServiceSpy}], + }); + + service = TestBed.inject(GuestShowDataService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should read the guest collection for list$', () => { + service.list$.subscribe(); + + expect(dbServiceSpy.col$).toHaveBeenCalledWith('guest'); + }); + + it('should read a single guest show by id', () => { + service.read$('guest-7').subscribe(); + + expect(dbServiceSpy.doc$).toHaveBeenCalledWith('guest/guest-7'); + }); + + it('should update a guest show document', async () => { + 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]; + 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'); + + expect(colSpy).toHaveBeenCalledWith('guest'); + const [addPayload] = colAddSpy.calls.mostRecent().args as unknown as [Record]; + expect(addPayload).toEqual({published: false}); + }); + + it('should delete a guest show document', async () => { + await service.delete('guest-7'); + + expect(docSpy).toHaveBeenCalledWith('guest/guest-7'); + expect(docDeleteSpy).toHaveBeenCalled(); + }); +}); diff --git a/src/app/modules/guest/guest-show.service.spec.ts b/src/app/modules/guest/guest-show.service.spec.ts new file mode 100644 index 0000000..69b2e02 --- /dev/null +++ b/src/app/modules/guest/guest-show.service.spec.ts @@ -0,0 +1,62 @@ +import {TestBed} from '@angular/core/testing'; +import {GuestShowDataService} from './guest-show-data.service'; +import {GuestShowService} from './guest-show.service'; +import {ShowService} from '../shows/services/show.service'; + +describe('GuestShowService', () => { + let service: GuestShowService; + let guestShowDataServiceSpy: jasmine.SpyObj; + let showServiceSpy: jasmine.SpyObj; + + beforeEach(() => { + 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(); + + void TestBed.configureTestingModule({ + providers: [ + {provide: GuestShowDataService, useValue: guestShowDataServiceSpy}, + {provide: ShowService, useValue: showServiceSpy}, + ], + }); + + service = TestBed.inject(GuestShowService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should create a new guest share, persist the generated shareId on the show and return the share url', async () => { + const show = {id: 'show-1', showType: 'service-worship', date: new Date(), shareId: ''} as any; + const songs = [{id: 'song-1'}] as any; + const expectedUrl = window.location.protocol + '//' + window.location.host + '/guest/share-1'; + + await expectAsync(service.share(show, songs)).toBeResolvedTo(expectedUrl); + + expect(guestShowDataServiceSpy.add).toHaveBeenCalledWith({ + showType: 'service-worship', + date: show.date, + songs, + }); + expect(showServiceSpy.update$).toHaveBeenCalledWith('show-1', {shareId: 'share-1'}); + }); + + it('should update an existing share and reuse its id in the returned url', async () => { + const show = {id: 'show-1', showType: 'service-worship', date: new Date(), shareId: 'share-9'} as any; + const songs = [{id: 'song-1'}] as any; + const expectedUrl = window.location.protocol + '//' + window.location.host + '/guest/share-9'; + + await expectAsync(service.share(show, songs)).toBeResolvedTo(expectedUrl); + + expect(guestShowDataServiceSpy.update$).toHaveBeenCalledWith('share-9', { + showType: 'service-worship', + date: show.date, + songs, + }); + expect(guestShowDataServiceSpy.add).not.toHaveBeenCalled(); + expect(showServiceSpy.update$).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/modules/guest/guest-show.service.ts b/src/app/modules/guest/guest-show.service.ts index 6c6a3e9..558f17e 100644 --- a/src/app/modules/guest/guest-show.service.ts +++ b/src/app/modules/guest/guest-show.service.ts @@ -17,14 +17,15 @@ export class GuestShowService { date: show.date, songs: songs, }; + let shareId = show.shareId; if (!show.shareId) { - const shareId = await this.guestShowDataService.add(data); + shareId = await this.guestShowDataService.add(data); await this.showService.update$(show.id, {shareId}); } else { await this.guestShowDataService.update$(show.shareId, data); } - return window.location.protocol + '//' + window.location.host + '/guest/' + show.shareId; + return window.location.protocol + '//' + window.location.host + '/guest/' + shareId; } } 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 a5373b6..fb91cad 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,12 +1,15 @@ import {ComponentFixture, TestBed} from '@angular/core/testing'; import {ShareDialogComponent} from './share-dialog.component'; +import QRCode from 'qrcode'; describe('ShareDialogComponent', () => { let component: ShareDialogComponent; let fixture: ComponentFixture; beforeEach(async () => { + spyOn(QRCode, 'toDataURL').and.resolveTo('data:image/jpeg;base64,test'); + await TestBed.configureTestingModule({ imports: [ShareDialogComponent], }).compileComponents(); 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 ae25ab6..dd72a5a 100644 --- a/src/app/modules/shows/services/show-data.service.spec.ts +++ b/src/app/modules/shows/services/show-data.service.spec.ts @@ -1,12 +1,115 @@ import {TestBed} from '@angular/core/testing'; - +import {firstValueFrom, of, Subject} from 'rxjs'; +import {skip, take} from 'rxjs/operators'; +import {DbService} from '../../../services/db.service'; import {ShowDataService} from './show-data.service'; describe('ShowDataService', () => { - beforeEach(() => void TestBed.configureTestingModule({})); + 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; + + beforeEach(() => { + 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); + + void TestBed.configureTestingModule({ + providers: [{provide: DbService, useValue: dbServiceSpy}], + }); + + service = TestBed.inject(ShowDataService); + }); it('should be created', () => { - const service: ShowDataService = TestBed.inject(ShowDataService); - void expect(service).toBeTruthy(); + expect(service).toBeTruthy(); + }); + + it('should load the raw show list from the shows collection on creation', () => { + expect(dbServiceSpy.col$).toHaveBeenCalledWith('shows'); + }); + + it('should sort the list by ascending show date', async () => { + const listPromise = firstValueFrom(service.list$.pipe(take(1))); + + shows$.next([ + {id: 'show-2', date: {toMillis: () => 200}}, + {id: 'show-1', date: {toMillis: () => 100}}, + {id: 'show-3', date: {toMillis: () => 300}}, + ]); + + const list = await listPromise; + + expect(list.map(show => show.id)).toEqual(['show-1', 'show-2', 'show-3']); + }); + + it('should replay the latest sorted list to late subscribers', async () => { + const initialSubscription = service.list$.subscribe(); + + shows$.next([ + {id: 'show-2', date: {toMillis: () => 200}}, + {id: 'show-1', date: {toMillis: () => 100}}, + ]); + + const replayedList = await firstValueFrom(service.list$.pipe(take(1))); + + expect(replayedList.map(show => show.id)).toEqual(['show-1', 'show-2']); + + initialSubscription.unsubscribe(); + }); + + it('should expose the raw list without sorting via listRaw$', () => { + service.listRaw$().subscribe(); + + expect(dbServiceSpy.col$).toHaveBeenCalledWith('shows'); + }); + + 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); + + 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(queryConstraints.length).toBe(3); + expect(result.map(show => show.id)).toEqual(['show-1', 'show-3']); + }); + + it('should read a single show by id', () => { + service.read$('show-7').subscribe(); + + expect(dbServiceSpy.doc$).toHaveBeenCalledWith('shows/show-7'); + }); + + it('should update a show at the expected document path', async () => { + await service.update('show-8', {archived: true}); + + expect(docSpy).toHaveBeenCalledWith('shows/show-8'); + const [updatePayload] = docUpdateSpy.calls.mostRecent().args as unknown 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'); + + expect(colSpy).toHaveBeenCalledWith('shows'); + const [addPayload] = colAddSpy.calls.mostRecent().args as unknown as [Record]; + expect(addPayload).toEqual({published: true}); }); }); diff --git a/src/app/modules/shows/services/show-data.service.ts b/src/app/modules/shows/services/show-data.service.ts index 6af5825..41787b7 100644 --- a/src/app/modules/shows/services/show-data.service.ts +++ b/src/app/modules/shows/services/show-data.service.ts @@ -3,7 +3,7 @@ import {Observable} from 'rxjs'; import {DbService} from '../../../services/db.service'; import {Show} from './show'; import {map, shareReplay} from 'rxjs/operators'; -import {orderBy, QueryConstraint, Timestamp, where} from '@angular/fire/firestore'; +import {orderBy, QueryConstraint, Timestamp, where} from 'firebase/firestore'; @Injectable({ providedIn: 'root', 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 3278549..d69dfdd 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,16 +1,98 @@ import {TestBed} from '@angular/core/testing'; - +import {of} from 'rxjs'; +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; beforeEach(() => { - void TestBed.configureTestingModule({}); + docUpdateSpy = jasmine.createSpy('update').and.resolveTo(); + docDeleteSpy = jasmine.createSpy('delete').and.resolveTo(); + docSpy = jasmine.createSpy('doc').and.returnValue({ + 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); + + void TestBed.configureTestingModule({ + providers: [{provide: DbService, useValue: dbServiceSpy}], + }); + service = TestBed.inject(ShowSongDataService); }); it('should be created', () => { - void expect(service).toBeTruthy(); + expect(service).toBeTruthy(); + }); + + it('should cache the list observable per show id when no query constraints are passed', () => { + const first = service.list$('show-1'); + const second = service.list$('show-1'); + + expect(first).toBe(second); + expect(dbServiceSpy.col$).toHaveBeenCalledTimes(1); + expect(dbServiceSpy.col$).toHaveBeenCalledWith('shows/show-1/songs'); + }); + + it('should not reuse the cache when query constraints are passed', () => { + const constraints = [{}] as never[]; + + const first = service.list$('show-1', constraints as never); + const second = service.list$('show-1', constraints as never); + + expect(first).not.toBe(second); + expect(dbServiceSpy.col$).toHaveBeenCalledTimes(2); + expect(dbServiceSpy.col$).toHaveBeenCalledWith('shows/show-1/songs', constraints as never); + }); + + it('should keep separate caches for different shows', () => { + service.list$('show-1'); + service.list$('show-2'); + + expect(dbServiceSpy.col$).toHaveBeenCalledTimes(2); + expect(dbServiceSpy.col$).toHaveBeenCalledWith('shows/show-1/songs'); + expect(dbServiceSpy.col$).toHaveBeenCalledWith('shows/show-2/songs'); + }); + + it('should read a single show song by nested document path', () => { + service.read$('show-4', 'song-5').subscribe(); + + expect(dbServiceSpy.doc$).toHaveBeenCalledWith('shows/show-4/songs/song-5'); + }); + + it('should update a nested show song document', async () => { + 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]; + expect(updatePayload).toEqual({title: 'Updated'}); + }); + + it('should delete a nested show song document', async () => { + await service.delete('show-4', 'song-5'); + + expect(docSpy).toHaveBeenCalledWith('shows/show-4/songs/song-5'); + expect(docDeleteSpy).toHaveBeenCalled(); + }); + + 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'); + + expect(colSpy).toHaveBeenCalledWith('shows/show-4/songs'); + const [addPayload] = colAddSpy.calls.mostRecent().args as unknown as [Record]; + expect(addPayload).toEqual({songId: 'song-5'}); }); }); 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 1c23ca3..ccdc34e 100644 --- a/src/app/modules/shows/services/show-song.service.spec.ts +++ b/src/app/modules/shows/services/show-song.service.spec.ts @@ -1,16 +1,131 @@ import {TestBed} from '@angular/core/testing'; - +import {BehaviorSubject, of} from 'rxjs'; +import {SongDataService} from '../../songs/services/song-data.service'; +import {UserService} from '../../../services/user/user.service'; +import {ShowService} from './show.service'; +import {ShowSongDataService} from './show-song-data.service'; import {ShowSongService} from './show-song.service'; describe('ShowSongService', () => { let service: ShowSongService; + let showSongDataServiceSpy: jasmine.SpyObj; + let songDataServiceSpy: jasmine.SpyObj; + let userServiceSpy: jasmine.SpyObj; + let showServiceSpy: jasmine.SpyObj; + let user$: BehaviorSubject; + const song = {id: 'song-1', key: 'G', title: 'Amazing Grace'} as any; + const showSong = {id: 'show-song-1', songId: 'song-1'} as any; + const show = {id: 'show-1', order: ['show-song-1', 'show-song-2']} as any; beforeEach(() => { - void TestBed.configureTestingModule({}); + user$ = new BehaviorSubject({id: 'user-1', name: 'Benjamin', role: 'editor', chordMode: 'letters', songUsage: {}}); + showSongDataServiceSpy = jasmine.createSpyObj('ShowSongDataService', ['add', 'read$', 'list$', 'delete', 'update$']); + songDataServiceSpy = jasmine.createSpyObj('SongDataService', ['read$']); + userServiceSpy = jasmine.createSpyObj('UserService', ['incSongCount', 'decSongCount'], { + user$: user$.asObservable(), + }); + showServiceSpy = jasmine.createSpyObj('ShowService', ['read$', 'update$']); + + 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(); + + void TestBed.configureTestingModule({ + providers: [ + {provide: ShowSongDataService, useValue: showSongDataServiceSpy}, + {provide: SongDataService, useValue: songDataServiceSpy}, + {provide: UserService, useValue: userServiceSpy}, + {provide: ShowService, useValue: showServiceSpy}, + ], + }); + service = TestBed.inject(ShowSongService); }); it('should be created', () => { - void expect(service).toBeTruthy(); + expect(service).toBeTruthy(); + }); + + 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'); + + expect(userServiceSpy.incSongCount).toHaveBeenCalledWith('song-1'); + expect(showSongDataServiceSpy.add).toHaveBeenCalledWith('show-1', { + ...song, + songId: 'song-1', + key: 'G', + keyOriginal: 'G', + chordMode: 'letters', + addedLive: true, + }); + }); + + it('should return null when the song is missing', async () => { + songDataServiceSpy.read$.and.returnValue(of(null)); + + await expectAsync(service.new$('show-1', 'song-1')).toBeResolvedTo(null); + + expect(showSongDataServiceSpy.add).not.toHaveBeenCalled(); + expect(userServiceSpy.incSongCount).not.toHaveBeenCalled(); + }); + + it('should return null when the current user is missing', async () => { + user$.next(null); + + await expectAsync(service.new$('show-1', 'song-1')).toBeResolvedTo(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); + 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]); + expect(showSongDataServiceSpy.list$).toHaveBeenCalledWith('show-1'); + }); + + it('should delete a show song, update the order and decrement song usage', async () => { + await service.delete$('show-1', 'show-song-1', 0); + + expect(showSongDataServiceSpy.delete).toHaveBeenCalledWith('show-1', 'show-song-1'); + expect(showServiceSpy.update$).toHaveBeenCalledWith('show-1', {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)); + + await service.delete$('show-1', 'show-song-1', 0); + + expect(showSongDataServiceSpy.delete).not.toHaveBeenCalled(); + expect(showServiceSpy.update$).not.toHaveBeenCalled(); + expect(userServiceSpy.decSongCount).not.toHaveBeenCalled(); + }); + + it('should stop delete when the show song is missing', async () => { + showSongDataServiceSpy.read$.and.returnValue(of(null)); + + await service.delete$('show-1', 'show-song-1', 0); + + expect(showSongDataServiceSpy.delete).not.toHaveBeenCalled(); + expect(showServiceSpy.update$).not.toHaveBeenCalled(); + expect(userServiceSpy.decSongCount).not.toHaveBeenCalled(); + }); + + it('should delegate updates to the data service', async () => { + await service.update$('show-1', 'show-song-1', {title: 'Updated'} as never); + + expect(showSongDataServiceSpy.update$).toHaveBeenCalledWith('show-1', 'show-song-1', {title: 'Updated'} as never); }); }); diff --git a/src/app/modules/shows/services/show.service.spec.ts b/src/app/modules/shows/services/show.service.spec.ts index 9b4825e..7532e82 100644 --- a/src/app/modules/shows/services/show.service.spec.ts +++ b/src/app/modules/shows/services/show.service.spec.ts @@ -1,27 +1,102 @@ import {TestBed} from '@angular/core/testing'; - -import {ShowService} from './show.service'; +import {BehaviorSubject, of} from 'rxjs'; import {ShowDataService} from './show-data.service'; +import {ShowService} from './show.service'; +import {UserService} from '../../../services/user/user.service'; describe('ShowService', () => { - const mockShowDataService = {add: Promise.resolve(null)}; - beforeEach( - () => - void TestBed.configureTestingModule({ - providers: [{provide: ShowDataService, useValue: mockShowDataService}], - }) - ); + let service: ShowService; + let showDataServiceSpy: jasmine.SpyObj; + let user$: BehaviorSubject; + const shows = [ + {id: 'show-1', owner: 'user-1', published: false, archived: false}, + {id: 'show-2', owner: 'other-user', published: true, archived: false}, + {id: 'show-3', owner: 'user-1', published: true, archived: true}, + ] as never; + + beforeEach(() => { + user$ = new BehaviorSubject({id: 'user-1'}); + showDataServiceSpy = jasmine.createSpyObj('ShowDataService', ['read$', 'listPublicSince$', 'update', 'add'], { + list$: of(shows), + }); + 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'); + + void TestBed.configureTestingModule({ + providers: [ + {provide: ShowDataService, useValue: showDataServiceSpy}, + {provide: UserService, useValue: {user$: user$.asObservable()}}, + ], + }); + + service = TestBed.inject(ShowService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should list published shows and own drafts, but exclude archived ones', done => { + service.list$().subscribe(result => { + expect(result.map(show => show.id)).toEqual(['show-1', 'show-2']); + done(); + }); + }); + + it('should filter out private drafts when publishedOnly is true', done => { + service.list$(true).subscribe(result => { + expect(result.map(show => show.id)).toEqual(['show-2']); + done(); + }); + }); + + it('should delegate public listing to the data service', done => { + service.listPublicSince$(6).subscribe(result => { + expect(result).toEqual([shows[1]]); + expect(showDataServiceSpy.listPublicSince$).toHaveBeenCalledWith(6); + done(); + }); + }); + + it('should delegate reads to the data service', done => { + service.read$('show-1').subscribe(result => { + expect(result).toEqual(shows[0]); + expect(showDataServiceSpy.read$).toHaveBeenCalledWith('show-1'); + done(); + }); + }); + + it('should delegate updates to the data service', async () => { + await service.update$('show-1', {published: true}); + + expect(showDataServiceSpy.update).toHaveBeenCalledWith('show-1', {published: true}); + }); + + it('should return null when creating a show without showType', async () => { + await expectAsync(service.new$({published: true})).toBeResolvedTo(null); + + expect(showDataServiceSpy.add).not.toHaveBeenCalled(); + }); + + 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); + + expect(showDataServiceSpy.add).not.toHaveBeenCalled(); + }); ShowService.SHOW_TYPE_PUBLIC.forEach(type => { it('should calc public flag for ' + type, async () => { - const service: ShowService = TestBed.inject(ShowService); - const addSpy = spyOn(TestBed.inject(ShowDataService), 'add').and.returnValue(Promise.resolve('id')); - const id = await service.new$({showType: type}); - void expect(id).toBe('id'); - void expect(addSpy).toHaveBeenCalledWith({ + expect(id).toBe('new-show-id'); + expect(showDataServiceSpy.add).toHaveBeenCalledWith({ showType: type, + owner: 'user-1', + order: [], public: true, }); }); @@ -29,14 +104,13 @@ describe('ShowService', () => { ShowService.SHOW_TYPE_PRIVATE.forEach(type => { it('should calc private flag for ' + type, async () => { - const service: ShowService = TestBed.inject(ShowService); - const addSpy = spyOn(TestBed.inject(ShowDataService), 'add').and.returnValue(Promise.resolve('id')); - const id = await service.new$({showType: type}); - void expect(id).toBe('id'); - void expect(addSpy).toHaveBeenCalledWith({ + expect(id).toBe('new-show-id'); + expect(showDataServiceSpy.add).toHaveBeenCalledWith({ showType: type, + owner: 'user-1', + order: [], public: false, }); }); 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 03516e5..f075969 100644 --- a/src/app/modules/songs/services/file-data.service.spec.ts +++ b/src/app/modules/songs/services/file-data.service.spec.ts @@ -1,12 +1,68 @@ import {TestBed} from '@angular/core/testing'; - +import {of} from 'rxjs'; +import {DbService} from '../../../services/db.service'; import {FileDataService} from './file-data.service'; describe('FileDataService', () => { - beforeEach(() => void TestBed.configureTestingModule({})); + 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; + + beforeEach(() => { + 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({ + add: filesCollectionAddSpy, + valueChanges: filesCollectionValueChangesSpy, + }); + songDocSpy = jasmine.createSpy('songDoc').and.callFake((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); + + void TestBed.configureTestingModule({ + providers: [{provide: DbService, useValue: dbServiceSpy}], + }); + + service = TestBed.inject(FileDataService); + }); it('should be created', () => { - const service: FileDataService = TestBed.inject(FileDataService); - void expect(service).toBeTruthy(); + expect(service).toBeTruthy(); + }); + + 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'); + + expect(songDocSpy).toHaveBeenCalledWith('songs/song-1'); + expect(songDocCollectionSpy).toHaveBeenCalledWith('files'); + expect(filesCollectionAddSpy).toHaveBeenCalledWith(file); + }); + + it('should delete files from the nested file document path', async () => { + await service.delete('song-4', 'file-9'); + + expect(songDocSpy).toHaveBeenCalledWith('songs/song-4/files/file-9'); + expect(fileDeleteSpy).toHaveBeenCalled(); + }); + + it('should read files from the files subcollection with the id field attached', () => { + service.read$('song-3').subscribe(); + + expect(songDocSpy).toHaveBeenCalledWith('songs/song-3'); + expect(songDocCollectionSpy).toHaveBeenCalledWith('files'); + expect(filesCollectionValueChangesSpy).toHaveBeenCalledWith({idField: 'id'}); }); }); diff --git a/src/app/modules/songs/services/file.service.ts b/src/app/modules/songs/services/file.service.ts index f26876e..3a82309 100644 --- a/src/app/modules/songs/services/file.service.ts +++ b/src/app/modules/songs/services/file.service.ts @@ -1,4 +1,4 @@ -import {Injectable, inject} from '@angular/core'; +import {EnvironmentInjector, Injectable, inject, runInInjectionContext} from '@angular/core'; import {deleteObject, getDownloadURL, ref, Storage} from '@angular/fire/storage'; import {from, Observable} from 'rxjs'; import {FileDataService} from './file-data.service'; @@ -9,13 +9,14 @@ import {FileDataService} from './file-data.service'; export class FileService { private storage = inject(Storage); private fileDataService = inject(FileDataService); + private environmentInjector = inject(EnvironmentInjector); public getDownloadUrl(path: string): Observable { - return from(getDownloadURL(ref(this.storage, path))); + return from(runInInjectionContext(this.environmentInjector, () => getDownloadURL(ref(this.storage, path)))); } public delete(path: string, songId: string, fileId: string): void { - void deleteObject(ref(this.storage, path)); + void runInInjectionContext(this.environmentInjector, () => deleteObject(ref(this.storage, path))); void this.fileDataService.delete(songId, fileId); } } 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 a1ef7a8..2b543b6 100644 --- a/src/app/modules/songs/services/song-data.service.spec.ts +++ b/src/app/modules/songs/services/song-data.service.spec.ts @@ -1,31 +1,104 @@ import {TestBed} from '@angular/core/testing'; - -import {SongDataService} from './song-data.service'; -import {firstValueFrom, of} from 'rxjs'; +import {firstValueFrom, Subject} from 'rxjs'; +import {skip, take, toArray} from 'rxjs/operators'; import {DbService} from '../../../services/db.service'; +import {SongDataService} from './song-data.service'; describe('SongDataService', () => { - const songs = [{title: 'title1'}]; + 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; - const mockDbService = { - col$: () => of(songs), - }; + beforeEach(() => { + songs$ = new Subject>(); + docUpdateSpy = jasmine.createSpy('update').and.resolveTo(); + docDeleteSpy = jasmine.createSpy('delete').and.resolveTo(); + docSpy = jasmine.createSpy('doc').and.callFake(() => ({ + update: docUpdateSpy, + delete: docDeleteSpy, + })); + colAddSpy = jasmine.createSpy('add').and.resolveTo({id: 'song-3'}); + colSpy = jasmine.createSpy('col').and.returnValue({ + 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); - beforeEach( - () => - void TestBed.configureTestingModule({ - providers: [{provide: DbService, useValue: mockDbService}], - }) - ); + void TestBed.configureTestingModule({ + providers: [{provide: DbService, useValue: dbServiceSpy}], + }); + + service = TestBed.inject(SongDataService); + }); it('should be created', () => { - const service: SongDataService = TestBed.inject(SongDataService); - void expect(service).toBeTruthy(); + expect(service).toBeTruthy(); }); - it('should list songs', async () => { - const service: SongDataService = TestBed.inject(SongDataService); - const list = await firstValueFrom(service.list$); - void expect(list[0].title).toEqual('title1'); + it('should load songs from the songs collection once on creation', () => { + expect(dbServiceSpy.col$).toHaveBeenCalledTimes(1); + expect(dbServiceSpy.col$).toHaveBeenCalledWith('songs'); + }); + + it('should emit an empty list first and then the loaded songs', async () => { + const emissionsPromise = firstValueFrom(service.list$.pipe(take(2), toArray())); + + songs$.next([{id: 'song-1', title: 'Amazing Grace'}]); + + const emissions = await emissionsPromise; + + expect(emissions[0]).toEqual([]); + expect(emissions[1].map(song => song.id)).toEqual(['song-1']); + expect(emissions[1].map(song => song.title)).toEqual(['Amazing Grace']); + }); + + it('should replay the latest songs to late subscribers', async () => { + const initialSubscription = service.list$.subscribe(); + + songs$.next([{id: 'song-2', title: 'How Great'}]); + + const latestSongs = await firstValueFrom(service.list$.pipe(take(1))); + + expect(latestSongs.map(song => song.id)).toEqual(['song-2']); + expect(latestSongs.map(song => song.title)).toEqual(['How Great']); + + initialSubscription.unsubscribe(); + }); + + it('should read a single song by id', () => { + service.read$('song-7'); + + expect(dbServiceSpy.doc$).toHaveBeenCalledWith('songs/song-7'); + }); + + it('should update a song at the expected document path', async () => { + await service.update$('song-8', {title: 'Updated'}); + + expect(docSpy).toHaveBeenCalledWith('songs/song-8'); + const [updatePayload] = docUpdateSpy.calls.mostRecent().args 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'); + + expect(colSpy).toHaveBeenCalledWith('songs'); + const [addPayload] = colAddSpy.calls.mostRecent().args as unknown as [Record]; + expect(addPayload).toEqual({title: 'New Song'}); + }); + + it('should delete a song at the expected document path', async () => { + await service.delete('song-9'); + + expect(docSpy).toHaveBeenCalledWith('songs/song-9'); + expect(docDeleteSpy).toHaveBeenCalled(); }); }); diff --git a/src/app/modules/songs/services/song.service.spec.ts b/src/app/modules/songs/services/song.service.spec.ts index 8d2c56f..8e3828f 100644 --- a/src/app/modules/songs/services/song.service.spec.ts +++ b/src/app/modules/songs/services/song.service.spec.ts @@ -1,32 +1,100 @@ -import {TestBed, waitForAsync} from '@angular/core/testing'; - -import {SongService} from './song.service'; -import {SongDataService} from './song-data.service'; +import {TestBed} from '@angular/core/testing'; import {of} from 'rxjs'; +import {SongDataService} from './song-data.service'; +import {SongService} from './song.service'; +import {UserService} from '../../../services/user/user.service'; +import {Timestamp} from '@angular/fire/firestore'; describe('SongService', () => { - const songs = [{title: 'title1'}]; + let service: SongService; + let songDataServiceSpy: jasmine.SpyObj; + let userServiceSpy: jasmine.SpyObj; + const song = { + id: 'song-1', + title: 'Amazing Grace', + edits: [], + } as never; - const mockSongDataService = { - list: () => of(songs), - }; + beforeEach(() => { + songDataServiceSpy = jasmine.createSpyObj('SongDataService', ['read$', 'update$', 'add', 'delete'], { + list$: of([song]), + }); + userServiceSpy = jasmine.createSpyObj('UserService', ['currentUser']); - beforeEach( - () => - void TestBed.configureTestingModule({ - providers: [{provide: SongDataService, useValue: mockSongDataService}], - }) - ); + 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); - it('should be created', () => { - const service: SongService = TestBed.inject(SongService); - void expect(service).toBeTruthy(); + void TestBed.configureTestingModule({ + providers: [ + {provide: SongDataService, useValue: songDataServiceSpy}, + {provide: UserService, useValue: userServiceSpy}, + ], + }); + + service = TestBed.inject(SongService); }); - it('should list songs', waitForAsync(() => { - const service: SongService = TestBed.inject(SongService); - service.list$().subscribe(s => { - void expect(s[0].title).toEqual('title1'); + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should list songs from the data service', done => { + service.list$().subscribe(songs => { + expect(songs).toEqual([song]); + done(); }); - })); + }); + + it('should delegate reads to the data service', async () => { + await expectAsync(service.read('song-1')).toBeResolvedTo(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); + + await service.update$('song-1', {title: 'Updated'}); + + expect(songDataServiceSpy.update$).toHaveBeenCalled(); + const [, payload] = songDataServiceSpy.update$.calls.mostRecent().args 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)); + + await service.update$('missing-song', {title: 'Updated'}); + + expect(songDataServiceSpy.update$).not.toHaveBeenCalled(); + }); + + it('should not update when no current user is available', async () => { + userServiceSpy.currentUser.and.resolveTo(null); + + await service.update$('song-1', {title: 'Updated'}); + + expect(songDataServiceSpy.update$).not.toHaveBeenCalled(); + }); + + it('should create new songs with the expected defaults', async () => { + await expectAsync(service.new(42, 'New Song')).toBeResolvedTo('song-2'); + + expect(songDataServiceSpy.add).toHaveBeenCalledWith({ + number: 42, + title: 'New Song', + status: 'draft', + legalType: 'open', + }); + }); + + it('should delete songs via the data service', async () => { + await service.delete('song-1'); + + expect(songDataServiceSpy.delete).toHaveBeenCalledWith('song-1'); + }); }); 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 f765898..15e1e63 100644 --- a/src/app/modules/songs/services/text-rendering.service.spec.ts +++ b/src/app/modules/songs/services/text-rendering.service.spec.ts @@ -73,7 +73,7 @@ Cool bridge without any chords void expect(sections[1].lines[2].type).toBe(LineType.chord); void expect(sections[1].lines[2].text).toBe(' a d e f g a h c b'); void expect(sections[2].lines[0].type).toBe(LineType.chord); - void expect(sections[2].lines[0].text).toBe('c c# db c7 cmaj7 c/e'); + void expect(sections[2].lines[0].text).toBe('c c♯ d♭ c7 cmaj7 c/e'); // c c# db c7 cmaj7 c/e void expect(sections[2].lines[0].chords).toEqual([ @@ -93,7 +93,7 @@ g# F# E g# F# E text`; const sections = service.parse(text, null); void expect(sections[0].lines[0].type).toBe(LineType.chord); - void expect(sections[0].lines[0].text).toBe('g# F# E g# F# E'); + void expect(sections[0].lines[0].text).toBe('g♯ F♯ E g♯ F♯ E'); void expect(sections[0].lines[1].type).toBe(LineType.text); void expect(sections[0].lines[1].text).toBe('text'); }); diff --git a/src/app/modules/songs/services/transpose.service.spec.ts b/src/app/modules/songs/services/transpose.service.spec.ts index 9ad1b6d..80cb2c8 100644 --- a/src/app/modules/songs/services/transpose.service.spec.ts +++ b/src/app/modules/songs/services/transpose.service.spec.ts @@ -45,7 +45,7 @@ describe('TransposeService', () => { void expect(distance).toBe(1); void expect(map?.['H']).toBe('C'); - void expect(map?.['B']).toBe('C#'); + void expect(map?.['B']).toBe('H'); }); it('should render unknown chords as X', () => { diff --git a/src/app/modules/songs/services/transpose.service.ts b/src/app/modules/songs/services/transpose.service.ts index 28c7ce1..aeb7bf5 100644 --- a/src/app/modules/songs/services/transpose.service.ts +++ b/src/app/modules/songs/services/transpose.service.ts @@ -11,6 +11,19 @@ type ScaleVariants = [string[], string[]]; providedIn: 'root', }) export class TransposeService { + private readonly enharmonicAliases: Array<[string, string]> = [ + ['C#', 'Db'], + ['D#', 'Eb'], + ['F#', 'Gb'], + ['G#', 'Ab'], + ['A#', 'B'], + ['c#', 'db'], + ['d#', 'eb'], + ['f#', 'gb'], + ['g#', 'ab'], + ['a#', 'b'], + ]; + private readonly keyToSemitone: Record = { C: 0, 'C#': 1, @@ -105,6 +118,15 @@ export class TransposeService { } }); + this.enharmonicAliases.forEach(([left, right]) => { + if (map[left] && !map[right]) { + map[right] = map[left]; + } + if (map[right] && !map[left]) { + map[left] = map[right]; + } + }); + this.mapCache.set(cacheKey, map); return map; } diff --git a/src/app/modules/songs/services/upload.service.ts b/src/app/modules/songs/services/upload.service.ts index 9c59295..df38233 100644 --- a/src/app/modules/songs/services/upload.service.ts +++ b/src/app/modules/songs/services/upload.service.ts @@ -1,4 +1,4 @@ -import {Injectable, inject} from '@angular/core'; +import {EnvironmentInjector, Injectable, inject, runInInjectionContext} from '@angular/core'; import {Upload} from './upload'; import {FileDataService} from './file-data.service'; import {ref, Storage, uploadBytesResumable} from '@angular/fire/storage'; @@ -11,14 +11,19 @@ import {FileServer} from './fileServer'; export class UploadService extends FileBase { private fileDataService = inject(FileDataService); private storage = inject(Storage); + private environmentInjector = inject(EnvironmentInjector); public pushUpload(songId: string, upload: Upload): void { const directory = this.directory(songId); const filePath = `${directory}/${upload.file.name}`; upload.path = directory; - const storageRef = ref(this.storage, filePath); - const task = uploadBytesResumable(storageRef, upload.file); + const {task} = runInInjectionContext(this.environmentInjector, () => { + const storageRef = ref(this.storage, filePath); + return { + task: uploadBytesResumable(storageRef, upload.file), + }; + }); task.on( 'state_changed', diff --git a/src/app/modules/songs/song-list/song-list.component.spec.ts b/src/app/modules/songs/song-list/song-list.component.spec.ts index 509d5e4..21913c4 100644 --- a/src/app/modules/songs/song-list/song-list.component.spec.ts +++ b/src/app/modules/songs/song-list/song-list.component.spec.ts @@ -12,7 +12,7 @@ describe('SongListComponent', () => { const songs = [{title: 'title1'}]; const mockSongService = { - list: () => of(songs), + list$: () => of(songs), }; beforeEach(waitForAsync(() => { diff --git a/src/app/modules/songs/song/edit/edit-song/save-dialog/save-dialog.component.spec.ts b/src/app/modules/songs/song/edit/edit-song/save-dialog/save-dialog.component.spec.ts index b3ef5c8..3d95d97 100644 --- a/src/app/modules/songs/song/edit/edit-song/save-dialog/save-dialog.component.spec.ts +++ b/src/app/modules/songs/song/edit/edit-song/save-dialog/save-dialog.component.spec.ts @@ -8,7 +8,7 @@ describe('SaveDialogComponent', () => { beforeEach(waitForAsync(() => { void TestBed.configureTestingModule({ - declarations: [SaveDialogComponent], + imports: [SaveDialogComponent], }).compileComponents(); })); diff --git a/src/app/modules/songs/song/file/file.component.ts b/src/app/modules/songs/song/file/file.component.ts index 9b8cee7..4a6f208 100644 --- a/src/app/modules/songs/song/file/file.component.ts +++ b/src/app/modules/songs/song/file/file.component.ts @@ -1,4 +1,4 @@ -import {Component, Input, inject} from '@angular/core'; +import {Component, EnvironmentInjector, Input, inject, runInInjectionContext} from '@angular/core'; import {File} from '../../services/file'; import {getDownloadURL, ref, Storage} from '@angular/fire/storage'; import {from, Observable} from 'rxjs'; @@ -12,13 +12,14 @@ import {AsyncPipe} from '@angular/common'; }) export class FileComponent { private storage = inject(Storage); + private environmentInjector = inject(EnvironmentInjector); public url$: Observable | null = null; public name = ''; @Input() public set file(file: File) { - this.url$ = from(getDownloadURL(ref(this.storage, file.path + '/' + file.name))); + this.url$ = from(runInInjectionContext(this.environmentInjector, () => getDownloadURL(ref(this.storage, file.path + '/' + file.name)))); this.name = file.name; } } diff --git a/src/app/services/db.service.ts b/src/app/services/db.service.ts index 1ed6922..e6e9cb9 100644 --- a/src/app/services/db.service.ts +++ b/src/app/services/db.service.ts @@ -1,4 +1,4 @@ -import {Injectable, inject} from '@angular/core'; +import {EnvironmentInjector, Injectable, inject, runInInjectionContext} from '@angular/core'; import { addDoc, collection, @@ -25,7 +25,8 @@ type DocumentPredicate = string | DbDocument; export class DbCollection { public constructor( private readonly fs: Firestore, - private readonly path: string + private readonly path: string, + private readonly environmentInjector: EnvironmentInjector ) {} public add(data: Partial): Promise> { @@ -33,7 +34,7 @@ export class DbCollection { } public valueChanges(options?: {idField?: string}): Observable { - return collectionData(this.ref, options as {idField?: never}) as Observable; + return runInInjectionContext(this.environmentInjector, () => collectionData(this.ref, options as {idField?: never}) as Observable); } private get ref(): CollectionReference { @@ -44,7 +45,8 @@ export class DbCollection { export class DbDocument { public constructor( private readonly fs: Firestore, - private readonly path: string + private readonly path: string, + private readonly environmentInjector: EnvironmentInjector ) {} public set(data: Partial): Promise { @@ -60,11 +62,14 @@ export class DbDocument { } public collection(subPath: string): DbCollection { - return new DbCollection(this.fs, `${this.path}/${subPath}`); + return new DbCollection(this.fs, `${this.path}/${subPath}`, this.environmentInjector); } public valueChanges(options?: {idField?: string}): Observable<(NonNullable & {id?: string}) | undefined> { - return docData(this.ref as DocumentReference, options as {idField?: never}) as Observable<(NonNullable & {id?: string}) | undefined>; + return runInInjectionContext( + this.environmentInjector, + () => docData(this.ref as DocumentReference, options as {idField?: never}) as Observable<(NonNullable & {id?: string}) | undefined> + ); } private get ref(): DocumentReference { @@ -77,13 +82,14 @@ export class DbDocument { }) export class DbService { private fs = inject(Firestore); + private environmentInjector = inject(EnvironmentInjector); public col(ref: CollectionPredicate): DbCollection { - return typeof ref === 'string' ? new DbCollection(this.fs, ref) : ref; + return typeof ref === 'string' ? new DbCollection(this.fs, ref, this.environmentInjector) : ref; } public doc(ref: DocumentPredicate): DbDocument { - return typeof ref === 'string' ? new DbDocument(this.fs, ref) : ref; + return typeof ref === 'string' ? new DbDocument(this.fs, ref, this.environmentInjector) : ref; } public doc$(ref: DocumentPredicate): Observable<(NonNullable & {id?: string}) | null> { @@ -98,6 +104,6 @@ export class DbService { } const q = query(collection(this.fs, ref), ...queryConstraints); - return collectionData(q, {idField: 'id'}) as Observable; + return runInInjectionContext(this.environmentInjector, () => collectionData(q, {idField: 'id'}) as Observable); } } diff --git a/src/app/services/user/user.service.ts b/src/app/services/user/user.service.ts index 2c8b62f..543f75a 100644 --- a/src/app/services/user/user.service.ts +++ b/src/app/services/user/user.service.ts @@ -1,4 +1,4 @@ -import {Injectable, inject} from '@angular/core'; +import {EnvironmentInjector, Injectable, inject, runInInjectionContext} from '@angular/core'; import {Auth, authState, createUserWithEmailAndPassword, sendPasswordResetEmail, signInWithEmailAndPassword, signOut} from '@angular/fire/auth'; import {BehaviorSubject, firstValueFrom, Observable} from 'rxjs'; import {filter, map, shareReplay, switchMap, take, tap} from 'rxjs/operators'; @@ -25,6 +25,7 @@ export class UserService { private router = inject(Router); private showDataService = inject(ShowDataService); private showSongDataService = inject(ShowSongDataService); + private environmentInjector = inject(EnvironmentInjector); public users$ = this.db.col$('users').pipe(shareReplay({bufferSize: 1, refCount: true})); private iUserId$ = new BehaviorSubject(null); @@ -32,7 +33,7 @@ export class UserService { private userByIdCache = new Map>(); public constructor() { - authState(this.auth) + this.authState$() .pipe( filter(auth => !!auth), map(auth => auth?.uid ?? ''), @@ -65,7 +66,7 @@ export class UserService { }; public async login(user: string, password: string): Promise { - const aUser = await signInWithEmailAndPassword(this.auth, user, password); + const aUser = await this.runInFirebaseContext(() => signInWithEmailAndPassword(this.auth, user, password)); if (!aUser.user) return null; const dUser = await this.readUser(aUser.user.uid); if (!dUser) return null; @@ -76,12 +77,12 @@ export class UserService { return aUser.user.uid; } - public loggedIn$: () => Observable = () => authState(this.auth).pipe(map(_ => !!_)); + public loggedIn$: () => Observable = () => this.authState$().pipe(map(_ => !!_)); public list$: () => Observable = (): Observable => this.users$; public async logout(): Promise { - await signOut(this.auth); + await this.runInFirebaseContext(() => signOut(this.auth)); this.iUser$.next(null); this.iUserId$.next(null); } @@ -92,11 +93,11 @@ export class UserService { public async changePassword(user: string): Promise { const url = environment.url; - await sendPasswordResetEmail(this.auth, user, {url}); + await this.runInFirebaseContext(() => sendPasswordResetEmail(this.auth, user, {url})); } public async createNewUser(user: string, name: string, password: string): Promise { - const aUser = await createUserWithEmailAndPassword(this.auth, user, password); + const aUser = await this.runInFirebaseContext(() => createUserWithEmailAndPassword(this.auth, user, password)); if (!aUser.user) return; const userId = aUser.user.uid; await this.db.doc('users/' + userId).set({name, chordMode: 'onlyFirst', songUsage: {}}); @@ -176,6 +177,8 @@ export class UserService { return role.split(';').includes('admin'); } + private authState$ = () => runInInjectionContext(this.environmentInjector, () => authState(this.auth)); + private runInFirebaseContext = (factory: () => T): T => runInInjectionContext(this.environmentInjector, factory); private readUser$ = (uid: string) => this.db.doc$('users/' + uid); private readUser: (uid: string) => Promise = (uid: string) => firstValueFrom(this.readUser$(uid)); } diff --git a/src/app/widget-modules/guards/auth.guard.ts b/src/app/widget-modules/guards/auth.guard.ts index 2ba46fc..1070b56 100644 --- a/src/app/widget-modules/guards/auth.guard.ts +++ b/src/app/widget-modules/guards/auth.guard.ts @@ -1,4 +1,4 @@ -import {Injectable, inject} from '@angular/core'; +import {EnvironmentInjector, Injectable, inject, runInInjectionContext} from '@angular/core'; import {CanActivate, Router, UrlTree} from '@angular/router'; import {Auth, authState} from '@angular/fire/auth'; import {Observable} from 'rxjs'; @@ -10,9 +10,10 @@ import {map, take} from 'rxjs/operators'; export class AuthGuard implements CanActivate { private auth = inject(Auth); private router = inject(Router); + private environmentInjector = inject(EnvironmentInjector); public canActivate(): Observable { - return authState(this.auth).pipe( + return runInInjectionContext(this.environmentInjector, () => authState(this.auth)).pipe( take(1), map(user => (user ? true : this.router.createUrlTree(['user', 'login']))) ); diff --git a/src/test.ts b/src/test.ts index 1f2a751..90f5e75 100644 --- a/src/test.ts +++ b/src/test.ts @@ -1,10 +1,74 @@ // This file is required by karma.conf.js and loads recursively all the .spec and framework files import 'zone.js/testing'; -import {getTestBed} from '@angular/core/testing'; +import {getTestBed, TestBed} from '@angular/core/testing'; import {BrowserDynamicTestingModule, platformBrowserDynamicTesting} from '@angular/platform-browser-dynamic/testing'; +import {provideNoopAnimations} from '@angular/platform-browser/animations'; +import {ActivatedRoute, provideRouter} from '@angular/router'; +import {BehaviorSubject, of} from 'rxjs'; +import {MAT_DIALOG_DATA, MatDialogRef} from '@angular/material/dialog'; +import {provideNativeDateAdapter} from '@angular/material/core'; +import {provideFirebaseApp, initializeApp} from '@angular/fire/app'; +import {getApp, getApps} from '@angular/fire/app'; +import {getAuth, provideAuth} from '@angular/fire/auth'; +import {initializeFirestore, provideFirestore} from '@angular/fire/firestore'; +import {getStorage, provideStorage} from '@angular/fire/storage'; +import {environment} from './environments/environment'; +import {DbService} from './app/services/db.service'; type req = {keys: () => {map: (context: req) => void}}; // First, initialize the Angular testing environment. getTestBed().initTestEnvironment(BrowserDynamicTestingModule, platformBrowserDynamicTesting()); + +const routeParams$ = new BehaviorSubject>({}); +const queryParams$ = new BehaviorSubject>({}); + +const defaultTestingProviders = [ + provideNoopAnimations(), + provideNativeDateAdapter(), + provideRouter([]), + provideFirebaseApp(() => (getApps().some(app => app.name === 'wgenerator-tests') ? getApp('wgenerator-tests') : initializeApp(environment.firebase, 'wgenerator-tests'))), + provideAuth(() => getAuth(getApp('wgenerator-tests'))), + provideFirestore(() => initializeFirestore(getApp('wgenerator-tests'), {})), + provideStorage(() => getStorage(getApp('wgenerator-tests'))), + { + provide: ActivatedRoute, + useValue: { + snapshot: {params: {}, queryParams: {}, data: {}}, + params: routeParams$.asObservable(), + queryParams: queryParams$.asObservable(), + data: of({}), + fragment: of(null), + }, + }, + {provide: MAT_DIALOG_DATA, useValue: {}}, + {provide: MatDialogRef, useValue: {close: () => void 0}}, + { + provide: DbService, + useValue: { + col$: () => of([]), + doc$: () => of(null), + col: () => ({ + valueChanges: () => of([]), + add: async () => ({id: 'test-id'}), + }), + doc: () => ({ + set: async () => void 0, + update: async () => void 0, + delete: async () => void 0, + collection: () => ({ + valueChanges: () => of([]), + add: async () => ({id: 'test-id'}), + }), + }), + }, + }, +]; + +const originalConfigureTestingModule = TestBed.configureTestingModule.bind(TestBed); +TestBed.configureTestingModule = ((moduleDef?: Parameters[0]) => + originalConfigureTestingModule({ + ...moduleDef, + providers: [...defaultTestingProviders, ...(moduleDef?.providers ?? [])], + })) as typeof TestBed.configureTestingModule;