Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
155 changes: 155 additions & 0 deletions src/api/FlagsmithClient.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
104 changes: 104 additions & 0 deletions src/utils/flagHelpers.test.ts
Original file line number Diff line number Diff line change
@@ -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([]);
});
});
});