Skip to content

Commit

Permalink
Fix PATCH variants (old endpoint) when variants per environment are e…
Browse files Browse the repository at this point in the history
…nabled (#2591)

## About the changes
This PR addresses some issues when working with variants after migrating
to variants per environment.

**Problem:** since PATCH
`/api/admin/projects/default/features/${featureName}/variants` does not
take into account `featureEnvironments`, when variantsPerEnvironment
gets enabled, this method will override the variants in other
environments (i.e. not doing a patch). This method has to be maintained
because of backward compatibility but it has to be adapted to deal with
variants per environment


https://linear.app/unleash/issue/2-476/when-using-patch-for-variants-without-environments-it-wipes-out

Co-authored-by: Nuno Góis <github@nunogois.com>
  • Loading branch information
Gastón Fournier and nunogois committed Dec 6, 2022
1 parent a151574 commit aa20d2d
Show file tree
Hide file tree
Showing 4 changed files with 150 additions and 12 deletions.
10 changes: 9 additions & 1 deletion src/lib/db/feature-strategy-store.ts
Expand Up @@ -303,7 +303,15 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore {
if (withEnvironmentVariants) {
env.variants = variants;
}
acc.variants = variants;

// this code sets variants at the feature level (should be deprecated with variants per environment)
const currentVariants = new Map(
acc.variants?.map((v) => [v.name, v]),
);
variants.forEach((variant) => {
currentVariants.set(variant.name, variant);
});
acc.variants = Array.from(currentVariants.values());

env.enabled = r.enabled;
env.type = r.environment_type;
Expand Down
6 changes: 5 additions & 1 deletion src/lib/routes/admin-api/project/variants.ts
Expand Up @@ -143,6 +143,11 @@ export default class VariantsController extends Controller {
});
}

