import { beforeEach, describe, expect, it } from 'vitest' import { buildServer } from '../../src/server.js' import { createAdminKey, createClientKey, createProject, createRootKey, } from '../helpers/fixtures.js' import { getTestDb, truncateAll } from '../helpers/db.js' const describeIfDb = process.env.TEST_DATABASE_URL ? describe : describe.skip async function getEnvironments( app: Awaited>, key: string, projectId: string, ) { const res = await app.inject({ method: 'GET', url: `/api/v1/admin/projects/${projectId}/environments`, headers: { authorization: key }, }) return res.json>() } async function createFlag( app: Awaited>, key: string, projectId: string, flagKey: string, ) { await app.inject({ method: 'POST', url: `/api/v1/admin/projects/${projectId}/flags`, headers: { authorization: key }, payload: { name: flagKey, key: flagKey }, }) } async function enableFlag( app: Awaited>, key: string, projectId: string, flagKey: string, envSlug: string, ) { await app.inject({ method: 'POST', url: `/api/v1/admin/projects/${projectId}/flags/${flagKey}/environments/${envSlug}/enable`, headers: { authorization: key }, }) } describeIfDb('client eval', () => { const db = process.env.TEST_DATABASE_URL ? getTestDb() : undefined beforeEach(async () => { process.env.DATABASE_URL = process.env.TEST_DATABASE_URL await truncateAll(db!) }) describe('GET /api/v1/client/features', () => { it('returns empty features array when no flags exist', async () => { const app = await buildServer({ db }) const rootKey = await createRootKey(db!) const project = await createProject(app, rootKey) const adminKey = await createAdminKey(app, rootKey, project.id) const envs = await getEnvironments(app, adminKey, project.id) const devEnv = envs.find((e) => e.slug === 'development')! const clientKey = await createClientKey(app, adminKey, project.id, devEnv.id) const res = await app.inject({ method: 'GET', url: '/api/v1/client/features', headers: { authorization: clientKey }, }) expect(res.statusCode).toBe(200) expect(res.json()).toEqual({ features: [] }) await app.close() }) it('returns all flags with their enabled status for the client environment', async () => { const app = await buildServer({ db }) const rootKey = await createRootKey(db!) const project = await createProject(app, rootKey) const adminKey = await createAdminKey(app, rootKey, project.id) const envs = await getEnvironments(app, adminKey, project.id) const devEnv = envs.find((e) => e.slug === 'development')! const clientKey = await createClientKey(app, adminKey, project.id, devEnv.id) await createFlag(app, adminKey, project.id, 'alpha') await createFlag(app, adminKey, project.id, 'beta') await enableFlag(app, adminKey, project.id, 'alpha', 'development') const res = await app.inject({ method: 'GET', url: '/api/v1/client/features', headers: { authorization: clientKey }, }) expect(res.statusCode).toBe(200) const { features } = res.json<{ features: Array<{ name: string; enabled: boolean }> }>() expect(features).toHaveLength(2) const alpha = features.find((f) => f.name === 'alpha') const beta = features.find((f) => f.name === 'beta') expect(alpha).toMatchObject({ name: 'alpha', enabled: true }) expect(beta).toMatchObject({ name: 'beta', enabled: false }) await app.close() }) it('shows a flag as disabled by default before it is explicitly enabled', async () => { const app = await buildServer({ db }) const rootKey = await createRootKey(db!) const project = await createProject(app, rootKey) const adminKey = await createAdminKey(app, rootKey, project.id) const envs = await getEnvironments(app, adminKey, project.id) const productionEnv = envs.find((e) => e.slug === 'production')! const clientKey = await createClientKey(app, adminKey, project.id, productionEnv.id) await createFlag(app, adminKey, project.id, 'new-flag') const res = await app.inject({ method: 'GET', url: '/api/v1/client/features', headers: { authorization: clientKey }, }) expect(res.statusCode).toBe(200) const { features } = res.json<{ features: Array<{ name: string; enabled: boolean }> }>() expect(features).toHaveLength(1) expect(features[0]).toMatchObject({ name: 'new-flag', enabled: false }) await app.close() }) it('shows a flag as enabled after it is enabled in the client environment', async () => { const app = await buildServer({ db }) const rootKey = await createRootKey(db!) const project = await createProject(app, rootKey) const adminKey = await createAdminKey(app, rootKey, project.id) const envs = await getEnvironments(app, adminKey, project.id) const productionEnv = envs.find((e) => e.slug === 'production')! const clientKey = await createClientKey(app, adminKey, project.id, productionEnv.id) await createFlag(app, adminKey, project.id, 'launched') await enableFlag(app, adminKey, project.id, 'launched', 'production') const res = await app.inject({ method: 'GET', url: '/api/v1/client/features', headers: { authorization: clientKey }, }) expect(res.statusCode).toBe(200) const { features } = res.json<{ features: Array<{ name: string; enabled: boolean }> }>() expect(features).toHaveLength(1) expect(features[0]).toMatchObject({ name: 'launched', enabled: true }) await app.close() }) it('env A client sees disabled flag while env B client sees enabled flag', async () => { const app = await buildServer({ db }) const rootKey = await createRootKey(db!) const project = await createProject(app, rootKey) const adminKey = await createAdminKey(app, rootKey, project.id) const envs = await getEnvironments(app, adminKey, project.id) const devEnv = envs.find((e) => e.slug === 'development')! const productionEnv = envs.find((e) => e.slug === 'production')! const devClient = await createClientKey(app, adminKey, project.id, devEnv.id) const productionClient = await createClientKey(app, adminKey, project.id, productionEnv.id) await createFlag(app, adminKey, project.id, 'staged-only') await enableFlag(app, adminKey, project.id, 'staged-only', 'production') const devRes = await app.inject({ method: 'GET', url: '/api/v1/client/features', headers: { authorization: devClient }, }) const productionRes = await app.inject({ method: 'GET', url: '/api/v1/client/features', headers: { authorization: productionClient }, }) const devFeatures = devRes.json<{ features: Array<{ name: string; enabled: boolean }> }>() .features const productionFeatures = productionRes.json<{ features: Array<{ name: string; enabled: boolean }> }>().features expect(devFeatures.find((f) => f.name === 'staged-only')).toMatchObject({ enabled: false }) expect(productionFeatures.find((f) => f.name === 'staged-only')).toMatchObject({ enabled: true, }) await app.close() }) it('ignores query params and returns the environment default', async () => { const app = await buildServer({ db }) const rootKey = await createRootKey(db!) const project = await createProject(app, rootKey) const adminKey = await createAdminKey(app, rootKey, project.id) const envs = await getEnvironments(app, adminKey, project.id) const productionEnv = envs.find((e) => e.slug === 'production')! const clientKey = await createClientKey(app, adminKey, project.id, productionEnv.id) await createFlag(app, adminKey, project.id, 'ctx-flag') const withQueryRes = await app.inject({ method: 'GET', url: '/api/v1/client/features?userId=user_abc', headers: { authorization: clientKey }, }) const withoutQueryRes = await app.inject({ method: 'GET', url: '/api/v1/client/features', headers: { authorization: clientKey }, }) const withQueryFeatures = withQueryRes.json<{ features: Array<{ name: string; enabled: boolean }> }>().features const withoutQueryFeatures = withoutQueryRes.json<{ features: Array<{ name: string; enabled: boolean }> }>().features expect(withQueryFeatures.find((f) => f.name === 'ctx-flag')).toMatchObject({ enabled: false, }) expect(withoutQueryFeatures.find((f) => f.name === 'ctx-flag')).toMatchObject({ enabled: false, }) await app.close() }) it('returns multiple flags together with correct statuses', async () => { const app = await buildServer({ db }) const rootKey = await createRootKey(db!) const project = await createProject(app, rootKey) const adminKey = await createAdminKey(app, rootKey, project.id) const envs = await getEnvironments(app, adminKey, project.id) const prodEnv = envs.find((e) => e.slug === 'production')! const clientKey = await createClientKey(app, adminKey, project.id, prodEnv.id) await createFlag(app, adminKey, project.id, 'flag-one') await createFlag(app, adminKey, project.id, 'flag-two') await createFlag(app, adminKey, project.id, 'flag-three') await enableFlag(app, adminKey, project.id, 'flag-one', 'production') await enableFlag(app, adminKey, project.id, 'flag-three', 'production') const res = await app.inject({ method: 'GET', url: '/api/v1/client/features', headers: { authorization: clientKey }, }) expect(res.statusCode).toBe(200) const { features } = res.json<{ features: Array<{ name: string; enabled: boolean }> }>() expect(features).toHaveLength(3) expect(features.find((f) => f.name === 'flag-one')).toMatchObject({ enabled: true }) expect(features.find((f) => f.name === 'flag-two')).toMatchObject({ enabled: false }) expect(features.find((f) => f.name === 'flag-three')).toMatchObject({ enabled: true }) // reason is not included in the bulk endpoint features.forEach((f) => expect(f).not.toHaveProperty('reason')) await app.close() }) }) describe('conditional requests on GET /api/v1/client/features', () => { async function setup() { const app = await buildServer({ db }) const rootKey = await createRootKey(db!) const project = await createProject(app, rootKey) const adminKey = await createAdminKey(app, rootKey, project.id) const envs = await getEnvironments(app, adminKey, project.id) const devEnv = envs.find((e) => e.slug === 'development')! const clientKey = await createClientKey(app, adminKey, project.id, devEnv.id) await createFlag(app, adminKey, project.id, 'alpha') await enableFlag(app, adminKey, project.id, 'alpha', 'development') return { app, adminKey, clientKey, projectId: project.id } } function get( app: Awaited>, clientKey: string, headers: Record = {}, url = '/api/v1/client/features', ) { return app.inject({ method: 'GET', url, headers: { authorization: clientKey, ...headers } }) } it('returns an ETag on the full response', async () => { const { app, clientKey } = await setup() const res = await get(app, clientKey) expect(res.statusCode).toBe(200) expect(res.headers.etag).toMatch(/^".+"$/) await app.close() }) it('answers 304 with an empty body when the tag still matches', async () => { const { app, clientKey } = await setup() const first = await get(app, clientKey) const second = await get(app, clientKey, { 'if-none-match': first.headers.etag as string }) expect(second.statusCode).toBe(304) expect(second.body).toBe('') await app.close() }) it('is stable across repeated requests when nothing changes', async () => { const { app, clientKey } = await setup() const a = await get(app, clientKey) const b = await get(app, clientKey) expect(a.headers.etag).toBe(b.headers.etag) await app.close() }) it('changes the tag when a flag is toggled', async () => { const { app, adminKey, clientKey, projectId } = await setup() const before = await get(app, clientKey) await app.inject({ method: 'POST', url: `/api/v1/admin/projects/${projectId}/flags/alpha/environments/development/disable`, headers: { authorization: adminKey }, }) const after = await get(app, clientKey, { 'if-none-match': before.headers.etag as string }) expect(after.statusCode).toBe(200) expect(after.headers.etag).not.toBe(before.headers.etag) await app.close() }) it('gives different tags to different evaluation contexts', async () => { const { app, clientKey } = await setup() const a = await get(app, clientKey, {}, '/api/v1/client/features?plan=pro') const b = await get(app, clientKey, {}, '/api/v1/client/features?plan=free') expect(a.headers.etag).not.toBe(b.headers.etag) await app.close() }) it('ignores a tag belonging to another context', async () => { const { app, clientKey } = await setup() const pro = await get(app, clientKey, {}, '/api/v1/client/features?plan=pro') const free = await get( app, clientKey, { 'if-none-match': pro.headers.etag as string }, '/api/v1/client/features?plan=free', ) expect(free.statusCode).toBe(200) await app.close() }) }) describe('GET /api/v1/client/features/:flagKey', () => { it('returns enabled false with reason disabled for a disabled flag', async () => { const app = await buildServer({ db }) const rootKey = await createRootKey(db!) const project = await createProject(app, rootKey) const adminKey = await createAdminKey(app, rootKey, project.id) const envs = await getEnvironments(app, adminKey, project.id) const devEnv = envs.find((e) => e.slug === 'development')! const clientKey = await createClientKey(app, adminKey, project.id, devEnv.id) await createFlag(app, adminKey, project.id, 'off-flag') const res = await app.inject({ method: 'GET', url: '/api/v1/client/features/off-flag', headers: { authorization: clientKey }, }) expect(res.statusCode).toBe(200) expect(res.json()).toMatchObject({ name: 'off-flag', enabled: false, reason: 'disabled' }) await app.close() }) it('returns enabled true with reason default for an enabled flag', async () => { const app = await buildServer({ db }) const rootKey = await createRootKey(db!) const project = await createProject(app, rootKey) const adminKey = await createAdminKey(app, rootKey, project.id) const envs = await getEnvironments(app, adminKey, project.id) const productionEnv = envs.find((e) => e.slug === 'production')! const clientKey = await createClientKey(app, adminKey, project.id, productionEnv.id) await createFlag(app, adminKey, project.id, 'on-flag') await enableFlag(app, adminKey, project.id, 'on-flag', 'production') const res = await app.inject({ method: 'GET', url: '/api/v1/client/features/on-flag', headers: { authorization: clientKey }, }) expect(res.statusCode).toBe(200) expect(res.json()).toMatchObject({ name: 'on-flag', enabled: true, reason: 'default' }) await app.close() }) it('ignores query params and returns the environment default with reason default', async () => { const app = await buildServer({ db }) const rootKey = await createRootKey(db!) const project = await createProject(app, rootKey) const adminKey = await createAdminKey(app, rootKey, project.id) const envs = await getEnvironments(app, adminKey, project.id) const productionEnv = envs.find((e) => e.slug === 'production')! const clientKey = await createClientKey(app, adminKey, project.id, productionEnv.id) await createFlag(app, adminKey, project.id, 'global-on') await enableFlag(app, adminKey, project.id, 'global-on', 'production') const res = await app.inject({ method: 'GET', url: '/api/v1/client/features/global-on?userId=blocked_user', headers: { authorization: clientKey }, }) expect(res.statusCode).toBe(200) expect(res.json()).toMatchObject({ name: 'global-on', enabled: true, reason: 'default' }) await app.close() }) it('returns 404 for a flag key that does not exist in the project', async () => { const app = await buildServer({ db }) const rootKey = await createRootKey(db!) const project = await createProject(app, rootKey) const adminKey = await createAdminKey(app, rootKey, project.id) const envs = await getEnvironments(app, adminKey, project.id) const devEnv = envs.find((e) => e.slug === 'development')! const clientKey = await createClientKey(app, adminKey, project.id, devEnv.id) const res = await app.inject({ method: 'GET', url: '/api/v1/client/features/nonexistent-flag', headers: { authorization: clientKey }, }) expect(res.statusCode).toBe(404) expect(res.json()).toMatchObject({ error: 'NotFound', statusCode: 404 }) await app.close() }) }) })