@sneat/api 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,138 @@
1
+ import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http';
2
+ import {
3
+ Inject,
4
+ Injectable,
5
+ InjectionToken,
6
+ OnDestroy,
7
+ Optional,
8
+ } from '@angular/core';
9
+ import { Auth, onIdTokenChanged } from '@angular/fire/auth';
10
+ import { Observable, Subject, throwError } from 'rxjs';
11
+ import { ISneatApiService } from './sneat-api-service.interface';
12
+
13
+ const userIsNotAuthenticatedNoFirebaseToken =
14
+ 'User is not authenticated yet - no Firebase ID token';
15
+
16
+ export const SneatApiAuthTokenProvider = new InjectionToken(
17
+ 'SneatApiAuthTokenProvider',
18
+ );
19
+ export const SneatApiBaseUrl = new InjectionToken('SneatApiBaseUrl');
20
+ export const DefaultSneatAppApiBaseUrl = 'https://api.sneat.ws/v0/';
21
+
22
+ @Injectable({ providedIn: 'root' }) // Should it be in root? Probably it is OK.
23
+ export class SneatApiService implements ISneatApiService, OnDestroy {
24
+ private readonly baseUrl: string;
25
+
26
+ private readonly destroyed = new Subject<void>();
27
+ private authToken?: string;
28
+
29
+ constructor(
30
+ private readonly httpClient: HttpClient,
31
+ @Inject(SneatApiBaseUrl) @Optional() baseUrl: string | null,
32
+ private readonly afAuth: Auth,
33
+ ) {
34
+ this.baseUrl = baseUrl ?? DefaultSneatAppApiBaseUrl;
35
+ // console.log('SneatApiService.constructor()', this.baseUrl);
36
+ onIdTokenChanged(this.afAuth, {
37
+ next: (user) => {
38
+ user
39
+ ?.getIdToken()
40
+ .then(this.setApiAuthToken)
41
+ .catch((err) => console.error('getIdToken() error:', err));
42
+ },
43
+ error: (error) => {
44
+ console.error('onIdTokenChanged() error:', error);
45
+ },
46
+ complete: () => void 0,
47
+ });
48
+ }
49
+
50
+ ngOnDestroy() {
51
+ this.destroyed.next();
52
+ this.destroyed.complete();
53
+ }
54
+
55
+ // TODO: It's made public because we use it in Login page, might be a bad idea consider making private and rely on afAuth.idToken event
56
+ setApiAuthToken = (token?: string) => {
57
+ // console.log('setApiAuthToken()', token);
58
+ this.authToken = token;
59
+ };
60
+
61
+ public post<T>(endpoint: string, body: unknown): Observable<T> {
62
+ const url = this.baseUrl + endpoint;
63
+ // console.log('post()', endpoint, url, body);
64
+ return (
65
+ this.errorIfNotAuthenticated() ||
66
+ this.httpClient.post<T>(url, body, {
67
+ headers: this.headers(),
68
+ })
69
+ );
70
+ }
71
+
72
+ public put<T>(endpoint: string, body: unknown): Observable<T> {
73
+ return (
74
+ this.errorIfNotAuthenticated() ||
75
+ this.httpClient.put<T>(this.baseUrl + endpoint, body, {
76
+ headers: this.headers(),
77
+ })
78
+ );
79
+ }
80
+
81
+ public get<T>(endpoint: string, params?: HttpParams): Observable<T> {
82
+ return (
83
+ this.errorIfNotAuthenticated() ||
84
+ this.httpClient.get<T>(this.baseUrl + endpoint, {
85
+ headers: this.headers(),
86
+ params,
87
+ })
88
+ );
89
+ }
90
+
91
+ public getAsAnonymous<T>(
92
+ endpoint: string,
93
+ params?: HttpParams,
94
+ ): Observable<T> {
95
+ return this.httpClient.get<T>(this.baseUrl + endpoint, {
96
+ params,
97
+ });
98
+ }
99
+
100
+ public postAsAnonymous<T>(endpoint: string, body: unknown): Observable<T> {
101
+ const url = this.baseUrl + endpoint;
102
+ // alert('postAsAnonymous(), url=' + url);
103
+ return this.httpClient.post<T>(url, body);
104
+ }
105
+
106
+ public delete<T>(
107
+ endpoint: string,
108
+ params?: HttpParams,
109
+ body?: unknown,
110
+ ): Observable<T> {
111
+ // console.log('delete()', endpoint, params);
112
+ const url = this.baseUrl + endpoint;
113
+ return (
114
+ this.errorIfNotAuthenticated() ||
115
+ this.httpClient.delete<T>(url, {
116
+ params,
117
+ headers: this.headers(),
118
+ body,
119
+ })
120
+ );
121
+ }
122
+
123
+ private errorIfNotAuthenticated(): Observable<never> | undefined {
124
+ const result: Observable<never> | undefined =
125
+ (!this.authToken &&
126
+ throwError(() => userIsNotAuthenticatedNoFirebaseToken)) ||
127
+ undefined;
128
+ return result;
129
+ }
130
+
131
+ private headers(): HttpHeaders {
132
+ let headers = new HttpHeaders();
133
+ if (this.authToken) {
134
+ headers = headers.append('Authorization', 'Bearer ' + this.authToken);
135
+ }
136
+ return headers;
137
+ }
138
+ }
@@ -0,0 +1,18 @@
1
+ import { TestBed } from '@angular/core/testing';
2
+ import { SneatApiModule } from './sneat-api.module';
3
+
4
+ describe('SneatApiModule', () => {
5
+ it('should create module', () => {
6
+ const module = new SneatApiModule();
7
+ expect(module).toBeTruthy();
8
+ });
9
+
10
+ it('should be importable in TestBed', () => {
11
+ TestBed.configureTestingModule({
12
+ imports: [SneatApiModule],
13
+ });
14
+
15
+ const module = TestBed.inject(SneatApiModule);
16
+ expect(module).toBeTruthy();
17
+ });
18
+ });
@@ -0,0 +1,10 @@
1
+ import { NgModule } from '@angular/core';
2
+ import { HttpClientModule } from '@angular/common/http';
3
+
4
+ @NgModule({
5
+ imports: [HttpClientModule],
6
+ providers: [
7
+ // SneatTeamApiService,
8
+ ],
9
+ })
10
+ export class SneatApiModule {}
@@ -0,0 +1,465 @@
1
+ import { TestBed, fakeAsync, tick } from '@angular/core/testing';
2
+ import { Injector } from '@angular/core';
3
+ import {
4
+ CollectionReference,
5
+ DocumentReference,
6
+ DocumentSnapshot,
7
+ QuerySnapshot,
8
+ } from '@angular/fire/firestore';
9
+ import { Subject } from 'rxjs';
10
+ import {
11
+ SneatFirestoreService,
12
+ docSnapshotToDto,
13
+ IQueryArgs,
14
+ } from './sneat-firestore.service';
15
+
16
+ // Mock Firebase functions
17
+ const mockDoc = vi.fn();
18
+ const mockGetDoc = vi.fn();
19
+ const mockOnSnapshot = vi.fn();
20
+ const mockQuery = vi.fn();
21
+ const mockWhere = vi.fn();
22
+ const mockLimit = vi.fn();
23
+
24
+ vi.mock('@angular/fire/firestore', () => ({
25
+ doc: (...args: unknown[]) => mockDoc(...args),
26
+ getDoc: (...args: unknown[]) => mockGetDoc(...args),
27
+ onSnapshot: (...args: unknown[]) => mockOnSnapshot(...args),
28
+ query: (...args: unknown[]) => mockQuery(...args),
29
+ where: (...args: unknown[]) => mockWhere(...args),
30
+ limit: (...args: unknown[]) => mockLimit(...args),
31
+ }));
32
+
33
+ interface TestBrief {
34
+ id: string;
35
+ name: string;
36
+ }
37
+
38
+ interface TestDbo extends TestBrief {
39
+ email: string;
40
+ }
41
+
42
+ describe('SneatFirestoreService', () => {
43
+ let service: SneatFirestoreService<TestBrief, TestDbo>;
44
+ let injector: Injector;
45
+ let dto2brief: (id: string, dto: TestDbo) => TestBrief;
46
+
47
+ beforeEach(() => {
48
+ TestBed.configureTestingModule({
49
+ providers: [],
50
+ });
51
+ injector = TestBed.inject(Injector);
52
+ dto2brief = (id: string, dto: TestDbo) => ({ id, name: dto.name });
53
+
54
+ // Clear all mocks
55
+ mockDoc.mockClear();
56
+ mockGetDoc.mockClear();
57
+ mockOnSnapshot.mockClear();
58
+ mockQuery.mockClear();
59
+ mockWhere.mockClear();
60
+ mockLimit.mockClear();
61
+ });
62
+
63
+ it('should be created', () => {
64
+ service = new SneatFirestoreService(injector, dto2brief);
65
+ expect(service).toBeTruthy();
66
+ });
67
+
68
+ it('should use default dto2brief if not provided', () => {
69
+ // The constructor has a default value for dto2brief, so it will never throw
70
+ // when undefined is passed - it will just use the default
71
+ service = new SneatFirestoreService(injector);
72
+ expect(service).toBeTruthy();
73
+
74
+ // Test that the default dto2brief works correctly
75
+ const mockSnapshot = {
76
+ id: 'doc1',
77
+ exists: () => true,
78
+ data: () =>
79
+ ({ id: 'doc1', name: 'Test', email: 'test1@example.com' }) as TestDbo,
80
+ } as unknown as DocumentSnapshot<TestDbo>;
81
+
82
+ const result = service.docSnapshotToContext(mockSnapshot);
83
+ expect(result.id).toBe('doc1');
84
+ expect(result.dto).toBeDefined();
85
+ expect((result.brief as TestBrief).id).toBe('doc1');
86
+ });
87
+
88
+ it('should throw error if dto2brief is explicitly null', () => {
89
+ // Use type assertion to bypass TypeScript and test runtime behavior
90
+ expect(() => {
91
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
92
+ new SneatFirestoreService(injector, null as any);
93
+ }).toThrow('dto2brief is required');
94
+ });
95
+
96
+ describe('watchByID', () => {
97
+ it('should watch document by ID', fakeAsync(() => {
98
+ service = new SneatFirestoreService(injector, dto2brief);
99
+ const mockCollection = {
100
+ path: 'test-collection',
101
+ } as CollectionReference<TestDbo>;
102
+ const mockDocRef = {
103
+ id: 'doc1',
104
+ path: 'test-collection/doc1',
105
+ } as DocumentReference<TestDbo>;
106
+ const mockSnapshot = {
107
+ exists: () => true,
108
+ data: () => ({ id: 'doc1', name: 'Test', email: 'test1@example.com' }),
109
+ } as unknown as DocumentSnapshot<TestDbo>;
110
+
111
+ mockDoc.mockReturnValue(mockDocRef);
112
+ mockOnSnapshot.mockImplementation((docRef, next) => {
113
+ // Simulate snapshot
114
+ setTimeout(() => next(mockSnapshot), 0);
115
+ });
116
+
117
+ const result$ = service.watchByID(mockCollection, 'doc1');
118
+ let result: unknown;
119
+ result$.subscribe((data) => {
120
+ result = data;
121
+ });
122
+
123
+ tick();
124
+
125
+ expect(mockDoc).toHaveBeenCalledWith(mockCollection, 'doc1');
126
+ expect(result).toEqual({
127
+ id: 'doc1',
128
+ dbo: { id: 'doc1', name: 'Test', email: 'test1@example.com' },
129
+ brief: { id: 'doc1', name: 'Test' },
130
+ });
131
+ }));
132
+ });
133
+
134
+ describe('watchByDocRef', () => {
135
+ it('should watch document by reference', fakeAsync(() => {
136
+ service = new SneatFirestoreService(injector, dto2brief);
137
+ const mockDocRef = {
138
+ id: 'doc2',
139
+ path: 'test-collection/doc2',
140
+ } as DocumentReference<TestDbo>;
141
+ const mockSnapshot = {
142
+ exists: () => true,
143
+ data: () => ({ id: 'doc2', name: 'Test2', email: 'test2@example.com' }),
144
+ } as unknown as DocumentSnapshot<TestDbo>;
145
+
146
+ mockOnSnapshot.mockImplementation((docRef, next) => {
147
+ setTimeout(() => next(mockSnapshot), 0);
148
+ });
149
+
150
+ const result$ = service.watchByDocRef(mockDocRef);
151
+ let result: unknown;
152
+ result$.subscribe((data) => {
153
+ result = data;
154
+ });
155
+
156
+ tick();
157
+
158
+ expect(mockOnSnapshot).toHaveBeenCalled();
159
+ expect(result).toEqual({
160
+ id: 'doc2',
161
+ dbo: { id: 'doc2', name: 'Test2', email: 'test2@example.com' },
162
+ brief: { id: 'doc2', name: 'Test2' },
163
+ });
164
+ }));
165
+
166
+ it('should handle errors', fakeAsync(() => {
167
+ service = new SneatFirestoreService(injector, dto2brief);
168
+ const mockDocRef = {
169
+ id: 'doc3',
170
+ path: 'test-collection/doc3',
171
+ } as DocumentReference<TestDbo>;
172
+ const testError = new Error('Firestore error');
173
+
174
+ mockOnSnapshot.mockImplementation((docRef, next, error) => {
175
+ setTimeout(() => error(testError), 0);
176
+ });
177
+
178
+ const result$ = service.watchByDocRef(mockDocRef);
179
+ let caughtError: unknown;
180
+ result$.subscribe({
181
+ error: (err) => {
182
+ caughtError = err;
183
+ },
184
+ });
185
+
186
+ tick();
187
+
188
+ expect(caughtError).toBe(testError);
189
+ }));
190
+
191
+ it('should handle complete', fakeAsync(() => {
192
+ service = new SneatFirestoreService(injector, dto2brief);
193
+ const mockDocRef = {
194
+ id: 'doc5',
195
+ path: 'test-collection/doc5',
196
+ } as DocumentReference<TestDbo>;
197
+
198
+ mockOnSnapshot.mockImplementation((docRef, next, error, complete) => {
199
+ setTimeout(() => complete(), 0);
200
+ });
201
+
202
+ const result$ = service.watchByDocRef(mockDocRef);
203
+ let completed = false;
204
+ result$.subscribe({
205
+ complete: () => {
206
+ completed = true;
207
+ },
208
+ });
209
+
210
+ tick();
211
+
212
+ expect(completed).toBe(true);
213
+ }));
214
+ });
215
+
216
+ describe('getByDocRef', () => {
217
+ it('should get document by reference', fakeAsync(() => {
218
+ service = new SneatFirestoreService(injector, dto2brief);
219
+ const mockDocRef = {
220
+ id: 'doc4',
221
+ path: 'test-collection/doc4',
222
+ } as DocumentReference<TestDbo>;
223
+ const mockSnapshot = {
224
+ exists: () => true,
225
+ data: () => ({ id: 'doc4', name: 'Test4', email: 'test4@example.com' }),
226
+ } as unknown as DocumentSnapshot<TestDbo>;
227
+
228
+ mockGetDoc.mockResolvedValue(mockSnapshot);
229
+
230
+ const result$ = service.getByDocRef(mockDocRef);
231
+ let result: unknown;
232
+ result$.subscribe((data) => {
233
+ result = data;
234
+ });
235
+
236
+ tick();
237
+
238
+ expect(mockGetDoc).toHaveBeenCalledWith(mockDocRef);
239
+ expect(result).toEqual({
240
+ id: 'doc4',
241
+ dbo: { id: 'doc4', name: 'Test4', email: 'test4@example.com' },
242
+ brief: { id: 'doc4', name: 'Test4' },
243
+ });
244
+ }));
245
+ });
246
+
247
+ describe('watchSnapshotsByFilter', () => {
248
+ it('should watch documents by filter', () => {
249
+ service = new SneatFirestoreService(injector, dto2brief);
250
+ const mockCollection = {
251
+ path: 'test-collection',
252
+ } as CollectionReference<TestDbo>;
253
+ const queryArgs: IQueryArgs = {
254
+ filter: [{ field: 'name', operator: '==', value: 'Test' }],
255
+ };
256
+ const mockQueryObj = {};
257
+
258
+ mockWhere.mockReturnValue({});
259
+ mockQuery.mockReturnValue(mockQueryObj);
260
+ mockOnSnapshot.mockImplementation(() => {
261
+ // Just return to simulate subscription
262
+ });
263
+
264
+ const result$ = service.watchSnapshotsByFilter(mockCollection, queryArgs);
265
+
266
+ expect(mockQuery).toHaveBeenCalled();
267
+ expect(mockWhere).toHaveBeenCalledWith('name', '==', 'Test');
268
+ expect(result$).toBeTruthy();
269
+ });
270
+
271
+ it('should use array-contains for fields ending with IDs', () => {
272
+ service = new SneatFirestoreService(injector, dto2brief);
273
+ const mockCollection = {
274
+ path: 'test-collection',
275
+ } as CollectionReference<TestDbo>;
276
+ const queryArgs: IQueryArgs = {
277
+ filter: [{ field: 'userIDs', operator: '==', value: 'user1' }],
278
+ };
279
+
280
+ mockWhere.mockReturnValue({});
281
+ mockQuery.mockReturnValue({});
282
+ mockOnSnapshot.mockImplementation(() => {
283
+ // Just return
284
+ });
285
+
286
+ service.watchSnapshotsByFilter(mockCollection, queryArgs);
287
+
288
+ expect(mockWhere).toHaveBeenCalledWith(
289
+ 'userIDs',
290
+ 'array-contains',
291
+ 'user1',
292
+ );
293
+ });
294
+
295
+ it('should apply limit when specified', () => {
296
+ service = new SneatFirestoreService(injector, dto2brief);
297
+ const mockCollection = {
298
+ path: 'test-collection',
299
+ } as CollectionReference<TestDbo>;
300
+ const queryArgs: IQueryArgs = {
301
+ limit: 10,
302
+ };
303
+
304
+ mockLimit.mockReturnValue({});
305
+ mockQuery.mockReturnValue({});
306
+ mockOnSnapshot.mockImplementation(() => {
307
+ // Just return
308
+ });
309
+
310
+ service.watchSnapshotsByFilter(mockCollection, queryArgs);
311
+
312
+ expect(mockLimit).toHaveBeenCalledWith(10);
313
+ });
314
+ });
315
+
316
+ describe('watchByFilter', () => {
317
+ it('should watch and transform documents by filter', fakeAsync(() => {
318
+ service = new SneatFirestoreService(injector, dto2brief);
319
+ const mockCollection = {
320
+ path: 'test-collection',
321
+ } as CollectionReference<TestDbo>;
322
+ const mockSnapshot1 = {
323
+ id: 'doc1',
324
+ exists: () => true,
325
+ data: () => ({ id: 'doc1', name: 'Test1', email: 'test1@example.com' }),
326
+ } as unknown as DocumentSnapshot<TestDbo>;
327
+ const mockQuerySnapshot = {
328
+ docs: [mockSnapshot1],
329
+ } as QuerySnapshot<TestDbo>;
330
+
331
+ mockQuery.mockReturnValue({});
332
+ mockOnSnapshot.mockImplementation(
333
+ (q, subj: Subject<QuerySnapshot<TestDbo>>) => {
334
+ setTimeout(() => subj.next(mockQuerySnapshot), 0);
335
+ },
336
+ );
337
+
338
+ const result$ = service.watchByFilter(mockCollection);
339
+ let result: unknown;
340
+ result$.subscribe((data) => {
341
+ result = data;
342
+ });
343
+
344
+ tick();
345
+
346
+ expect(result).toEqual([
347
+ {
348
+ id: 'doc1',
349
+ dto: { id: 'doc1', name: 'Test1', email: 'test1@example.com' },
350
+ brief: { id: 'doc1', name: 'Test1' },
351
+ },
352
+ ]);
353
+ }));
354
+ });
355
+
356
+ describe('docSnapshotsToContext', () => {
357
+ it('should transform array of snapshots to contexts', () => {
358
+ service = new SneatFirestoreService(injector, dto2brief);
359
+ const mockSnapshot1 = {
360
+ id: 'doc1',
361
+ exists: () => true,
362
+ data: () => ({ id: 'doc1', name: 'Test1', email: 'test1@example.com' }),
363
+ } as unknown as DocumentSnapshot<TestDbo>;
364
+ const mockSnapshot2 = {
365
+ id: 'doc2',
366
+ exists: () => true,
367
+ data: () => ({ id: 'doc2', name: 'Test2', email: 'test2@example.com' }),
368
+ } as unknown as DocumentSnapshot<TestDbo>;
369
+
370
+ const result = service.docSnapshotsToContext([
371
+ mockSnapshot1,
372
+ mockSnapshot2,
373
+ ]);
374
+
375
+ expect(result).toHaveLength(2);
376
+ expect(result[0]).toEqual({
377
+ id: 'doc1',
378
+ dto: { id: 'doc1', name: 'Test1', email: 'test1@example.com' },
379
+ brief: { id: 'doc1', name: 'Test1' },
380
+ });
381
+ });
382
+ });
383
+
384
+ describe('docSnapshotToContext', () => {
385
+ it('should transform snapshot to context', () => {
386
+ service = new SneatFirestoreService(injector, dto2brief);
387
+ const mockSnapshot = {
388
+ id: 'doc1',
389
+ exists: () => true,
390
+ data: () => ({ id: 'doc1', name: 'Test1', email: 'test1@example.com' }),
391
+ } as unknown as DocumentSnapshot<TestDbo>;
392
+
393
+ const result = service.docSnapshotToContext(mockSnapshot);
394
+
395
+ expect(result).toEqual({
396
+ id: 'doc1',
397
+ dto: { id: 'doc1', name: 'Test1', email: 'test1@example.com' },
398
+ brief: { id: 'doc1', name: 'Test1' },
399
+ });
400
+ });
401
+
402
+ it('should handle snapshot with no data', () => {
403
+ service = new SneatFirestoreService(injector, dto2brief);
404
+ const mockSnapshot = {
405
+ id: 'doc1',
406
+ exists: () => true,
407
+ data: () => undefined,
408
+ } as unknown as DocumentSnapshot<TestDbo>;
409
+
410
+ const result = service.docSnapshotToContext(mockSnapshot);
411
+
412
+ expect(result).toEqual({
413
+ id: 'doc1',
414
+ dto: undefined,
415
+ brief: undefined,
416
+ });
417
+ });
418
+ });
419
+
420
+ describe('docSnapshotToDto', () => {
421
+ it('should convert existing snapshot to dto', () => {
422
+ const mockSnapshot = {
423
+ exists: true,
424
+ data: () => ({ id: 'doc1', name: 'Test1', email: 'test1@example.com' }),
425
+ } as unknown as DocumentSnapshot<TestDbo>;
426
+
427
+ const result = docSnapshotToDto('doc1', dto2brief, mockSnapshot);
428
+
429
+ expect(result).toEqual({
430
+ id: 'doc1',
431
+ dbo: { id: 'doc1', name: 'Test1', email: 'test1@example.com' },
432
+ brief: { id: 'doc1', name: 'Test1' },
433
+ });
434
+ });
435
+
436
+ it('should handle non-existing snapshot', () => {
437
+ const mockSnapshot = {
438
+ exists: false,
439
+ } as unknown as DocumentSnapshot<TestDbo>;
440
+
441
+ const result = docSnapshotToDto('doc1', dto2brief, mockSnapshot);
442
+
443
+ expect(result).toEqual({
444
+ id: 'doc1',
445
+ brief: null,
446
+ dbo: null,
447
+ });
448
+ });
449
+
450
+ it('should handle snapshot with undefined data', () => {
451
+ const mockSnapshot = {
452
+ exists: true,
453
+ data: () => undefined,
454
+ } as unknown as DocumentSnapshot<TestDbo>;
455
+
456
+ const result = docSnapshotToDto('doc1', dto2brief, mockSnapshot);
457
+
458
+ expect(result).toEqual({
459
+ id: 'doc1',
460
+ dbo: undefined,
461
+ brief: undefined,
462
+ });
463
+ });
464
+ });
465
+ });