Fix features.spec.ts.

This commit is contained in:
Sam Rose 2025-04-02 17:47:09 +01:00
parent ddb448f06e
commit 99f9b3585f
No known key found for this signature in database
1 changed files with 111 additions and 66 deletions

View File

@ -4,7 +4,6 @@ import * as context from "../../context"
import environment, { withEnv } from "../../environment" import environment, { withEnv } from "../../environment"
import nodeFetch from "node-fetch" import nodeFetch from "node-fetch"
import nock from "nock" import nock from "nock"
import * as crypto from "crypto"
const schema = { const schema = {
TEST_BOOLEAN: false, TEST_BOOLEAN: false,
@ -16,26 +15,74 @@ interface TestCase {
it: string it: string
identity?: Partial<IdentityContext> identity?: Partial<IdentityContext>
environmentFlags?: string environmentFlags?: string
posthogFlags?: PostHogFlags posthogFlags?: Record<string, boolean>
expected?: Partial<typeof schema> expected?: Partial<typeof schema>
errorMessage?: string | RegExp errorMessage?: string | RegExp
} }
interface PostHogFlags { interface Property {
featureFlags?: Record<string, boolean> key: string
featureFlagPayloads?: Record<string, string> value: string
operator: string
type: string
} }
function mockPosthogFlags( interface Group {
flags: PostHogFlags, properties: Property[]
opts?: { token?: string; distinct_id?: string } rollout_percentage: number
) { variant: string | null
const { token = "test", distinct_id = "us_1234" } = opts || {} }
interface Filters {
groups: Group[]
}
interface FlagRules {
active: boolean
deleted: boolean
ensure_experience_continuity: boolean
filters: Filters
has_encrypted_payloads: boolean
id: string
key: string
name: string
team_id: number
version: number
}
interface LocalEvaluationResponse {
flags: FlagRules[]
}
function posthogFlags(flags: Record<string, boolean>): LocalEvaluationResponse {
return {
flags: Object.entries(flags).map(([name, value]) => ({
active: value,
deleted: false,
ensure_experience_continuity: false,
filters: {
groups: [
{
properties: [],
rollout_percentage: 100,
variant: null,
},
],
},
version: 2,
has_encrypted_payloads: false,
id: name,
name,
team_id: 1,
key: name,
})),
}
}
function mockPosthogFlags(flags: Record<string, boolean>) {
nock("https://us.i.posthog.com") nock("https://us.i.posthog.com")
.post("/decide/?v=3", body => { .get("/api/feature_flag/local_evaluation?token=test&send_cohorts")
return body.token === token && body.distinct_id === distinct_id .reply(200, posthogFlags(flags))
})
.reply(200, flags)
.persist() .persist()
} }
@ -76,33 +123,27 @@ describe("feature flags", () => {
}, },
{ {
it: "should be able to read boolean flags from PostHog", it: "should be able to read boolean flags from PostHog",
posthogFlags: { posthogFlags: { TEST_BOOLEAN: true },
featureFlags: { TEST_BOOLEAN: true },
},
expected: { TEST_BOOLEAN: true }, expected: { TEST_BOOLEAN: true },
}, },
{ {
it: "should not be able to override a negative environment flag from PostHog", it: "should not be able to override a negative environment flag from PostHog",
environmentFlags: "default:!TEST_BOOLEAN", environmentFlags: "default:!TEST_BOOLEAN",
posthogFlags: { posthogFlags: { TEST_BOOLEAN: true },
featureFlags: { TEST_BOOLEAN: true },
},
expected: { TEST_BOOLEAN: false }, expected: { TEST_BOOLEAN: false },
}, },
{ {
it: "should not be able to override a positive environment flag from PostHog", it: "should not be able to override a positive environment flag from PostHog",
environmentFlags: "default:TEST_BOOLEAN", environmentFlags: "default:TEST_BOOLEAN",
posthogFlags: { posthogFlags: {
featureFlags: { TEST_BOOLEAN: false,
TEST_BOOLEAN: false,
},
}, },
expected: { TEST_BOOLEAN: true }, expected: { TEST_BOOLEAN: true },
}, },
{ {
it: "should not error on unrecognised PostHog flag", it: "should not error on unrecognised PostHog flag",
posthogFlags: { posthogFlags: {
featureFlags: { UNDEFINED: true }, UNDEFINED: true,
}, },
expected: flags.defaults(), expected: flags.defaults(),
}, },
@ -136,6 +177,8 @@ describe("feature flags", () => {
// We need to pass in node-fetch here otherwise nock won't get used // We need to pass in node-fetch here otherwise nock won't get used
// because posthog-node uses axios under the hood. // because posthog-node uses axios under the hood.
init({ init({
// Required for local evaluation rule polling to start
personalApiKey: "test",
fetch: (url, opts) => { fetch: (url, opts) => {
return nodeFetch(url, opts) return nodeFetch(url, opts)
}, },
@ -151,23 +194,25 @@ describe("feature flags", () => {
...identity, ...identity,
} }
await context.doInIdentityContext(fullIdentity, async () => { try {
if (errorMessage) { await context.doInIdentityContext(fullIdentity, async () => {
await expect(flags.fetch()).rejects.toThrow(errorMessage) if (errorMessage) {
} else if (expected) { await expect(flags.fetch()).rejects.toThrow(errorMessage)
const values = await flags.fetch() } else if (expected) {
expect(values).toMatchObject(expected) const values = await flags.fetch()
expect(values).toMatchObject(expected)
for (const [key, expectedValue] of Object.entries(expected)) { for (const [key, expectedValue] of Object.entries(expected)) {
const value = await flags.isEnabled(key as keyof typeof schema) const value = await flags.isEnabled(key as keyof typeof schema)
expect(value).toBe(expectedValue) expect(value).toBe(expectedValue)
}
} else {
throw new Error("No expected value")
} }
} else { })
throw new Error("No expected value") } finally {
} shutdown()
}) }
shutdown()
}) })
} }
) )
@ -185,26 +230,30 @@ describe("feature flags", () => {
// We need to pass in node-fetch here otherwise nock won't get used // We need to pass in node-fetch here otherwise nock won't get used
// because posthog-node uses axios under the hood. // because posthog-node uses axios under the hood.
init({ init({
// Required for local evaluation rule polling to start
personalApiKey: "test",
fetch: (url, opts) => { fetch: (url, opts) => {
return nodeFetch(url, opts) return nodeFetch(url, opts)
}, },
}) })
nock("https://us.i.posthog.com") nock("https://us.i.posthog.com")
.post("/decide/?v=3", body => { .get("/api/feature_flag/local_evaluation?token=test&send_cohorts")
return body.token === "test" && body.distinct_id === "us_1234"
})
.reply(503) .reply(503)
.persist() .persist()
await withEnv( try {
{ POSTHOG_TOKEN: "test", POSTHOG_API_HOST: "https://us.i.posthog.com" }, await withEnv(
async () => { { POSTHOG_TOKEN: "test", POSTHOG_API_HOST: "https://us.i.posthog.com" },
await context.doInIdentityContext(identity, async () => { async () => {
await flags.fetch() await context.doInIdentityContext(identity, async () => {
}) await flags.fetch()
} })
) }
)
} finally {
shutdown()
}
}) })
it("should still get flags when user is logged out", async () => { it("should still get flags when user is logged out", async () => {
@ -216,34 +265,30 @@ describe("feature flags", () => {
} }
const ip = "127.0.0.1" const ip = "127.0.0.1"
const hashedIp = crypto.createHash("sha512").update(ip).digest("hex")
await withEnv(env, async () => { await withEnv(env, async () => {
mockPosthogFlags( mockPosthogFlags({ TEST_BOOLEAN: true })
{
featureFlags: { TEST_BOOLEAN: true },
},
{
distinct_id: hashedIp,
}
)
// We need to pass in node-fetch here otherwise nock won't get used // We need to pass in node-fetch here otherwise nock won't get used
// because posthog-node uses axios under the hood. // because posthog-node uses axios under the hood.
init({ init({
// Required for local evaluation rule polling to start
personalApiKey: "test",
fetch: (url, opts) => { fetch: (url, opts) => {
return nodeFetch(url, opts) return nodeFetch(url, opts)
}, },
}) })
await context.doInIPContext(ip, async () => { try {
await context.doInTenant("default", async () => { await context.doInIPContext(ip, async () => {
const result = await flags.fetch() await context.doInTenant("default", async () => {
expect(result.TEST_BOOLEAN).toBe(true) const result = await flags.fetch()
expect(result.TEST_BOOLEAN).toBe(true)
})
}) })
}) } finally {
shutdown()
shutdown() }
}) })
}) })
}) })