Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: Handle uncaught fetch exception #256

Merged
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
5 changes: 4 additions & 1 deletion flagsmith-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -479,7 +479,10 @@ const Flagsmith = class {
}
if (shouldFetchFlags) {
// We want to resolve init since we have cached flags
this.getFlags();

this.getFlags().catch((error) => {
this.onError?.(error)
})
}
} else {
if (!preventFetch) {
Expand Down
259 changes: 179 additions & 80 deletions test/init.test.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
// Sample test
import { defaultState, environmentID, getFlagsmith, getStateToCheck, identityState } from './test-constants';
import { promises as fs } from 'fs'
import { waitFor } from '@testing-library/react';
import { defaultState, getFlagsmith, getStateToCheck, identityState } from './test-constants';
import { promises as fs } from 'fs';

describe('Flagsmith.init', () => {

beforeEach(() => {
// Avoid mocks, but if you need to add them here
});
test('should initialize with expected values', async () => {
const onChange = jest.fn()
const {flagsmith,initConfig, AsyncStorage,mockFetch} = getFlagsmith({onChange})
const onChange = jest.fn();
const { flagsmith, initConfig, AsyncStorage, mockFetch } = getFlagsmith({ onChange });
await flagsmith.init(initConfig);

expect(flagsmith.environmentID).toBe(initConfig.environmentID);
Expand All @@ -19,14 +19,17 @@ describe('Flagsmith.init', () => {
expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledWith(
{},
{"flagsChanged": Object.keys(defaultState.flags), "isFromServer": true, "traitsChanged": null},
{"error": null, "isFetching": false, "isLoading": false, "source": "SERVER"}
{ flagsChanged: Object.keys(defaultState.flags), isFromServer: true, traitsChanged: null },
{ error: null, isFetching: false, isLoading: false, source: 'SERVER' },
);
expect(getStateToCheck(flagsmith.getState())).toEqual(defaultState)
expect(getStateToCheck(flagsmith.getState())).toEqual(defaultState);
});
test('should initialize with identity', async () => {
const onChange = jest.fn()
const {flagsmith,initConfig, AsyncStorage,mockFetch} = getFlagsmith({onChange, identity:"test_identity"})
const onChange = jest.fn();
const { flagsmith, initConfig, AsyncStorage, mockFetch } = getFlagsmith({
onChange,
identity: 'test_identity',
});
await flagsmith.init(initConfig);

expect(flagsmith.environmentID).toBe(initConfig.environmentID);
Expand All @@ -36,16 +39,27 @@ describe('Flagsmith.init', () => {
expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledWith(
{},
{"flagsChanged": Object.keys(defaultState.flags), "isFromServer": true, "traitsChanged": expect.arrayContaining(Object.keys(identityState.evaluationContext.identity.traits))},
{"error": null, "isFetching": false, "isLoading": false, "source": "SERVER"}
{
flagsChanged: Object.keys(defaultState.flags),
isFromServer: true,
traitsChanged: expect.arrayContaining(Object.keys(identityState.evaluationContext.identity.traits)),
},
{ error: null, isFetching: false, isLoading: false, source: 'SERVER' },
);
expect(getStateToCheck(flagsmith.getState())).toEqual(identityState)
expect(getStateToCheck(flagsmith.getState())).toEqual(identityState);
});
test('should initialize with identity and traits', async () => {
const onChange = jest.fn()
const testIdentityWithTraits = `test_identity_with_traits`
const {flagsmith,initConfig, AsyncStorage,mockFetch} = getFlagsmith({onChange, identity:testIdentityWithTraits, traits:{number_trait:1, string_trait:"Example"}})
mockFetch.mockResolvedValueOnce({status: 200, text: () => fs.readFile(`./test/data/identities_${testIdentityWithTraits}.json`, 'utf8')})
const onChange = jest.fn();
const testIdentityWithTraits = `test_identity_with_traits`;
const { flagsmith, initConfig, AsyncStorage, mockFetch } = getFlagsmith({
onChange,
identity: testIdentityWithTraits,
traits: { number_trait: 1, string_trait: 'Example' },
});
mockFetch.mockResolvedValueOnce({
status: 200,
text: () => fs.readFile(`./test/data/identities_${testIdentityWithTraits}.json`, 'utf8'),
});

await flagsmith.init(initConfig);

Expand All @@ -56,97 +70,182 @@ describe('Flagsmith.init', () => {
expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledWith(
{},
{"flagsChanged": Object.keys(defaultState.flags), "isFromServer": true, "traitsChanged": ["number_trait","string_trait"]},
{"error": null, "isFetching": false, "isLoading": false, "source": "SERVER"}
{
flagsChanged: Object.keys(defaultState.flags),
isFromServer: true,
traitsChanged: ['number_trait', 'string_trait'],
},
{ error: null, isFetching: false, isLoading: false, source: 'SERVER' },
);
expect(getStateToCheck(flagsmith.getState())).toEqual({
...identityState,
evaluationContext: {
...identityState.evaluationContext,
identity: {
...identityState.evaluationContext.identity,
identifier: testIdentityWithTraits
identifier: testIdentityWithTraits,
},
},
})
});
});
test('should reject initialize with identity no key', async () => {
const onChange = jest.fn()
const {flagsmith,initConfig} = getFlagsmith({onChange, evaluationContext:{environment:{apiKey: ""}}})
const onChange = jest.fn();
const { flagsmith, initConfig } = getFlagsmith({
onChange,
evaluationContext: { environment: { apiKey: '' } },
});
await expect(flagsmith.init(initConfig)).rejects.toThrow(Error);
});
test('should reject initialize with identity bad key', async () => {
const onChange = jest.fn()
const {flagsmith,initConfig,mockFetch} = getFlagsmith({onChange, environmentID:"bad"})
mockFetch.mockResolvedValueOnce({status: 404, text: async () => ''})
const onChange = jest.fn();
const { flagsmith, initConfig, mockFetch } = getFlagsmith({ onChange, environmentID: 'bad' });
mockFetch.mockResolvedValueOnce({ status: 404, text: async () => '' });
await expect(flagsmith.init(initConfig)).rejects.toThrow(Error);
});
test('identifying with new identity should not carry over previous traits for different identity', async () => {
const onChange = jest.fn()
const identityA = `test_identity_a`
const identityB = `test_identity_b`
const {flagsmith,initConfig,mockFetch} = getFlagsmith({onChange, identity:identityA, traits: {a:`example`}})
mockFetch.mockResolvedValueOnce({status: 200, text: () => fs.readFile(`./test/data/identities_${identityA}.json`, 'utf8')})
const onChange = jest.fn();
const identityA = `test_identity_a`;
const identityB = `test_identity_b`;
const { flagsmith, initConfig, mockFetch } = getFlagsmith({
onChange,
identity: identityA,
traits: { a: `example` },
});
mockFetch.mockResolvedValueOnce({
status: 200,
text: () => fs.readFile(`./test/data/identities_${identityA}.json`, 'utf8'),
});
await flagsmith.init(initConfig);
expect(flagsmith.getTrait("a")).toEqual(`example`)
mockFetch.mockResolvedValueOnce({status: 200, text: () => fs.readFile(`./test/data/identities_${identityB}.json`, 'utf8')})
await flagsmith.identify(identityB)
expect(flagsmith.getTrait("a")).toEqual(undefined)
mockFetch.mockResolvedValueOnce({status: 200, text: () => fs.readFile(`./test/data/identities_${identityA}.json`, 'utf8')})
await flagsmith.identify(identityA)
expect(flagsmith.getTrait("a")).toEqual(`example`)
mockFetch.mockResolvedValueOnce({status: 200, text: () => fs.readFile(`./test/data/identities_${identityB}.json`, 'utf8')})
await flagsmith.identify(identityB)
expect(flagsmith.getTrait("a")).toEqual(undefined)
expect(flagsmith.getTrait('a')).toEqual(`example`);
mockFetch.mockResolvedValueOnce({
status: 200,
text: () => fs.readFile(`./test/data/identities_${identityB}.json`, 'utf8'),
});
await flagsmith.identify(identityB);
expect(flagsmith.getTrait('a')).toEqual(undefined);
mockFetch.mockResolvedValueOnce({
status: 200,
text: () => fs.readFile(`./test/data/identities_${identityA}.json`, 'utf8'),
});
await flagsmith.identify(identityA);
expect(flagsmith.getTrait('a')).toEqual(`example`);
mockFetch.mockResolvedValueOnce({
status: 200,
text: () => fs.readFile(`./test/data/identities_${identityB}.json`, 'utf8'),
});
await flagsmith.identify(identityB);
expect(flagsmith.getTrait('a')).toEqual(undefined);
});
test('identifying with transient identity should request the API correctly', async () => {
const onChange = jest.fn()
const testTransientIdentity = `test_transient_identity`
const onChange = jest.fn();
const testTransientIdentity = `test_transient_identity`;
const evaluationContext = {
identity: {identifier: testTransientIdentity, transient: true}
}
const {flagsmith,initConfig,mockFetch} = getFlagsmith({onChange, evaluationContext})
mockFetch.mockResolvedValueOnce({status: 200, text: () => fs.readFile(`./test/data/identities_${testTransientIdentity}.json`, 'utf8')})
identity: { identifier: testTransientIdentity, transient: true },
};
const { flagsmith, initConfig, mockFetch } = getFlagsmith({ onChange, evaluationContext });
mockFetch.mockResolvedValueOnce({
status: 200,
text: () => fs.readFile(`./test/data/identities_${testTransientIdentity}.json`, 'utf8'),
});
await flagsmith.init(initConfig);
expect(mockFetch).toHaveBeenCalledWith(`https://edge.api.flagsmith.com/api/v1/identities/?identifier=${testTransientIdentity}&transient=true`,
expect.objectContaining({method: 'GET'}),
)
expect(mockFetch).toHaveBeenCalledWith(
`https://edge.api.flagsmith.com/api/v1/identities/?identifier=${testTransientIdentity}&transient=true`,
expect.objectContaining({ method: 'GET' }),
);
});
test('identifying with transient traits should request the API correctly', async () => {
const onChange = jest.fn()
const testIdentityWithTransientTraits = `test_identity_with_transient_traits`
const onChange = jest.fn();
const testIdentityWithTransientTraits = `test_identity_with_transient_traits`;
const evaluationContext = {
identity: {
identifier: testIdentityWithTransientTraits,
traits: {
number_trait: {value: 1},
string_trait: {value: 'Example'},
transient_trait: {value: 'Example', transient: true},
}
}
}
const {flagsmith,initConfig,mockFetch} = getFlagsmith({onChange, evaluationContext})
mockFetch.mockResolvedValueOnce({status: 200, text: () => fs.readFile(`./test/data/identities_${testIdentityWithTransientTraits}.json`, 'utf8')})
number_trait: { value: 1 },
string_trait: { value: 'Example' },
transient_trait: { value: 'Example', transient: true },
},
},
};
const { flagsmith, initConfig, mockFetch } = getFlagsmith({ onChange, evaluationContext });
mockFetch.mockResolvedValueOnce({
status: 200,
text: () => fs.readFile(`./test/data/identities_${testIdentityWithTransientTraits}.json`, 'utf8'),
});
await flagsmith.init(initConfig);
expect(mockFetch).toHaveBeenCalledWith('https://edge.api.flagsmith.com/api/v1/identities/',
expect.objectContaining({method: 'POST', body: JSON.stringify({
"identifier": testIdentityWithTransientTraits,
"traits": [
{
"trait_key": "number_trait",
"trait_value": 1
},
{
"trait_key": "string_trait",
"trait_value": "Example"
},
{
"trait_key": "transient_trait",
"trait_value": "Example",
"transient": true
}
]
})}),
)
expect(mockFetch).toHaveBeenCalledWith(
'https://edge.api.flagsmith.com/api/v1/identities/',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({
identifier: testIdentityWithTransientTraits,
traits: [
{
trait_key: 'number_trait',
trait_value: 1,
},
{
trait_key: 'string_trait',
trait_value: 'Example',
},
{
trait_key: 'transient_trait',
trait_value: 'Example',
transient: true,
},
],
}),
}),
);
});
test('should not reject but call onError, when the API cannot be reached with the cache populated', async () => {
const onError = jest.fn();
const { flagsmith, initConfig, AsyncStorage } = getFlagsmith({
cacheFlags: true,
fetch: async () => {
return Promise.resolve({ text: () => Promise.resolve('Mocked fetch error'), ok: false, status: 401 });
},
onError,
});
await AsyncStorage.setItem('BULLET_TRAIN_DB', JSON.stringify(defaultState));
await flagsmith.init(initConfig);

expect(getStateToCheck(flagsmith.getState())).toEqual(defaultState);

await waitFor(() => {
expect(onError).toHaveBeenCalledTimes(1);
});
expect(onError).toHaveBeenCalledWith(new Error('Mocked fetch error'));
});
test('should not reject when the API cannot be reached but default flags are set', async () => {
const { flagsmith, initConfig } = getFlagsmith({
defaultFlags: defaultState.flags,
cacheFlags: true,
fetch: async () => {
return Promise.resolve({ text: () => Promise.resolve('Mocked fetch error'), ok: false, status: 401 });
},
});
await flagsmith.init(initConfig);

expect(getStateToCheck(flagsmith.getState())).toEqual(defaultState);
});
test('should not reject but call onError, when the identities/ API cannot be reached with the cache populated', async () => {
const onError = jest.fn();
const { flagsmith, initConfig, AsyncStorage } = getFlagsmith({
evaluationContext: identityState.evaluationContext,
cacheFlags: true,
fetch: async () => {
return Promise.resolve({ text: () => Promise.resolve('Mocked fetch error'), ok: false, status: 401 });
},
onError,
});
await AsyncStorage.setItem('BULLET_TRAIN_DB', JSON.stringify(identityState));
await flagsmith.init(initConfig);

expect(getStateToCheck(flagsmith.getState())).toEqual(identityState);

await waitFor(() => {
expect(onError).toHaveBeenCalledTimes(1);
});
expect(onError).toHaveBeenCalledWith(new Error('Mocked fetch error'));
});
});