|
| 1 | +import { Observable, lastValueFrom, toArray } from "rxjs"; |
| 2 | +import { BSVersionLibService } from "main/services/bs-version-lib.service"; |
| 3 | +import { BSVersion } from "shared/bs-version.interface"; |
| 4 | + |
| 5 | +jest.mock("electron-log", () => ({ info: jest.fn(), warn: jest.fn(), error: jest.fn() })); |
| 6 | +jest.mock("main/services/utils.service", () => ({ |
| 7 | + UtilsService: { getInstance: jest.fn(() => ({ getAssestsJsonsPath: jest.fn(() => "C:/assets") })) }, |
| 8 | +})); |
| 9 | +jest.mock("main/services/request.service", () => ({ |
| 10 | + RequestService: { getInstance: jest.fn(() => ({ getJSON: jest.fn() })) }, |
| 11 | +})); |
| 12 | +jest.mock("main/services/static-configuration.service", () => ({ |
| 13 | + StaticConfigurationService: { getInstance: jest.fn(() => ({ get: jest.fn(), set: jest.fn() })) }, |
| 14 | +})); |
| 15 | + |
| 16 | +type Deferred<T> = { |
| 17 | + promise: Promise<T>; |
| 18 | + resolve: (value: T) => void; |
| 19 | + reject: (reason?: unknown) => void; |
| 20 | +}; |
| 21 | + |
| 22 | +type JSONResponse = { |
| 23 | + data: unknown; |
| 24 | + headers: Record<string, string>; |
| 25 | +}; |
| 26 | + |
| 27 | +type BSVersionLibInternals = { |
| 28 | + REMOTE_BS_VERSIONS_TIMEOUT: number; |
| 29 | + cachedVersions: BSVersion[] | null; |
| 30 | + loadPromise: Promise<BSVersion[]> | null; |
| 31 | + refreshPromise: Promise<BSVersion[] | null> | null; |
| 32 | + requestService: { |
| 33 | + getJSON: jest.Mock; |
| 34 | + }; |
| 35 | + getLocalVersions: () => Promise<unknown>; |
| 36 | + updateLocalVersions: (versions: BSVersion[]) => Promise<void>; |
| 37 | +}; |
| 38 | + |
| 39 | +const LOCAL_VERSIONS: BSVersion[] = [ |
| 40 | + { BSVersion: "1.40.8", ReleaseDate: "2025-04-10" }, |
| 41 | +]; |
| 42 | + |
| 43 | +const REMOTE_VERSIONS: BSVersion[] = [ |
| 44 | + { BSVersion: "1.40.8", ReleaseDate: "2025-04-10" }, |
| 45 | + { BSVersion: "1.41.0", ReleaseDate: "2025-05-08" }, |
| 46 | +]; |
| 47 | + |
| 48 | +function deferred<T>(): Deferred<T> { |
| 49 | + let resolveDeferred: Deferred<T>["resolve"]; |
| 50 | + let rejectDeferred: Deferred<T>["reject"]; |
| 51 | + const promise = new Promise<T>((resolve, reject) => { |
| 52 | + resolveDeferred = resolve; |
| 53 | + rejectDeferred = reject; |
| 54 | + }); |
| 55 | + |
| 56 | + return { promise, resolve: resolveDeferred, reject: rejectDeferred }; |
| 57 | +} |
| 58 | + |
| 59 | +function nextEventLoopTurn(): Promise<void> { |
| 60 | + return new Promise(resolve => { |
| 61 | + setImmediate(resolve); |
| 62 | + }); |
| 63 | +} |
| 64 | + |
| 65 | +function collect<T>(stream: Observable<T>): Promise<T[]> { |
| 66 | + return lastValueFrom(stream.pipe(toArray())); |
| 67 | +} |
| 68 | + |
| 69 | +function createHarness( |
| 70 | + localResult: unknown | Promise<unknown> = LOCAL_VERSIONS, |
| 71 | + remoteRequest: jest.Mock = jest.fn().mockResolvedValue({ data: REMOTE_VERSIONS, headers: {} }) |
| 72 | +) { |
| 73 | + (BSVersionLibService as any).instance = undefined; |
| 74 | + const service = BSVersionLibService.getInstance(); |
| 75 | + const internals = service as unknown as BSVersionLibInternals; |
| 76 | + const getLocalVersions = jest.spyOn(internals, "getLocalVersions").mockReturnValue(Promise.resolve(localResult)); |
| 77 | + const updateLocalVersions = jest.spyOn(internals, "updateLocalVersions").mockResolvedValue(undefined); |
| 78 | + internals.requestService = { getJSON: remoteRequest }; |
| 79 | + |
| 80 | + return { service, internals, getLocalVersions, updateLocalVersions, remoteRequest }; |
| 81 | +} |
| 82 | + |
| 83 | +describe("BSVersionLibService cache-first refresh", () => { |
| 84 | + beforeEach(() => { |
| 85 | + jest.clearAllMocks(); |
| 86 | + (BSVersionLibService as any).instance = undefined; |
| 87 | + }); |
| 88 | + |
| 89 | + afterEach(() => { |
| 90 | + jest.restoreAllMocks(); |
| 91 | + (BSVersionLibService as any).instance = undefined; |
| 92 | + }); |
| 93 | + |
| 94 | + it("loads the cached catalog without starting a remote request", async () => { |
| 95 | + const { service, getLocalVersions, updateLocalVersions, remoteRequest } = createHarness(); |
| 96 | + |
| 97 | + await expect(collect(service.getAvailableVersions$())).resolves.toEqual([LOCAL_VERSIONS]); |
| 98 | + await expect(service.getCachedVersions()).resolves.toBe(LOCAL_VERSIONS); |
| 99 | + expect(getLocalVersions).toHaveBeenCalledTimes(1); |
| 100 | + expect(remoteRequest).not.toHaveBeenCalled(); |
| 101 | + expect(updateLocalVersions).not.toHaveBeenCalled(); |
| 102 | + }); |
| 103 | + |
| 104 | + it("normalizes an invalid local catalog without starting a remote request", async () => { |
| 105 | + const { service, updateLocalVersions, remoteRequest } = createHarness([{ ReleaseDate: "2025-04-10" }]); |
| 106 | + |
| 107 | + await expect(collect(service.getAvailableVersions$())).resolves.toEqual([[]]); |
| 108 | + await expect(service.getCachedVersions()).resolves.toEqual([]); |
| 109 | + expect(remoteRequest).not.toHaveBeenCalled(); |
| 110 | + expect(updateLocalVersions).not.toHaveBeenCalled(); |
| 111 | + }); |
| 112 | + |
| 113 | + it("emits the local catalog before the next event-loop turn when the remote request never settles", async () => { |
| 114 | + const remoteRequest = jest.fn(() => new Promise<JSONResponse>(() => { |
| 115 | + // Intentionally left pending to reproduce the stalled network request. |
| 116 | + })); |
| 117 | + const { service, getLocalVersions, updateLocalVersions } = createHarness(LOCAL_VERSIONS, remoteRequest); |
| 118 | + const timeout = jest.spyOn(global, "setTimeout").mockImplementation((() => 0) as unknown as typeof setTimeout); |
| 119 | + const emissions: BSVersion[][] = []; |
| 120 | + let completed = false; |
| 121 | + let streamError: unknown; |
| 122 | + |
| 123 | + const subscription = service.getAvailableVersions$(true).subscribe({ |
| 124 | + next: versions => emissions.push(versions), |
| 125 | + error: error => { streamError = error; }, |
| 126 | + complete: () => { completed = true; }, |
| 127 | + }); |
| 128 | + |
| 129 | + try { |
| 130 | + await nextEventLoopTurn(); |
| 131 | + |
| 132 | + expect(emissions).toEqual([LOCAL_VERSIONS]); |
| 133 | + expect(completed).toBe(false); |
| 134 | + expect(streamError).toBeUndefined(); |
| 135 | + expect(getLocalVersions).toHaveBeenCalledTimes(1); |
| 136 | + expect(remoteRequest).toHaveBeenCalledTimes(1); |
| 137 | + expect(remoteRequest).toHaveBeenCalledWith( |
| 138 | + expect.any(String), |
| 139 | + { signal: expect.any(AbortSignal), retryLimit: 0 } |
| 140 | + ); |
| 141 | + expect(updateLocalVersions).not.toHaveBeenCalled(); |
| 142 | + } finally { |
| 143 | + subscription.unsubscribe(); |
| 144 | + timeout.mockRestore(); |
| 145 | + } |
| 146 | + }); |
| 147 | + |
| 148 | + it("emits a successful remote refresh, updates memory, and persists it", async () => { |
| 149 | + const remote = deferred<JSONResponse>(); |
| 150 | + const remoteRequest = jest.fn(() => remote.promise); |
| 151 | + const { service, getLocalVersions, updateLocalVersions } = createHarness(LOCAL_VERSIONS, remoteRequest); |
| 152 | + const emissionsPromise = collect(service.getAvailableVersions$(true)); |
| 153 | + |
| 154 | + await nextEventLoopTurn(); |
| 155 | + remote.resolve({ data: REMOTE_VERSIONS, headers: {} }); |
| 156 | + |
| 157 | + await expect(emissionsPromise).resolves.toEqual([LOCAL_VERSIONS, REMOTE_VERSIONS]); |
| 158 | + await expect(service.getCachedVersions()).resolves.toBe(REMOTE_VERSIONS); |
| 159 | + expect(getLocalVersions).toHaveBeenCalledTimes(1); |
| 160 | + expect(remoteRequest).toHaveBeenCalledTimes(1); |
| 161 | + expect(updateLocalVersions).toHaveBeenCalledTimes(1); |
| 162 | + expect(updateLocalVersions).toHaveBeenCalledWith(REMOTE_VERSIONS); |
| 163 | + }); |
| 164 | + |
| 165 | + it("deduplicates concurrent streams, the local read, and the remote request", async () => { |
| 166 | + const local = deferred<BSVersion[]>(); |
| 167 | + const remote = deferred<JSONResponse>(); |
| 168 | + const remoteRequest = jest.fn(() => remote.promise); |
| 169 | + const { service, getLocalVersions, updateLocalVersions } = createHarness(local.promise, remoteRequest); |
| 170 | + |
| 171 | + const firstStream = collect(service.getAvailableVersions$(true)); |
| 172 | + const secondStream = collect(service.getAvailableVersions$(true)); |
| 173 | + |
| 174 | + expect(getLocalVersions).toHaveBeenCalledTimes(1); |
| 175 | + expect(remoteRequest).not.toHaveBeenCalled(); |
| 176 | + |
| 177 | + local.resolve(LOCAL_VERSIONS); |
| 178 | + await nextEventLoopTurn(); |
| 179 | + |
| 180 | + expect(remoteRequest).toHaveBeenCalledTimes(1); |
| 181 | + remote.resolve({ data: REMOTE_VERSIONS, headers: {} }); |
| 182 | + |
| 183 | + await expect(Promise.all([firstStream, secondStream])).resolves.toEqual([ |
| 184 | + [LOCAL_VERSIONS, REMOTE_VERSIONS], |
| 185 | + [LOCAL_VERSIONS, REMOTE_VERSIONS], |
| 186 | + ]); |
| 187 | + expect(getLocalVersions).toHaveBeenCalledTimes(1); |
| 188 | + expect(remoteRequest).toHaveBeenCalledTimes(1); |
| 189 | + expect(updateLocalVersions).toHaveBeenCalledTimes(1); |
| 190 | + }); |
| 191 | + |
| 192 | + it("recovers from a local read failure with a valid remote catalog", async () => { |
| 193 | + const localError = Promise.reject<unknown>(new Error("corrupted local catalog")); |
| 194 | + const { service, getLocalVersions, updateLocalVersions, remoteRequest } = createHarness(localError); |
| 195 | + |
| 196 | + await expect(collect(service.getAvailableVersions$(true))).resolves.toEqual([ |
| 197 | + [], |
| 198 | + REMOTE_VERSIONS, |
| 199 | + ]); |
| 200 | + await expect(service.getCachedVersions()).resolves.toBe(REMOTE_VERSIONS); |
| 201 | + expect(getLocalVersions).toHaveBeenCalledTimes(1); |
| 202 | + expect(remoteRequest).toHaveBeenCalledTimes(1); |
| 203 | + expect(updateLocalVersions).toHaveBeenCalledWith(REMOTE_VERSIONS); |
| 204 | + }); |
| 205 | + |
| 206 | + it.each([ |
| 207 | + ["a network error", () => Promise.reject(new Error("offline"))], |
| 208 | + ["an empty remote catalog", () => Promise.resolve({ data: [], headers: {} })], |
| 209 | + ["an invalid remote catalog", () => Promise.resolve({ data: [{}], headers: {} })], |
| 210 | + ["an unchanged remote catalog", () => Promise.resolve({ data: LOCAL_VERSIONS.map(version => ({ ...version })), headers: {} })], |
| 211 | + ])("keeps only the local emission without persistence after %s", async (_label, request) => { |
| 212 | + const remoteRequest = jest.fn(request); |
| 213 | + const { service, updateLocalVersions } = createHarness(LOCAL_VERSIONS, remoteRequest); |
| 214 | + |
| 215 | + await expect(collect(service.getAvailableVersions$(true))).resolves.toEqual([LOCAL_VERSIONS]); |
| 216 | + await expect(service.getCachedVersions()).resolves.toBe(LOCAL_VERSIONS); |
| 217 | + expect(updateLocalVersions).not.toHaveBeenCalled(); |
| 218 | + }); |
| 219 | + |
| 220 | + it("aborts a timed-out refresh, then completes with only the local catalog", async () => { |
| 221 | + const remoteRequest = jest.fn((_url: string, options: { signal: AbortSignal }) => ( |
| 222 | + new Promise<JSONResponse>((_resolve, reject) => { |
| 223 | + options.signal.addEventListener("abort", () => reject(new Error("aborted")), { once: true }); |
| 224 | + }) |
| 225 | + )); |
| 226 | + const { service, internals, updateLocalVersions } = createHarness(LOCAL_VERSIONS, remoteRequest); |
| 227 | + internals.REMOTE_BS_VERSIONS_TIMEOUT = 0; |
| 228 | + |
| 229 | + await expect(collect(service.getAvailableVersions$(true))).resolves.toEqual([LOCAL_VERSIONS]); |
| 230 | + expect(remoteRequest.mock.calls[0][1].signal.aborted).toBe(true); |
| 231 | + expect(updateLocalVersions).not.toHaveBeenCalled(); |
| 232 | + }); |
| 233 | + |
| 234 | + it("keeps the remote emission and memory cache when persistence fails", async () => { |
| 235 | + const remoteRequest = jest.fn().mockResolvedValue({ data: REMOTE_VERSIONS, headers: {} }); |
| 236 | + const { service, updateLocalVersions } = createHarness(LOCAL_VERSIONS, remoteRequest); |
| 237 | + updateLocalVersions.mockRejectedValue(new Error("read-only installation")); |
| 238 | + |
| 239 | + await expect(collect(service.getAvailableVersions$(true))).resolves.toEqual([ |
| 240 | + LOCAL_VERSIONS, |
| 241 | + REMOTE_VERSIONS, |
| 242 | + ]); |
| 243 | + await expect(service.getCachedVersions()).resolves.toBe(REMOTE_VERSIONS); |
| 244 | + expect(updateLocalVersions).toHaveBeenCalledTimes(1); |
| 245 | + expect(updateLocalVersions).toHaveBeenCalledWith(REMOTE_VERSIONS); |
| 246 | + }); |
| 247 | +}); |
0 commit comments