diff --git a/src/api/data.service.spec.ts b/src/api/data.service.spec.ts index 8961a48..062e315 100644 --- a/src/api/data.service.spec.ts +++ b/src/api/data.service.spec.ts @@ -1,8 +1,10 @@ import { TestBed } from '@/testing'; -import { provideHttpClientTesting } from '@/testing/http'; +import { + HTTP_CLIENT_TEST_CONTROLLER, + provideHttpClientTesting, +} from '@/testing/http'; import { HttpHeaders } from '@ngify/http'; -import { HttpTestingController } from '@ngify/http/testing'; -import { provideHttpClient, withInterceptorsFromDi } from 'oidc-client-rx'; +import type { HttpTestingController } from '@ngify/http/testing'; import { firstValueFrom } from 'rxjs'; import { DataService } from './data.service'; import { HttpBaseService } from './http-base.service'; @@ -14,15 +16,10 @@ describe('Data Service', () => { beforeEach(() => { TestBed.configureTestingModule({ imports: [], - providers: [ - DataService, - HttpBaseService, - provideHttpClient(withInterceptorsFromDi()), - provideHttpClientTesting(), - ], + providers: [DataService, HttpBaseService, provideHttpClientTesting()], }); dataService = TestBed.inject(DataService); - httpMock = TestBed.inject(HttpTestingController); + httpMock = TestBed.inject(HTTP_CLIENT_TEST_CONTROLLER); }); it('should create', () => { diff --git a/src/http/index.ts b/src/http/index.ts index 8daced4..256fc95 100644 --- a/src/http/index.ts +++ b/src/http/index.ts @@ -1,17 +1,121 @@ -import type { HttpFeature, HttpInterceptor } from '@ngify/http'; -import { InjectionToken } from 'injection-js'; +import { + type HttpBackend, + HttpClient, + type HttpFeature, + HttpFeatureKind, + type HttpInterceptor, + type HttpInterceptorFn, + withInterceptors, + withLegacyInterceptors, +} from '@ngify/http'; +import { InjectionToken, Optional, type Provider } from 'injection-js'; +import type { ArrayOrNullableOne } from '../utils/types'; export { HttpParams, HttpParamsOptions } from './params'; -export const HTTP_INTERCEPTORS = new InjectionToken( - 'HTTP_INTERCEPTORS' +export const HTTP_FEATURES = new InjectionToken('HTTP_FEATURES'); + +export const HTTP_INTERCEPTOR_FNS = new InjectionToken( + 'HTTP_INTERCEPTOR_FNS' ); -export function provideHttpClient() { - // todo - throw new Error('todo!'); -} +export const HTTP_LEGACY_INTERCEPTORS = new InjectionToken( + 'HTTP_LEGACY_INTERCEPTORS' +); -export function withInterceptorsFromDi(): HttpFeature { - // todo - throw new Error('todo!'); +export const HTTP_BACKEND = new InjectionToken('HTTP_BACKEND'); + +export const HTTP_XSRF_PROTECTION = new InjectionToken( + 'HTTP_XSRF_PROTECTION' +); + +export function provideHttpClient(features: HttpFeature[] = []): Provider[] { + return [ + { + provide: HTTP_INTERCEPTOR_FNS, + multi: true, + useValue: [], + }, + { + provide: HTTP_LEGACY_INTERCEPTORS, + multi: true, + useValue: [], + }, + { + provide: HTTP_FEATURES, + useFactory: ( + interceptors: ArrayOrNullableOne[] + ): HttpFeature[] => { + const normalizedInterceptors = [interceptors] + .flat(Number.MAX_SAFE_INTEGER) + .filter(Boolean) as HttpInterceptorFn[]; + return normalizedInterceptors.length + ? [withInterceptors(normalizedInterceptors)] + : []; + }, + multi: true, + deps: [HTTP_INTERCEPTOR_FNS], + }, + { + provide: HTTP_FEATURES, + useFactory: ( + interceptors: ArrayOrNullableOne[] + ): HttpFeature[] => { + const normalizedInterceptors = [interceptors] + .flat(Number.MAX_SAFE_INTEGER) + .filter(Boolean) as HttpInterceptor[]; + return normalizedInterceptors.length + ? [withLegacyInterceptors(normalizedInterceptors)] + : []; + }, + multi: true, + deps: [HTTP_LEGACY_INTERCEPTORS], + }, + { + provide: HTTP_FEATURES, + useFactory: (backend: HttpBackend | null | undefined): HttpFeature[] => { + return backend + ? [ + { + kind: HttpFeatureKind.Backend, + value: backend, + }, + ] + : []; + }, + multi: true, + deps: [[new Optional(), HTTP_BACKEND]], + }, + { + provide: HTTP_FEATURES, + useFactory: ( + interceptor: HttpInterceptorFn | null | undefined + ): HttpFeature[] => { + return interceptor + ? [ + { + kind: HttpFeatureKind.XsrfProtection, + value: interceptor, + }, + ] + : []; + }, + multi: true, + deps: [[new Optional(), HTTP_XSRF_PROTECTION]], + }, + { + provide: HTTP_FEATURES, + useValue: features, + multi: true, + }, + { + provide: HttpClient, + useFactory: (features: ArrayOrNullableOne[]) => { + const normalizedFeatures = [features] + .flat(Number.MAX_SAFE_INTEGER) + .filter(Boolean) as HttpFeature[]; + return new HttpClient(...normalizedFeatures); + }, + deps: [HTTP_FEATURES], + }, + ]; } diff --git a/src/interceptor/auth.interceptor.spec.ts b/src/interceptor/auth.interceptor.spec.ts index 7b51fb1..1f25c88 100644 --- a/src/interceptor/auth.interceptor.spec.ts +++ b/src/interceptor/auth.interceptor.spec.ts @@ -1,16 +1,12 @@ import { TestBed } from '@/testing'; import { - HTTP_INTERCEPTORS, - HttpClient, - provideHttpClient, - withInterceptors, - withInterceptorsFromDi, -} from '@ngify/http'; -import { - HttpTestingController, + HTTP_CLIENT_TEST_CONTROLLER, provideHttpClientTesting, -} from '@ngify/http/testing'; -import { firstValueFrom } from 'rxjs'; +} from '@/testing'; +import { HttpClient } from '@ngify/http'; +import type { HttpTestingController } from '@ngify/http/testing'; +import { HTTP_INTERCEPTOR_FNS, HTTP_LEGACY_INTERCEPTORS } from 'oidc-client-rx'; +import { ReplaySubject, firstValueFrom, share } from 'rxjs'; import { vi } from 'vitest'; import { AuthStateService } from '../auth-state/auth-state.service'; import { ConfigurationService } from '../config/config.service'; @@ -33,20 +29,19 @@ describe('AuthHttpInterceptor', () => { providers: [ ClosestMatchingRouteService, { - provide: HTTP_INTERCEPTORS, + provide: HTTP_LEGACY_INTERCEPTORS, useClass: AuthInterceptor, multi: true, }, mockProvider(AuthStateService), mockProvider(LoggerService), mockProvider(ConfigurationService), - provideHttpClient(withInterceptorsFromDi()), provideHttpClientTesting(), ], }); httpClient = TestBed.inject(HttpClient); - httpTestingController = TestBed.inject(HttpTestingController); + httpTestingController = TestBed.inject(HTTP_CLIENT_TEST_CONTROLLER); configurationService = TestBed.inject(ConfigurationService); authStateService = TestBed.inject(AuthStateService); closestMatchingRouteService = TestBed.inject(ClosestMatchingRouteService); @@ -64,8 +59,12 @@ describe('AuthHttpInterceptor', () => { beforeEach(() => { TestBed.configureTestingModule({ providers: [ + { + provide: HTTP_INTERCEPTOR_FNS, + useFactory: authInterceptor, + multi: true, + }, ClosestMatchingRouteService, - provideHttpClient(withInterceptors([authInterceptor()])), provideHttpClientTesting(), mockProvider(AuthStateService), mockProvider(LoggerService), @@ -74,7 +73,7 @@ describe('AuthHttpInterceptor', () => { }); httpClient = TestBed.inject(HttpClient); - httpTestingController = TestBed.inject(HttpTestingController); + httpTestingController = TestBed.inject(HTTP_CLIENT_TEST_CONTROLLER); configurationService = TestBed.inject(ConfigurationService); authStateService = TestBed.inject(AuthStateService); closestMatchingRouteService = TestBed.inject(ClosestMatchingRouteService); @@ -106,14 +105,27 @@ describe('AuthHttpInterceptor', () => { true ); - const response = await firstValueFrom(httpClient.get(actionUrl)); - expect(response).toBeTruthy(); + const test$ = httpClient.get(actionUrl).pipe( + share({ + connector: () => new ReplaySubject(), + resetOnComplete: false, + resetOnError: false, + resetOnRefCountZero: false, + }) + ); + + test$.subscribe(); const httpRequest = httpTestingController.expectOne(actionUrl); + httpRequest.flush('something'); + expect(httpRequest.request.headers.has('Authorization')).toEqual(true); - httpRequest.flush('something'); + const response = await firstValueFrom(test$); + + expect(response).toBeTruthy(); + httpTestingController.verify(); }); @@ -132,14 +144,26 @@ describe('AuthHttpInterceptor', () => { true ); - const response = await firstValueFrom(httpClient.get(actionUrl)); - expect(response).toBeTruthy(); + const test$ = httpClient.get(actionUrl).pipe( + share({ + connector: () => new ReplaySubject(), + resetOnComplete: false, + resetOnError: false, + resetOnRefCountZero: false, + }) + ); + + test$.subscribe(); const httpRequest = httpTestingController.expectOne(actionUrl); expect(httpRequest.request.headers.has('Authorization')).toEqual(false); httpRequest.flush('something'); + + const response = await firstValueFrom(test$); + expect(response).toBeTruthy(); + httpTestingController.verify(); }); @@ -159,15 +183,27 @@ describe('AuthHttpInterceptor', () => { vi.spyOn(authStateService, 'getAccessToken').mockReturnValue( 'thisIsAToken' ); + const test$ = httpClient.get(actionUrl).pipe( + share({ + connector: () => new ReplaySubject(), + resetOnComplete: false, + resetOnError: false, + resetOnRefCountZero: false, + }) + ); - const response = await firstValueFrom(httpClient.get(actionUrl)); - expect(response).toBeTruthy(); + test$.subscribe(); const httpRequest = httpTestingController.expectOne(actionUrl); expect(httpRequest.request.headers.has('Authorization')).toEqual(false); httpRequest.flush('something'); + + const response = await firstValueFrom(test$); + + expect(response).toBeTruthy(); + httpTestingController.verify(); }); @@ -185,14 +221,26 @@ describe('AuthHttpInterceptor', () => { true ); - const response = await firstValueFrom(httpClient.get(actionUrl)); - expect(response).toBeTruthy(); + const test$ = httpClient.get(actionUrl).pipe( + share({ + connector: () => new ReplaySubject(), + resetOnComplete: false, + resetOnError: false, + resetOnRefCountZero: false, + }) + ); + + test$.subscribe(); const httpRequest = httpTestingController.expectOne(actionUrl); expect(httpRequest.request.headers.has('Authorization')).toEqual(false); httpRequest.flush('something'); + + const response = await firstValueFrom(test$); + expect(response).toBeTruthy(); + httpTestingController.verify(); }); @@ -211,14 +259,27 @@ describe('AuthHttpInterceptor', () => { ); vi.spyOn(authStateService, 'getAccessToken').mockReturnValue(''); - const response = await firstValueFrom(httpClient.get(actionUrl)); - expect(response).toBeTruthy(); + const test$ = httpClient.get(actionUrl).pipe( + share({ + connector: () => new ReplaySubject(), + resetOnComplete: false, + resetOnError: false, + resetOnRefCountZero: false, + }) + ); + + test$.subscribe(); const httpRequest = httpTestingController.expectOne(actionUrl); expect(httpRequest.request.headers.has('Authorization')).toEqual(false); httpRequest.flush('something'); + + const response = await firstValueFrom(test$); + + expect(response).toBeTruthy(); + httpTestingController.verify(); }); @@ -229,14 +290,26 @@ describe('AuthHttpInterceptor', () => { false ); - const response = await firstValueFrom(httpClient.get(actionUrl)); - expect(response).toBeTruthy(); + const test$ = httpClient.get(actionUrl).pipe( + share({ + connector: () => new ReplaySubject(), + resetOnComplete: false, + resetOnError: false, + resetOnRefCountZero: false, + }) + ); + + test$.subscribe(); const httpRequest = httpTestingController.expectOne(actionUrl); expect(httpRequest.request.headers.has('Authorization')).toEqual(false); httpRequest.flush('something'); + + const response = await firstValueFrom(test$); + expect(response).toBeTruthy(); + httpTestingController.verify(); }); @@ -260,14 +333,25 @@ describe('AuthHttpInterceptor', () => { matchingConfig: null, }); - const response = await firstValueFrom(httpClient.get(actionUrl)); - expect(response).toBeTruthy(); + const test$ = httpClient.get(actionUrl).pipe( + share({ + connector: () => new ReplaySubject(), + resetOnComplete: false, + resetOnError: false, + resetOnRefCountZero: false, + }) + ); + + test$.subscribe(); const httpRequest = httpTestingController.expectOne(actionUrl); expect(httpRequest.request.headers.has('Authorization')).toEqual(false); httpRequest.flush('something'); + + const response = await firstValueFrom(test$); + expect(response).toBeTruthy(); httpTestingController.verify(); }); @@ -286,11 +370,25 @@ describe('AuthHttpInterceptor', () => { true ); - let response = await firstValueFrom(httpClient.get(actionUrl)); - expect(response).toBeTruthy(); + const test$ = httpClient.get(actionUrl).pipe( + share({ + connector: () => new ReplaySubject(), + resetOnComplete: false, + resetOnError: false, + resetOnRefCountZero: false, + }) + ); + const test2$ = httpClient.get(actionUrl2).pipe( + share({ + connector: () => new ReplaySubject(), + resetOnComplete: false, + resetOnError: false, + resetOnRefCountZero: false, + }) + ); - response = await firstValueFrom(httpClient.get(actionUrl2)); - expect(response).toBeTruthy(); + test$.subscribe(); + test2$.subscribe(); const httpRequest = httpTestingController.expectOne(actionUrl); @@ -302,6 +400,14 @@ describe('AuthHttpInterceptor', () => { httpRequest.flush('something'); httpRequest2.flush('something'); + + const [response, response2] = await Promise.all([ + firstValueFrom(test$), + firstValueFrom(test2$), + ]); + expect(response).toBeTruthy(); + expect(response2).toBeTruthy(); + httpTestingController.verify(); }); } diff --git a/src/interceptor/auth.interceptor.ts b/src/interceptor/auth.interceptor.ts index fee8365..6e5d009 100644 --- a/src/interceptor/auth.interceptor.ts +++ b/src/interceptor/auth.interceptor.ts @@ -40,13 +40,14 @@ export class AuthInterceptor implements HttpInterceptor { } export function authInterceptor(): HttpInterceptorFn { + const deps = { + configurationService: inject(ConfigurationService), + authStateService: inject(AuthStateService), + closestMatchingRouteService: inject(ClosestMatchingRouteService), + loggerService: inject(LoggerService), + }; return (req, next) => { - return interceptRequest(req, next, { - configurationService: inject(ConfigurationService), - authStateService: inject(AuthStateService), - closestMatchingRouteService: inject(ClosestMatchingRouteService), - loggerService: inject(LoggerService), - }); + return interceptRequest(req, next, deps); }; } diff --git a/src/testing/http.ts b/src/testing/http.ts index c94b2b9..4acf744 100644 --- a/src/testing/http.ts +++ b/src/testing/http.ts @@ -1,9 +1,21 @@ -import { HttpFeatureKind } from '@ngify/http'; import { HttpClientTestingBackend } from '@ngify/http/testing'; +import { InjectionToken, type Provider } from 'injection-js'; +import { HTTP_BACKEND, provideHttpClient } from 'oidc-client-rx'; -export function provideHttpClientTesting() { - return { - provide: HttpFeatureKind.Backend, - useClass: HttpClientTestingBackend, - }; +export const HTTP_CLIENT_TEST_CONTROLLER = + new InjectionToken('HTTP_CLIENT_TEST_CONTROLLER'); + +export function provideHttpClientTesting(): Provider[] { + const backend = new HttpClientTestingBackend(); + return [ + { + provide: HTTP_CLIENT_TEST_CONTROLLER, + useValue: backend, + }, + { + provide: HTTP_BACKEND, + useValue: backend, + }, + ...provideHttpClient(), + ]; } diff --git a/src/testing/index.ts b/src/testing/index.ts index 48ebc61..c458acb 100644 --- a/src/testing/index.ts +++ b/src/testing/index.ts @@ -6,4 +6,4 @@ export { } from './spy'; export { createRetriableStream } from './create-retriable-stream.helper'; export { MockRouter, mockRouterProvider } from './router'; -export { provideHttpClientTesting } from './http'; +export { provideHttpClientTesting, HTTP_CLIENT_TEST_CONTROLLER } from './http'; diff --git a/src/utils/types/index.ts b/src/utils/types/index.ts new file mode 100644 index 0000000..a572687 --- /dev/null +++ b/src/utils/types/index.ts @@ -0,0 +1,3 @@ +export type ArrayOrOne = T[] | T; + +export type ArrayOrNullableOne = T[] | T | null | undefined;