import { FanavaranAuthService } from "./fanavaran-auth.service"; describe("FanavaranAuthService", () => { const createAuthTokenModel = () => { const store = new Map< string, { clientKey: string; authenticationToken: string; expiresAt: Date; authFingerprint?: string; } >(); return { findOne: jest.fn((query: { clientKey: string }) => ({ lean: () => ({ exec: async () => store.get(query.clientKey) ?? null, }), })), findOneAndUpdate: jest.fn( ( query: { clientKey: string }, update: { $set: { authenticationToken: string; expiresAt: Date; authFingerprint?: string; }; }, ) => ({ exec: async () => { const next = { clientKey: query.clientKey, authenticationToken: update.$set.authenticationToken, expiresAt: update.$set.expiresAt, authFingerprint: update.$set.authFingerprint, }; store.set(query.clientKey, next); return next; }, }), ), deleteOne: jest.fn((query: { clientKey: string }) => ({ exec: async () => { store.delete(query.clientKey); return { deletedCount: 1 }; }, })), _store: store, }; }; it("detects Fanavaran transient try-later messages", () => { expect( FanavaranAuthService.isTransientTryLaterError( "کد پیگیری خطا: 10573755\r\n1405/05/1016:56:16:276\r\n.لطفا پس از چند لحظه مجدد تلاش فرمایید.", ), ).toBe(true); expect( FanavaranAuthService.isTransientTryLaterError( "فیلد شماره بيمه نامه ضروری میباشد", ), ).toBe(false); }); it("caches token and single-flights concurrent logins", async () => { const http = { post: jest.fn(), }; const audit = { recordStep: jest.fn().mockResolvedValue(undefined), extractErrorMessage: (e: unknown) => e instanceof Error ? e.message : String(e), sanitizeErrorDetails: () => ({}), formatErrorWithTrackingCode: (m: string) => m, captureAxiosExchange: () => ({}), }; const authTokenModel = createAuthTokenModel(); let loginCalls = 0; const { of, delay } = await import("rxjs"); http.post.mockImplementation((url: string) => { if (url.includes("GetAppToken")) { return of({ status: 200, headers: { apptoken: "app-1" }, data: {}, }); } loginCalls += 1; return of({ status: 200, headers: { authenticationtoken: "auth-1" }, data: {}, }).pipe(delay(20)); }); const service = new FanavaranAuthService( http as any, audit as any, authTokenModel as any, ); const [a, b, c] = await Promise.all([ service.getAuthenticationToken("parsian"), service.getAuthenticationToken("parsian"), service.getAuthenticationToken("parsian"), ]); expect(a).toBe("auth-1"); expect(b).toBe("auth-1"); expect(c).toBe("auth-1"); expect(loginCalls).toBe(1); // Cache hit — no extra login await service.getAuthenticationToken("parsian"); expect(loginCalls).toBe(1); }); it("reuses persisted token across service instances", async () => { const http = { post: jest.fn() }; const audit = { recordStep: jest.fn().mockResolvedValue(undefined), extractErrorMessage: (e: unknown) => e instanceof Error ? e.message : String(e), sanitizeErrorDetails: () => ({}), formatErrorWithTrackingCode: (m: string) => m, captureAxiosExchange: () => ({}), }; const authTokenModel = createAuthTokenModel(); const { of } = await import("rxjs"); let loginCalls = 0; http.post.mockImplementation((url: string) => { if (url.includes("GetAppToken")) { return of({ status: 200, headers: { apptoken: "app-1" }, data: {}, }); } loginCalls += 1; return of({ status: 200, headers: { authenticationtoken: "auth-persisted" }, data: {}, }); }); const first = new FanavaranAuthService( http as any, audit as any, authTokenModel as any, ); await first.getAuthenticationToken("tejaratno"); expect(loginCalls).toBe(1); const second = new FanavaranAuthService( http as any, audit as any, authTokenModel as any, ); const token = await second.getAuthenticationToken("tejaratno"); expect(token).toBe("auth-persisted"); expect(loginCalls).toBe(1); }); it("enters tenant backoff on try-later errors", () => { const service = new FanavaranAuthService( {} as any, {} as any, createAuthTokenModel() as any, ); service.registerFailure( "parsian", "کد پیگیری خطا: 1\r\n.لطفا پس از چند لحظه مجدد تلاش فرمایید.", ); expect(service.isInBackoff("parsian")).toBe(true); expect(() => service.assertNotInBackoff("parsian")).toThrow(/backoff/i); }); it("computes next Asia/Tehran midnight expiry after now", () => { const now = Date.parse("2026-08-02T10:00:00.000Z"); const expiry = FanavaranAuthService.getNextMidnightExpiryMs(now); expect(expiry).toBeGreaterThan(now); expect(expiry - now).toBeLessThanOrEqual(24 * 60 * 60 * 1000); expect(expiry - now).toBeGreaterThan(0); const justAfterMidnight = Date.parse("2026-08-01T20:30:01.000Z"); const next = FanavaranAuthService.getNextMidnightExpiryMs(justAfterMidnight); expect(next - justAfterMidnight).toBeGreaterThan(23 * 60 * 60 * 1000); }); });