Skip to content

Commit 2be8a90

Browse files
authored
feat(fe): flag-driven warehouse plan gating for free orgs (#8379)
1 parent 27dbc25 commit 2be8a90

2 files changed

Lines changed: 88 additions & 13 deletions

File tree

‎frontend/common/utils/__tests__/utils.test.ts‎

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@
33
// mocked out to allow `common/utils/utils` to load for real.
44
jest.mock('common/stores/account-store', () => ({}))
55
jest.mock('common/stores/project-store', () => ({}))
6-
jest.mock('common/store', () => ({ getStore: () => ({}) }))
6+
jest.mock('common/store', () => ({
7+
getStore: () => ({ getState: () => ({}) }),
8+
}))
79
jest.mock('@flagsmith/flagsmith', () => ({
810
getValue: (_key: string, opts: { fallback: unknown }) => opts.fallback,
911
}))
@@ -96,3 +98,67 @@ describe('validateRule', () => {
9698
expect(Utils.validateRule(rule)).toBe(false)
9799
})
98100
})
101+
102+
describe('getPlanPermission', () => {
103+
beforeEach(() => {
104+
jest.spyOn(Utils, 'isEnterpriseImage').mockReturnValue(false)
105+
jest.spyOn(Utils, 'isSaas').mockReturnValue(true)
106+
})
107+
afterEach(() => jest.restoreAllMocks())
108+
109+
it.each([
110+
['scale-up', 'AUDIT', true],
111+
['enterprise', 'AUDIT', true],
112+
['start-up', 'AUDIT', false],
113+
['free', 'AUDIT', false],
114+
['enterprise', 'SCIM', true],
115+
['scale-up', 'SCIM', false],
116+
] as const)(
117+
'hardcoded features: plan=%s feature=%s => %s',
118+
(plan, feature, expected) => {
119+
expect(Utils.getPlanPermission(plan, feature)).toBe(expected)
120+
},
121+
)
122+
123+
describe('WAREHOUSE with legacy array value', () => {
124+
it('allows free plan when value includes "free"', () => {
125+
jest.spyOn(Utils, 'getFlagsmithJSONValue').mockReturnValue(['free'])
126+
expect(Utils.getPlanPermission('free', 'WAREHOUSE')).toBe(true)
127+
})
128+
129+
it('blocks free plan when value is empty', () => {
130+
jest.spyOn(Utils, 'getFlagsmithJSONValue').mockReturnValue([])
131+
expect(Utils.getPlanPermission('free', 'WAREHOUSE')).toBe(false)
132+
})
133+
134+
it('allows scale-up when value includes "scale-up"', () => {
135+
jest.spyOn(Utils, 'getFlagsmithJSONValue').mockReturnValue(['scale-up'])
136+
expect(Utils.getPlanPermission('scale-up', 'WAREHOUSE')).toBe(true)
137+
})
138+
})
139+
140+
describe('WAREHOUSE with object value', () => {
141+
it('allows free plan when allowed_plans includes "free"', () => {
142+
jest.spyOn(Utils, 'getFlagsmithJSONValue').mockReturnValue({
143+
allowed_plans: ['free'],
144+
auto_connect_warehouse: true,
145+
})
146+
expect(Utils.getPlanPermission('free', 'WAREHOUSE')).toBe(true)
147+
})
148+
149+
it('blocks free plan when allowed_plans does not include "free"', () => {
150+
jest
151+
.spyOn(Utils, 'getFlagsmithJSONValue')
152+
.mockReturnValue({ allowed_plans: ['scale-up'] })
153+
expect(Utils.getPlanPermission('free', 'WAREHOUSE')).toBe(false)
154+
})
155+
156+
it('falls back to enterprise with malformed value', () => {
157+
jest
158+
.spyOn(Utils, 'getFlagsmithJSONValue')
159+
.mockReturnValue({ allowed_plans: 'not-an-array' })
160+
expect(Utils.getPlanPermission('enterprise', 'WAREHOUSE')).toBe(true)
161+
expect(Utils.getPlanPermission('scale-up', 'WAREHOUSE')).toBe(false)
162+
})
163+
})
164+
})

‎frontend/common/utils/utils.tsx‎

Lines changed: 21 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ export type PaidFeature =
6161
export type AppFeature = PaidFeature | 'FEATURE_HEALTH'
6262

6363
// Define a type for plan categories
64-
type Plan = 'start-up' | 'scale-up' | 'enterprise' | null
64+
type Plan = 'free' | 'start-up' | 'scale-up' | 'enterprise' | null
6565

6666
export const planNames = {
6767
enterprise: 'Enterprise',
@@ -180,8 +180,6 @@ const Utils = Object.assign({}, BaseUtils, {
180180
}
181181
return null
182182
},
183-
// Delegates to the standalone, Flux-free module so callers that can't import
184-
// this file (e.g. unit-tested hooks) can use the same logic directly.
185183
featureStateToValue,
186184
findOperator(
187185
operator: SegmentCondition['operator'],
@@ -479,16 +477,19 @@ const Utils = Object.assign({}, BaseUtils, {
479477

480478
getPlanPermission: (plan: string, feature: PaidFeature) => {
481479
const planName = Utils.getPlanName(plan)
482-
if (!plan || planName === planNames.free) {
483-
return false
484-
}
480+
if (!plan) return false
481+
482+
const requiredPlan = Utils.getRequiredPlan(feature)
483+
if (requiredPlan === 'free') return true
484+
485+
if (planName === planNames.free) return false
486+
485487
const isScaleupOrGreater = planName !== planNames.startup
486488
const isEnterprise = planName === planNames.enterprise
487489
if (feature === 'AUTO_SEATS') {
488490
return isScaleupOrGreater && !isEnterprise
489491
}
490492

491-
const requiredPlan = Utils.getRequiredPlan(feature)
492493
if (requiredPlan === 'enterprise') {
493494
return isEnterprise
494495
} else if (requiredPlan === 'scale-up') {
@@ -542,15 +543,23 @@ const Utils = Object.assign({}, BaseUtils, {
542543
break
543544
}
544545
case 'WAREHOUSE': {
545-
const remotePlansValue = Utils.getFlagsmithJSONValue(
546+
const remoteValue = Utils.getFlagsmithJSONValue(
546547
'experimentation_warehouse_connection',
547548
[],
548549
)
549-
const remotePlans: string[] = Array.isArray(remotePlansValue)
550-
? remotePlansValue
551-
: []
550+
let remotePlans: string[] = []
551+
if (Array.isArray(remoteValue)) {
552+
remotePlans = remoteValue
553+
} else if (Array.isArray(remoteValue?.allowed_plans)) {
554+
remotePlans = remoteValue.allowed_plans
555+
}
552556
const allowedPlans = [...remotePlans, 'enterprise']
553-
const planHierarchy: Plan[] = ['start-up', 'scale-up', 'enterprise']
557+
const planHierarchy: Plan[] = [
558+
'free',
559+
'start-up',
560+
'scale-up',
561+
'enterprise',
562+
]
554563
plan =
555564
planHierarchy.find((p) => allowedPlans.includes(p)) || 'enterprise'
556565
break

0 commit comments

Comments
 (0)