Skip to content
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
2 changes: 2 additions & 0 deletions src/__tests__/fixtures/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './mockData';
export * from './testUtils';
192 changes: 192 additions & 0 deletions src/__tests__/fixtures/mockData.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
import type {
FlagsmithProject,
FlagsmithEnvironment,
FlagsmithFeature,
FlagsmithUsageData,
FlagsmithFeatureVersion,
FlagsmithFeatureState,
FlagsmithOrganization,
} from '../../api/FlagsmithClient';

export const mockOrganization: FlagsmithOrganization = {
id: 1,
name: 'Test Organization',
created_date: '2024-01-01T00:00:00Z',
};

export const mockProject: FlagsmithProject = {
id: 123,
name: 'Test Project',
organisation: 1,
created_date: '2024-01-01T00:00:00Z',
};

export const mockEnvironments: FlagsmithEnvironment[] = [
{
id: 1,
name: 'Development',
api_key: 'dev-api-key',
project: 123,
},
{
id: 2,
name: 'Production',
api_key: 'prod-api-key',
project: 123,
},
];

export const mockFeatures: FlagsmithFeature[] = [
{
id: 1,
name: 'feature-one',
description: 'First test feature',
created_date: '2024-01-15T10:00:00Z',
project: 123,
default_enabled: true,
type: 'FLAG',
environment_state: [
{ id: 1, enabled: true, feature_segment: null },
{ id: 2, enabled: false, feature_segment: null },
],
num_segment_overrides: 2,
num_identity_overrides: 0,
live_version: {
is_live: true,
live_from: '2024-01-15T10:00:00Z',
published: true,
published_by: '[email protected]',
uuid: 'version-uuid-1',
},
},
{
id: 2,
name: 'feature-two',
description: 'Second test feature',
created_date: '2024-02-01T12:00:00Z',
project: 123,
default_enabled: false,
type: 'CONFIG',
environment_state: [
{ id: 1, enabled: false, feature_segment: null },
{ id: 2, enabled: false, feature_segment: null },
],
num_segment_overrides: 0,
num_identity_overrides: 1,
},
{
id: 3,
name: 'feature-three',
created_date: '2024-03-01T08:00:00Z',
project: 123,
default_enabled: true,
type: 'FLAG',
},
];

export const mockFeatureVersions: FlagsmithFeatureVersion[] = [
{
uuid: 'version-uuid-1',
is_live: true,
live_from: '2024-01-15T10:00:00Z',
published: true,
published_by: '[email protected]',
},
{
uuid: 'version-uuid-2',
is_live: false,
live_from: null,
published: false,
published_by: null,
},
];

export const mockFeatureStates: FlagsmithFeatureState[] = [
{
id: 1,
enabled: true,
environment: 1,
feature_segment: null,
feature_state_value: {
string_value: 'test-value',
integer_value: null,
boolean_value: null,
},
updated_at: '2024-01-15T10:00:00Z',
},
{
id: 2,
enabled: true,
environment: 1,
feature_segment: { segment: 100, priority: 1 },
feature_state_value: {
string_value: 'segment-value',
integer_value: null,
boolean_value: null,
},
updated_at: '2024-01-16T10:00:00Z',
},
];

export const mockUsageData: FlagsmithUsageData[] = [
{
flags: 100,
identities: 50,
traits: 25,
environment_document: 10,
day: '2024-01-01',
labels: {
client_application_name: 'test-app',
client_application_version: '1.0.0',
user_agent: 'test-agent',
},
},
{
flags: 150,
identities: 75,
traits: 30,
environment_document: 15,
day: '2024-01-02',
labels: {
client_application_name: 'test-app',
client_application_version: '1.0.0',
user_agent: 'test-agent',
},
},
{
flags: 200,
identities: 100,
traits: 50,
environment_document: 20,
day: '2024-01-03',
labels: {
client_application_name: null,
client_application_version: null,
user_agent: null,
},
},
];

// Feature with no environment state (uses default_enabled)
export const mockFeatureNoEnvState: FlagsmithFeature = {
id: 100,
name: 'no-env-state-feature',
created_date: '2024-01-01T00:00:00Z',
project: 123,
default_enabled: true,
};

// Feature with null environment state
export const mockFeatureNullEnvState: FlagsmithFeature = {
id: 101,
name: 'null-env-state-feature',
created_date: '2024-01-01T00:00:00Z',
project: 123,
default_enabled: false,
environment_state: null,
};