/**
* @deprecated - Variants should be fetched from featureService.getVariantsForEnv (since variants are now; since 4.18, connected to environments)
* @param req
* @param res
*/
async getVariants(
req: Request<FeatureParams, any, any, any>,
res: Response<FeatureVariantsSchema>,
Expand All @@ -158,7 +163,6 @@ export default class VariantsController extends Controller {
): Promise<void> {
const { projectId, featureName } = req.params;
const userName = extractUsername(req);

const updatedFeature = await this.featureService.updateVariants(
featureName,
projectId,
Expand Down
38 changes: 28 additions & 10 deletions src/lib/services/feature-toggle-service.ts
Expand Up @@ -655,6 +655,7 @@ class FeatureToggleService {

/**
* GET /api/admin/projects/:project/features/:featureName/variants
* @deprecated - Variants should be fetched from FeatureEnvironmentStore (since variants are now; since 4.18, connected to environments)
* @param featureName
* @return The list of variants
*/
Expand Down Expand Up @@ -1250,9 +1251,22 @@ class FeatureToggleService {
newVariants: Operation[],
createdBy: string,
): Promise<FeatureToggle> {
const oldVariants = await this.getVariants(featureName);
const { newDocument } = applyPatch(oldVariants, newVariants);
return this.saveVariants(featureName, project, newDocument, createdBy);
const ft =
await this.featureStrategiesStore.getFeatureToggleWithVariantEnvs(
featureName,
);
const promises = ft.environments.map((env) =>
this.updateVariantsOnEnv(
featureName,
project,
env.name,
newVariants,
createdBy,
).then((resultingVariants) => (env.variants = resultingVariants)),
);
await Promise.all(promises);
ft.variants = ft.environments[0].variants;
return ft;
}

async updateVariantsOnEnv(
Expand All @@ -1273,6 +1287,7 @@ class FeatureToggleService {
environment,
newDocument,
createdBy,
oldVariants,
);
}

Expand Down Expand Up @@ -1312,23 +1327,26 @@ class FeatureToggleService {
environment: string,
newVariants: IVariant[],
createdBy: string,
oldVariants?: IVariant[],
): Promise<IVariant[]> {
await variantsArraySchema.validateAsync(newVariants);
const fixedVariants = this.fixVariantWeights(newVariants);
const oldVariants = (
await this.featureEnvironmentStore.get({
featureName,
environment,
})
).variants;
const theOldVariants: IVariant[] =
oldVariants ||
(
await this.featureEnvironmentStore.get({
featureName,
environment,
})
).variants;

await this.eventStore.store(
new EnvironmentVariantEvent({
featureName,
environment,
project: projectId,
createdBy,
oldVariants,
oldVariants: theOldVariants,
newVariants: fixedVariants,
}),
);
Expand Down
108 changes: 108 additions & 0 deletions src/test/e2e/api/admin/project/variants.e2e.test.ts
Expand Up @@ -120,6 +120,104 @@ test('Can patch variants for a feature and get a response of new variant', async
});
});

test('Can patch variants for a feature patches all environments independently', async () => {
const featureName = 'feature-to-patch';
const addedVariantName = 'patched-variant-name';
const variants = (name: string) => [
{
name,
stickiness: 'default',
weight: 1000,
weightType: WeightType.VARIABLE,
},
];

await db.stores.environmentStore.create({
name: 'development',
type: 'development',
});
await db.stores.environmentStore.create({
name: 'production',
type: 'production',
});
await db.stores.featureToggleStore.create('default', {
name: featureName,
});
await db.stores.featureEnvironmentStore.addEnvironmentToFeature(
featureName,
'development',
true,
);
await db.stores.featureEnvironmentStore.addEnvironmentToFeature(
featureName,
'production',
true,
);
await db.stores.featureEnvironmentStore.addVariantsToFeatureEnvironment(
featureName,
'development',
variants('dev-variant'),
);
await db.stores.featureEnvironmentStore.addVariantsToFeatureEnvironment(
featureName,
'production',
variants('prod-variant'),
);

const patch = [
{
op: 'add',
path: '/1',
value: {
name: addedVariantName,
weightType: 'fix',
weight: 50,
},
},
];

await app.request
.patch(`/api/admin/projects/default/features/${featureName}/variants`)
.send(patch)
.expect(200)
.expect((res) => {
expect(res.body.version).toBe(1);
expect(res.body.variants).toHaveLength(2);
// it picks variants from the first environment (sorted by name)
expect(res.body.variants[0].name).toBe('dev-variant');
expect(res.body.variants[1].name).toBe(addedVariantName);
});

await app.request
.get(
`/api/admin/projects/default/features/${featureName}?variantEnvironments=true`,
)
.expect((res) => {
const environments = res.body.environments;
expect(environments).toHaveLength(2);
const developmentVariants = environments.find(
(x) => x.name === 'development',
).variants;
const productionVariants = environments.find(
(x) => x.name === 'production',
).variants;
expect(developmentVariants).toHaveLength(2);
expect(productionVariants).toHaveLength(2);
expect(
developmentVariants.find((x) => x.name === addedVariantName),
).toBeTruthy();
expect(
productionVariants.find((x) => x.name === addedVariantName),
).toBeTruthy();
expect(
developmentVariants.find((x) => x.name === 'dev-variant'),
).toBeTruthy();
expect(
productionVariants.find((x) => x.name === 'prod-variant'),
).toBeTruthy();
});
});

test('Can add variant for a feature', async () => {
const featureName = 'feature-variants-patch-add';
const variantName = 'fancy-variant-patch';
Expand Down Expand Up @@ -315,6 +413,11 @@ test('Invalid variant in PATCH also throws 400 exception', async () => {
await db.stores.featureToggleStore.create('default', {
name: featureName,
});
await db.stores.featureEnvironmentStore.addEnvironmentToFeature(
featureName,
'default',
true,
);

const invalidPatch = `[{
"op": "add",
Expand Down Expand Up @@ -439,6 +542,11 @@ test('PATCHING with no variable variants fails with 400', async () => {
await db.stores.featureToggleStore.create('default', {
name: featureName,
});
await db.stores.featureEnvironmentStore.addEnvironmentToFeature(
featureName,
'default',
true,
);

const newVariants: IVariant[] = [];

Expand Down

0 comments on commit aa20d2d

Please sign in to comment.