diff --git a/CHANGELOG.md b/CHANGELOG.md index dbdae6a..afd6821 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## [Unreleased] ### Added +- **`client.guilds.getGuildConfigBatch(params, options?)`** — resolves [#389](https://github.com/Adamantine-Guild/guildpass-sdk/issues/389). Fetches configuration for several guilds in one call, returning `BatchItemResult[]` in input order with per-guild failure isolation, matching the contract of `checkAccessBatch` and `getGuildOwnersBatch`. + - **`BatchItemResult` is now generic**, `BatchItemResult`. The default preserves the existing meaning (raw hex from the contract batch methods) so every current call site and consumer type stays source-compatible; batch methods resolving richer values parameterise it instead. + - Client-side fan-out over the existing `GET /guilds/:id/config` endpoint — no batch endpoint is assumed — through a bounded worker pool. `concurrency` defaults to `5` and is capped at `50`, matching `checkAccessBatch`. + - Each guild is cached individually under `guilds:getGuildConfig:{guildId}`, so a batch call warms the cache for later single lookups and reuses entries already stored. In-flight deduplication is disabled inside the batch so one caller's failure or cancellation cannot affect another sharing a key. + - Throws `INVALID_INPUT` for a missing, non-array or empty `guildIds`, and for a `concurrency` outside `1..50`. - **EIP-1271 smart-contract wallet support for SIWE** — resolves [#213](https://github.com/Adamantine-Guild/guildpass-sdk/issues/213). New `verifySiweSignatureAsync(params)` runs the same EIP-4361 checks as `verifySiweSignature` and, when the signature does not verify as an EOA signature, asks the claimed address's contract via `isValidSignature(bytes32,bytes)`. Safe, Argent and other account-abstraction wallets can now sign in. - The fallback fires on **any** signature failure, not only a recovered-address mismatch. This is load-bearing: an EIP-1271 signature has no fixed length, so a multi-owner Safe signature is rejected by the 65-byte guard before ECDSA recovery is ever attempted — a fallback keyed on the mismatch alone would never reach a real contract wallet. - Domain, nonce, expiry and `notBefore` failures stay terminal and are returned unchanged; a contract signature cannot rescue a message addressed to the wrong domain. diff --git a/api-report/guildpass-sdk.api.md b/api-report/guildpass-sdk.api.md index 64844d9..6bdb4be 100644 --- a/api-report/guildpass-sdk.api.md +++ b/api-report/guildpass-sdk.api.md @@ -136,6 +136,8 @@ export class AdaptiveContractProvider implements ContractProvider { export type AdaptiveHealthConfig = { failureThreshold?: number; cooldownMs?: number; + onCircuitOpen?: (url: string, openUntil: number) => void; + onCircuitClosed?: (url: string) => void; latencyEmaAlpha?: number; multicallPreferenceThreshold?: number; }; @@ -174,6 +176,13 @@ export type AndRule = { rules: AccessRule[]; }; +// @public (undocumented) +export class ApiKeyAuthenticationProvider implements AuthenticationProvider { + constructor(apiKey: string); + // (undocumented) + getAuthorizationHeaders(): Record; +} + // @public export const areAddressesEqual: (addr1: string, addr2: string) => boolean; @@ -185,6 +194,12 @@ export function assertValidRequest(value: unknown, guard: ((value: unknown) = // @public export function assertValidResponse(value: unknown, guard: ((value: unknown) => value is T) & Partial>, typeName: string, context?: ResponseValidationContext): T; +// @public (undocumented) +export interface AuthenticationProvider { + getAuthorizationHeaders(): Promise> | Record; + onUnauthorized?(): Promise; +} + // @public export const BALANCE_OF_SELECTOR = "0x70a08231"; @@ -195,9 +210,9 @@ export type BatchEthCallItem = { }; // @public -export type BatchItemResult = { +export type BatchItemResult = { status: 'success' | 'error'; - result?: string; + result?: T; error?: string; }; @@ -282,6 +297,8 @@ export class ContractClient { chunkConcurrency?: number; }): Promise; getChainConfig(chainId?: number): ChainConfig; + // (undocumented) + getCircuitBreakerSnapshot(): Record; getERC1155Balance(params: ERC1155BalanceParams, options?: RequestOptions): Promise; getERC20Balance(params: ERC20BalanceParams, options?: RequestOptions): Promise; getGuildOwner(params: GuildOwnerParams, options?: RequestOptions): Promise; @@ -573,6 +590,16 @@ export type GuildConfig = { socialLinks?: Record; }; +// @public +export type GuildConfigBatchOptions = { + concurrency?: number; +}; + +// @public +export type GuildConfigBatchParams = { + guildIds: string[]; +}; + // @public (undocumented) export type GuildOwnerParams = { guildId: string; @@ -621,6 +648,10 @@ export class GuildPassClient { clearCache(): Promise; // (undocumented) readonly contracts: ContractClient; + // Warning: (ae-forgotten-export) The symbol "DiagnosticsModule" needs to be exported by the entry point index.d.ts + // + // (undocumented) + readonly diagnostics: DiagnosticsModule; getConfig(): PublicClientConfig; // (undocumented) readonly guilds: GuildsService; @@ -642,6 +673,8 @@ export class GuildPassClientBuilder { // (undocumented) withApiUrl(apiUrl: string): this; // (undocumented) + withAuthProvider(authProvider: AuthenticationProvider): this; + // (undocumented) withBatchStrategy(strategy: 'jsonrpc' | 'multicall3'): this; // (undocumented) withCache(cache: CacheAdapter, ttlMs?: number): this; @@ -694,6 +727,7 @@ export type GuildPassClientConfig = { multicallAddress?: string; batchStrategy?: 'jsonrpc' | 'multicall3'; chains?: Record; + authProvider?: AuthenticationProvider; apiKey?: string; timeoutMs?: number; defaultTimeoutMs?: number; @@ -874,6 +908,7 @@ export class GuildsService { }>; // (undocumented) getGuildConfig(params: GetGuildParams, options?: RequestOptions): Promise; + getGuildConfigBatch(params: GuildConfigBatchParams, options?: RequestOptions & GuildConfigBatchOptions): Promise[]>; } // @public @@ -912,6 +947,8 @@ export class HealthTracker { recordFailure(url: string, now?: number): void; recordSuccess(url: string, latencyMs: number): void; snapshot(url: string): Readonly | undefined; + // (undocumented) + snapshotAll(): Record>; } // @public (undocumented) @@ -926,6 +963,7 @@ export type HttpClientConfig = { transport?: HttpTransport; metadata?: ClientMetadata; rateLimit?: RateLimitConfig; + authProvider?: AuthenticationProvider; }; // @public (undocumented) @@ -1186,8 +1224,11 @@ export type PaginatedResult = { // @public export function parseSiweMessage(raw: string): SiweParseResult; +// @public (undocumented) +export const parseUnits: (value: string, decimals: number) => string; + // @public -export type PublicClientConfig = Omit; +export type PublicClientConfig = Omit; // @public export type ReadContractParams = { @@ -1623,7 +1664,7 @@ export function verifyTypedDataSignature(domain: EIP712Domain, types: EIP712Type // Warnings were encountered during analysis: // -// dist/common-CabkBCAt.d.ts:224:5 - (ae-forgotten-export) The symbol "ClientMetadata" needs to be exported by the entry point index.d.ts +// dist/common-_RgT9NFP.d.ts:239:5 - (ae-forgotten-export) The symbol "ClientMetadata" needs to be exported by the entry point index.d.ts // (No @packageDocumentation comment for this package) diff --git a/docs/api-reference.md b/docs/api-reference.md index 4ef302f..e1120f2 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -213,6 +213,46 @@ Fetches full guild configuration. - **Returns**: `Promise` +### `getGuildConfigBatch(params: { guildIds: string[] }, options?: RequestOptions & { concurrency?: number })` + +Fetches configuration for several guilds in one call, with the same +order-preserving, per-item error isolation as the rest of the batch surface +(`checkAccessBatch`, `getGuildOwnersBatch`). + +```typescript +const results = await client.guilds.getGuildConfigBatch({ + guildIds: ['prime-guild', 'second-guild', 'missing-guild'], +}); + +results.forEach((entry, i) => { + if (entry.status === 'success') { + console.log(entry.result.theme); + } else { + console.error(`guild ${i} failed:`, entry.error); + } +}); +``` + +- **Returns**: `Promise[]>` — one entry per input + guild ID, in input order. `BatchItemResult` is the shape already used by the + contract batch methods; `T` defaults to `string` (raw hex) there, and is + parameterised to `GuildConfig` here. +- **Client-side fan-out.** There is no batch endpoint on the API: this issues one + `GET /guilds/:id/config` per ID through a bounded worker pool. It saves the + caller the orchestration, not the round trips. +- **Concurrency**: defaults to `5`, capped at `50`, matching `checkAccessBatch`. + Out-of-range values throw `INVALID_INPUT`. +- **Partial failures**: a guild that 404s or fails response validation becomes an + `'error'` entry; its siblings are unaffected and the batch still resolves. +- **Caching**: each guild is cached individually under + `guilds:getGuildConfig:{guildId}`, so a batch call warms the cache for later + single lookups and reuses entries a previous call already stored. In-flight + deduplication is deliberately disabled inside a batch, so one caller's failure + or cancellation cannot affect another sharing the same key. +- **Duplicate IDs** are preserved: each input position gets its own result. +- **Errors**: throws `INVALID_INPUT` when `guildIds` is missing, is not an array, + or is empty. + --- ## Contract Module (`client.contracts`) diff --git a/src/client/GuildPassClient.ts b/src/client/GuildPassClient.ts index df06aac..ff6af31 100644 --- a/src/client/GuildPassClient.ts +++ b/src/client/GuildPassClient.ts @@ -23,7 +23,13 @@ import { encodePathSegment } from '../utils/formatting'; import type { AccessCheckParams, RoleAccessCheckParams, AccessCheckBatchOptions, AccessCheckBatchResult, AccessCheckBatchByResourceParams, AccessCheckBatchByResourceResult, AccessCheckResult } from '../access/access.types'; import type { MembershipParams } from '../membership/membership.types'; import type { GetRolesParams, GetUserRolesParams, HasRoleParams } from '../roles/roles.types'; -import type { GetGuildParams } from '../guilds/guilds.types'; +import type { + GetGuildParams, + GuildConfig, + GuildConfigBatchOptions, + GuildConfigBatchParams, +} from '../guilds/guilds.types'; +import type { BatchItemResult } from '../contracts/contract.types'; import { DiagnosticsModule } from '../diagnostics/DiagnosticsModule'; import type { RequestOptions } from '../types/common'; import type { ResponseMetadata } from '../http/http.types'; @@ -516,7 +522,7 @@ export class GuildPassClient { } private buildCachedGuildsService(raw: GuildsService): GuildsService { - return Object.create(raw, { + const cached: GuildsService = Object.create(raw, { getGuild: { value: async (params: GetGuildParams, options?: O): Promise => { const key = buildCacheKey('guilds', 'getGuild', params.guildId); @@ -530,6 +536,31 @@ export class GuildPassClient { }, }, }); + + // The batch method calls `this.getGuildConfig` internally. Left alone it + // would run against `raw` and bypass the cache entirely, so it is rebound to + // a view that still caches each guild but never coalesces in-flight + // requests — mirroring how `checkAccessBatch` is wired above. Coalescing + // inside a batch would let one caller's abort or failure affect an + // unrelated caller sharing the same key. + const neverCoalesce: GuildsService = Object.create(raw, { + getGuildConfig: { + value: async (params: GetGuildParams, options?: any): Promise => { + const key = buildCacheKey('guilds', 'getGuildConfig', params.guildId); + return this.withCache(key, () => raw.getGuildConfig(params, options), undefined, false); + }, + }, + }); + + Object.defineProperty(cached, 'getGuildConfigBatch', { + value: async ( + params: GuildConfigBatchParams, + options?: RequestOptions & GuildConfigBatchOptions, + ): Promise[]> => + raw.getGuildConfigBatch.call(neverCoalesce, params, options), + }); + + return cached; } // GuildPass SDK: End of logic containment structure block. } diff --git a/src/contracts/contract.types.ts b/src/contracts/contract.types.ts index f8aac5f..c644563 100644 --- a/src/contracts/contract.types.ts +++ b/src/contracts/contract.types.ts @@ -77,12 +77,17 @@ export type BatchEthCallItem = { /** * Result of a single item in a batch response. - * On success, `status` is `'success'` and `result` contains the raw hex output. + * On success, `status` is `'success'` and `result` contains the item's value. * On failure, `status` is `'error'` and `error` contains a descriptive message. + * + * `T` defaults to `string`, which is the raw hex output returned by the + * contract batch methods, so `BatchItemResult` on its own keeps meaning exactly + * what it always did. Batch methods that resolve richer values parameterise it + * instead — for example `BatchItemResult`. */ -export type BatchItemResult = { +export type BatchItemResult = { status: 'success' | 'error'; - result?: string; + result?: T; error?: string; }; diff --git a/src/guilds/guilds.service.ts b/src/guilds/guilds.service.ts index f0cdbb3..44db2a7 100644 --- a/src/guilds/guilds.service.ts +++ b/src/guilds/guilds.service.ts @@ -13,7 +13,14 @@ import { verifySignedPayload, SignedEnvelope } from '../security'; import { GuildPassConfigError } from '../errors/errorTypes'; import { GuildPassErrorCode } from '../errors/errorCodes'; // GuildPass SDK: Import external module dependencies. -import { GetGuildParams, Guild, GuildConfig } from './guilds.types'; +import { + GetGuildParams, + Guild, + GuildConfig, + GuildConfigBatchOptions, + GuildConfigBatchParams, +} from './guilds.types'; +import type { BatchItemResult } from '../contracts/contract.types'; // GuildPass SDK: Core operational type definition. export class GuildsService { @@ -113,5 +120,86 @@ export class GuildsService { return validatedResult; // GuildPass SDK: End of logic containment structure block. } + + /** + * Fetches full configuration for several guilds in one call. + * + * This is a **client-side fan-out** over the existing single-guild + * `GET /guilds/:id/config` endpoint — there is no batch endpoint on the API — + * so it saves the caller the orchestration, not the round trips. Requests are + * issued through a bounded worker pool rather than all at once. + * + * Results are returned in the same order as `guildIds`, one entry per input, + * with per-guild failure isolation: a guild that 404s or whose response fails + * validation becomes an `'error'` entry and leaves its siblings untouched. + * This matches the contract of `getGuildOwnersBatch` and the rest of the + * batch surface. + * + * Duplicate IDs are preserved as-is: each input position gets its own result. + * + * @throws `INVALID_INPUT` when `guildIds` is missing, not an array, or empty, + * or when `concurrency` is outside `1..50`. + */ + public async getGuildConfigBatch( + params: GuildConfigBatchParams, + options?: RequestOptions & GuildConfigBatchOptions, + ): Promise[]> { + const guildIds = params?.guildIds; + + if (!Array.isArray(guildIds) || guildIds.length === 0) { + throw new GuildPassConfigError( + 'guildIds array is required and must not be empty', + GuildPassErrorCode.INVALID_INPUT, + ); + } + + const concurrency = options?.concurrency ?? 5; + if (!Number.isInteger(concurrency) || concurrency < 1 || !Number.isFinite(concurrency)) { + throw new GuildPassConfigError( + 'concurrency must be a positive finite integer', + GuildPassErrorCode.INVALID_INPUT, + ); + } + if (concurrency > 50) { + throw new GuildPassConfigError( + 'concurrency must not exceed 50', + GuildPassErrorCode.INVALID_INPUT, + ); + } + + const results: BatchItemResult[] = new Array(guildIds.length); + let next = 0; + + const worker = async (): Promise => { + // Index is claimed before awaiting, so each result lands at its own input + // position no matter what order the responses come back in. + while (next < guildIds.length) { + const index = next++; + try { + const config = await this.getGuildConfig( + { guildId: guildIds[index] }, + // `includeMeta` would change the resolved shape, so it is dropped: + // per-item metadata has nowhere to live in BatchItemResult. + options ? { ...options, includeMeta: false } : undefined, + ); + results[index] = { status: 'success', result: config }; + } catch (err) { + results[index] = { + status: 'error', + error: err instanceof Error ? err.message : 'Unknown error fetching guild config', + }; + } + } + }; + + await Promise.all( + Array(Math.min(concurrency, guildIds.length)) + .fill(null) + .map(() => worker()), + ); + + return results; + // GuildPass SDK: End of logic containment structure block. + } // GuildPass SDK: End of logic containment structure block. } diff --git a/src/guilds/guilds.types.ts b/src/guilds/guilds.types.ts index b5a2720..a0ffb91 100644 --- a/src/guilds/guilds.types.ts +++ b/src/guilds/guilds.types.ts @@ -24,3 +24,20 @@ export type GuildConfig = { socialLinks?: Record; // GuildPass SDK: End of logic containment structure block. }; + +/** Parameters for a batched guild configuration lookup. */ +export type GuildConfigBatchParams = { + /** Guild IDs to look up. Results preserve this order. */ + guildIds: string[]; +}; + +/** Options accepted by `getGuildConfigBatch`. */ +export type GuildConfigBatchOptions = { + /** + * Maximum number of in-flight requests. Defaults to `5`, capped at `50`, + * matching `checkAccessBatch`. This is a client-side fan-out over the + * single-guild endpoint, so the limit is what protects the API from a large + * `guildIds` array arriving all at once. + */ + concurrency?: number; +}; diff --git a/tests/cache.test.ts b/tests/cache.test.ts index 92b8891..b373287 100644 --- a/tests/cache.test.ts +++ b/tests/cache.test.ts @@ -925,3 +925,71 @@ describe('GuildPassClient – deleteByPrefix-absent adapter fallback', () => { await expect(client.invalidateWalletCache('not-an-address')).rejects.toThrow(); }); }); + +// --------------------------------------------------------------------------- +// getGuildConfigBatch cache wiring (#389) +// --------------------------------------------------------------------------- + +describe('GuildPassClient – getGuildConfigBatch cache wiring', () => { + const configFor = (id: string) => ({ id, theme: 'dark' }); + + it('populates the per-guild cache from a batch call', async () => { + // The batch method calls getGuildConfig internally. Without rebinding it in + // the client it would run against the raw service and bypass the cache + // entirely, silently making batch results uncacheable. + const adapter = new InMemoryCacheAdapter(); + const client = new GuildPassClient({ ...BASE_CONFIG, cache: adapter }); + + vi.spyOn(client['http'] as any, 'get').mockImplementation(async (path: string) => + configFor(path.split('/')[2]), + ); + + await client.guilds.getGuildConfigBatch({ guildIds: ['g1', 'g2'] }); + + expect(await adapter.get('guilds:getGuildConfig:g1')).toEqual(configFor('g1')); + expect(await adapter.get('guilds:getGuildConfig:g2')).toEqual(configFor('g2')); + }); + + it('serves a subsequent single lookup from the cache the batch filled', async () => { + const adapter = new InMemoryCacheAdapter(); + const client = new GuildPassClient({ ...BASE_CONFIG, cache: adapter }); + + const httpSpy = vi + .spyOn(client['http'] as any, 'get') + .mockImplementation(async (path: string) => configFor(path.split('/')[2])); + + await client.guilds.getGuildConfigBatch({ guildIds: ['g1'] }); + expect(httpSpy).toHaveBeenCalledTimes(1); + + const single = await client.guilds.getGuildConfig({ guildId: 'g1' }); + + expect(single).toEqual(configFor('g1')); + expect(httpSpy).toHaveBeenCalledTimes(1); // still 1: served from cache + }); + + it('reads through the cache instead of refetching inside the batch', async () => { + const adapter = new InMemoryCacheAdapter(); + await adapter.set('guilds:getGuildConfig:g1', configFor('g1')); + + const client = new GuildPassClient({ ...BASE_CONFIG, cache: adapter }); + const httpSpy = vi + .spyOn(client['http'] as any, 'get') + .mockImplementation(async (path: string) => configFor(path.split('/')[2])); + + const results = await client.guilds.getGuildConfigBatch({ guildIds: ['g1', 'g2'] }); + + expect(results[0]).toEqual({ status: 'success', result: configFor('g1') }); + expect(httpSpy).toHaveBeenCalledTimes(1); // only g2 went to the network + }); + + it('still works with no cache adapter configured', async () => { + const client = new GuildPassClient(BASE_CONFIG); + vi.spyOn(client['http'] as any, 'get').mockImplementation(async (path: string) => + configFor(path.split('/')[2]), + ); + + const results = await client.guilds.getGuildConfigBatch({ guildIds: ['g1'] }); + + expect(results[0]).toEqual({ status: 'success', result: configFor('g1') }); + }); +}); diff --git a/tests/guilds.service.test.ts b/tests/guilds.service.test.ts index 2075cb5..16f6d3a 100644 --- a/tests/guilds.service.test.ts +++ b/tests/guilds.service.test.ts @@ -112,4 +112,151 @@ describe('GuildsService request options forwarding', () => { retry: { maxRetries: 2 }, }); }); -}); \ No newline at end of file +}); + +describe('GuildsService.getGuildConfigBatch (#389)', () => { + /** Service whose HTTP layer resolves or rejects per path. */ + function createBatchService(byPath: Record) { + const get = vi.fn(async (path: string) => { + const entry = byPath[path]; + if (entry instanceof Error) throw entry; + if (entry === undefined) throw new Error(`Unexpected path ${path}`); + return entry; + }); + return { get, service: new GuildsService({ get } as unknown as HttpClient) }; + } + + const configFor = (id: string) => ({ ...getGuildConfigSuccess, id }); + + it('returns one result per input, in input order', async () => { + const { service } = createBatchService({ + '/guilds/guild_a/config': configFor('guild_a'), + '/guilds/guild_b/config': configFor('guild_b'), + '/guilds/guild_c/config': configFor('guild_c'), + }); + + const results = await service.getGuildConfigBatch({ + guildIds: ['guild_a', 'guild_b', 'guild_c'], + }); + + expect(results).toHaveLength(3); + expect(results.map((r) => r.result?.id)).toEqual(['guild_a', 'guild_b', 'guild_c']); + expect(results.every((r) => r.status === 'success')).toBe(true); + }); + + it('preserves order even when responses resolve out of order', async () => { + // The first guild resolves last; index is claimed before awaiting, so its + // result must still land in position 0. + const get = vi.fn(async (path: string) => { + if (path === '/guilds/slow/config') { + await new Promise((resolve) => setTimeout(resolve, 20)); + return configFor('slow'); + } + return configFor('fast'); + }); + const service = new GuildsService({ get } as unknown as HttpClient); + + const results = await service.getGuildConfigBatch({ guildIds: ['slow', 'fast'] }); + + expect(results[0].result?.id).toBe('slow'); + expect(results[1].result?.id).toBe('fast'); + }); + + it('isolates a single failing guild without failing the batch', async () => { + const { service } = createBatchService({ + '/guilds/ok_1/config': configFor('ok_1'), + '/guilds/missing/config': new Error('Guild not found'), + '/guilds/ok_2/config': configFor('ok_2'), + }); + + const results = await service.getGuildConfigBatch({ + guildIds: ['ok_1', 'missing', 'ok_2'], + }); + + expect(results[0]).toMatchObject({ status: 'success' }); + expect(results[1]).toMatchObject({ status: 'error', error: 'Guild not found' }); + expect(results[1].result).toBeUndefined(); + expect(results[2]).toMatchObject({ status: 'success' }); + }); + + it('throws INVALID_INPUT for an empty guildIds array', async () => { + const { get, service } = createBatchService({}); + + await expect(service.getGuildConfigBatch({ guildIds: [] })).rejects.toMatchObject({ + code: 'INVALID_INPUT', + }); + expect(get).not.toHaveBeenCalled(); + }); + + it('throws INVALID_INPUT for a missing or non-array guildIds', async () => { + const { service } = createBatchService({}); + + await expect(service.getGuildConfigBatch({} as any)).rejects.toBeInstanceOf( + GuildPassConfigError, + ); + await expect( + service.getGuildConfigBatch({ guildIds: 'guild_1' } as any), + ).rejects.toBeInstanceOf(GuildPassConfigError); + await expect(service.getGuildConfigBatch(undefined as any)).rejects.toBeInstanceOf( + GuildPassConfigError, + ); + }); + + it('rejects an out-of-range concurrency', async () => { + const { get, service } = createBatchService({}); + + await expect( + service.getGuildConfigBatch({ guildIds: ['guild_1'] }, { concurrency: 0 }), + ).rejects.toMatchObject({ code: 'INVALID_INPUT' }); + await expect( + service.getGuildConfigBatch({ guildIds: ['guild_1'] }, { concurrency: 51 }), + ).rejects.toMatchObject({ code: 'INVALID_INPUT' }); + await expect( + service.getGuildConfigBatch({ guildIds: ['guild_1'] }, { concurrency: 1.5 }), + ).rejects.toMatchObject({ code: 'INVALID_INPUT' }); + expect(get).not.toHaveBeenCalled(); + }); + + it('bounds in-flight requests to the concurrency limit', async () => { + let inFlight = 0; + let peak = 0; + const get = vi.fn(async () => { + inFlight += 1; + peak = Math.max(peak, inFlight); + await new Promise((resolve) => setTimeout(resolve, 5)); + inFlight -= 1; + return getGuildConfigSuccess; + }); + const service = new GuildsService({ get } as unknown as HttpClient); + + await service.getGuildConfigBatch( + { guildIds: Array.from({ length: 10 }, (_, i) => `guild_${i}`) }, + { concurrency: 3 }, + ); + + expect(peak).toBeLessThanOrEqual(3); + expect(get).toHaveBeenCalledTimes(10); + }); + + it('gives each duplicate ID its own result slot', async () => { + const { get, service } = createBatchService({ + '/guilds/guild_1/config': configFor('guild_1'), + }); + + const results = await service.getGuildConfigBatch({ guildIds: ['guild_1', 'guild_1'] }); + + expect(results).toHaveLength(2); + expect(get).toHaveBeenCalledTimes(2); + }); + + it('rejects an invalid guild ID as a per-item error, not a thrown batch', async () => { + const { service } = createBatchService({ + '/guilds/guild_1/config': configFor('guild_1'), + }); + + const results = await service.getGuildConfigBatch({ guildIds: ['guild_1', ''] }); + + expect(results[0].status).toBe('success'); + expect(results[1].status).toBe('error'); + }); +});