diff --git a/README.md b/README.md index bd11ae9..2657a41 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,11 @@ const configInstance = await config({ configServerUrl: 'http://localhost:8080', schema: commonBoilerplateV4, version: 'latest', - offlineMode: false + offlineMode: false, + pollIntervalMs: 30000, + onChange: () => { + console.log('Configuration changed! The process will now restart...'); + } }); const port = configInstance.get('server.port'); @@ -36,14 +40,14 @@ This section describes the API provided by the package for interacting with the ### `ConfigInstance` -The `ConfigInstance` interface represents the your way to interact with the configuration. It provides methods to retrieve configuration values and parts. -`T` is the typescript type associated with the chosen schema. it can be imported from the `@map-colonies/schemas` package. +The `ConfigInstance` interface represents your way to interact with the configuration. +`T` is the typescript type associated with the chosen schema. It can be imported from the `@map-colonies/schemas` package. #### Methods ##### `get(path: TPath): _.GetFieldType` -- **Description**: Retrieves the value at the specified path from the configuration object. Note that the type of returned object is based on the path in the schema. +- **Description**: Retrieves the value at the specified path from the configuration object. - **Parameters**: - `path` (`TPath`): The path to the desired value. - **Returns**: The value at the specified path. @@ -55,7 +59,7 @@ The `ConfigInstance` interface represents the your way to interact with the conf ##### `getConfigParts(): { localConfig: object; config: object; envConfig: object }` -- **Description**: Retrieves different parts of the configuration object before being merged and validated. Useful for debugging. +- **Description**: Retrieves different parts of the configuration object before being merged and validated. - **Returns**: An object containing the `localConfig`, `config`, and `envConfig` parts of the configuration. - `localConfig`: The local configuration object. - `config`: The remote configuration object. @@ -71,6 +75,9 @@ The `ConfigInstance` interface represents the your way to interact with the conf - **Parameters**: - `registry` (`promClient.Registry`): The prometheus registry to use for the metrics. +##### `stop(): void` +- **Description**: Stops any background processes (like hot-reloading polling). Use this during application teardown or in tests to prevent memory leaks and hanging processes. + # Configuration Options This package allows you to configure various options for loading and managing configurations. Below are the available options and their descriptions. @@ -121,6 +128,18 @@ This package allows you to configure various options for loading and managing co - **Default**: `./config` - **Description**: The path to the local configuration folder. +### `pollIntervalMs` +- **Type**: `number` +- **Optional**: `true` +- **Default**: `30000` +- **Description**: The polling interval in milliseconds for hot-reloading. +- **Environment Variable**: `CONFIG_POLL_INTERVAL_MS` + +### `onChange` +- **Type**: `(config: T) => void | Promise` +- **Optional**: `true` +- **Description**: A callback function triggered when a configuration change is detected. + ## Environment Variable Configuration The following environment variables can be used to configure the options: @@ -130,6 +149,7 @@ The following environment variables can be used to configure the options: - `CONFIG_SERVER_URL`: Sets the `configServerUrl` option. - `CONFIG_OFFLINE_MODE`: Sets the `offlineMode` option. - `CONFIG_IGNORE_SERVER_IS_OLDER_VERSION_ERROR`: Sets the `ignoreServerIsOlderVersionError` option. +- `CONFIG_POLL_INTERVAL_MS`: Sets the `pollIntervalMs` option. ## Configuration Merging and Validation @@ -143,6 +163,7 @@ The package supports merging configurations from multiple sources (local, remote 1. The remote configuration is fetched from the server specified by the `configServerUrl` option. 2. If the `version` is set to `'latest'`, the latest version of the configuration is fetched. Otherwise, the specified version is fetched. +3. **Continuous Polling:** The SDK continuously polls the server using HTTP ETags (`If-None-Match`). When a `200 OK` is received (indicating a change), the `onChange` callback is triggered (if provided), and the process is then terminated to allow for a fresh start with the new configuration. `304 Not Modified` responses are silently ignored. ### Environment Variables @@ -164,7 +185,7 @@ If the value of the `x-env-format` key is `json`, the environment variable value 1. After merging, the final configuration is validated against the defined schema using ajv. 2. The validation ensures that all required properties are present, and the types and values of properties conform to the schema. 3. Any default value according to the schema is added to the final object. -4. If the validation fails, an error is thrown, indicating the invalid properties and their issues. +4. If the validation fails, an error is thrown (for initial boot). # Error handling diff --git a/src/config.ts b/src/config.ts index 625558e..2e68865 100644 --- a/src/config.ts +++ b/src/config.ts @@ -15,6 +15,7 @@ import { LOCAL_SCHEMAS_PACKAGE_VERSION } from './constants'; import { createConfigError } from './errors'; import { initializeMetrics as initializeMetricsInternal } from './metrics'; import { deepFreeze } from './utils/helpers'; +import { ChangeDetector } from './rollout/ChangeDetector'; const debug = createDebug('config'); @@ -25,22 +26,59 @@ const semverSatisfies = '2.x'; /** * Retrieves the configuration based on the provided options. * + * If `offlineMode` is not enabled, the SDK starts a background + * polling mechanism. When a configuration change is detected, the SDK + * will execute the `onChange` callback (if provided). + * Depending on the `terminatePod` option (default `true`), it will either + * stop polling and wait for the user to terminate the process, or continue polling. + * * @template T - The type of the configuration schema. * @param {ConfigOptions} options - The options for retrieving the configuration. - * @returns {Promise>} - A promise that resolves to the configuration object. + * @returns {Promise>} - A promise that resolves to the configuration object. */ export async function config( options: ConfigOptions ): Promise> { // handle package options debug('config called with options: %j', { ...options, schema: options.schema.$id }); - const { schema: baseSchema, metricsRegistry, ...unvalidatedOptions } = options; - const { configName, offlineMode, version, ignoreServerIsOlderVersionError } = initializeOptions(unvalidatedOptions); + const { schema: baseSchema, metricsRegistry, onChange, ...unvalidatedOptions } = options; + const initOptions = initializeOptions(unvalidatedOptions); + const { configName, offlineMode, version, ignoreServerIsOlderVersionError } = initOptions; - let remoteConfig: object | T = {}; + // Load Local and Env Configs First (Independent of remote state) + const dereferencedSchema = await loadSchema(baseSchema); + const localConfig = configPkg.util.loadFileConfigs(options.localConfigPath) as { [key: string]: unknown }; + debug('local config: %j', localConfig); + const envConfig = getEnvValues(dereferencedSchema); + debug('env config: %j', envConfig); + /** + * Function to merge local, remote, and environment configs and validate the merged result against the schema. + * The precedence order for merging is: local < remote < environment. + */ + function mergeAndValidate(remoteConfig: object | T): unknown { + const mergedConfig = deepmerge.all([localConfig, remoteConfig, envConfig], { arrayMerge }); + debug('merged config: %j', mergedConfig); + + // validate the merged config + const [errors, validatedConfig] = validate(ajvConfigValidator, dereferencedSchema, mergedConfig); + if (errors) { + debug('config validation error: %j', errors); + throw createConfigError('configValidationError', 'Config validation error', errors); + } + + debug('freezing validated config'); + // freeze the merged config so it can't be modified by the package user and to ensure that the config instance always returns the same reference for the same config, which is important for change detection and to prevent unnecessary re-renders in case the config is used in a React application and the user is using the getConfigParts method to get the config and pass it to their components + deepFreeze(validatedConfig); + return validatedConfig; + } + + let changeDetector: ChangeDetector | undefined = undefined; + let remoteConfig: object | T = {}; let serverConfigResponse: Config | undefined = undefined; - // handle remote config + let validatedConfig: ReturnType; + + // Handle Remote Config and Polling if (offlineMode !== true) { debug('handling fetching remote data'); // check if the server is using an older version of the schemas package @@ -70,8 +108,10 @@ export async function config( ); } - // get the remote config - serverConfigResponse = await getRemoteConfig(configName, options.schema.$id, version); + // get the initial remote config + const remoteResponse = await getRemoteConfig(configName, options.schema.$id, version); + serverConfigResponse = remoteResponse.config!; + const currentEtag = remoteResponse.etag; if (serverConfigResponse.schemaId !== baseSchema.$id) { debug('schema version mismatch. local: %s, remote: %s', baseSchema.$id, serverConfigResponse.schemaId); @@ -86,32 +126,18 @@ export async function config( } remoteConfig = serverConfigResponse.config; - } - debug('remote config: %j', remoteConfig); + debug('remote config: %j', remoteConfig); - const dereferencedSchema = await loadSchema(baseSchema); + validatedConfig = mergeAndValidate(remoteConfig); - const localConfig = configPkg.util.loadFileConfigs(options.localConfigPath) as { [key: string]: unknown }; - debug('local config: %j', localConfig); - - const envConfig = getEnvValues(dereferencedSchema); - debug('env config: %j', envConfig); - - // merge all the configs into one object with the following priority: localConfig < remoteConfig < envConfig - const mergedConfig = deepmerge.all([localConfig, remoteConfig, envConfig], { arrayMerge }); - debug('merged config: %j', mergedConfig); - - // validate the merged config - const [errors, validatedConfig] = validate(ajvConfigValidator, dereferencedSchema, mergedConfig); - if (errors) { - debug('config validation error: %j', errors); - throw createConfigError('configValidationError', 'Config validation error', errors); + // Setup polling + changeDetector = new ChangeDetector(baseSchema.$id, initOptions, currentEtag, onChange); + changeDetector.start(); + } else { + // If offline, bypass remote and just merge local/env + validatedConfig = mergeAndValidate({}); } - debug('freezing validated config'); - // freeze the merged config so it can't be modified by the package user - deepFreeze(validatedConfig); - function get(path: TPath): GetFieldType { debug('get called with path: %s', path); return lodash.get(validatedConfig as (typeof baseSchema)[typeof typeSymbol], path); @@ -140,5 +166,12 @@ export async function config( initializeMetricsInternal(registry, baseSchema.$id, serverConfigResponse?.version); } - return { get, getAll, getConfigParts, getResolvedOptions, initializeMetrics }; + function stop(): void { + debug('stop called'); + if (changeDetector) { + changeDetector.stop(); + } + } + + return { get, getAll, getConfigParts, getResolvedOptions, initializeMetrics, stop }; } diff --git a/src/httpClient.ts b/src/httpClient.ts index eef4279..e3f5053 100644 --- a/src/httpClient.ts +++ b/src/httpClient.ts @@ -15,10 +15,10 @@ async function createHttpErrorPayload(res: Dispatcher.ResponseData): Promise): Promise { +async function requestWrapper(url: string, options: Parameters>[1] = undefined): Promise { debug('Making request to %s', url); try { - const res = await request(url, { query }); + const res = await request(url, options); if (res.statusCode > statusCodes.NOT_FOUND) { debug('Failed to fetch config. Status code: %d', res.statusCode); throw createConfigError('httpResponseError', 'Failed to fetch', await createHttpErrorPayload(res)); @@ -33,12 +33,25 @@ async function requestWrapper(url: string, query?: Record): Pro } } -export async function getRemoteConfig(configName: string, schemaId: string, version: number | 'latest'): Promise { +export async function getRemoteConfig( + configName: string, + schemaId: string, + version: number | 'latest', + etag?: string +): Promise<{ config: Config | null; etag: string }> { debug('Fetching remote config %s@%s', configName, version); const { configServerUrl } = getOptions(); const url = `${configServerUrl}/config/${configName}/${version}`; - const res = await requestWrapper(url, { shouldDereference: true, schemaId }); + const headers = etag !== undefined ? { 'If-None-Match': etag } : undefined; + const queryParams = { schemaId, shouldDereference: 'true' }; + + const res = await requestWrapper(url, { query: queryParams, headers }); + + if (res.statusCode === statusCodes.NOT_MODIFIED) { + debug('Config was not modified'); + return { config: null, etag: etag! }; + } if (res.statusCode === statusCodes.BAD_REQUEST) { debug('Invalid request to getConfig'); @@ -51,7 +64,7 @@ export async function getRemoteConfig(configName: string, schemaId: string, vers } debug('Config fetched successfully'); - return (await res.body.json()) as Config; + return { config: (await res.body.json()) as Config, etag: res.headers.etag as string }; } export async function getServerCapabilities(): Promise { diff --git a/src/options.ts b/src/options.ts index 6ef024d..a40893b 100644 --- a/src/options.ts +++ b/src/options.ts @@ -11,6 +11,8 @@ const defaultOptions: BaseOptions = { configName: PACKAGE_NAME, configServerUrl: 'http://localhost:8080', version: 'latest', + pollIntervalMs: 30000, + terminatePod: true, }; const envOptions: Partial> = { @@ -19,6 +21,8 @@ const envOptions: Partial> = { version: process.env.CONFIG_VERSION, offlineMode: process.env.CONFIG_OFFLINE_MODE, ignoreServerIsOlderVersionError: process.env.CONFIG_IGNORE_SERVER_IS_OLDER_VERSION_ERROR, + pollIntervalMs: process.env.CONFIG_POLL_INTERVAL_MS, + terminatePod: process.env.CONFIG_TERMINATE_POD, }; // in order to merge correctly the keys should not exist, undefined is not enough diff --git a/src/rollout/ChangeDetector.ts b/src/rollout/ChangeDetector.ts new file mode 100644 index 0000000..4c72cbe --- /dev/null +++ b/src/rollout/ChangeDetector.ts @@ -0,0 +1,75 @@ +import { getRemoteConfig } from '../httpClient'; +import { BaseOptions } from '../types'; +import { createDebug } from '../utils/debug'; +import { isConfigError } from '../errors'; + +const debug = createDebug('changeDetector'); + +/** + * Class responsible for detecting changes in the remote configuration by periodically polling the server and comparing ETags. + * If a change is detected, it invokes the provided callback with the new configuration. + */ +export class ChangeDetector { + private currentEtag: string; + private timer?: NodeJS.Timeout; + + public constructor( + private readonly schemaId: string, + private readonly options: BaseOptions, + initialEtag: string, + private readonly onConfigUpdate?: () => void | Promise + ) { + this.currentEtag = initialEtag; + } + + public start(): void { + const interval = this.options.pollIntervalMs; + debug('Starting change detector with interval %d ms', interval); + + this.timer = setInterval(() => { + this.poll().catch((err) => { + debug('Error during polling: %s', (err as Error).message); + }); + }, interval); + } + + public stop(): void { + if (this.timer) { + clearInterval(this.timer); + this.timer = undefined; + } + } + + private async poll(): Promise { + debug('Polling config %s@%s with etag %s', this.options.configName, this.options.version, this.currentEtag); + + const response = await getRemoteConfig(this.options.configName, this.schemaId, this.options.version, this.currentEtag); + + if (response.config === null) { + debug('No config changes detected'); + return; + } + + debug('Config change detected. Stopping polling. New etag: %s', response.etag); + this.stop(); + try { + if (this.onConfigUpdate) { + await this.onConfigUpdate(); + } + } catch (err) { + if (isConfigError(err, 'httpResponseError') || isConfigError(err, 'httpGeneralError')) { + debug('Error during onChange callback: %s', err.message); + } else { + debug('Error during onChange callback: %s', err instanceof Error ? err.message : String(err)); + } + } finally { + if (this.options.terminatePod) { + debug('Pod termination is expected by the user.'); + } else { + this.currentEtag = response.etag; + debug('Pod termination is not expected.'); + this.start(); + } + } + } +} diff --git a/src/types.ts b/src/types.ts index 8854437..014da30 100644 --- a/src/types.ts +++ b/src/types.ts @@ -66,6 +66,16 @@ export interface BaseOptions { * @default './config' */ localConfigPath?: string; + /** + * The polling interval in milliseconds. + * @default 30000 + */ + pollIntervalMs?: number; + /** + * Indicates whether the pod will be terminated after an update. + * @default true + */ + terminatePod: boolean; } /** @@ -82,6 +92,10 @@ export type ConfigOptions = Prettify< * Depends on the prom-client package being installed. */ metricsRegistry?: Registry; + /** + * The callback function that is triggered when the configuration changes. + */ + onChange?: () => void | Promise; } >; @@ -101,11 +115,13 @@ export const optionsSchema: JSONSchemaType = { offlineMode: { type: 'boolean', nullable: true }, ignoreServerIsOlderVersionError: { type: 'boolean', nullable: true }, localConfigPath: { type: 'string', default: './config', nullable: true }, + pollIntervalMs: { type: 'integer', default: 30000, nullable: true }, + terminatePod: { type: 'boolean', default: true }, }, }; /** - * Represents the schema of the configuration object. + * Represents a configuration instance. * @template T - The type of the configuration schema. */ export interface ConfigInstance { @@ -144,4 +160,9 @@ export interface ConfigInstance { * @param registry - The registry for the metrics. */ initializeMetrics: (registry: Registry) => void; + + /** + * Stops any background processes (like hot-reloading polling). + */ + stop: () => void; } diff --git a/tests/config.spec.ts b/tests/config.spec.ts index 8dbb0de..4e6447c 100644 --- a/tests/config.spec.ts +++ b/tests/config.spec.ts @@ -3,6 +3,7 @@ import { Interceptable, MockAgent, setGlobalDispatcher } from 'undici'; import { commonDbPartialV1, commonS3PartialV1 } from '@map-colonies/schemas'; import { StatusCodes } from 'http-status-codes'; import { config } from '../src/config'; +import { createMockConfigData } from './mocks'; const URL = 'http://localhost:8080'; describe('config', () => { @@ -17,15 +18,11 @@ describe('config', () => { }); it('should return the config with all the default values', async () => { - const configData = { - configName: 'name', - schemaId: commonDbPartialV1.$id, - version: 1, + const configData = createMockConfigData({ config: { host: 'avi', }, - createdAt: 0, - }; + }); client .intercept({ path: `/config/name/1?shouldDereference=true&schemaId=${commonDbPartialV1.$id}`, method: 'GET' }) @@ -162,15 +159,11 @@ describe('config', () => { }); it('should return all the config parts', async () => { - const configData = { - configName: 'name', - schemaId: commonDbPartialV1.$id, - version: 1, + const configData = createMockConfigData({ config: { host: 'avi', }, - createdAt: 0, - }; + }); client .intercept({ path: `/config/name/1?shouldDereference=true&schemaId=${commonDbPartialV1.$id}`, method: 'GET' }) @@ -210,6 +203,7 @@ describe('config', () => { configServerUrl: URL, localConfigPath: './tests/config', offlineMode: true, + pollIntervalMs: 3000, }); const options = configInstance.getResolvedOptions(); @@ -220,19 +214,17 @@ describe('config', () => { configServerUrl: URL, localConfigPath: './tests/config', offlineMode: true, + pollIntervalMs: 3000, + terminatePod: true, }); }); it('should throw an error if the schema of the config is different from the schema of the server', async () => { - const configData = { - configName: 'name', - schemaId: commonDbPartialV1.$id, - version: 1, + const configData = createMockConfigData({ config: { host: 'avi', }, - createdAt: 0, - }; + }); client .intercept({ path: `/config/name/1?shouldDereference=true&schemaId=${commonS3PartialV1.$id}`, method: 'GET' }) diff --git a/tests/httpClient.spec.ts b/tests/httpClient.spec.ts index fbb7772..61af7c8 100644 --- a/tests/httpClient.spec.ts +++ b/tests/httpClient.spec.ts @@ -62,7 +62,7 @@ describe('httpClient', () => { client.intercept({ path: '/config/name/1?shouldDereference=true&schemaId=schema', method: 'GET' }).reply(StatusCodes.OK, config); const result = await getRemoteConfig('name', 'schema', 1); - expect(result).toEqual(config); + expect(result).toEqual({ config: config, etag: undefined }); }); it('should throw an error if the response is bad request', async () => { diff --git a/tests/mocks.ts b/tests/mocks.ts new file mode 100644 index 0000000..2572c45 --- /dev/null +++ b/tests/mocks.ts @@ -0,0 +1,13 @@ +import { commonDbPartialV1 } from '@map-colonies/schemas'; +import type { Config } from '../src/types'; + +export function createMockConfigData(overrides?: Partial): Partial { + return { + configName: 'name', + schemaId: commonDbPartialV1.$id, + version: 1, + config: { host: 'initial-host' }, + createdAt: 0, + ...overrides, + }; +} diff --git a/tests/options.spec.ts b/tests/options.spec.ts index b228eae..2fbaf93 100644 --- a/tests/options.spec.ts +++ b/tests/options.spec.ts @@ -73,6 +73,7 @@ describe('options', () => { ['version', 'CONFIG_VERSION', 'latest', 'latest'], ['offlineMode', 'CONFIG_OFFLINE_MODE', 'true', true], ['ignoreServerIsOlderVersionError', 'CONFIG_IGNORE_SERVER_IS_OLDER_VERSION_ERROR', 'true', true], + ['pollIntervalMs', 'CONFIG_POLL_INTERVAL_MS', '10000', 10000], ])('should initialize options and override with provided environment variable %s', async (key, envKey, envValue, expected) => { process.env[envKey] = envValue; diff --git a/tests/rollout.spec.ts b/tests/rollout.spec.ts new file mode 100644 index 0000000..c6a0276 --- /dev/null +++ b/tests/rollout.spec.ts @@ -0,0 +1,239 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { Interceptable, MockAgent, setGlobalDispatcher } from 'undici'; +import { commonDbPartialV1 } from '@map-colonies/schemas'; +import { StatusCodes } from 'http-status-codes'; +import { config } from '../src/config'; +import { createMockConfigData } from './mocks'; + +const URL = 'http://localhost:8080'; +const DEFAULT_POLL_INTERVAL = 30000; + +describe('Continuous Polling (ChangeDetector)', () => { + let client: Interceptable; + const onChangeMock = vi.fn(); + + beforeEach(() => { + vi.useFakeTimers(); + const agent = new MockAgent(); + agent.disableNetConnect(); + + setGlobalDispatcher(agent); + client = agent.get(URL); + }); + + afterEach(() => { + vi.restoreAllMocks(); + onChangeMock.mockReset(); + }); + + it('should trigger onChange when polling returns a new config (200 OK)', async () => { + // Arrange + const initialConfigData = createMockConfigData(); + const newConfigData = createMockConfigData({ config: { host: 'updated-host' }, createdAt: 1 }); + + client + .intercept({ path: '/capabilities', method: 'GET' }) + .reply(StatusCodes.OK, { serverVersion: '2.0.0', schemasPackageVersion: '99.9.9', pubSubEnabled: false }); + client + .intercept({ path: `/config/name/1?shouldDereference=true&schemaId=${commonDbPartialV1.$id}`, method: 'GET' }) + .reply(StatusCodes.OK, initialConfigData, { headers: { etag: 'etag-1' } }); + + // Act + const configInstance = await config({ + configName: 'name', + version: 1, + schema: commonDbPartialV1, + localConfigPath: './tests/config', + onChange: onChangeMock, + }); + + // Assert (Initial State) + expect(configInstance.get('host')).toBe('initial-host'); + expect(onChangeMock).not.toHaveBeenCalled(); + + // Arrange (Next Poll) + client + .intercept({ + path: `/config/name/1?shouldDereference=true&schemaId=${commonDbPartialV1.$id}`, + method: 'GET', + headers: { 'if-none-match': 'etag-1' }, + }) + .reply(StatusCodes.OK, newConfigData, { headers: { etag: 'etag-2' } }); + + // Act (Wait for Poll) + await vi.advanceTimersByTimeAsync(DEFAULT_POLL_INTERVAL); + + // Assert (Updated State) + expect(onChangeMock).toHaveBeenCalled(); + }); + + it('should stop polling when found new config and terminatePod is true', async () => { + // Arrange + const initialConfigData = createMockConfigData(); + const newConfigData = createMockConfigData({ config: { host: 'updated-host' }, createdAt: 1 }); + + client + .intercept({ path: '/capabilities', method: 'GET' }) + .reply(StatusCodes.OK, { serverVersion: '2.0.0', schemasPackageVersion: '99.9.9', pubSubEnabled: false }); + client + .intercept({ path: `/config/name/1?shouldDereference=true&schemaId=${commonDbPartialV1.$id}`, method: 'GET' }) + .reply(StatusCodes.OK, initialConfigData, { headers: { etag: 'etag-1' } }); + + // Act + await config({ + configName: 'name', + version: 1, + schema: commonDbPartialV1, + localConfigPath: './tests/config', + terminatePod: true, + onChange: onChangeMock, + }); + + // Assert (Initial State) + expect(onChangeMock).not.toHaveBeenCalled(); + + // Arrange (First config change) + client + .intercept({ + path: `/config/name/1?shouldDereference=true&schemaId=${commonDbPartialV1.$id}`, + method: 'GET', + headers: { 'if-none-match': 'etag-1' }, + }) + .reply(StatusCodes.OK, newConfigData, { headers: { etag: 'etag-2' } }); + + // Act (Wait for First Poll) + await vi.advanceTimersByTimeAsync(DEFAULT_POLL_INTERVAL); + + // Assert (First change triggered) + expect(onChangeMock).toHaveBeenCalledTimes(1); + + // Act (Advance time to see if polling continues) + await vi.advanceTimersByTimeAsync(DEFAULT_POLL_INTERVAL); + + // Assert (Polling stopped, no further calls) + expect(onChangeMock).toHaveBeenCalledTimes(1); + }); + + it('should resume polling when found new config and terminatePod is false', async () => { + // Arrange + const initialConfigData = createMockConfigData(); + const newConfigData1 = createMockConfigData({ config: { host: 'updated-host' }, createdAt: 1 }); + const newConfigData2 = createMockConfigData({ config: { host: 'updated-host-again' }, createdAt: 2 }); + + client + .intercept({ path: '/capabilities', method: 'GET' }) + .reply(StatusCodes.OK, { serverVersion: '2.0.0', schemasPackageVersion: '99.9.9', pubSubEnabled: false }); + client + .intercept({ path: `/config/name/1?shouldDereference=true&schemaId=${commonDbPartialV1.$id}`, method: 'GET' }) + .reply(StatusCodes.OK, initialConfigData, { headers: { etag: 'etag-1' } }); + + // Act + await config({ + configName: 'name', + version: 1, + schema: commonDbPartialV1, + localConfigPath: './tests/config', + terminatePod: false, + onChange: onChangeMock, + }); + + // Assert (Initial State) + expect(onChangeMock).not.toHaveBeenCalled(); + + // Arrange (First config change) + client + .intercept({ + path: `/config/name/1?shouldDereference=true&schemaId=${commonDbPartialV1.$id}`, + method: 'GET', + headers: { 'if-none-match': 'etag-1' }, + }) + .reply(StatusCodes.OK, newConfigData1, { headers: { etag: 'etag-2' } }); + + // Act (Wait for First Poll) + await vi.advanceTimersByTimeAsync(DEFAULT_POLL_INTERVAL); + + // Assert (First change triggered) + expect(onChangeMock).toHaveBeenCalledTimes(1); + + // Arrange (Second config change to verify polling resumed) + client + .intercept({ + path: `/config/name/1?shouldDereference=true&schemaId=${commonDbPartialV1.$id}`, + method: 'GET', + headers: { 'if-none-match': 'etag-2' }, + }) + .reply(StatusCodes.OK, newConfigData2, { headers: { etag: 'etag-3' } }); + + // Act (Wait for Second Poll) + await vi.advanceTimersByTimeAsync(DEFAULT_POLL_INTERVAL); + + // Assert (Second change triggered) + expect(onChangeMock).toHaveBeenCalledTimes(2); + }); + + it('should not trigger onChange when polling returns 304 Not Modified', async () => { + // Arrange + const initialConfigData = createMockConfigData(); + + client + .intercept({ path: '/capabilities', method: 'GET' }) + .reply(StatusCodes.OK, { serverVersion: '2.0.0', schemasPackageVersion: '99.9.9', pubSubEnabled: false }); + client + .intercept({ path: `/config/name/1?shouldDereference=true&schemaId=${commonDbPartialV1.$id}`, method: 'GET' }) + .reply(StatusCodes.OK, initialConfigData, { headers: { etag: 'etag-1' } }); + + // Act + const configInstance = await config({ + configName: 'name', + version: 1, + schema: commonDbPartialV1, + localConfigPath: './tests/config', + onChange: onChangeMock, + }); + + // Arrange (Setup 304 response) + client + .intercept({ + path: `/config/name/1?shouldDereference=true&schemaId=${commonDbPartialV1.$id}`, + method: 'GET', + headers: { 'if-none-match': 'etag-1' }, + }) + .reply(StatusCodes.NOT_MODIFIED); + + // Act (Wait for Poll) + await vi.advanceTimersByTimeAsync(DEFAULT_POLL_INTERVAL); + + // Assert + expect(onChangeMock).not.toHaveBeenCalled(); + expect(configInstance.get('host')).toBe('initial-host'); + }); + + it('should stop polling when stop is called', async () => { + // Arrange + const initialConfigData = createMockConfigData(); + + client + .intercept({ path: '/capabilities', method: 'GET' }) + .reply(StatusCodes.OK, { serverVersion: '2.0.0', schemasPackageVersion: '99.9.9', pubSubEnabled: false }); + client + .intercept({ path: `/config/name/1?shouldDereference=true&schemaId=${commonDbPartialV1.$id}`, method: 'GET' }) + .reply(StatusCodes.OK, initialConfigData, { headers: { etag: 'etag-1' } }); + + const configInstance = await config({ + configName: 'name', + version: 1, + schema: commonDbPartialV1, + localConfigPath: './tests/config', + onChange: onChangeMock, + }); + + // Act + configInstance.stop(); + + // Advance time beyond the polling interval + await vi.advanceTimersByTimeAsync(DEFAULT_POLL_INTERVAL * 2); + + // Assert + expect(onChangeMock).not.toHaveBeenCalled(); + }); +});