From c22d37b08bbf94ca60661167e0e3dc88f3618cd7 Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Mon, 19 Jan 2026 16:19:29 -0300 Subject: [PATCH] test: add unit tests for API client and utilities - Add CI workflow test job - Add unit tests for FlagsmithClient (12 tests) - Add unit tests for flagHelpers utilities (11 tests) Co-Authored-By: Claude Opus 4.5 --- .github/workflows/ci.yml | 19 ++++ src/api/FlagsmithClient.test.ts | 155 ++++++++++++++++++++++++++++++++ src/utils/flagHelpers.test.ts | 104 +++++++++++++++++++++ 3 files changed, 278 insertions(+) create mode 100644 src/api/FlagsmithClient.test.ts create mode 100644 src/utils/flagHelpers.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f9d27e6..f72e44e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,3 +63,22 @@ jobs: - name: Build package run: yarn build:all + + test: + name: Test + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'yarn' + + - name: Install dependencies + run: yarn install --frozen-lockfile + + - name: Run tests + run: yarn test --coverage --ci diff --git a/src/api/FlagsmithClient.test.ts b/src/api/FlagsmithClient.test.ts new file mode 100644 index 0000000..ac3ad3e --- /dev/null +++ b/src/api/FlagsmithClient.test.ts @@ -0,0 +1,155 @@ +import { FlagsmithClient } from './FlagsmithClient'; + +describe('FlagsmithClient', () => { + const baseUrl = 'http://localhost:7007/api/proxy/flagsmith'; + let client: FlagsmithClient; + let mockFetch: jest.Mock; + + beforeEach(() => { + mockFetch = jest.fn(); + client = new FlagsmithClient( + { getBaseUrl: jest.fn().mockResolvedValue('http://localhost:7007/api/proxy') }, + { fetch: mockFetch }, + ); + }); + + const mockOk = (data: unknown) => ({ ok: true, json: async () => data }); + const mockError = (status: string) => ({ ok: false, statusText: status }); + + describe('getOrganizations', () => { + it('fetches organizations', async () => { + const org = { id: 1, name: 'Test Org', created_date: '2024-01-01' }; + mockFetch.mockResolvedValue(mockOk({ results: [org] })); + + const result = await client.getOrganizations(); + + expect(mockFetch).toHaveBeenCalledWith(`${baseUrl}/organisations/`); + expect(result).toEqual([org]); + }); + + it('throws on error', async () => { + mockFetch.mockResolvedValue(mockError('Unauthorized')); + await expect(client.getOrganizations()).rejects.toThrow('Failed to fetch organizations: Unauthorized'); + }); + }); + + describe('getProjectsInOrg', () => { + it('fetches projects', async () => { + const project = { id: 1, name: 'Project', organisation: 1, created_date: '2024-01-01' }; + mockFetch.mockResolvedValue(mockOk({ results: [project] })); + + const result = await client.getProjectsInOrg(1); + + expect(mockFetch).toHaveBeenCalledWith(`${baseUrl}/organisations/1/projects/`); + expect(result).toEqual([project]); + }); + }); + + describe('getProject', () => { + it('fetches single project', async () => { + const project = { id: 123, name: 'Project', organisation: 1, created_date: '2024-01-01' }; + mockFetch.mockResolvedValue(mockOk(project)); + + const result = await client.getProject(123); + + expect(mockFetch).toHaveBeenCalledWith(`${baseUrl}/projects/123/`); + expect(result).toEqual(project); + }); + }); + + describe('getProjectFeatures', () => { + it('fetches features', async () => { + const features = [{ id: 1, name: 'feature', created_date: '2024-01-01', project: 1 }]; + mockFetch.mockResolvedValue(mockOk({ results: features })); + + const result = await client.getProjectFeatures('123'); + + expect(mockFetch).toHaveBeenCalledWith(`${baseUrl}/projects/123/features/`); + expect(result).toEqual(features); + }); + }); + + describe('getProjectEnvironments', () => { + it('fetches environments', async () => { + const envs = [{ id: 1, name: 'Dev', api_key: 'key', project: 123 }]; + mockFetch.mockResolvedValue(mockOk({ results: envs })); + + const result = await client.getProjectEnvironments(123); + + expect(mockFetch).toHaveBeenCalledWith(`${baseUrl}/projects/123/environments/`); + expect(result).toEqual(envs); + }); + }); + + describe('getUsageData', () => { + it('fetches usage data', async () => { + const usage = [{ flags: 100, identities: 50, traits: 25, environment_document: 10, day: '2024-01-01', labels: {} }]; + mockFetch.mockResolvedValue(mockOk(usage)); + + const result = await client.getUsageData(1); + + expect(mockFetch).toHaveBeenCalledWith(`${baseUrl}/organisations/1/usage-data/`); + expect(result).toEqual(usage); + }); + + it('includes project_id filter', async () => { + mockFetch.mockResolvedValue(mockOk([])); + await client.getUsageData(1, 123); + expect(mockFetch).toHaveBeenCalledWith(`${baseUrl}/organisations/1/usage-data/?project_id=123`); + }); + }); + + describe('getFeatureVersions', () => { + it('fetches versions', async () => { + const versions = [{ uuid: 'v1', is_live: true, published: true }]; + mockFetch.mockResolvedValue(mockOk({ results: versions })); + + const result = await client.getFeatureVersions(1, 100); + + expect(mockFetch).toHaveBeenCalledWith(`${baseUrl}/environments/1/features/100/versions/`); + expect(result).toEqual(versions); + }); + }); + + describe('getFeatureStates', () => { + it('fetches states', async () => { + const states = [{ id: 1, enabled: true, feature_segment: null }]; + mockFetch.mockResolvedValue(mockOk(states)); + + const result = await client.getFeatureStates(1, 100, 'uuid'); + + expect(mockFetch).toHaveBeenCalledWith(`${baseUrl}/environments/1/features/100/versions/uuid/featurestates/`); + expect(result).toEqual(states); + }); + }); + + describe('getFeatureDetails', () => { + it('combines versions and states', async () => { + const versions = [{ uuid: 'v1', is_live: true, published: true }]; + const states = [ + { id: 1, enabled: true, feature_segment: null }, + { id: 2, enabled: true, feature_segment: { segment: 1, priority: 1 } }, + ]; + + mockFetch + .mockResolvedValueOnce(mockOk({ results: versions })) + .mockResolvedValueOnce(mockOk(states)); + + const result = await client.getFeatureDetails(1, 100); + + expect(result.liveVersion).toEqual(versions[0]); + expect(result.featureState).toEqual(states); + expect(result.segmentOverrides).toBe(1); + }); + + it('returns nulls when no live version', async () => { + mockFetch.mockResolvedValue(mockOk({ results: [] })); + + const result = await client.getFeatureDetails(1, 100); + + expect(result.liveVersion).toBeNull(); + expect(result.featureState).toBeNull(); + expect(result.segmentOverrides).toBe(0); + }); + }); +}); diff --git a/src/utils/flagHelpers.test.ts b/src/utils/flagHelpers.test.ts new file mode 100644 index 0000000..a65acb7 --- /dev/null +++ b/src/utils/flagHelpers.test.ts @@ -0,0 +1,104 @@ +import { + getFeatureEnvStatus, + buildEnvStatusTooltip, + calculateFeatureStats, + paginate, +} from './flagHelpers'; +import { FlagsmithFeature, FlagsmithEnvironment } from '../api/FlagsmithClient'; + +describe('flagHelpers', () => { + describe('getFeatureEnvStatus', () => { + const feature: FlagsmithFeature = { + id: 1, + name: 'test', + created_date: '2024-01-01', + project: 1, + default_enabled: true, + environment_state: [ + { id: 1, enabled: true }, + { id: 2, enabled: false }, + ], + }; + + it('returns status from environment_state', () => { + expect(getFeatureEnvStatus(feature, 1)).toBe(true); + expect(getFeatureEnvStatus(feature, 2)).toBe(false); + }); + + it('falls back to default_enabled when env not found', () => { + expect(getFeatureEnvStatus(feature, 999)).toBe(true); + }); + + it('falls back to default_enabled when environment_state is null', () => { + const f = { ...feature, environment_state: null }; + expect(getFeatureEnvStatus(f, 1)).toBe(true); + }); + + it('returns false when no environment_state and no default_enabled', () => { + const f = { id: 1, name: 'test', created_date: '2024-01-01', project: 1 }; + expect(getFeatureEnvStatus(f, 1)).toBe(false); + }); + }); + + describe('buildEnvStatusTooltip', () => { + const feature: FlagsmithFeature = { + id: 1, + name: 'test', + created_date: '2024-01-01', + project: 1, + environment_state: [ + { id: 1, enabled: true }, + { id: 2, enabled: false }, + ], + }; + const envs: FlagsmithEnvironment[] = [ + { id: 1, name: 'Dev', api_key: 'k1', project: 1 }, + { id: 2, name: 'Prod', api_key: 'k2', project: 1 }, + ]; + + it('builds tooltip with all environments', () => { + expect(buildEnvStatusTooltip(feature, envs)).toBe('Dev: On • Prod: Off'); + }); + + it('returns empty string for empty environments', () => { + expect(buildEnvStatusTooltip(feature, [])).toBe(''); + }); + }); + + describe('calculateFeatureStats', () => { + it('counts enabled and disabled features', () => { + const features: FlagsmithFeature[] = [ + { id: 1, name: 'f1', created_date: '2024-01-01', project: 1, default_enabled: true }, + { id: 2, name: 'f2', created_date: '2024-01-01', project: 1, default_enabled: false }, + { id: 3, name: 'f3', created_date: '2024-01-01', project: 1, default_enabled: true }, + ]; + + const stats = calculateFeatureStats(features); + + expect(stats.enabledCount).toBe(2); + expect(stats.disabledCount).toBe(1); + }); + + it('returns zeros for empty array', () => { + expect(calculateFeatureStats([])).toEqual({ enabledCount: 0, disabledCount: 0 }); + }); + }); + + describe('paginate', () => { + const items = ['a', 'b', 'c', 'd', 'e']; + + it('returns correct page', () => { + expect(paginate(items, 0, 2)).toEqual({ paginatedItems: ['a', 'b'], totalPages: 3 }); + expect(paginate(items, 1, 2)).toEqual({ paginatedItems: ['c', 'd'], totalPages: 3 }); + expect(paginate(items, 2, 2)).toEqual({ paginatedItems: ['e'], totalPages: 3 }); + }); + + it('handles empty array', () => { + expect(paginate([], 0, 10)).toEqual({ paginatedItems: [], totalPages: 0 }); + }); + + it('handles out of bounds page', () => { + expect(paginate(items, 10, 2).paginatedItems).toEqual([]); + }); + }); +});