migrate angular 21 tests

This commit is contained in:
2026-03-09 23:25:11 +01:00
parent bb08e46b0c
commit 0d0873730a
24 changed files with 924 additions and 109 deletions

View File

@@ -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<void>>;
let docDeleteSpy: jasmine.Spy<() => Promise<void>>;
let docSpy: jasmine.Spy;
let colAddSpy: jasmine.Spy<() => Promise<{id: string}>>;
let colSpy: jasmine.Spy;
let dbServiceSpy: jasmine.SpyObj<DbService>;
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>('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<string, unknown>];
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<string, unknown>];
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();
});
});

View File

@@ -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<GuestShowDataService>;
let showServiceSpy: jasmine.SpyObj<ShowService>;
beforeEach(() => {
guestShowDataServiceSpy = jasmine.createSpyObj<GuestShowDataService>('GuestShowDataService', ['add', 'update$']);
showServiceSpy = jasmine.createSpyObj<ShowService>('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();
});
});

View File

@@ -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;
}
}

View File

@@ -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<ShareDialogComponent>;
beforeEach(async () => {
spyOn(QRCode, 'toDataURL').and.resolveTo('data:image/jpeg;base64,test');
await TestBed.configureTestingModule({
imports: [ShareDialogComponent],
}).compileComponents();

View File

@@ -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<Array<{id: string; date: {toMillis: () => number}; archived?: boolean}>>;
let docUpdateSpy: jasmine.Spy<() => Promise<void>>;
let docSpy: jasmine.Spy;
let colAddSpy: jasmine.Spy<() => Promise<{id: string}>>;
let colSpy: jasmine.Spy;
let dbServiceSpy: jasmine.SpyObj<DbService>;
beforeEach(() => {
shows$ = new Subject<Array<{id: string; date: {toMillis: () => 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>('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<string, unknown>];
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<string, unknown>];
expect(addPayload).toEqual({published: true});
});
});

View File

@@ -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',

View File

@@ -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<void>>;
let docDeleteSpy: jasmine.Spy<() => Promise<void>>;
let docSpy: jasmine.Spy;
let colAddSpy: jasmine.Spy<() => Promise<{id: string}>>;
let colSpy: jasmine.Spy;
let dbServiceSpy: jasmine.SpyObj<DbService>;
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>('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<string, unknown>];
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<string, unknown>];
expect(addPayload).toEqual({songId: 'song-5'});
});
});

View File

@@ -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<ShowSongDataService>;
let songDataServiceSpy: jasmine.SpyObj<SongDataService>;
let userServiceSpy: jasmine.SpyObj<UserService>;
let showServiceSpy: jasmine.SpyObj<ShowService>;
let user$: BehaviorSubject<any>;
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<any>({id: 'user-1', name: 'Benjamin', role: 'editor', chordMode: 'letters', songUsage: {}});
showSongDataServiceSpy = jasmine.createSpyObj<ShowSongDataService>('ShowSongDataService', ['add', 'read$', 'list$', 'delete', 'update$']);
songDataServiceSpy = jasmine.createSpyObj<SongDataService>('SongDataService', ['read$']);
userServiceSpy = jasmine.createSpyObj<UserService>('UserService', ['incSongCount', 'decSongCount'], {
user$: user$.asObservable(),
});
showServiceSpy = jasmine.createSpyObj<ShowService>('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);
});
});

View File

@@ -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<ShowDataService>;
let user$: BehaviorSubject<unknown>;
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<unknown>({id: 'user-1'});
showDataServiceSpy = jasmine.createSpyObj<ShowDataService>('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,
});
});

View File

@@ -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<DbService>;
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>('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'});
});
});

View File

@@ -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<string> {
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);
}
}

View File

@@ -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<Array<{id: string; title: string}>>;
let docUpdateSpy: jasmine.Spy<() => Promise<void>>;
let docDeleteSpy: jasmine.Spy<() => Promise<void>>;
let docSpy: jasmine.Spy;
let colAddSpy: jasmine.Spy<() => Promise<{id: string}>>;
let colSpy: jasmine.Spy;
let dbServiceSpy: jasmine.SpyObj<DbService>;
const mockDbService = {
col$: () => of(songs),
};
beforeEach(() => {
songs$ = new Subject<Array<{id: string; title: string}>>();
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>('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<string, unknown>];
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<string, unknown>];
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();
});
});

View File

@@ -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<SongDataService>;
let userServiceSpy: jasmine.SpyObj<UserService>;
const song = {
id: 'song-1',
title: 'Amazing Grace',
edits: [],
} as never;
const mockSongDataService = {
list: () => of(songs),
};
beforeEach(() => {
songDataServiceSpy = jasmine.createSpyObj<SongDataService>('SongDataService', ['read$', 'update$', 'add', 'delete'], {
list$: of([song]),
});
userServiceSpy = jasmine.createSpyObj<UserService>('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<string, unknown>];
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');
});
});

