From 4b71e438654fc13b780b03df43c98f4eb650c35c Mon Sep 17 00:00:00 2001 From: duyhungtnn Date: Thu, 9 Apr 2026 11:38:57 +0700 Subject: [PATCH] chore(test): Add E2E API client tests and update config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add E2E API client tests and update config Populate ava-e2e.config.mjs env vars with dev API endpoint, scheme, and example client/server keys, and add new end-to-end tests and plan: - e2e/api_client.ts: behavioral E2E tests for APIClient (getFeatureFlags, getSegmentUsers, getEvaluation). - e2e/api_client_schema.ts: schema validation tests with composable assert helpers that verify presence and types of nested response fields (features, rules, strategies, segment users, evaluations, etc.). - plan-addFieldPresenceAssertionsSchema.prompt.md: plan describing the bottom-up approach for adding field presence assertions and verification steps. These changes add stricter schema checks to catch silent backend field removals and separate behavioral tests from schema validation. Document API client schema tests Add a header comment to e2e/api_client_schema.ts describing the test suite's purpose and approach. The comment explains that the tests perform deep, bottom-up schema validation of Bucketeer API responses—verifying presence and types of fields including nested objects and arrays—to catch breaking changes or field removals at the SDK level and prevent silent production failures. No functional code changes. fix: schema checks and update Variation type Use empty arrays instead of [''] in getSegmentUsers test calls, and adjust schema assertions to handle optional fields: cast allowed strings, only validate name/description when present, and make reason validation conditional. Also update Variation type to require id and name (make them non-optional) in src/objects/feature.ts. These changes fix false-positive test assumptions and align TypeScript types with expected runtime data. Add rationale for runtime schema tests Expand the header comment in e2e/api_client_schema.ts to explain why runtime schema tests are necessary: JSON.parse yields unknown/any at runtime and TypeScript types are erased at compile time, so a runtime test suite acts as the contract to catch backend field removals or type changes that could otherwise silently break production. e2e: assert forceUpdate and segment users Add assertions and clarifying comments to e2e/api_client.ts: ensure response.forceUpdate is true on initial getFeatureFlags and getSegmentUsers calls (so clients fetch latest data), and assert first.segmentUsers.length > 0 in the segment users test to guarantee a non-empty initial result. Minor formatting tweak. Tighten featureFlagsId assertions in e2e test Replace a loose inequality check with explicit assertions: verify featureFlagsId is a string and has length > 0. This makes the test more precise and ensures the response field is both the correct type and non-empty. --- e2e/api_client.ts | 125 ++++++++++++++++ e2e/api_client_schema.ts | 314 +++++++++++++++++++++++++++++++++++++++ src/objects/feature.ts | 4 +- 3 files changed, 441 insertions(+), 2 deletions(-) create mode 100644 e2e/api_client.ts create mode 100644 e2e/api_client_schema.ts diff --git a/e2e/api_client.ts b/e2e/api_client.ts new file mode 100644 index 0000000..eaf4255 --- /dev/null +++ b/e2e/api_client.ts @@ -0,0 +1,125 @@ +import test from 'ava'; +import { APIClient } from '../lib/api/client'; +import { + API_ENDPOINT, + SERVER_API_KEY, + CLIENT_API_KEY, + FEATURE_TAG, + FEATURE_ID_BOOLEAN, + FEATURE_ID_STRING, + FEATURE_ID_INT, + FEATURE_ID_FLOAT, + FEATURE_ID_JSON, + TARGETED_USER_ID, +} from './constants/constants'; +import { SourceId } from '../lib/objects/sourceId'; +import { nodeSDKVersion } from '../lib/objects/version'; + +test('getFeatureFlags: response contains expected fields with meaningful values', async (t) => { + const client = new APIClient(API_ENDPOINT, SERVER_API_KEY); + const requestedAt = 1; + + const [res] = await client.getFeatureFlags( + FEATURE_TAG, + '', + requestedAt, + SourceId.NODE_SERVER, + nodeSDKVersion, + ); + + t.true(res.features.length >= 1); + t.is(typeof res.featureFlagsId, 'string'); + t.true(res.featureFlagsId.length > 0); + t.true(Number(res.requestedAt) > requestedAt); + // forceUpdate should be true on the first request to ensure clients fetch the latest flags + t.true(res.forceUpdate); + t.true(res.features.some((f) => f.id === FEATURE_ID_BOOLEAN)); + t.true(res.features.some((f) => f.id === FEATURE_ID_STRING)); + t.true(res.features.some((f) => f.id === FEATURE_ID_INT)); + t.true(res.features.some((f) => f.id === FEATURE_ID_FLOAT)); + t.true(res.features.some((f) => f.id === FEATURE_ID_JSON)); +}); + +test('getFeatureFlags: second request with same featureFlagsId returns empty features', async (t) => { + const client = new APIClient(API_ENDPOINT, SERVER_API_KEY); + + const [first] = await client.getFeatureFlags( + FEATURE_TAG, + '', + 1, + SourceId.NODE_SERVER, + nodeSDKVersion, + ); + + const requestedAt = Number(first.requestedAt); + const [second] = await client.getFeatureFlags( + FEATURE_TAG, + first.featureFlagsId, + requestedAt, + SourceId.NODE_SERVER, + nodeSDKVersion, + ); + + t.is(second.featureFlagsId, first.featureFlagsId); + t.is(second.features.length, 0); + t.false(second.forceUpdate); +}); + +test('getSegmentUsers: response contains expected fields with meaningful values', async (t) => { + const client = new APIClient(API_ENDPOINT, SERVER_API_KEY); + const requestedAt = 1; + + const [res] = await client.getSegmentUsers( + [], + requestedAt, + SourceId.NODE_SERVER, + nodeSDKVersion, + ); + + t.true(res.segmentUsers.length > 0); + t.is(res.deletedSegmentIds.length, 0); + t.true(Number(res.requestedAt) > requestedAt); + // forceUpdate should be true on the first request to ensure clients fetch the latest segments + t.true(res.forceUpdate); +}); + +test('getSegmentUsers: second request with updated requestedAt returns empty response', async (t) => { + const client = new APIClient(API_ENDPOINT, SERVER_API_KEY); + + const [first] = await client.getSegmentUsers( + [], + 1, + SourceId.NODE_SERVER, + nodeSDKVersion, + ); + + const requestedAt = Number(first.requestedAt); + t.true(first.segmentUsers.length > 0); + + const segmentIds = [first.segmentUsers[0].segmentId, 'random-id']; + const [second] = await client.getSegmentUsers( + segmentIds, + requestedAt, + SourceId.NODE_SERVER, + nodeSDKVersion, + ); + + t.is(second.segmentUsers.length, 0); + t.false(second.forceUpdate); +}); + +test('getEvaluation: response contains evaluation for known feature', async (t) => { + const client = new APIClient(API_ENDPOINT, CLIENT_API_KEY); + const user = { id: TARGETED_USER_ID, data: {} }; + + const [res] = await client.getEvaluation( + FEATURE_TAG, + user, + FEATURE_ID_BOOLEAN, + SourceId.NODE_SERVER, + nodeSDKVersion, + ); + + t.truthy(res.evaluation); + t.is(res.evaluation?.featureId, FEATURE_ID_BOOLEAN); +}); diff --git a/e2e/api_client_schema.ts b/e2e/api_client_schema.ts new file mode 100644 index 0000000..76c9dda --- /dev/null +++ b/e2e/api_client_schema.ts @@ -0,0 +1,314 @@ +/** + * This test suite provides deep schema validation for the Bucketeer API responses. + * + * It uses a bottom-up assertion pattern to verify the presence and type of every field + * in the API responses, including nested objects and arrays. This ensures that any + * breaking changes or unexpected field removals in the backend are caught immediately + * at the SDK level, preventing silent failures in production. + * + * Why this test is necessary + * + * `JSON.parse` returns `unknown` / `any` at runtime. TypeScript types are erased at + * compile time and provide no protection against what the server actually sends. + * If the backend removes a field or changes its type, the SDK receives `undefined` or an + * unexpected value and silently propagates it — potentially breaking evaluations in + * production without any error being thrown. This test suite acts as the runtime contract + * that TypeScript's type system cannot enforce on its own. + */ + +import test, { ExecutionContext } from 'ava'; +import { APIClient } from '../lib/api/client'; +import { + API_ENDPOINT, + SERVER_API_KEY, + CLIENT_API_KEY, + FEATURE_TAG, + TARGETED_USER_ID, + FEATURE_ID_BOOLEAN, + FEATURE_ID_STRING, + FEATURE_ID_INT, + FEATURE_ID_FLOAT, + FEATURE_ID_JSON, +} from './constants/constants'; +import { SourceId } from '../lib/objects/sourceId'; +import { nodeSDKVersion } from '../lib/objects/version'; + +const FEATURE_IDS = [ + FEATURE_ID_BOOLEAN, + FEATURE_ID_STRING, + FEATURE_ID_INT, + FEATURE_ID_FLOAT, + FEATURE_ID_JSON, +] as const; + +const CLAUSE_OPERATORS = [ + 'EQUALS', + 'IN', + 'ENDS_WITH', + 'STARTS_WITH', + 'SEGMENT', + 'GREATER', + 'GREATER_OR_EQUAL', + 'LESS', + 'LESS_OR_EQUAL', + 'BEFORE', + 'AFTER', + 'FEATURE_FLAG', + 'PARTIALLY_MATCH', + 'NOT_EQUALS', +] as const; + +const STRATEGY_TYPES = ['FIXED', 'ROLLOUT'] as const; +const VARIATION_TYPES = ['STRING', 'BOOLEAN', 'NUMBER', 'JSON'] as const; +const SEGMENT_USER_STATES = ['INCLUDED', 'EXCLUDED'] as const; +const REASON_TYPES = [ + 'TARGET', + 'RULE', + 'DEFAULT', + 'CLIENT', + 'OFF_VARIATION', + 'PREREQUISITE', +] as const; + +function assertStringArray(t: ExecutionContext, values: unknown): void { + t.true(Array.isArray(values)); + (values as unknown[]).forEach((value) => t.is(typeof value, 'string')); +} + +function assertAllowedString( + t: ExecutionContext, + value: unknown, + allowedValues: readonly string[], +): void { + t.is(typeof value, 'string'); + const stringValue = value as string; + t.true(allowedValues.includes(stringValue)); +} + +// ─── Leaf helpers ───────────────────────────────────────────────────────────── + +function assertVariation(t: ExecutionContext, v: unknown): void { + t.truthy(v); + const obj = v as Record; + t.is(typeof obj.id, 'string'); + t.is(typeof obj.value, 'string'); + // name and description are optional, so only check type if they are present + if (obj.name !== undefined) { + t.is(typeof obj.name, 'string'); + } + if (obj.description !== undefined) { + t.is(typeof obj.description, 'string'); + } +} + +function assertTarget(t: ExecutionContext, target: unknown): void { + t.truthy(target); + const obj = target as Record; + t.is(typeof obj.variation, 'string'); + assertStringArray(t, obj.users); +} + +function assertClause(t: ExecutionContext, clause: unknown): void { + t.truthy(clause); + const obj = clause as Record; + t.is(typeof obj.id, 'string'); + t.is(typeof obj.attribute, 'string'); + assertAllowedString(t, obj.operator, CLAUSE_OPERATORS); + assertStringArray(t, obj.values); +} + +function assertFixedStrategy(t: ExecutionContext, fs: unknown): void { + t.truthy(fs); + const obj = fs as Record; + t.is(typeof obj.variation, 'string'); +} + +function assertRolloutStrategyVariation(t: ExecutionContext, rsv: unknown): void { + t.truthy(rsv); + const obj = rsv as Record; + t.is(typeof obj.variation, 'string'); + t.is(typeof obj.weight, 'number'); +} + +function assertFeatureLastUsedInfo(t: ExecutionContext, info: unknown): void { + t.truthy(info); + const obj = info as Record; + t.is(typeof obj.featureId, 'string'); + t.is(typeof obj.version, 'number'); + t.is(typeof obj.lastUsedAt, 'string'); + t.is(typeof obj.createdAt, 'string'); + t.is(typeof obj.clientOldestVersion, 'string'); + t.is(typeof obj.clientLatestVersion, 'string'); +} + +function assertPrerequisite(t: ExecutionContext, p: unknown): void { + t.truthy(p); + const obj = p as Record; + t.is(typeof obj.featureId, 'string'); + t.is(typeof obj.variationId, 'string'); +} + +function assertSegmentUser(t: ExecutionContext, user: unknown): void { + t.truthy(user); + const obj = user as Record; + t.is(typeof obj.id, 'string'); + t.is(typeof obj.segmentId, 'string'); + t.is(typeof obj.userId, 'string'); + assertAllowedString(t, obj.state, SEGMENT_USER_STATES); + t.is(typeof obj.deleted, 'boolean'); +} + +function assertReason(t: ExecutionContext, reason: unknown): void { + t.truthy(reason); + const obj = reason as Record; + assertAllowedString(t, obj.type, REASON_TYPES); + if (obj.ruleId != null) { + t.is(typeof obj.ruleId, 'string'); + } +} + +// ─── Composed helpers ───────────────────────────────────────────────────────── + +function assertRolloutStrategy(t: ExecutionContext, rs: unknown): void { + t.truthy(rs); + const obj = rs as Record; + t.true(Array.isArray(obj.variations)); + (obj.variations as unknown[]).forEach((rsv) => assertRolloutStrategyVariation(t, rsv)); +} + +function assertStrategy(t: ExecutionContext, s: unknown): void { + t.truthy(s); + const obj = s as Record; + assertAllowedString(t, obj.type, STRATEGY_TYPES); + if (obj.fixedStrategy != null) assertFixedStrategy(t, obj.fixedStrategy); + if (obj.rolloutStrategy != null) assertRolloutStrategy(t, obj.rolloutStrategy); +} + +function assertRule(t: ExecutionContext, rule: unknown): void { + t.truthy(rule); + const obj = rule as Record; + t.is(typeof obj.id, 'string'); + t.true(Array.isArray(obj.clauses)); + if (obj.strategy != null) assertStrategy(t, obj.strategy); + (obj.clauses as unknown[]).forEach((clause) => assertClause(t, clause)); +} + +function assertFeature(t: ExecutionContext, feature: unknown): void { + t.truthy(feature); + const obj = feature as Record; + t.is(typeof obj.id, 'string'); + t.is(typeof obj.name, 'string'); + t.is(typeof obj.description, 'string'); + t.is(typeof obj.enabled, 'boolean'); + t.is(typeof obj.deleted, 'boolean'); + t.is(typeof obj.ttl, 'number'); + t.is(typeof obj.version, 'number'); + t.is(typeof obj.createdAt, 'string'); + t.is(typeof obj.updatedAt, 'string'); + t.true(Array.isArray(obj.variations)); + t.true(Array.isArray(obj.targets)); + t.true(Array.isArray(obj.rules)); + t.is(typeof obj.offVariation, 'string'); + assertStringArray(t, obj.tags); + t.is(typeof obj.maintainer, 'string'); + assertAllowedString(t, obj.variationType, VARIATION_TYPES); + t.is(typeof obj.archived, 'boolean'); + t.is(typeof obj.samplingSeed, 'string'); + (obj.variations as unknown[]).forEach((v) => assertVariation(t, v)); + (obj.targets as unknown[]).forEach((target) => assertTarget(t, target)); + (obj.rules as unknown[]).forEach((rule) => assertRule(t, rule)); + if (obj.defaultStrategy != null) assertStrategy(t, obj.defaultStrategy); + if (obj.lastUsedInfo != null) assertFeatureLastUsedInfo(t, obj.lastUsedInfo); + if (obj.prerequisites != null) { + t.true(Array.isArray(obj.prerequisites)); + (obj.prerequisites as unknown[]).forEach((p) => assertPrerequisite(t, p)); + } +} + +function assertSegmentUsers(t: ExecutionContext, su: unknown): void { + t.truthy(su); + const obj = su as Record; + t.is(typeof obj.segmentId, 'string'); + t.is(typeof obj.updatedAt, 'string'); + t.true(Array.isArray(obj.users)); + (obj.users as unknown[]).forEach((user) => assertSegmentUser(t, user)); +} + +function assertEvaluation(t: ExecutionContext, e: unknown): void { + t.truthy(e); + const obj = e as Record; + t.is(typeof obj.id, 'string'); + t.is(typeof obj.featureId, 'string'); + t.is(typeof obj.featureVersion, 'number'); + t.is(typeof obj.userId, 'string'); + t.is(typeof obj.variationId, 'string'); + t.is(typeof obj.variationName, 'string'); + t.is(typeof obj.variationValue, 'string'); + if (obj.reason !== undefined) { + assertReason(t, obj.reason); + } +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +test('getFeatureFlags: response schema is valid', async (t) => { + const client = new APIClient(API_ENDPOINT, SERVER_API_KEY); + + const [res] = await client.getFeatureFlags( + FEATURE_TAG, + '', + 1, + SourceId.NODE_SERVER, + nodeSDKVersion, + ); + + t.is(typeof res.featureFlagsId, 'string'); + t.true(Array.isArray(res.features)); + assertStringArray(t, res.archivedFeatureFlagIds); + t.is(typeof res.requestedAt, 'string'); + t.is(typeof res.forceUpdate, 'boolean'); + + t.true(res.features.length >= FEATURE_IDS.length); + res.features.forEach((feature) => assertFeature(t, feature)); + + const featuresById = new Map(res.features.map((feature) => [feature.id, feature])); + FEATURE_IDS.forEach((featureId) => t.true(featuresById.has(featureId))); + + t.true((featuresById.get(FEATURE_ID_BOOLEAN)?.targets.length ?? 0) > 0); + t.true((featuresById.get(FEATURE_ID_STRING)?.rules.length ?? 0) > 0); +}); + +test('getSegmentUsers: response schema is valid', async (t) => { + const client = new APIClient(API_ENDPOINT, SERVER_API_KEY); + + const [res] = await client.getSegmentUsers( + [], + 1, + SourceId.NODE_SERVER, + nodeSDKVersion, + ); + + t.true(Array.isArray(res.segmentUsers)); + assertStringArray(t, res.deletedSegmentIds); + t.is(typeof res.requestedAt, 'string'); + t.is(typeof res.forceUpdate, 'boolean'); + + t.true(res.segmentUsers.length > 0); + res.segmentUsers.forEach((segmentUsers) => assertSegmentUsers(t, segmentUsers)); +}); + +test('getEvaluation: response schema is valid', async (t) => { + const client = new APIClient(API_ENDPOINT, CLIENT_API_KEY); + const user = { id: TARGETED_USER_ID, data: {} }; + + const [res] = await client.getEvaluation( + FEATURE_TAG, + user, + FEATURE_ID_BOOLEAN, + SourceId.NODE_SERVER, + nodeSDKVersion, + ); + + t.truthy(res.evaluation); + assertEvaluation(t, res.evaluation); +}); diff --git a/src/objects/feature.ts b/src/objects/feature.ts index c2cc153..c874fae 100644 --- a/src/objects/feature.ts +++ b/src/objects/feature.ts @@ -36,8 +36,8 @@ export type Rule = { }; export type Variation = { - id?: string; - value?: string; + id: string; + value: string; name?: string; description?: string; };