@webstudio-is/plans 0.260.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +661 -0
- package/package.json +32 -0
- package/src/dev-plan.server.test.ts +84 -0
- package/src/dev-plan.server.ts +85 -0
- package/src/index.server.ts +11 -0
- package/src/index.ts +6 -0
- package/src/plan-client.server.test.ts +272 -0
- package/src/plan-client.server.ts +218 -0
- package/src/plan-features.test.ts +132 -0
- package/src/plan-features.ts +150 -0
- package/tsconfig.json +3 -0
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@webstudio-is/plans",
|
|
3
|
+
"version": "0.260.2",
|
|
4
|
+
"description": "Plan features and billing logic for Webstudio",
|
|
5
|
+
"author": "Webstudio <github@webstudio.is>",
|
|
6
|
+
"homepage": "https://webstudio.is",
|
|
7
|
+
"license": "AGPL-3.0-or-later",
|
|
8
|
+
"type": "module",
|
|
9
|
+
"dependencies": {
|
|
10
|
+
"zod": "^3.24.2"
|
|
11
|
+
},
|
|
12
|
+
"devDependencies": {
|
|
13
|
+
"vitest": "^3.1.2",
|
|
14
|
+
"@webstudio-is/postgrest": "0.260.2",
|
|
15
|
+
"@webstudio-is/tsconfig": "1.0.7"
|
|
16
|
+
},
|
|
17
|
+
"exports": {
|
|
18
|
+
".": {
|
|
19
|
+
"webstudio": "./src/index.ts",
|
|
20
|
+
"import": "./src/index.ts"
|
|
21
|
+
},
|
|
22
|
+
"./index.server": {
|
|
23
|
+
"webstudio": "./src/index.server.ts",
|
|
24
|
+
"import": "./src/index.server.ts"
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
"sideEffects": false,
|
|
28
|
+
"scripts": {
|
|
29
|
+
"typecheck": "tsgo --noEmit",
|
|
30
|
+
"test": "vitest run"
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
createTestServer,
|
|
4
|
+
db,
|
|
5
|
+
testContext,
|
|
6
|
+
json,
|
|
7
|
+
empty,
|
|
8
|
+
} from "@webstudio-is/postgrest/testing";
|
|
9
|
+
import { applyDevPlan } from "./dev-plan.server";
|
|
10
|
+
|
|
11
|
+
const server = createTestServer();
|
|
12
|
+
|
|
13
|
+
// ─── applyDevPlan ─────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
describe("applyDevPlan (msw)", () => {
|
|
16
|
+
test("does nothing when user is not found", async () => {
|
|
17
|
+
server.use(
|
|
18
|
+
db.get("User", () =>
|
|
19
|
+
json(
|
|
20
|
+
{ code: "PGRST116", message: "Row not found", details: "", hint: "" },
|
|
21
|
+
{ status: 406 }
|
|
22
|
+
)
|
|
23
|
+
)
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
// Should resolve without throwing even when user is missing
|
|
27
|
+
await expect(
|
|
28
|
+
applyDevPlan("ghost@example.com", "Pro", testContext)
|
|
29
|
+
).resolves.toBeUndefined();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("upserts Product and TransactionLog when plan is set", async () => {
|
|
33
|
+
let productUpserted = false;
|
|
34
|
+
let txUpserted = false;
|
|
35
|
+
|
|
36
|
+
server.use(
|
|
37
|
+
db.get("User", () => json({ id: "user-1" })),
|
|
38
|
+
db.post("Product", () => {
|
|
39
|
+
productUpserted = true;
|
|
40
|
+
return empty({ status: 201 });
|
|
41
|
+
}),
|
|
42
|
+
db.post("TransactionLog", () => {
|
|
43
|
+
txUpserted = true;
|
|
44
|
+
return empty({ status: 201 });
|
|
45
|
+
})
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
await applyDevPlan("dev@example.com", "Pro", testContext);
|
|
49
|
+
expect(productUpserted).toBe(true);
|
|
50
|
+
expect(txUpserted).toBe(true);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("deletes TransactionLog when plan is null (free tier)", async () => {
|
|
54
|
+
let txDeleted = false;
|
|
55
|
+
|
|
56
|
+
server.use(
|
|
57
|
+
db.get("User", () => json({ id: "user-1" })),
|
|
58
|
+
db.delete("TransactionLog", () => {
|
|
59
|
+
txDeleted = true;
|
|
60
|
+
return empty({ status: 204 });
|
|
61
|
+
})
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
await applyDevPlan("dev@example.com", null, testContext);
|
|
65
|
+
expect(txDeleted).toBe(true);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("does nothing when Product upsert fails", async () => {
|
|
69
|
+
server.use(
|
|
70
|
+
db.get("User", () => json({ id: "user-1" })),
|
|
71
|
+
db.post("Product", () =>
|
|
72
|
+
json(
|
|
73
|
+
{ code: "PGRST000", message: "DB error", details: "", hint: "" },
|
|
74
|
+
{ status: 500 }
|
|
75
|
+
)
|
|
76
|
+
)
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
// Should resolve (logs error but doesn't throw)
|
|
80
|
+
await expect(
|
|
81
|
+
applyDevPlan("dev@example.com", "Pro", testContext)
|
|
82
|
+
).resolves.toBeUndefined();
|
|
83
|
+
});
|
|
84
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type { Client } from "@webstudio-is/postgrest/index.server";
|
|
2
|
+
|
|
3
|
+
type PostgrestContext = { client: Client };
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Upsert or delete dev plan rows in the DB for the given user email.
|
|
7
|
+
* This lets getPlanInfo work naturally (same as production) for all callers,
|
|
8
|
+
* including getOwnerPlanFeatures for shared-workspace suspension checks.
|
|
9
|
+
*
|
|
10
|
+
* - Plan selected: upsert a Product row (id = "dev-product-{planName}") and a
|
|
11
|
+
* TransactionLog row (eventId = "dev-{userId}") that satisfies the UserProduct view.
|
|
12
|
+
* - No plan selected: delete the TransactionLog row so the user has no active purchase.
|
|
13
|
+
*/
|
|
14
|
+
export const applyDevPlan = async (
|
|
15
|
+
email: string,
|
|
16
|
+
planName: string | null,
|
|
17
|
+
context: { postgrest: PostgrestContext }
|
|
18
|
+
) => {
|
|
19
|
+
const { postgrest } = context;
|
|
20
|
+
// Resolve userId from email (user was already created by the authenticator).
|
|
21
|
+
const userResult = await postgrest.client
|
|
22
|
+
.from("User")
|
|
23
|
+
.select("id")
|
|
24
|
+
.eq("email", email)
|
|
25
|
+
.single();
|
|
26
|
+
|
|
27
|
+
if (userResult.error) {
|
|
28
|
+
console.error("[applyDevPlan] Failed to find user:", userResult.error);
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const userId = userResult.data?.id as string | undefined;
|
|
33
|
+
if (!userId) {
|
|
34
|
+
console.error("[applyDevPlan] No user found for email:", email);
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (planName !== null) {
|
|
39
|
+
const productId = `dev-product-${planName}`;
|
|
40
|
+
|
|
41
|
+
const productResult = await postgrest.client.from("Product").upsert({
|
|
42
|
+
id: productId,
|
|
43
|
+
name: planName,
|
|
44
|
+
meta: {},
|
|
45
|
+
features: [],
|
|
46
|
+
images: [],
|
|
47
|
+
});
|
|
48
|
+
if (productResult.error) {
|
|
49
|
+
console.error(
|
|
50
|
+
"[applyDevPlan] Failed to upsert Product:",
|
|
51
|
+
productResult.error
|
|
52
|
+
);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const txResult = await postgrest.client.from("TransactionLog").upsert({
|
|
57
|
+
eventId: `dev-${userId}`,
|
|
58
|
+
userId,
|
|
59
|
+
productId,
|
|
60
|
+
eventData: {
|
|
61
|
+
type: "checkout.session.completed",
|
|
62
|
+
created: Math.floor(Date.now() / 1000),
|
|
63
|
+
data: { object: { status: "complete" } },
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
if (txResult.error) {
|
|
67
|
+
console.error(
|
|
68
|
+
"[applyDevPlan] Failed to upsert TransactionLog:",
|
|
69
|
+
txResult.error
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
} else {
|
|
73
|
+
// Remove any existing dev plan row — the user becomes free tier.
|
|
74
|
+
const deleteResult = await postgrest.client
|
|
75
|
+
.from("TransactionLog")
|
|
76
|
+
.delete()
|
|
77
|
+
.eq("eventId", `dev-${userId}`);
|
|
78
|
+
if (deleteResult.error) {
|
|
79
|
+
console.error(
|
|
80
|
+
"[applyDevPlan] Failed to delete TransactionLog:",
|
|
81
|
+
deleteResult.error
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
};
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import { describe, test, expect, beforeEach, vi } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
createTestServer,
|
|
4
|
+
db,
|
|
5
|
+
json,
|
|
6
|
+
testContext,
|
|
7
|
+
} from "@webstudio-is/postgrest/testing";
|
|
8
|
+
import { type PlanFeatures, defaultPlanFeatures } from "./plan-features";
|
|
9
|
+
import { getPlanInfo, __testing__ } from "./plan-client.server";
|
|
10
|
+
|
|
11
|
+
const { parseProductMeta, mergeProductMetas, resetProductCache } = __testing__;
|
|
12
|
+
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// Unit tests for private helpers
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
const server = createTestServer();
|
|
18
|
+
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
resetProductCache();
|
|
21
|
+
vi.unstubAllEnvs();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const proProduct = {
|
|
25
|
+
id: "prod-pro",
|
|
26
|
+
name: "Pro",
|
|
27
|
+
meta: { maxDomainsAllowedPerUser: 10 },
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const proUserProduct = {
|
|
31
|
+
userId: "user-1",
|
|
32
|
+
productId: "prod-pro",
|
|
33
|
+
subscriptionId: "sub-abc",
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
describe("getPlanInfo (msw)", () => {
|
|
37
|
+
test("empty userIds returns empty map without hitting DB", async () => {
|
|
38
|
+
const result = await getPlanInfo([], testContext);
|
|
39
|
+
expect(result.size).toBe(0);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("user with no products returns defaultPlanFeatures", async () => {
|
|
43
|
+
server.use(db.get("UserProduct", () => json([])));
|
|
44
|
+
|
|
45
|
+
const result = await getPlanInfo(["user-1"], testContext);
|
|
46
|
+
expect(result.get("user-1")).toEqual({
|
|
47
|
+
planFeatures: defaultPlanFeatures,
|
|
48
|
+
purchases: [],
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("user with one product resolves plan features from PLANS env + meta merge", async () => {
|
|
53
|
+
vi.stubEnv(
|
|
54
|
+
"PLANS",
|
|
55
|
+
JSON.stringify([{ name: "Pro", maxDomainsAllowedPerUser: 5 }])
|
|
56
|
+
);
|
|
57
|
+
server.use(
|
|
58
|
+
db.get("UserProduct", () => json([proUserProduct])),
|
|
59
|
+
db.get("Product", () => json([proProduct]))
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
const result = await getPlanInfo(["user-1"], testContext);
|
|
63
|
+
const info = result.get("user-1");
|
|
64
|
+
// meta overrides env plan: maxDomainsAllowedPerUser should be 10 (from meta)
|
|
65
|
+
expect(info?.planFeatures.maxDomainsAllowedPerUser).toBe(10);
|
|
66
|
+
expect(info?.purchases).toEqual([
|
|
67
|
+
{ planName: "Pro", subscriptionId: "sub-abc" },
|
|
68
|
+
]);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("product not in PLANS falls back to defaultPlanFeatures for that product", async () => {
|
|
72
|
+
vi.stubEnv("PLANS", JSON.stringify([]));
|
|
73
|
+
server.use(
|
|
74
|
+
db.get("UserProduct", () => json([proUserProduct])),
|
|
75
|
+
db.get("Product", () => json([proProduct]))
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
const result = await getPlanInfo(["user-1"], testContext);
|
|
79
|
+
const info = result.get("user-1");
|
|
80
|
+
// meta provides maxDomainsAllowedPerUser: 10, rest from defaultPlanFeatures
|
|
81
|
+
expect(info?.planFeatures.maxDomainsAllowedPerUser).toBe(10);
|
|
82
|
+
expect(info?.planFeatures.canDownloadAssets).toBe(
|
|
83
|
+
defaultPlanFeatures.canDownloadAssets
|
|
84
|
+
);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("multiple users batched in one UserProduct query", async () => {
|
|
88
|
+
vi.stubEnv("PLANS", JSON.stringify([]));
|
|
89
|
+
server.use(
|
|
90
|
+
db.get("UserProduct", ({ request }) => {
|
|
91
|
+
const url = new URL(request.url);
|
|
92
|
+
// PostgREST IN filter: userId=in.(user-1,user-2)
|
|
93
|
+
expect(url.searchParams.get("userId")).toMatch(/user-1/);
|
|
94
|
+
expect(url.searchParams.get("userId")).toMatch(/user-2/);
|
|
95
|
+
return json([
|
|
96
|
+
proUserProduct,
|
|
97
|
+
{ userId: "user-2", productId: "prod-pro", subscriptionId: null },
|
|
98
|
+
]);
|
|
99
|
+
}),
|
|
100
|
+
db.get("Product", () => json([proProduct]))
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
const result = await getPlanInfo(["user-1", "user-2"], testContext);
|
|
104
|
+
expect(result.size).toBe(2);
|
|
105
|
+
expect(result.get("user-1")?.purchases[0].planName).toBe("Pro");
|
|
106
|
+
expect(result.get("user-2")?.purchases[0].subscriptionId).toBeUndefined();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("UserProduct DB error throws", async () => {
|
|
110
|
+
server.use(
|
|
111
|
+
db.get("UserProduct", () =>
|
|
112
|
+
json({ message: "db error" }, { status: 500 })
|
|
113
|
+
)
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
await expect(getPlanInfo(["user-1"], testContext)).rejects.toThrow(
|
|
117
|
+
"Failed to fetch user products"
|
|
118
|
+
);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("Product DB error throws and cache is cleared for retry", async () => {
|
|
122
|
+
server.use(
|
|
123
|
+
db.get("UserProduct", () => json([proUserProduct])),
|
|
124
|
+
db.get("Product", () => json({ message: "db error" }, { status: 500 }))
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
await expect(getPlanInfo(["user-1"], testContext)).rejects.toThrow(
|
|
128
|
+
"Failed to fetch products"
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
// After error the cache is cleared — next call should hit DB again
|
|
132
|
+
server.use(
|
|
133
|
+
db.get("UserProduct", () => json([proUserProduct])),
|
|
134
|
+
db.get("Product", () => json([proProduct]))
|
|
135
|
+
);
|
|
136
|
+
vi.stubEnv("PLANS", JSON.stringify([]));
|
|
137
|
+
const result = await getPlanInfo(["user-1"], testContext);
|
|
138
|
+
expect(result.get("user-1")?.purchases[0].planName).toBe("Pro");
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("Product table is queried only once across two calls (cache hit)", async () => {
|
|
142
|
+
vi.stubEnv("PLANS", JSON.stringify([]));
|
|
143
|
+
let productFetchCount = 0;
|
|
144
|
+
server.use(
|
|
145
|
+
db.get("UserProduct", () => json([proUserProduct])),
|
|
146
|
+
db.get("Product", () => {
|
|
147
|
+
productFetchCount += 1;
|
|
148
|
+
return json([proProduct]);
|
|
149
|
+
})
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
await getPlanInfo(["user-1"], testContext);
|
|
153
|
+
await getPlanInfo(["user-1"], testContext);
|
|
154
|
+
|
|
155
|
+
expect(productFetchCount).toBe(1);
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
/** Full-featured plan for use in tests — all booleans true, numeric limits at max */
|
|
160
|
+
const fullFeatures: PlanFeatures = {
|
|
161
|
+
...defaultPlanFeatures,
|
|
162
|
+
canDownloadAssets: true,
|
|
163
|
+
canRestoreBackups: true,
|
|
164
|
+
allowAdditionalPermissions: true,
|
|
165
|
+
allowDynamicData: true,
|
|
166
|
+
allowContentMode: true,
|
|
167
|
+
allowStagingPublish: true,
|
|
168
|
+
maxContactEmailsPerProject: 5,
|
|
169
|
+
maxDomainsAllowedPerUser: 100,
|
|
170
|
+
maxDailyPublishesPerUser: 100,
|
|
171
|
+
maxWorkspaces: 1,
|
|
172
|
+
maxProjectsAllowedPerUser: 100,
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
describe("parseProductMeta", () => {
|
|
176
|
+
test("extracts known keys from a valid object", () => {
|
|
177
|
+
const result = parseProductMeta({
|
|
178
|
+
maxContactEmailsPerProject: 10,
|
|
179
|
+
canDownloadAssets: true,
|
|
180
|
+
});
|
|
181
|
+
expect(result).toEqual({
|
|
182
|
+
maxContactEmailsPerProject: 10,
|
|
183
|
+
canDownloadAssets: true,
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("strips unknown keys", () => {
|
|
188
|
+
const result = parseProductMeta({
|
|
189
|
+
admin: true,
|
|
190
|
+
maxContactEmailsPerProject: 3,
|
|
191
|
+
});
|
|
192
|
+
expect(result).toEqual({ maxContactEmailsPerProject: 3 });
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("returns empty object for non-object input", () => {
|
|
196
|
+
expect(parseProductMeta("not an object")).toEqual({});
|
|
197
|
+
expect(parseProductMeta(42)).toEqual({});
|
|
198
|
+
expect(parseProductMeta(undefined)).toEqual({});
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("returns empty object for null input", () => {
|
|
202
|
+
expect(parseProductMeta(null)).toEqual({});
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test("rejects negative numbers", () => {
|
|
206
|
+
const result = parseProductMeta({ maxContactEmailsPerProject: -1 });
|
|
207
|
+
expect(result).toEqual({});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("accepts zero for numeric fields", () => {
|
|
211
|
+
const result = parseProductMeta({ maxWorkspaces: 0 });
|
|
212
|
+
expect(result).toEqual({ maxWorkspaces: 0 });
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test("rejects wrong types for fields", () => {
|
|
216
|
+
const result = parseProductMeta({
|
|
217
|
+
canDownloadAssets: "yes",
|
|
218
|
+
maxContactEmailsPerProject: "five",
|
|
219
|
+
});
|
|
220
|
+
expect(result).toEqual({});
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
describe("mergeProductMetas", () => {
|
|
225
|
+
test("booleans: true if ANY product grants the feature", () => {
|
|
226
|
+
const result = mergeProductMetas([
|
|
227
|
+
{ ...fullFeatures, canDownloadAssets: false, canRestoreBackups: true },
|
|
228
|
+
{ ...fullFeatures, canDownloadAssets: true, canRestoreBackups: false },
|
|
229
|
+
]);
|
|
230
|
+
expect(result.canDownloadAssets).toBe(true);
|
|
231
|
+
expect(result.canRestoreBackups).toBe(true);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test("booleans: false when NO product grants the feature", () => {
|
|
235
|
+
const result = mergeProductMetas([
|
|
236
|
+
{ ...fullFeatures, canDownloadAssets: false },
|
|
237
|
+
{ ...fullFeatures, canDownloadAssets: false },
|
|
238
|
+
]);
|
|
239
|
+
expect(result.canDownloadAssets).toBe(false);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test("numbers: takes the highest value across products", () => {
|
|
243
|
+
const result = mergeProductMetas([
|
|
244
|
+
{ ...fullFeatures, maxContactEmailsPerProject: 3, maxWorkspaces: 2 },
|
|
245
|
+
{ ...fullFeatures, maxContactEmailsPerProject: 10, maxWorkspaces: 5 },
|
|
246
|
+
]);
|
|
247
|
+
expect(result.maxContactEmailsPerProject).toBe(10);
|
|
248
|
+
expect(result.maxWorkspaces).toBe(5);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test("single product returns its values unchanged", () => {
|
|
252
|
+
const plan = { ...fullFeatures, maxWorkspaces: 7 };
|
|
253
|
+
const result = mergeProductMetas([plan]);
|
|
254
|
+
expect(result).toEqual(plan);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test("empty array returns defaultPlanFeatures", () => {
|
|
258
|
+
const result = mergeProductMetas([]);
|
|
259
|
+
expect(result).toEqual(defaultPlanFeatures);
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
describe("workspaces plan", () => {
|
|
264
|
+
test("merging pro + workspaces product picks highest maxWorkspaces", () => {
|
|
265
|
+
const proProduct = { ...fullFeatures, maxWorkspaces: 1 };
|
|
266
|
+
const workspacesProduct = { ...defaultPlanFeatures, maxWorkspaces: 20 };
|
|
267
|
+
|
|
268
|
+
const result = mergeProductMetas([proProduct, workspacesProduct]);
|
|
269
|
+
expect(result.maxWorkspaces).toBe(20);
|
|
270
|
+
expect(result.canDownloadAssets).toBe(true);
|
|
271
|
+
});
|
|
272
|
+
});
|