View File

@@ -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');
});

View File

@@ -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', () => {

View File

@@ -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<string, number> = {
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;
}

View File

@@ -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',

View File

@@ -12,7 +12,7 @@ describe('SongListComponent', () => {
const songs = [{title: 'title1'}];
const mockSongService = {
list: () => of(songs),
list$: () => of(songs),
};
beforeEach(waitForAsync(() => {

View File

@@ -8,7 +8,7 @@ describe('SaveDialogComponent', () => {
beforeEach(waitForAsync(() => {
void TestBed.configureTestingModule({
declarations: [SaveDialogComponent],
imports: [SaveDialogComponent],
}).compileComponents();
}));

View File

@@ -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<string> | 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;
}
}

View File

@@ -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<T> = string | DbDocument<T>;
export class DbCollection<T> {
public constructor(
private readonly fs: Firestore,
private readonly path: string
private readonly path: string,
private readonly environmentInjector: EnvironmentInjector
) {}
public add(data: Partial<T>): Promise<DocumentReference<T>> {
@@ -33,7 +34,7 @@ export class DbCollection<T> {
}
public valueChanges(options?: {idField?: string}): Observable<T[]> {
return collectionData(this.ref, options as {idField?: never}) as Observable<T[]>;
return runInInjectionContext(this.environmentInjector, () => collectionData(this.ref, options as {idField?: never}) as Observable<T[]>);
}
private get ref(): CollectionReference<DocumentData> {
@@ -44,7 +45,8 @@ export class DbCollection<T> {
export class DbDocument<T> {
public constructor(
private readonly fs: Firestore,
private readonly path: string
private readonly path: string,
private readonly environmentInjector: EnvironmentInjector
) {}
public set(data: Partial<T>): Promise<void> {
@@ -60,11 +62,14 @@ export class DbDocument<T> {
}
public collection<U>(subPath: string): DbCollection<U> {
return new DbCollection<U>(this.fs, `${this.path}/${subPath}`);
return new DbCollection<U>(this.fs, `${this.path}/${subPath}`, this.environmentInjector);
}
public valueChanges(options?: {idField?: string}): Observable<(NonNullable<T> & {id?: string}) | undefined> {
return docData(this.ref as DocumentReference<T>, options as {idField?: never}) as Observable<(NonNullable<T> & {id?: string}) | undefined>;
return runInInjectionContext(
this.environmentInjector,
() => docData(this.ref as DocumentReference<T>, options as {idField?: never}) as Observable<(NonNullable<T> & {id?: string}) | undefined>
);
}
private get ref(): DocumentReference<DocumentData> {
@@ -77,13 +82,14 @@ export class DbDocument<T> {
})
export class DbService {
private fs = inject(Firestore);
private environmentInjector = inject(EnvironmentInjector);
public col<T>(ref: CollectionPredicate<T>): DbCollection<T> {
return typeof ref === 'string' ? new DbCollection<T>(this.fs, ref) : ref;
return typeof ref === 'string' ? new DbCollection<T>(this.fs, ref, this.environmentInjector) : ref;
}
public doc<T>(ref: DocumentPredicate<T>): DbDocument<T> {
return typeof ref === 'string' ? new DbDocument<T>(this.fs, ref) : ref;
return typeof ref === 'string' ? new DbDocument<T>(this.fs, ref, this.environmentInjector) : ref;
}
public doc$<T>(ref: DocumentPredicate<T>): Observable<(NonNullable<T> & {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<T[]>;
return runInInjectionContext(this.environmentInjector, () => collectionData(q, {idField: 'id'}) as Observable<T[]>);
}
}

View File

@@ -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$<User>('users').pipe(shareReplay({bufferSize: 1, refCount: true}));
private iUserId$ = new BehaviorSubject<string | null>(null);
@@ -32,7 +33,7 @@ export class UserService {
private userByIdCache = new Map<string, Observable<User | null>>();
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<string | null> {
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<boolean> = () => authState(this.auth).pipe(map(_ => !!_));
public loggedIn$: () => Observable<boolean> = () => this.authState$().pipe(map(_ => !!_));
public list$: () => Observable<User[]> = (): Observable<User[]> => this.users$;
public async logout(): Promise<void> {
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<void> {
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<void> {
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 = <T>(factory: () => T): T => runInInjectionContext(this.environmentInjector, factory);
private readUser$ = (uid: string) => this.db.doc$<User>('users/' + uid);
private readUser: (uid: string) => Promise<User | null> = (uid: string) => firstValueFrom(this.readUser$(uid));
}

View File

@@ -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<boolean | UrlTree> {
return authState(this.auth).pipe(
return runInInjectionContext(this.environmentInjector, () => authState(this.auth)).pipe(
take(1),
map(user => (user ? true : this.router.createUrlTree(['user', 'login'])))
);