import { describe, expect, it, jest } from "@jest/globals"; import { ProxyResolver, buildProxyUrl } from "../../../src/service/networkProviders/ProxyResolver.ts"; import type { Monitor } from "../../../src/domain/monitors/monitor.type.ts"; import type { Proxy } from "../../../src/domain/proxies/proxy.type.ts"; import { createMockLogger } from "../../helpers/createMockLogger.ts"; const makeMonitor = (overrides?: Partial): Monitor => ({ id: "m1", teamId: "team", type: "http", proxyMode: "inherit", ...overrides, }) as Monitor; const makeProxy = (overrides?: Partial): Proxy => ({ id: "p1", teamId: "team", name: "corp", protocol: "http", host: "proxy.example.com", port: 8080, ...overrides, }) as Proxy; const createResolver = (overrides?: Record) => { const defaults = { proxiesRepository: { findByIdOrNull: jest.fn<() => Promise>().mockResolvedValue(makeProxy()) }, settingsService: { getCachedDBSettings: jest.fn<() => Promise>().mockResolvedValue({ globalProxyEnabled: true, globalProxyId: "p1" }) }, logger: createMockLogger(), ttlMs: 60_000, ...overrides, }; const resolver = new ProxyResolver(defaults.proxiesRepository as any, defaults.settingsService as any, defaults.logger as any, defaults.ttlMs); return { resolver, defaults }; }; describe("ProxyResolver", () => { // ── gates ───────────────────────────────────────────────────────────────── it("returns undefined for non-http monitors without touching repo or settings", async () => { const { resolver, defaults } = createResolver(); const result = await resolver.resolve(makeMonitor({ type: "port", proxyMode: "custom", proxyId: "p1" } as Partial)); expect(result).toBeUndefined(); expect(defaults.proxiesRepository.findByIdOrNull).not.toHaveBeenCalled(); expect(defaults.settingsService.getCachedDBSettings).not.toHaveBeenCalled(); }); it("returns undefined for proxyMode 'none' without calling settings", async () => { const { resolver, defaults } = createResolver(); const result = await resolver.resolve(makeMonitor({ proxyMode: "none" })); expect(result).toBeUndefined(); expect(defaults.settingsService.getCachedDBSettings).not.toHaveBeenCalled(); }); // ── custom mode ─────────────────────────────────────────────────────────── it("resolves a custom proxy with a team-scoped lookup", async () => { const { resolver, defaults } = createResolver(); const result = await resolver.resolve(makeMonitor({ proxyMode: "custom", proxyId: "p1" })); expect(result).toBe("http://proxy.example.com:8080"); expect(defaults.proxiesRepository.findByIdOrNull).toHaveBeenCalledWith("p1", "team"); }); it("returns undefined and warns when the custom proxy no longer exists", async () => { const { resolver, defaults } = createResolver({ proxiesRepository: { findByIdOrNull: jest.fn<() => Promise>().mockResolvedValue(null) }, }); const result = await resolver.resolve(makeMonitor({ proxyMode: "custom", proxyId: "gone" })); expect(result).toBeUndefined(); expect(defaults.logger.warn).toHaveBeenCalled(); }); it("returns undefined and warns when proxyMode is custom but proxyId is missing, without a repo call", async () => { const { resolver, defaults } = createResolver(); const result = await resolver.resolve(makeMonitor({ proxyMode: "custom", proxyId: undefined })); expect(result).toBeUndefined(); expect(defaults.logger.warn).toHaveBeenCalled(); expect(defaults.proxiesRepository.findByIdOrNull).not.toHaveBeenCalled(); }); // ── inherit mode ────────────────────────────────────────────────────────── it("returns undefined when the global proxy is disabled, without a repo call", async () => { const { resolver, defaults } = createResolver({ settingsService: { getCachedDBSettings: jest.fn<() => Promise>().mockResolvedValue({ globalProxyEnabled: false, globalProxyId: "p1" }) }, }); const result = await resolver.resolve(makeMonitor({ proxyMode: "inherit" })); expect(result).toBeUndefined(); expect(defaults.proxiesRepository.findByIdOrNull).not.toHaveBeenCalled(); }); it("resolves the global proxy with an unscoped lookup when enabled", async () => { const { resolver, defaults } = createResolver(); const result = await resolver.resolve(makeMonitor({ proxyMode: "inherit" })); expect(result).toBe("http://proxy.example.com:8080"); expect(defaults.proxiesRepository.findByIdOrNull).toHaveBeenCalledWith("p1"); }); it("returns undefined when the global proxy is enabled but globalProxyId is null, without a repo call", async () => { const { resolver, defaults } = createResolver({ settingsService: { getCachedDBSettings: jest.fn<() => Promise>().mockResolvedValue({ globalProxyEnabled: true, globalProxyId: null }) }, }); const result = await resolver.resolve(makeMonitor({ proxyMode: "inherit" })); expect(result).toBeUndefined(); expect(defaults.proxiesRepository.findByIdOrNull).not.toHaveBeenCalled(); }); it("returns undefined and warns when the global proxy references a proxy that no longer exists", async () => { const { resolver, defaults } = createResolver({ proxiesRepository: { findByIdOrNull: jest.fn<() => Promise>().mockResolvedValue(null) }, }); const result = await resolver.resolve(makeMonitor({ proxyMode: "inherit" })); expect(result).toBeUndefined(); expect(defaults.logger.warn).toHaveBeenCalled(); }); it("treats an unknown proxyMode as inherit", async () => { const { resolver, defaults } = createResolver(); const result = await resolver.resolve(makeMonitor({ proxyMode: "garbage" as Monitor["proxyMode"] })); expect(result).toBe("http://proxy.example.com:8080"); expect(defaults.settingsService.getCachedDBSettings).toHaveBeenCalled(); }); // ── never throws ────────────────────────────────────────────────────────── it("returns undefined and warns when the repository throws", async () => { const { resolver, defaults } = createResolver({ proxiesRepository: { findByIdOrNull: jest.fn<() => Promise>().mockRejectedValue(new Error("db down")) }, }); const result = await resolver.resolve(makeMonitor({ proxyMode: "custom", proxyId: "p1" })); expect(result).toBeUndefined(); expect(defaults.logger.warn).toHaveBeenCalled(); }); it("returns undefined and warns when the settings service throws", async () => { const { resolver, defaults } = createResolver({ settingsService: { getCachedDBSettings: jest.fn<() => Promise>().mockRejectedValue(new Error("db down")) }, }); const result = await resolver.resolve(makeMonitor({ proxyMode: "inherit" })); expect(result).toBeUndefined(); expect(defaults.logger.warn).toHaveBeenCalled(); }); // ── caching ─────────────────────────────────────────────────────────────── it("serves a second resolve within the TTL from cache", async () => { const { resolver, defaults } = createResolver(); await resolver.resolve(makeMonitor({ proxyMode: "inherit" })); await resolver.resolve(makeMonitor({ proxyMode: "inherit" })); expect(defaults.proxiesRepository.findByIdOrNull).toHaveBeenCalledTimes(1); }); it("refetches after the TTL expires", async () => { const { resolver, defaults } = createResolver({ ttlMs: 0 }); await resolver.resolve(makeMonitor({ proxyMode: "inherit" })); await resolver.resolve(makeMonitor({ proxyMode: "inherit" })); expect(defaults.proxiesRepository.findByIdOrNull).toHaveBeenCalledTimes(2); }); it("negative-caches a dangling ref, costing one repo call per TTL window", async () => { const { resolver, defaults } = createResolver({ proxiesRepository: { findByIdOrNull: jest.fn<() => Promise>().mockResolvedValue(null) }, }); await resolver.resolve(makeMonitor({ proxyMode: "custom", proxyId: "gone" })); await resolver.resolve(makeMonitor({ proxyMode: "custom", proxyId: "gone" })); expect(defaults.proxiesRepository.findByIdOrNull).toHaveBeenCalledTimes(1); }); it("shares one in-flight fetch between concurrent resolves", async () => { const { resolver, defaults } = createResolver(); await Promise.all([ resolver.resolve(makeMonitor({ proxyMode: "inherit" })), resolver.resolve(makeMonitor({ proxyMode: "inherit" })), resolver.resolve(makeMonitor({ proxyMode: "inherit" })), ]); expect(defaults.proxiesRepository.findByIdOrNull).toHaveBeenCalledTimes(1); }); it("does not cache a rejected fetch — the next resolve retries", async () => { const findByIdOrNull = jest.fn<() => Promise>().mockRejectedValueOnce(new Error("db down")).mockResolvedValueOnce(makeProxy()); const { resolver } = createResolver({ proxiesRepository: { findByIdOrNull } }); const first = await resolver.resolve(makeMonitor({ proxyMode: "custom", proxyId: "p1" })); const second = await resolver.resolve(makeMonitor({ proxyMode: "custom", proxyId: "p1" })); expect(first).toBeUndefined(); expect(second).toBe("http://proxy.example.com:8080"); expect(findByIdOrNull).toHaveBeenCalledTimes(2); }); it("does not let a scoped miss poison the unscoped global lookup for the same proxy id", async () => { const findByIdOrNull = jest.fn<(proxyId: string, teamId?: string) => Promise>(async (_proxyId, teamId) => teamId ? null : makeProxy() ); const { resolver } = createResolver({ proxiesRepository: { findByIdOrNull } }); const customResult = await resolver.resolve(makeMonitor({ proxyMode: "custom", proxyId: "p1" })); const inheritResult = await resolver.resolve(makeMonitor({ proxyMode: "inherit" })); expect(customResult).toBeUndefined(); expect(inheritResult).toBe("http://proxy.example.com:8080"); expect(findByIdOrNull).toHaveBeenCalledTimes(2); }); }); describe("buildProxyUrl", () => { it("builds a URL without credentials when there is no username", () => { expect(buildProxyUrl(makeProxy())).toBe("http://proxy.example.com:8080"); }); it("builds a URL with username and password", () => { expect(buildProxyUrl(makeProxy({ username: "user", password: "pass" }))).toBe("http://user:pass@proxy.example.com:8080"); }); it("percent-encodes credentials", () => { expect(buildProxyUrl(makeProxy({ username: "user@x", password: "p@ss" }))).toBe("http://user%40x:p%40ss@proxy.example.com:8080"); }); it("builds a URL with a username only, without a colon", () => { expect(buildProxyUrl(makeProxy({ username: "user" }))).toBe("http://user@proxy.example.com:8080"); }); it("emits no credentials for a password without a username", () => { expect(buildProxyUrl(makeProxy({ password: "pass" }))).toBe("http://proxy.example.com:8080"); }); it("uses the https scheme without a trailing slash", () => { expect(buildProxyUrl(makeProxy({ protocol: "https" }))).toBe("https://proxy.example.com:8080"); }); });