diff --git a/index.test.ts b/index.test.ts index 1679e3b..69ef0ee 100644 --- a/index.test.ts +++ b/index.test.ts @@ -77,6 +77,72 @@ describe("createRocketflagClient", () => { expect(flag).toEqual(mockFlag); }); + it("should fetch a flag with env in the user context", async () => { + const mockFlag: FlagStatus = { name: "Test Flag", enabled: true, id: flagId }; + (fetch as jest.Mock).mockResolvedValue({ ok: true, json: () => Promise.resolve(mockFlag) }); + + const client = createRocketflagClient(); + const flag = await client.getFlag(flagId, { env: "staging" }); + + expect(fetch).toHaveBeenCalledTimes(1); + + const expectedUrl = `${apiUrl}/v1/flags/${flagId}?env=staging`; + const expectedURLObject = new URL(expectedUrl); + + expect(fetch).toHaveBeenCalledWith(expect.objectContaining({ href: expectedURLObject.href }), { method: "GET" }); + expect(flag).toEqual(mockFlag); + }); + + it("should fetch a flag with cohort and env in the user context", async () => { + const mockFlag: FlagStatus = { name: "Test Flag", enabled: true, id: flagId }; + (fetch as jest.Mock).mockResolvedValue({ ok: true, json: () => Promise.resolve(mockFlag) }); + + const client = createRocketflagClient(); + const flag = await client.getFlag(flagId, { cohort: "user123", env: "staging" }); + + expect(fetch).toHaveBeenCalledTimes(1); + + const expectedUrl = `${apiUrl}/v1/flags/${flagId}?cohort=user123&env=staging`; + const expectedURLObject = new URL(expectedUrl); + + expect(fetch).toHaveBeenCalledWith(expect.objectContaining({ href: expectedURLObject.href }), { method: "GET" }); + expect(flag).toEqual(mockFlag); + }); + + describe("userContext validation", () => { + it.each([ + { value: "staging", shouldThrow: false }, + { value: "123", shouldThrow: false }, + { value: "production1", shouldThrow: false }, + { value: "test2", shouldThrow: false }, + { value: "staging test", shouldThrow: true }, + { value: "staging-test", shouldThrow: true }, + { value: "staging!", shouldThrow: true }, + { value: "staging@test", shouldThrow: true }, + { value: "staging+test@rocketflag.com", shouldThrow: true }, + ])("should handle env value: $value", async ({ value, shouldThrow }) => { + const client = createRocketflagClient(); + + if (shouldThrow) { + await expect(client.getFlag(flagId, { env: value })).rejects.toThrow( + "env values must be alphanumeric. Invalid value for env: env", + ); + } else { + const mockFlag: FlagStatus = { name: "Test Flag", enabled: true, id: flagId }; + (fetch as jest.Mock).mockResolvedValue({ ok: true, json: () => Promise.resolve(mockFlag) }); + await expect(client.getFlag(flagId, { env: value })).resolves.toEqual(mockFlag); + } + }); + }); + + it("should throw an error if env in userContext contains invalid values", async () => { + const client = createRocketflagClient(); + const invalidUserContext = { env: { a: 1 } }; + await expect(client.getFlag(flagId, invalidUserContext as unknown as UserContext)).rejects.toThrow( + "userContext values must be of type string, number, or boolean. Invalid value for key: env", + ); + }); + it("should throw an APIError on non-ok response with correct status and statusText", async () => { (fetch as jest.Mock).mockResolvedValue({ ok: false, @@ -126,7 +192,7 @@ describe("createRocketflagClient", () => { const client = createRocketflagClient(); const invalidUserContext = { cohort: { a: 1 } }; await expect(client.getFlag(flagId, invalidUserContext as unknown as UserContext)).rejects.toThrow( - "userContext values must be of type string, number, or boolean. Invalid value for key: cohort" + "userContext values must be of type string, number, or boolean. Invalid value for key: cohort", ); }); diff --git a/index.ts b/index.ts index 0de535b..f25a752 100644 --- a/index.ts +++ b/index.ts @@ -4,6 +4,7 @@ import { validateFlag } from "./validateFlag"; const GET_METHOD = "GET"; const DEFAULT_API_URL = "https://api.rocketflag.app"; const DEFAULT_VERSION = "v1"; +const ALPHANUMERIC_REGEX = /^[a-zA-Z0-9]+$/; export type FlagStatus = { name: string; @@ -13,6 +14,7 @@ export type FlagStatus = { export interface UserContext { cohort?: string | number | boolean; + env?: string; } export interface RocketFlagClient { @@ -36,6 +38,9 @@ const createRocketflagClient = (version = DEFAULT_VERSION, apiUrl = DEFAULT_API_ if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean") { throw new Error(`userContext values must be of type string, number, or boolean. Invalid value for key: ${key}`); } + if (key === "env" && (typeof value !== "string" || !ALPHANUMERIC_REGEX.test(value))) { + throw new Error(`env values must be alphanumeric. Invalid value for env: ${[key]}`); + } } const url = new URL(`${apiUrl}/${version}/flags/${flagId}`); diff --git a/package.json b/package.json index 2a13293..313e426 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@rocketflag/node-sdk", - "version": "1.0.0", + "version": "1.1.0", "author": "RocketFlag Developers (https://rocketflag.app)", "main": "index.js", "scripts": {