// Empty arrays for edge case testing
export const emptyFeatures: FlagsmithFeature[] = [];
export const emptyEnvironments: FlagsmithEnvironment[] = [];
export const emptyUsageData: FlagsmithUsageData[] = [];
177 changes: 177 additions & 0 deletions src/__tests__/fixtures/testUtils.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import { ReactElement, ReactNode } from 'react';
// eslint-disable-next-line @backstage/no-undeclared-imports
import { render, RenderOptions } from '@testing-library/react';
// eslint-disable-next-line @backstage/no-undeclared-imports
import { TestApiProvider } from '@backstage/test-utils';
import {
discoveryApiRef,
fetchApiRef,
DiscoveryApi,
} from '@backstage/core-plugin-api';
import { EntityProvider } from '@backstage/plugin-catalog-react';
import type { Entity } from '@backstage/catalog-model';

// Mock Discovery API
export const createMockDiscoveryApi = (baseUrl = 'http://localhost:7007'): DiscoveryApi => ({
getBaseUrl: jest.fn().mockResolvedValue(`${baseUrl}/api/proxy`),
});

// Mock Fetch API that tracks calls
export const createMockFetchApi = (responses: Record<string, unknown> = {}) => {
const mockFetch = jest.fn().mockImplementation(async (url: string) => {
// Find matching response based on URL pattern
for (const [pattern, data] of Object.entries(responses)) {
if (url.includes(pattern)) {
return {
ok: true,
json: async () => data,
};
}
}
// Default 404 response
return {
ok: false,
statusText: 'Not Found',
};
});

return {
fetch: mockFetch,
};
};

// Default mock entity with Flagsmith annotations
export const mockEntity: Entity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'test-component',
namespace: 'default',
annotations: {
'flagsmith.com/project-id': '123',
'flagsmith.com/org-id': '1',
},
},
spec: {
type: 'service',
lifecycle: 'production',
owner: 'team-a',
},
};

// Entity without Flagsmith annotations
export const mockEntityNoAnnotations: Entity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'test-component',
namespace: 'default',
},
spec: {
type: 'service',
lifecycle: 'production',
owner: 'team-a',
},
};

interface WrapperProps {
children: ReactNode;
}

interface RenderWithBackstageOptions extends Omit<RenderOptions, 'wrapper'> {
entity?: Entity;
discoveryApi?: DiscoveryApi;
fetchApi?: { fetch: jest.Mock };
fetchResponses?: Record<string, unknown>;
}

/**
* Render a component with Backstage providers and mock APIs
*/
export function renderWithBackstage(
ui: ReactElement,
options: RenderWithBackstageOptions = {},
) {
const {
entity = mockEntity,
discoveryApi = createMockDiscoveryApi(),
fetchApi,
fetchResponses = {},
...renderOptions
} = options;

const mockFetchApi = fetchApi || createMockFetchApi(fetchResponses);

function Wrapper({ children }: WrapperProps) {
return (
<TestApiProvider
apis={[
[discoveryApiRef, discoveryApi],
[fetchApiRef, mockFetchApi],
]}
>
<EntityProvider entity={entity}>
{children}
</EntityProvider>
</TestApiProvider>
);
}

return {
...render(ui, { wrapper: Wrapper, ...renderOptions }),
mockFetchApi,
mockDiscoveryApi: discoveryApi,
};
}

/**
* Render a component with just the API providers (no entity context)
*/
export function renderWithApis(
ui: ReactElement,
options: Omit<RenderWithBackstageOptions, 'entity'> = {},
) {
const {
discoveryApi = createMockDiscoveryApi(),
fetchApi,
fetchResponses = {},
...renderOptions
} = options;

const mockFetchApi = fetchApi || createMockFetchApi(fetchResponses);

function Wrapper({ children }: WrapperProps) {
return (
<TestApiProvider
apis={[
[discoveryApiRef, discoveryApi],
[fetchApiRef, mockFetchApi],
]}
>
{children}
</TestApiProvider>
);
}

return {
...render(ui, { wrapper: Wrapper, ...renderOptions }),
mockFetchApi,
mockDiscoveryApi: discoveryApi,
};
}

/**
* Wait for async state updates
*/
export const waitForNextTick = () => new Promise(resolve => setTimeout(resolve, 0));

/**
* Create a mock Response object
*/
export function createMockResponse(data: unknown, ok = true, statusText = 'OK') {
return {
ok,
statusText,
json: async () => data,
};
}
Loading