From 0c2d2f8d16b46d9cd3ed68f7a243bf8d2486dfd1 Mon Sep 17 00:00:00 2001 From: DarkSky Date: Thu, 14 Dec 2023 09:50:51 +0000 Subject: [PATCH] feat: struct type feature config (#5142) --- packages/backend/server/package.json | 3 +- .../1698652531198-user-features-init.ts | 6 +- .../backend/server/src/modules/doc/history.ts | 3 +- .../server/src/modules/features/feature.ts | 144 ++-- .../server/src/modules/features/index.ts | 6 +- .../server/src/modules/features/management.ts | 89 +++ .../features/{configure.ts => service.ts} | 33 +- .../server/src/modules/features/types.ts | 54 +- .../server/src/modules/payment/service.ts | 6 +- .../server/src/modules/quota/constant.ts | 5 + .../backend/server/src/modules/quota/index.ts | 5 +- .../backend/server/src/modules/quota/quota.ts | 214 ++---- .../server/src/modules/quota/schema.ts | 50 ++ .../server/src/modules/quota/service.ts | 147 +++++ .../server/src/modules/quota/storage.ts | 34 +- .../backend/server/src/modules/quota/types.ts | 103 +-- .../server/src/modules/users/resolver.ts | 12 +- packages/backend/server/tests/app.e2e.ts | 3 + .../server/tests/exception-logger.e2e.ts | 3 + packages/backend/server/tests/feature.spec.ts | 33 +- packages/backend/server/tests/mailer.spec.ts | 3 + packages/backend/server/tests/quota.spec.ts | 30 +- packages/backend/server/tests/utils.ts | 619 ------------------ packages/backend/server/tests/utils/blobs.ts | 112 ++++ packages/backend/server/tests/utils/common.ts | 1 + packages/backend/server/tests/utils/index.ts | 5 + packages/backend/server/tests/utils/invite.ts | 121 ++++ packages/backend/server/tests/utils/user.ts | 117 ++++ packages/backend/server/tests/utils/utils.ts | 82 +++ .../backend/server/tests/utils/workspace.ts | 172 +++++ .../server/tests/workspace-blobs.spec.ts | 2 +- .../server/tests/workspace-usage.spec.ts | 20 + yarn.lock | 1 + 33 files changed, 1223 insertions(+), 1015 deletions(-) create mode 100644 packages/backend/server/src/modules/features/management.ts rename packages/backend/server/src/modules/features/{configure.ts => service.ts} (84%) create mode 100644 packages/backend/server/src/modules/quota/constant.ts create mode 100644 packages/backend/server/src/modules/quota/schema.ts create mode 100644 packages/backend/server/src/modules/quota/service.ts delete mode 100644 packages/backend/server/tests/utils.ts create mode 100644 packages/backend/server/tests/utils/blobs.ts create mode 100644 packages/backend/server/tests/utils/common.ts create mode 100644 packages/backend/server/tests/utils/index.ts create mode 100644 packages/backend/server/tests/utils/invite.ts create mode 100644 packages/backend/server/tests/utils/user.ts create mode 100644 packages/backend/server/tests/utils/utils.ts create mode 100644 packages/backend/server/tests/utils/workspace.ts diff --git a/packages/backend/server/package.json b/packages/backend/server/package.json index 08b446e595..8938f9c15a 100644 --- a/packages/backend/server/package.json +++ b/packages/backend/server/package.json @@ -82,7 +82,8 @@ "socket.io": "^4.7.2", "stripe": "^14.5.0", "ws": "^8.14.2", - "yjs": "^13.6.10" + "yjs": "^13.6.10", + "zod": "^3.22.4" }, "devDependencies": { "@affine-test/kit": "workspace:*", diff --git a/packages/backend/server/src/data/migrations/1698652531198-user-features-init.ts b/packages/backend/server/src/data/migrations/1698652531198-user-features-init.ts index 0245770706..b877eb18b3 100644 --- a/packages/backend/server/src/data/migrations/1698652531198-user-features-init.ts +++ b/packages/backend/server/src/data/migrations/1698652531198-user-features-init.ts @@ -1,10 +1,12 @@ +import { Prisma } from '@prisma/client'; + import { CommonFeature, FeatureKind, Features, FeatureType, } from '../../modules/features'; -import { Quotas } from '../../modules/quota/types'; +import { Quotas } from '../../modules/quota/schema'; import { PrismaService } from '../../prisma'; export class UserFeaturesInit1698652531198 { @@ -48,7 +50,7 @@ async function upsertFeature( feature: feature.feature, type: feature.type, version: feature.version, - configs: feature.configs, + configs: feature.configs as Prisma.InputJsonValue, }, }); } diff --git a/packages/backend/server/src/modules/doc/history.ts b/packages/backend/server/src/modules/doc/history.ts index 108560bd45..ab6a5dcc7d 100644 --- a/packages/backend/server/src/modules/doc/history.ts +++ b/packages/backend/server/src/modules/doc/history.ts @@ -240,8 +240,7 @@ export class DocHistoryManager { } const quota = await this.quota.getUserQuota(permission.userId); - - return new Date(Date.now() + quota.feature.configs.historyPeriod); + return quota.feature.historyPeriodFromNow; } @Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT /* everyday at 12am */) diff --git a/packages/backend/server/src/modules/features/feature.ts b/packages/backend/server/src/modules/features/feature.ts index 0e842c5946..f0bd2a763c 100644 --- a/packages/backend/server/src/modules/features/feature.ts +++ b/packages/backend/server/src/modules/features/feature.ts @@ -1,80 +1,78 @@ -import { Injectable, Logger } from '@nestjs/common'; - -import { Config } from '../../config'; import { PrismaService } from '../../prisma'; -import { FeatureService } from './configure'; -import { FeatureType } from './types'; +import { Feature, FeatureSchema, FeatureType } from './types'; -enum NewFeaturesKind { - EarlyAccess, -} +class FeatureConfig { + readonly config: Feature; -@Injectable() -export class FeatureManagementService { - protected logger = new Logger(FeatureManagementService.name); - constructor( - private readonly feature: FeatureService, - private readonly prisma: PrismaService, - private readonly config: Config - ) {} - - isStaff(email: string) { - return email.endsWith('@toeverything.info'); - } - - async addEarlyAccess(userId: string) { - return this.feature.addUserFeature( - userId, - FeatureType.EarlyAccess, - 1, - 'Early access user' - ); - } - - async removeEarlyAccess(userId: string) { - return this.feature.removeUserFeature(userId, FeatureType.EarlyAccess); - } - - async listEarlyAccess() { - return this.feature.listFeatureUsers(FeatureType.EarlyAccess); - } - - /// check early access by email - async canEarlyAccess(email: string) { - if ( - this.config.featureFlags.earlyAccessPreview && - !email.endsWith('@toeverything.info') - ) { - const user = await this.prisma.user.findFirst({ - where: { - email, - }, - }); - if (user) { - const canEarlyAccess = await this.feature - .hasFeature(user.id, FeatureType.EarlyAccess) - .catch(() => false); - if (canEarlyAccess) { - return true; - } - - // TODO: Outdated, switch to feature gates - const oldCanEarlyAccess = await this.prisma.newFeaturesWaitingList - .findUnique({ - where: { email, type: NewFeaturesKind.EarlyAccess }, - }) - .then(x => !!x) - .catch(() => false); - if (oldCanEarlyAccess) { - this.logger.warn( - `User ${email} has early access in old table but not in new table` - ); - } - return oldCanEarlyAccess; - } - return false; + constructor(data: any) { + const config = FeatureSchema.safeParse(data); + if (config.success) { + this.config = config.data; } else { - return true; + throw new Error(`Invalid quota config: ${config.error.message}`); } } + + /// feature name of quota + get name() { + return this.config.feature; + } +} + +export class EarlyAccessFeatureConfig extends FeatureConfig { + constructor(data: any) { + super(data); + + if (this.config.feature !== FeatureType.EarlyAccess) { + throw new Error('Invalid feature config: type is not EarlyAccess'); + } + } + + checkWhiteList(email: string) { + for (const domain in this.config.configs.whitelist) { + if (email.endsWith(domain)) { + return true; + } + } + return false; + } +} + +const FeatureConfigMap = { + [FeatureType.EarlyAccess]: EarlyAccessFeatureConfig, +}; + +const FeatureCache = new Map< + number, + InstanceType<(typeof FeatureConfigMap)[FeatureType]> +>(); + +export async function getFeature(prisma: PrismaService, featureId: number) { + const cachedQuota = FeatureCache.get(featureId); + + if (cachedQuota) { + return cachedQuota; + } + + const feature = await prisma.features.findFirst({ + where: { + id: featureId, + }, + }); + if (!feature) { + // this should unreachable + throw new Error(`Quota config ${featureId} not found`); + } + const ConfigClass = FeatureConfigMap[feature.feature as FeatureType]; + + if (!ConfigClass) { + throw new Error(`Feature config ${featureId} not found`); + } + + const config = new ConfigClass(feature); + // we always edit quota config as a new quota config + // so we can cache it by featureId + FeatureCache.set(featureId, config); + + return config; } diff --git a/packages/backend/server/src/modules/features/index.ts b/packages/backend/server/src/modules/features/index.ts index d1e689fdf4..ee8d61a283 100644 --- a/packages/backend/server/src/modules/features/index.ts +++ b/packages/backend/server/src/modules/features/index.ts @@ -1,8 +1,8 @@ import { Module } from '@nestjs/common'; import { PrismaService } from '../../prisma'; -import { FeatureService } from './configure'; -import { FeatureManagementService } from './feature'; +import { FeatureManagementService } from './management'; +import { FeatureService } from './service'; /** * Feature module provider pre-user feature flag management. @@ -16,6 +16,6 @@ import { FeatureManagementService } from './feature'; }) export class FeatureModule {} -export type { CommonFeature, Feature } from './types'; +export { type CommonFeature, commonFeatureSchema } from './types'; export { FeatureKind, Features, FeatureType } from './types'; export { FeatureManagementService, FeatureService, PrismaService }; diff --git a/packages/backend/server/src/modules/features/management.ts b/packages/backend/server/src/modules/features/management.ts new file mode 100644 index 0000000000..d7e274525c --- /dev/null +++ b/packages/backend/server/src/modules/features/management.ts @@ -0,0 +1,89 @@ +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; + +import { Config } from '../../config'; +import { PrismaService } from '../../prisma'; +import { EarlyAccessFeatureConfig } from './feature'; +import { FeatureService } from './service'; +import { FeatureType } from './types'; + +enum NewFeaturesKind { + EarlyAccess, +} + +@Injectable() +export class FeatureManagementService implements OnModuleInit { + protected logger = new Logger(FeatureManagementService.name); + private earlyAccessFeature?: EarlyAccessFeatureConfig; + constructor( + private readonly feature: FeatureService, + private readonly prisma: PrismaService, + private readonly config: Config + ) {} + async onModuleInit() { + this.earlyAccessFeature = await this.feature.getFeature( + FeatureType.EarlyAccess + ); + } + + // ======== Admin ======== + + // todo(@darkskygit): replace this with abac + isStaff(email: string) { + return this.earlyAccessFeature?.checkWhiteList(email) ?? false; + } + + // ======== Early Access ======== + + async addEarlyAccess(userId: string) { + return this.feature.addUserFeature( + userId, + FeatureType.EarlyAccess, + 1, + 'Early access user' + ); + } + + async removeEarlyAccess(userId: string) { + return this.feature.removeUserFeature(userId, FeatureType.EarlyAccess); + } + + async listEarlyAccess() { + return this.feature.listFeatureUsers(FeatureType.EarlyAccess); + } + + /// check early access by email + async canEarlyAccess(email: string) { + if (this.config.featureFlags.earlyAccessPreview && !this.isStaff(email)) { + const user = await this.prisma.user.findFirst({ + where: { + email, + }, + }); + if (user) { + const canEarlyAccess = await this.feature + .hasFeature(user.id, FeatureType.EarlyAccess) + .catch(() => false); + if (canEarlyAccess) { + return true; + } + + // TODO: Outdated, switch to feature gates + const oldCanEarlyAccess = await this.prisma.newFeaturesWaitingList + .findUnique({ + where: { email, type: NewFeaturesKind.EarlyAccess }, + }) + .then(x => !!x) + .catch(() => false); + if (oldCanEarlyAccess) { + this.logger.warn( + `User ${email} has early access in old table but not in new table` + ); + } + return oldCanEarlyAccess; + } + return false; + } else { + return true; + } + } +} diff --git a/packages/backend/server/src/modules/features/configure.ts b/packages/backend/server/src/modules/features/service.ts similarity index 84% rename from packages/backend/server/src/modules/features/configure.ts rename to packages/backend/server/src/modules/features/service.ts index 118d0ad0b0..beed9dc4fb 100644 --- a/packages/backend/server/src/modules/features/configure.ts +++ b/packages/backend/server/src/modules/features/service.ts @@ -1,7 +1,9 @@ import { Injectable } from '@nestjs/common'; import { PrismaService } from '../../prisma'; -import { Feature, FeatureKind, FeatureType } from './types'; +import { UserType } from '../users/types'; +import { getFeature } from './feature'; +import { FeatureKind, FeatureType } from './types'; @Injectable() export class FeatureService { @@ -27,15 +29,20 @@ export class FeatureService { } async getFeature(feature: FeatureType) { - return this.prisma.features.findFirst({ + const data = await this.prisma.features.findFirst({ where: { feature, type: FeatureKind.Feature, }, + select: { id: true }, orderBy: { version: 'desc', }, }); + if (data) { + return getFeature(this.prisma, data.id); + } + return undefined; } async addUserFeature( @@ -120,21 +127,21 @@ export class FeatureService { reason: true, createdAt: true, expiredAt: true, - feature: { - select: { - feature: true, - configs: true, - }, - }, + featureId: true, }, }); - return features as typeof features & - { - feature: Pick; - }[]; + + const configs = await Promise.all( + features.map(async feature => ({ + ...feature, + feature: await getFeature(this.prisma, feature.featureId), + })) + ); + + return configs.filter(feature => !!feature.feature); } - async listFeatureUsers(feature: FeatureType) { + async listFeatureUsers(feature: FeatureType): Promise { return this.prisma.userFeatures .findMany({ where: { diff --git a/packages/backend/server/src/modules/features/types.ts b/packages/backend/server/src/modules/features/types.ts index 00818bb26f..007463b07d 100644 --- a/packages/backend/server/src/modules/features/types.ts +++ b/packages/backend/server/src/modules/features/types.ts @@ -1,26 +1,48 @@ -import type { Prisma } from '@prisma/client'; +import { URL } from 'node:url'; + +import { z } from 'zod'; + +/// ======== common schema ======== export enum FeatureKind { Feature, Quota, } -export type CommonFeature = { - feature: string; - type: FeatureKind; - version: number; - configs: Prisma.InputJsonValue; -}; +export const commonFeatureSchema = z.object({ + feature: z.string(), + type: z.nativeEnum(FeatureKind), + version: z.number(), + configs: z.unknown(), +}); -export type Feature = CommonFeature & { - type: FeatureKind.Feature; - feature: FeatureType; -}; +export type CommonFeature = z.infer; + +/// ======== feature define ======== export enum FeatureType { EarlyAccess = 'early_access', } +function checkHostname(host: string) { + try { + return new URL(`https://${host}`).hostname === host; + } catch (_) { + return false; + } +} + +const featureEarlyAccess = z.object({ + feature: z.literal(FeatureType.EarlyAccess), + configs: z.object({ + whitelist: z + .string() + .startsWith('@') + .refine(domain => checkHostname(domain.slice(1))) + .array(), + }), +}); + export const Features: Feature[] = [ { feature: FeatureType.EarlyAccess, @@ -31,3 +53,13 @@ export const Features: Feature[] = [ }, }, ]; + +/// ======== schema infer ======== + +export const FeatureSchema = commonFeatureSchema + .extend({ + type: z.literal(FeatureKind.Feature), + }) + .and(z.discriminatedUnion('feature', [featureEarlyAccess])); + +export type Feature = z.infer; diff --git a/packages/backend/server/src/modules/payment/service.ts b/packages/backend/server/src/modules/payment/service.ts index 25a0fedd8f..e858e12529 100644 --- a/packages/backend/server/src/modules/payment/service.ts +++ b/packages/backend/server/src/modules/payment/service.ts @@ -480,9 +480,9 @@ export class SubscriptionService { private getPlanQuota(plan: SubscriptionPlan) { if (plan === SubscriptionPlan.Free) { - return QuotaType.Quota_FreePlanV1; + return QuotaType.FreePlanV1; } else if (plan === SubscriptionPlan.Pro) { - return QuotaType.Quota_ProPlanV1; + return QuotaType.ProPlanV1; } else { throw new Error(`Unknown plan: ${plan}`); } @@ -520,7 +520,7 @@ export class SubscriptionService { } } else { // switch to free plan if subscription is canceled - await this.quota.switchUserQuota(user.id, QuotaType.Quota_FreePlanV1); + await this.quota.switchUserQuota(user.id, QuotaType.FreePlanV1); } const commonData = { diff --git a/packages/backend/server/src/modules/quota/constant.ts b/packages/backend/server/src/modules/quota/constant.ts new file mode 100644 index 0000000000..e6fb0d25a0 --- /dev/null +++ b/packages/backend/server/src/modules/quota/constant.ts @@ -0,0 +1,5 @@ +export const OneKB = 1024; +export const OneMB = OneKB * OneKB; +export const OneGB = OneKB * OneMB; +export const OneDay = 1000 * 60 * 60 * 24; +export const ByteUnit = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; diff --git a/packages/backend/server/src/modules/quota/index.ts b/packages/backend/server/src/modules/quota/index.ts index 4e7e8ccd7f..59ac207e37 100644 --- a/packages/backend/server/src/modules/quota/index.ts +++ b/packages/backend/server/src/modules/quota/index.ts @@ -1,7 +1,7 @@ import { Module } from '@nestjs/common'; import { PermissionService } from '../workspaces/permission'; -import { QuotaService } from './quota'; +import { QuotaService } from './service'; import { QuotaManagementService } from './storage'; /** @@ -17,4 +17,5 @@ import { QuotaManagementService } from './storage'; export class QuotaModule {} export { QuotaManagementService, QuotaService }; -export { Quota_FreePlanV1, Quota_ProPlanV1, Quotas, QuotaType } from './types'; +export { Quota_FreePlanV1, Quota_ProPlanV1, Quotas } from './schema'; +export { QuotaType } from './types'; diff --git a/packages/backend/server/src/modules/quota/quota.ts b/packages/backend/server/src/modules/quota/quota.ts index d22113c9c7..caf7c570eb 100644 --- a/packages/backend/server/src/modules/quota/quota.ts +++ b/packages/backend/server/src/modules/quota/quota.ts @@ -1,157 +1,81 @@ -import { Injectable } from '@nestjs/common'; - import { PrismaService } from '../../prisma'; -import { FeatureKind } from '../features'; -import { - formatDate, - formatSize, - getQuotaName, - Quota, - QuotaType, -} from './types'; +import { formatDate, formatSize, Quota, QuotaSchema } from './types'; -@Injectable() -export class QuotaService { - constructor(private readonly prisma: PrismaService) {} +const QuotaCache = new Map(); - // get activated user quota - async getUserQuota(userId: string) { - const quota = await this.prisma.userFeatures.findFirst({ +export class QuotaConfig { + readonly config: Quota; + + static async get(prisma: PrismaService, featureId: number) { + const cachedQuota = QuotaCache.get(featureId); + + if (cachedQuota) { + return cachedQuota; + } + + const quota = await prisma.features.findFirst({ where: { - user: { - id: userId, - }, - feature: { - type: FeatureKind.Quota, - }, - activated: true, - }, - select: { - reason: true, - createdAt: true, - expiredAt: true, - feature: { - select: { - feature: true, - configs: true, - }, - }, + id: featureId, }, }); - console.error(userId, quota); - return quota as typeof quota & { - feature: Pick; - }; + + if (!quota) { + throw new Error(`Quota config ${featureId} not found`); + } + + const config = new QuotaConfig(quota); + // we always edit quota config as a new quota config + // so we can cache it by featureId + QuotaCache.set(featureId, config); + + return config; } - getHumanReadableQuota(feature: QuotaType, configs: Quota['configs']) { + private constructor(data: any) { + const config = QuotaSchema.safeParse(data); + if (config.success) { + this.config = config.data; + } else { + throw new Error( + `Invalid quota config: ${config.error.message}, ${JSON.stringify( + data + )})}` + ); + } + } + + /// feature name of quota + get name() { + return this.config.feature; + } + + get blobLimit() { + return this.config.configs.blobLimit; + } + + get storageQuota() { + return this.config.configs.storageQuota; + } + + get historyPeriod() { + return this.config.configs.historyPeriod; + } + + get historyPeriodFromNow() { + return new Date(Date.now() + this.historyPeriod); + } + + get memberLimit() { + return this.config.configs.memberLimit; + } + + get humanReadable() { return { - name: getQuotaName(feature), - blobLimit: formatSize(configs.blobLimit), - storageQuota: formatSize(configs.storageQuota), - historyPeriod: formatDate(configs.historyPeriod), - memberLimit: configs.memberLimit.toString(), + name: this.config.configs.name, + blobLimit: formatSize(this.blobLimit), + storageQuota: formatSize(this.storageQuota), + historyPeriod: formatDate(this.historyPeriod), + memberLimit: this.memberLimit.toString(), }; } - - // get all user quota records - async getUserQuotas(userId: string) { - const quotas = await this.prisma.userFeatures.findMany({ - where: { - user: { - id: userId, - }, - feature: { - type: FeatureKind.Quota, - }, - }, - select: { - activated: true, - reason: true, - createdAt: true, - expiredAt: true, - feature: { - select: { - feature: true, - configs: true, - }, - }, - }, - }); - return quotas as typeof quotas & - { - feature: Pick; - }[]; - } - - // switch user to a new quota - // currently each user can only have one quota - async switchUserQuota( - userId: string, - quota: QuotaType, - reason?: string, - expiredAt?: Date - ) { - await this.prisma.$transaction(async tx => { - const latestFreePlan = await tx.features.aggregate({ - where: { - feature: QuotaType.Quota_FreePlanV1, - }, - _max: { - version: true, - }, - }); - - // we will deactivate all exists quota for this user - await tx.userFeatures.updateMany({ - where: { - id: undefined, - userId, - feature: { - type: FeatureKind.Quota, - }, - }, - data: { - activated: false, - }, - }); - - await tx.userFeatures.create({ - data: { - user: { - connect: { - id: userId, - }, - }, - feature: { - connect: { - feature_version: { - feature: quota, - version: latestFreePlan._max.version || 1, - }, - type: FeatureKind.Quota, - }, - }, - reason: reason ?? 'switch quota', - activated: true, - expiredAt, - }, - }); - }); - } - - async hasQuota(userId: string, quota: QuotaType) { - return this.prisma.userFeatures - .count({ - where: { - userId, - feature: { - feature: quota, - type: FeatureKind.Quota, - }, - activated: true, - }, - }) - .then(count => count > 0); - } } diff --git a/packages/backend/server/src/modules/quota/schema.ts b/packages/backend/server/src/modules/quota/schema.ts new file mode 100644 index 0000000000..a24f0db52a --- /dev/null +++ b/packages/backend/server/src/modules/quota/schema.ts @@ -0,0 +1,50 @@ +import { FeatureKind } from '../features'; +import { OneDay, OneGB, OneMB } from './constant'; +import { Quota, QuotaType } from './types'; + +export const Quotas: Quota[] = [ + { + feature: QuotaType.FreePlanV1, + type: FeatureKind.Quota, + version: 1, + configs: { + // quota name + name: 'Free', + // single blob limit 10MB + blobLimit: 10 * OneMB, + // total blob limit 10GB + storageQuota: 10 * OneGB, + // history period of validity 7 days + historyPeriod: 7 * OneDay, + // member limit 3 + memberLimit: 3, + }, + }, + { + feature: QuotaType.ProPlanV1, + type: FeatureKind.Quota, + version: 1, + configs: { + // quota name + name: 'Pro', + // single blob limit 100MB + blobLimit: 100 * OneMB, + // total blob limit 100GB + storageQuota: 100 * OneGB, + // history period of validity 30 days + historyPeriod: 30 * OneDay, + // member limit 10 + memberLimit: 10, + }, + }, +]; + +export const Quota_FreePlanV1 = { + feature: Quotas[0].feature, + version: Quotas[0].version, +}; + +export const Quota_ProPlanV1 = { + feature: Quotas[1].feature, + version: Quotas[1].version, +}; diff --git a/packages/backend/server/src/modules/quota/service.ts b/packages/backend/server/src/modules/quota/service.ts new file mode 100644 index 0000000000..b20cc3a455 --- /dev/null +++ b/packages/backend/server/src/modules/quota/service.ts @@ -0,0 +1,147 @@ +import { Injectable } from '@nestjs/common'; + +import { PrismaService } from '../../prisma'; +import { FeatureKind } from '../features'; +import { QuotaConfig } from './quota'; +import { QuotaType } from './types'; + +@Injectable() +export class QuotaService { + constructor(private readonly prisma: PrismaService) {} + + // get activated user quota + async getUserQuota(userId: string) { + const quota = await this.prisma.userFeatures.findFirst({ + where: { + user: { + id: userId, + }, + feature: { + type: FeatureKind.Quota, + }, + activated: true, + }, + select: { + reason: true, + createdAt: true, + expiredAt: true, + featureId: true, + }, + }); + + if (!quota) { + // this should unreachable + throw new Error(`User ${userId} has no quota`); + } + + const feature = await QuotaConfig.get(this.prisma, quota.featureId); + return { ...quota, feature }; + } + + // get user all quota records + async getUserQuotas(userId: string) { + const quotas = await this.prisma.userFeatures.findMany({ + where: { + user: { + id: userId, + }, + feature: { + type: FeatureKind.Quota, + }, + }, + select: { + activated: true, + reason: true, + createdAt: true, + expiredAt: true, + featureId: true, + }, + }); + const configs = await Promise.all( + quotas.map(async quota => { + try { + return { + ...quota, + feature: await QuotaConfig.get(this.prisma, quota.featureId), + }; + } catch (_) {} + return null as unknown as typeof quota & { + feature: QuotaConfig; + }; + }) + ); + + return configs.filter(quota => !!quota); + } + + // switch user to a new quota + // currently each user can only have one quota + async switchUserQuota( + userId: string, + quota: QuotaType, + reason?: string, + expiredAt?: Date + ) { + await this.prisma.$transaction(async tx => { + const latestFreePlan = await tx.features.aggregate({ + where: { + feature: QuotaType.FreePlanV1, + }, + _max: { + version: true, + }, + }); + + // we will deactivate all exists quota for this user + await tx.userFeatures.updateMany({ + where: { + id: undefined, + userId, + feature: { + type: FeatureKind.Quota, + }, + }, + data: { + activated: false, + }, + }); + + await tx.userFeatures.create({ + data: { + user: { + connect: { + id: userId, + }, + }, + feature: { + connect: { + feature_version: { + feature: quota, + version: latestFreePlan._max.version || 1, + }, + type: FeatureKind.Quota, + }, + }, + reason: reason ?? 'switch quota', + activated: true, + expiredAt, + }, + }); + }); + } + + async hasQuota(userId: string, quota: QuotaType) { + return this.prisma.userFeatures + .count({ + where: { + userId, + feature: { + feature: quota, + type: FeatureKind.Quota, + }, + activated: true, + }, + }) + .then(count => count > 0); + } +} diff --git a/packages/backend/server/src/modules/quota/storage.ts b/packages/backend/server/src/modules/quota/storage.ts index 5cd50848ea..fff2dd50df 100644 --- a/packages/backend/server/src/modules/quota/storage.ts +++ b/packages/backend/server/src/modules/quota/storage.ts @@ -1,14 +1,9 @@ import type { Storage } from '@affine/storage'; -import { - ForbiddenException, - Inject, - Injectable, - NotFoundException, -} from '@nestjs/common'; +import { Inject, Injectable, NotFoundException } from '@nestjs/common'; import { StorageProvide } from '../../storage'; import { PermissionService } from '../workspaces/permission'; -import { QuotaService } from './quota'; +import { QuotaService } from './service'; @Injectable() export class QuotaManagementService { @@ -20,17 +15,15 @@ export class QuotaManagementService { async getUserQuota(userId: string) { const quota = await this.quota.getUserQuota(userId); - if (quota) { - return { - name: quota.feature.feature, - reason: quota.reason, - createAt: quota.createdAt, - expiredAt: quota.expiredAt, - blobLimit: quota.feature.configs.blobLimit, - storageQuota: quota.feature.configs.storageQuota, - }; - } - return null; + + return { + name: quota.feature.name, + reason: quota.reason, + createAt: quota.createdAt, + expiredAt: quota.expiredAt, + blobLimit: quota.feature.blobLimit, + storageQuota: quota.feature.storageQuota, + }; } // TODO: lazy calc, need to be optimized with cache @@ -45,7 +38,7 @@ export class QuotaManagementService { const { user: owner } = await this.permissions.getWorkspaceOwner(workspaceId); if (!owner) throw new NotFoundException('Workspace owner not found'); - const { storageQuota } = (await this.getUserQuota(owner.id)) || {}; + const { storageQuota } = await this.getUserQuota(owner.id); // get all workspaces size of owner used const usageSize = await this.getUserUsage(owner.id); @@ -55,9 +48,6 @@ export class QuotaManagementService { async checkBlobQuota(workspaceId: string, size: number) { const { quota, size: usageSize } = await this.getWorkspaceUsage(workspaceId); - if (typeof quota !== 'number') { - throw new ForbiddenException(`user's quota not exists`); - } return quota - (size + usageSize); } diff --git a/packages/backend/server/src/modules/quota/types.ts b/packages/backend/server/src/modules/quota/types.ts index 498aba31a7..6097d6bc57 100644 --- a/packages/backend/server/src/modules/quota/types.ts +++ b/packages/backend/server/src/modules/quota/types.ts @@ -1,31 +1,37 @@ -import { CommonFeature, FeatureKind } from '../features'; +import { z } from 'zod'; + +import { commonFeatureSchema, FeatureKind } from '../features'; +import { ByteUnit, OneDay, OneKB } from './constant'; + +/// ======== quota define ======== export enum QuotaType { - Quota_FreePlanV1 = 'free_plan_v1', - Quota_ProPlanV1 = 'pro_plan_v1', + FreePlanV1 = 'free_plan_v1', + ProPlanV1 = 'pro_plan_v1', } -export enum QuotaName { - free_plan_v1 = 'Free Plan', - pro_plan_v1 = 'Pro Plan', -} +const quotaPlan = z.object({ + feature: z.enum([QuotaType.FreePlanV1, QuotaType.ProPlanV1]), + configs: z.object({ + name: z.string(), + blobLimit: z.number().positive().int(), + storageQuota: z.number().positive().int(), + historyPeriod: z.number().positive().int(), + memberLimit: z.number().positive().int(), + }), +}); -export type Quota = CommonFeature & { - type: FeatureKind.Quota; - feature: QuotaType; - configs: { - blobLimit: number; - storageQuota: number; - historyPeriod: number; - memberLimit: number; - }; -}; +/// ======== schema infer ======== -const OneKB = 1024; -const OneMB = OneKB * OneKB; -const OneGB = OneKB * OneMB; +export const QuotaSchema = commonFeatureSchema + .extend({ + type: z.literal(FeatureKind.Quota), + }) + .and(z.discriminatedUnion('feature', [quotaPlan])); -const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; +export type Quota = z.infer; + +/// ======== utils ======== export function formatSize(bytes: number, decimals: number = 2): string { if (bytes === 0) return '0 B'; @@ -34,60 +40,11 @@ export function formatSize(bytes: number, decimals: number = 2): string { const i = Math.floor(Math.log(bytes) / Math.log(OneKB)); - return parseFloat((bytes / Math.pow(OneKB, i)).toFixed(dm)) + ' ' + sizes[i]; + return ( + parseFloat((bytes / Math.pow(OneKB, i)).toFixed(dm)) + ' ' + ByteUnit[i] + ); } -const OneDay = 1000 * 60 * 60 * 24; - export function formatDate(ms: number): string { return `${(ms / OneDay).toFixed(0)} days`; } - -export function getQuotaName(quota: QuotaType): string { - return QuotaName[quota]; -} - -export const Quotas: Quota[] = [ - { - feature: QuotaType.Quota_FreePlanV1, - type: FeatureKind.Quota, - version: 1, - configs: { - // single blob limit 10MB - blobLimit: 10 * OneMB, - // total blob limit 10GB - storageQuota: 10 * OneGB, - // history period of validity 7 days - historyPeriod: 7 * OneDay, - // member limit 3 - memberLimit: 3, - }, - }, - { - feature: QuotaType.Quota_ProPlanV1, - type: FeatureKind.Quota, - version: 1, - configs: { - // single blob limit 100MB - blobLimit: 100 * OneMB, - // total blob limit 100GB - storageQuota: 100 * OneGB, - // history period of validity 30 days - historyPeriod: 30 * OneDay, - // member limit 10 - memberLimit: 10, - }, - }, -]; - -// ======== payload ======== - -export const Quota_FreePlanV1 = { - feature: Quotas[0].feature, - version: Quotas[0].version, -}; - -export const Quota_ProPlanV1 = { - feature: Quotas[1].feature, - version: Quotas[1].version, -}; diff --git a/packages/backend/server/src/modules/users/resolver.ts b/packages/backend/server/src/modules/users/resolver.ts index f002040a5d..d816c111d2 100644 --- a/packages/backend/server/src/modules/users/resolver.ts +++ b/packages/backend/server/src/modules/users/resolver.ts @@ -114,18 +114,8 @@ export class UserResolver { @ResolveField(() => UserQuotaType, { name: 'quota', nullable: true }) async getQuota(@CurrentUser() me: User) { const quota = await this.quota.getUserQuota(me.id); - const configs = quota.feature.configs; - return Object.assign( - { - name: quota.feature.feature, - humanReadable: this.quota.getHumanReadableQuota( - quota.feature.feature, - configs - ), - }, - configs - ); + return quota.feature; } @Throttle({ default: { limit: 10, ttl: 60 } }) diff --git a/packages/backend/server/tests/app.e2e.ts b/packages/backend/server/tests/app.e2e.ts index 95e8b6c5eb..1578fe5f87 100644 --- a/packages/backend/server/tests/app.e2e.ts +++ b/packages/backend/server/tests/app.e2e.ts @@ -12,6 +12,7 @@ import graphqlUploadExpress from 'graphql-upload/graphqlUploadExpress.mjs'; import request from 'supertest'; import { AppModule } from '../src/app'; +import { FeatureManagementService } from '../src/modules/features'; import { PrismaService } from '../src/prisma/service'; const gql = '/graphql'; @@ -60,6 +61,8 @@ test.beforeEach(async t => { }) .overrideProvider(PrismaService) .useClass(FakePrisma) + .overrideProvider(FeatureManagementService) + .useValue({ canEarlyAccess: () => true }) .compile(); t.context.app = module.createNestApplication({ cors: true, diff --git a/packages/backend/server/tests/exception-logger.e2e.ts b/packages/backend/server/tests/exception-logger.e2e.ts index 12f00ee9eb..fccd7117b0 100644 --- a/packages/backend/server/tests/exception-logger.e2e.ts +++ b/packages/backend/server/tests/exception-logger.e2e.ts @@ -6,6 +6,7 @@ import request from 'supertest'; import { AppModule } from '../src/app'; import { ExceptionLogger } from '../src/middleware/exception-logger'; +import { FeatureManagementService } from '../src/modules/features'; import { PrismaService } from '../src/prisma'; const gql = '/graphql'; @@ -38,6 +39,8 @@ test.beforeEach(async () => { }) .overrideProvider(PrismaService) .useClass(FakePrisma) + .overrideProvider(FeatureManagementService) + .useValue({}) .compile(); app = module.createNestApplication({ cors: true, diff --git a/packages/backend/server/tests/feature.spec.ts b/packages/backend/server/tests/feature.spec.ts index daaa5efed6..ecc73db85c 100644 --- a/packages/backend/server/tests/feature.spec.ts +++ b/packages/backend/server/tests/feature.spec.ts @@ -81,12 +81,8 @@ test('should be able to set feature', async t => { await feature.addUserFeature(u1.id, FeatureType.EarlyAccess, 1, 'test'); const f2 = await feature.getUserFeatures(u1.id); - t.is(f2.length, 1, 'should have one feature'); - t.is( - f2[0].feature.feature, - FeatureType.EarlyAccess, - 'should be early access' - ); + t.is(f2.length, 1, 'should have 1 feature'); + t.is(f2[0].feature.name, FeatureType.EarlyAccess, 'should be early access'); }); test('should be able to check early access', async t => { @@ -101,7 +97,7 @@ test('should be able to check early access', async t => { t.true(f2, 'should have early access'); const f3 = await feature.listFeatureUsers(FeatureType.EarlyAccess); - t.is(f3.length, 1, 'should have one user'); + t.is(f3.length, 1, 'should have 1 user'); t.is(f3[0].id, u1.id, 'should be the same user'); }); @@ -116,7 +112,7 @@ test('should be able revert quota', async t => { const f2 = await early_access.canEarlyAccess(u1.email); t.true(f2, 'should have early access'); const q1 = await early_access.listEarlyAccess(); - t.is(q1.length, 1, 'should have one user'); + t.is(q1.length, 1, 'should have 1 user'); t.is(q1[0].id, u1.id, 'should be the same user'); await early_access.removeEarlyAccess(u1.id); @@ -127,10 +123,21 @@ test('should be able revert quota', async t => { const q3 = await feature.getUserFeatures(u1.id); t.is(q3.length, 1, 'should have 1 feature'); - t.is( - q3[0].feature.feature, - FeatureType.EarlyAccess, - 'should be early access' - ); + t.is(q3[0].feature.name, FeatureType.EarlyAccess, 'should be early access'); t.is(q3[0].activated, false, 'should be deactivated'); }); + +test('should be same instance after reset the feature', async t => { + const { auth, feature, early_access } = t.context; + const u1 = await auth.signUp('DarkSky', 'darksky@example.org', '123456'); + + await early_access.addEarlyAccess(u1.id); + const f1 = (await feature.getUserFeatures(u1.id))[0]; + + await early_access.removeEarlyAccess(u1.id); + + await early_access.addEarlyAccess(u1.id); + const f2 = (await feature.getUserFeatures(u1.id))[1]; + + t.is(f1.feature, f2.feature, 'should be same instance'); +}); diff --git a/packages/backend/server/tests/mailer.spec.ts b/packages/backend/server/tests/mailer.spec.ts index 8d8c395c54..023cfdf35d 100644 --- a/packages/backend/server/tests/mailer.spec.ts +++ b/packages/backend/server/tests/mailer.spec.ts @@ -9,6 +9,7 @@ import graphqlUploadExpress from 'graphql-upload/graphqlUploadExpress.mjs'; import { AppModule } from '../src/app'; import { MailService } from '../src/modules/auth/mailer'; +import { FeatureManagementService } from '../src/modules/features'; import { PrismaService } from '../src/prisma'; import { createWorkspace, getInviteInfo, inviteUser, signUp } from './utils'; @@ -100,6 +101,8 @@ test.beforeEach(async t => { }) .overrideProvider(PrismaService) .useValue(FakePrisma) + .overrideProvider(FeatureManagementService) + .useValue({}) .compile(); const app = module.createNestApplication(); app.use( diff --git a/packages/backend/server/tests/quota.spec.ts b/packages/backend/server/tests/quota.spec.ts index 7f56190de3..f9d974929e 100644 --- a/packages/backend/server/tests/quota.spec.ts +++ b/packages/backend/server/tests/quota.spec.ts @@ -80,12 +80,12 @@ test('should be able to set quota', async t => { const q1 = await quota.getUserQuota(u1.id); t.truthy(q1, 'should have quota'); - t.is(q1?.feature.feature, QuotaType.Quota_FreePlanV1, 'should be free plan'); + t.is(q1?.feature.name, QuotaType.FreePlanV1, 'should be free plan'); - await quota.switchUserQuota(u1.id, QuotaType.Quota_ProPlanV1); + await quota.switchUserQuota(u1.id, QuotaType.ProPlanV1); const q2 = await quota.getUserQuota(u1.id); - t.is(q2?.feature.feature, QuotaType.Quota_ProPlanV1, 'should be pro plan'); + t.is(q2?.feature.name, QuotaType.ProPlanV1, 'should be pro plan'); const fail = quota.switchUserQuota(u1.id, 'not_exists_plan_v1' as QuotaType); await t.throwsAsync(fail, { instanceOf: Error }, 'should throw error'); @@ -99,7 +99,7 @@ test('should be able to check storage quota', async t => { t.is(q1?.blobLimit, Quotas[0].configs.blobLimit, 'should be free plan'); t.is(q1?.storageQuota, Quotas[0].configs.storageQuota, 'should be free plan'); - await quota.switchUserQuota(u1.id, QuotaType.Quota_ProPlanV1); + await quota.switchUserQuota(u1.id, QuotaType.ProPlanV1); const q2 = await storageQuota.getUserQuota(u1.id); t.is(q2?.blobLimit, Quotas[1].configs.blobLimit, 'should be pro plan'); t.is(q2?.storageQuota, Quotas[1].configs.storageQuota, 'should be pro plan'); @@ -113,32 +113,20 @@ test('should be able revert quota', async t => { t.is(q1?.blobLimit, Quotas[0].configs.blobLimit, 'should be free plan'); t.is(q1?.storageQuota, Quotas[0].configs.storageQuota, 'should be free plan'); - await quota.switchUserQuota(u1.id, QuotaType.Quota_ProPlanV1); + await quota.switchUserQuota(u1.id, QuotaType.ProPlanV1); const q2 = await storageQuota.getUserQuota(u1.id); t.is(q2?.blobLimit, Quotas[1].configs.blobLimit, 'should be pro plan'); t.is(q2?.storageQuota, Quotas[1].configs.storageQuota, 'should be pro plan'); - await quota.switchUserQuota(u1.id, QuotaType.Quota_FreePlanV1); + await quota.switchUserQuota(u1.id, QuotaType.FreePlanV1); const q3 = await storageQuota.getUserQuota(u1.id); t.is(q3?.blobLimit, Quotas[0].configs.blobLimit, 'should be free plan'); const quotas = await quota.getUserQuotas(u1.id); t.is(quotas.length, 3, 'should have 3 quotas'); - t.is( - quotas[0].feature.feature, - QuotaType.Quota_FreePlanV1, - 'should be free plan' - ); - t.is( - quotas[1].feature.feature, - QuotaType.Quota_ProPlanV1, - 'should be pro plan' - ); - t.is( - quotas[2].feature.feature, - QuotaType.Quota_FreePlanV1, - 'should be free plan' - ); + t.is(quotas[0].feature.name, QuotaType.FreePlanV1, 'should be free plan'); + t.is(quotas[1].feature.name, QuotaType.ProPlanV1, 'should be pro plan'); + t.is(quotas[2].feature.name, QuotaType.FreePlanV1, 'should be free plan'); t.is(quotas[0].activated, false, 'should be activated'); t.is(quotas[1].activated, false, 'should be activated'); t.is(quotas[2].activated, true, 'should be activated'); diff --git a/packages/backend/server/tests/utils.ts b/packages/backend/server/tests/utils.ts deleted file mode 100644 index e413de583e..0000000000 --- a/packages/backend/server/tests/utils.ts +++ /dev/null @@ -1,619 +0,0 @@ -import { randomUUID } from 'node:crypto'; - -import type { - DynamicModule, - FactoryProvider, - INestApplication, -} from '@nestjs/common'; -import { TestingModule } from '@nestjs/testing'; -import { hashSync } from '@node-rs/argon2'; -import { PrismaClient, type User } from '@prisma/client'; -import request from 'supertest'; - -import { RevertCommand, RunCommand } from '../src/data/commands/run'; -import type { TokenType } from '../src/modules/auth'; -import type { UserType } from '../src/modules/users'; -import type { InvitationType, WorkspaceType } from '../src/modules/workspaces'; -import { StorageProvide } from '../src/storage'; - -const gql = '/graphql'; - -async function signUp( - app: INestApplication, - name: string, - email: string, - password: string -): Promise { - const res = await request(app.getHttpServer()) - .post(gql) - .set({ 'x-request-id': 'test', 'x-operation-name': 'test' }) - .send({ - query: ` - mutation { - signUp(name: "${name}", email: "${email}", password: "${password}") { - id, name, email, token { token } - } - } - `, - }) - .expect(200); - return res.body.data.signUp; -} - -async function currentUser(app: INestApplication, token: string) { - const res = await request(app.getHttpServer()) - .post(gql) - .auth(token, { type: 'bearer' }) - .set({ 'x-request-id': 'test', 'x-operation-name': 'test' }) - .send({ - query: ` - query { - currentUser { - id, name, email, emailVerified, avatarUrl, createdAt, hasPassword, - token { token } - } - } - `, - }) - .expect(200); - return res.body.data.currentUser; -} - -async function createWorkspace( - app: INestApplication, - token: string -): Promise { - const res = await request(app.getHttpServer()) - .post(gql) - .auth(token, { type: 'bearer' }) - .set({ 'x-request-id': 'test', 'x-operation-name': 'test' }) - .field( - 'operations', - JSON.stringify({ - name: 'createWorkspace', - query: `mutation createWorkspace($init: Upload!) { - createWorkspace(init: $init) { - id - } - }`, - variables: { init: null }, - }) - ) - .field('map', JSON.stringify({ '0': ['variables.init'] })) - .attach('0', Buffer.from([0, 0]), 'init.data') - .expect(200); - return res.body.data.createWorkspace; -} - -export async function getWorkspacePublicPages( - app: INestApplication, - token: string, - workspaceId: string -) { - const res = await request(app.getHttpServer()) - .post(gql) - .auth(token, { type: 'bearer' }) - .set({ 'x-request-id': 'test', 'x-operation-name': 'test' }) - .send({ - query: ` - query { - workspace(id: "${workspaceId}") { - publicPages { - id - mode - } - } - } - `, - }) - .expect(200); - return res.body.data.workspace.publicPages; -} - -async function getWorkspace( - app: INestApplication, - token: string, - workspaceId: string, - skip = 0, - take = 8 -): Promise { - const res = await request(app.getHttpServer()) - .post(gql) - .auth(token, { type: 'bearer' }) - .set({ 'x-request-id': 'test', 'x-operation-name': 'test' }) - .send({ - query: ` - query { - workspace(id: "${workspaceId}") { - id, members(skip: ${skip}, take: ${take}) { id, name, email, permission, inviteId } - } - } - `, - }) - .expect(200); - return res.body.data.workspace; -} - -async function getPublicWorkspace( - app: INestApplication, - workspaceId: string -): Promise { - const res = await request(app.getHttpServer()) - .post(gql) - .set({ 'x-request-id': 'test', 'x-operation-name': 'test' }) - .send({ - query: ` - query { - publicWorkspace(id: "${workspaceId}") { - id - } - } - `, - }) - .expect(200); - return res.body.data.publicWorkspace; -} - -async function updateWorkspace( - app: INestApplication, - token: string, - workspaceId: string, - isPublic: boolean -): Promise { - const res = await request(app.getHttpServer()) - .post(gql) - .auth(token, { type: 'bearer' }) - .set({ 'x-request-id': 'test', 'x-operation-name': 'test' }) - .send({ - query: ` - mutation { - updateWorkspace(input: { id: "${workspaceId}", public: ${isPublic} }) { - public - } - } - `, - }) - .expect(200); - return res.body.data.updateWorkspace.public; -} - -async function inviteUser( - app: INestApplication, - token: string, - workspaceId: string, - email: string, - permission: string, - sendInviteMail = false -): Promise { - const res = await request(app.getHttpServer()) - .post(gql) - .auth(token, { type: 'bearer' }) - .set({ 'x-request-id': 'test', 'x-operation-name': 'test' }) - .send({ - query: ` - mutation { - invite(workspaceId: "${workspaceId}", email: "${email}", permission: ${permission}, sendInviteMail: ${sendInviteMail}) - } - `, - }) - .expect(200); - return res.body.data.invite; -} - -async function acceptInviteById( - app: INestApplication, - workspaceId: string, - inviteId: string, - sendAcceptMail = false -): Promise { - const res = await request(app.getHttpServer()) - .post(gql) - .set({ 'x-request-id': 'test', 'x-operation-name': 'test' }) - .send({ - query: ` - mutation { - acceptInviteById(workspaceId: "${workspaceId}", inviteId: "${inviteId}", sendAcceptMail: ${sendAcceptMail}) - } - `, - }) - .expect(200); - return res.body.data.acceptInviteById; -} - -async function leaveWorkspace( - app: INestApplication, - token: string, - workspaceId: string, - sendLeaveMail = false -): Promise { - const res = await request(app.getHttpServer()) - .post(gql) - .auth(token, { type: 'bearer' }) - .set({ 'x-request-id': 'test', 'x-operation-name': 'test' }) - .send({ - query: ` - mutation { - leaveWorkspace(workspaceId: "${workspaceId}", workspaceName: "test workspace", sendLeaveMail: ${sendLeaveMail}) - } - `, - }) - .expect(200); - return res.body.data.leaveWorkspace; -} - -async function revokeUser( - app: INestApplication, - token: string, - workspaceId: string, - userId: string -): Promise { - const res = await request(app.getHttpServer()) - .post(gql) - .auth(token, { type: 'bearer' }) - .set({ 'x-request-id': 'test', 'x-operation-name': 'test' }) - .send({ - query: ` - mutation { - revoke(workspaceId: "${workspaceId}", userId: "${userId}") - } - `, - }) - .expect(200); - return res.body.data.revoke; -} - -async function publishPage( - app: INestApplication, - token: string, - workspaceId: string, - pageId: string -) { - const res = await request(app.getHttpServer()) - .post(gql) - .auth(token, { type: 'bearer' }) - .set({ 'x-request-id': 'test', 'x-operation-name': 'test' }) - .send({ - query: ` - mutation { - publishPage(workspaceId: "${workspaceId}", pageId: "${pageId}") { - id - mode - } - } - `, - }) - .expect(200); - return res.body.errors?.[0]?.message || res.body.data?.publishPage; -} - -async function revokePublicPage( - app: INestApplication, - token: string, - workspaceId: string, - pageId: string -) { - const res = await request(app.getHttpServer()) - .post(gql) - .auth(token, { type: 'bearer' }) - .set({ 'x-request-id': 'test', 'x-operation-name': 'test' }) - .send({ - query: ` - mutation { - revokePublicPage(workspaceId: "${workspaceId}", pageId: "${pageId}") { - id - mode - public - } - } - `, - }) - .expect(200); - return res.body.errors?.[0]?.message || res.body.data?.revokePublicPage; -} - -async function listBlobs( - app: INestApplication, - token: string, - workspaceId: string -): Promise { - const res = await request(app.getHttpServer()) - .post(gql) - .auth(token, { type: 'bearer' }) - .set({ 'x-request-id': 'test', 'x-operation-name': 'test' }) - .send({ - query: ` - query { - listBlobs(workspaceId: "${workspaceId}") - } - `, - }) - .expect(200); - return res.body.data.listBlobs; -} - -async function getWorkspaceBlobsSize( - app: INestApplication, - token: string, - workspaceId: string -): Promise { - const res = await request(app.getHttpServer()) - .post(gql) - .auth(token, { type: 'bearer' }) - .send({ - query: ` - query { - workspace(id: "${workspaceId}") { - blobsSize - } - } - `, - }) - .expect(200); - return res.body.data.workspace.blobsSize; -} - -async function collectAllBlobSizes( - app: INestApplication, - token: string -): Promise { - const res = await request(app.getHttpServer()) - .post(gql) - .auth(token, { type: 'bearer' }) - .send({ - query: ` - query { - collectAllBlobSizes { - size - } - } - `, - }) - .expect(200); - return res.body.data.collectAllBlobSizes.size; -} - -async function checkBlobSize( - app: INestApplication, - token: string, - workspaceId: string, - size: number -): Promise { - const res = await request(app.getHttpServer()) - .post(gql) - .auth(token, { type: 'bearer' }) - .send({ - query: `query checkBlobSize($workspaceId: String!, $size: Float!) { - checkBlobSize(workspaceId: $workspaceId, size: $size) { - size - } - }`, - variables: { workspaceId, size }, - }) - .expect(200); - return res.body.data.checkBlobSize.size; -} - -async function setBlob( - app: INestApplication, - token: string, - workspaceId: string, - buffer: Buffer -): Promise { - const res = await request(app.getHttpServer()) - .post(gql) - .auth(token, { type: 'bearer' }) - .set({ 'x-request-id': 'test', 'x-operation-name': 'test' }) - .field( - 'operations', - JSON.stringify({ - name: 'setBlob', - query: `mutation setBlob($blob: Upload!) { - setBlob(workspaceId: "${workspaceId}", blob: $blob) - }`, - variables: { blob: null }, - }) - ) - .field('map', JSON.stringify({ '0': ['variables.blob'] })) - .attach('0', buffer, 'blob.data') - .expect(200); - return res.body.data.setBlob; -} - -async function flushDB() { - const client = new PrismaClient(); - await client.$connect(); - const result: { tablename: string }[] = - await client.$queryRaw`SELECT tablename - FROM pg_catalog.pg_tables - WHERE schemaname != 'pg_catalog' - AND schemaname != 'information_schema'`; - - // remove all table data - await client.$executeRawUnsafe( - `TRUNCATE TABLE ${result - .map(({ tablename }) => tablename) - .filter(name => !name.includes('migrations')) - .join(', ')}` - ); - - await client.$disconnect(); -} - -async function getInviteInfo( - app: INestApplication, - token: string, - inviteId: string -): Promise { - const res = await request(app.getHttpServer()) - .post(gql) - .auth(token, { type: 'bearer' }) - .set({ 'x-request-id': 'test', 'x-operation-name': 'test' }) - .send({ - query: ` - query { - getInviteInfo(inviteId: "${inviteId}") { - workspace { - id - name - avatar - } - user { - id - name - avatarUrl - } - } - } - `, - }) - .expect(200); - return res.body.data.getInviteInfo; -} - -async function sendChangeEmail( - app: INestApplication, - userToken: string, - email: string, - callbackUrl: string -): Promise { - const res = await request(app.getHttpServer()) - .post(gql) - .auth(userToken, { type: 'bearer' }) - .set({ 'x-request-id': 'test', 'x-operation-name': 'test' }) - .send({ - query: ` - mutation { - sendChangeEmail(email: "${email}", callbackUrl: "${callbackUrl}") - } - `, - }) - .expect(200); - - return res.body.data.sendChangeEmail; -} - -async function sendVerifyChangeEmail( - app: INestApplication, - userToken: string, - token: string, - email: string, - callbackUrl: string -): Promise { - const res = await request(app.getHttpServer()) - .post(gql) - .auth(userToken, { type: 'bearer' }) - .set({ 'x-request-id': 'test', 'x-operation-name': 'test' }) - .send({ - query: ` - mutation { - sendVerifyChangeEmail(token:"${token}", email: "${email}", callbackUrl: "${callbackUrl}") - } - `, - }) - .expect(200); - - return res.body.data.sendVerifyChangeEmail; -} - -async function changeEmail( - app: INestApplication, - userToken: string, - token: string -): Promise { - const res = await request(app.getHttpServer()) - .post(gql) - .auth(userToken, { type: 'bearer' }) - .set({ 'x-request-id': 'test', 'x-operation-name': 'test' }) - .send({ - query: ` - mutation { - changeEmail(token: "${token}") { - id - name - avatarUrl - email - } - } - `, - }) - .expect(200); - return res.body.data.changeEmail; -} - -export class FakePrisma { - fakeUser: User = { - id: randomUUID(), - name: 'Alex Yang', - avatarUrl: '', - email: 'alex.yang@example.org', - password: hashSync('123456'), - emailVerified: new Date(), - createdAt: new Date(), - }; - - get user() { - // eslint-disable-next-line @typescript-eslint/no-this-alias - const prisma = this; - return { - async findFirst() { - return prisma.fakeUser; - }, - async findUnique() { - return this.findFirst(); - }, - async update() { - return this.findFirst(); - }, - }; - } -} - -export class FakeStorageModule { - static forRoot(): DynamicModule { - const storageProvider: FactoryProvider = { - provide: StorageProvide, - useFactory: async () => { - return null; - }, - }; - - return { - global: true, - module: FakeStorageModule, - providers: [storageProvider], - exports: [storageProvider], - }; - } -} - -export async function initFeatureConfigs(module: TestingModule) { - const run = module.get(RunCommand); - const revert = module.get(RevertCommand); - await Promise.allSettled([revert.run(['UserFeaturesInit1698652531198'])]); - await run.runOne('UserFeaturesInit1698652531198'); -} - -export { - acceptInviteById, - changeEmail, - checkBlobSize, - collectAllBlobSizes, - createWorkspace, - currentUser, - flushDB, - getInviteInfo, - getPublicWorkspace, - getWorkspace, - getWorkspaceBlobsSize, - inviteUser, - leaveWorkspace, - listBlobs, - publishPage, - revokePublicPage, - revokeUser, - sendChangeEmail, - sendVerifyChangeEmail, - setBlob, - signUp, - updateWorkspace, -}; diff --git a/packages/backend/server/tests/utils/blobs.ts b/packages/backend/server/tests/utils/blobs.ts new file mode 100644 index 0000000000..c026a2922f --- /dev/null +++ b/packages/backend/server/tests/utils/blobs.ts @@ -0,0 +1,112 @@ +import type { INestApplication } from '@nestjs/common'; +import request from 'supertest'; + +import { gql } from './common'; + +export async function listBlobs( + app: INestApplication, + token: string, + workspaceId: string +): Promise { + const res = await request(app.getHttpServer()) + .post(gql) + .auth(token, { type: 'bearer' }) + .set({ 'x-request-id': 'test', 'x-operation-name': 'test' }) + .send({ + query: ` + query { + listBlobs(workspaceId: "${workspaceId}") + } + `, + }) + .expect(200); + return res.body.data.listBlobs; +} + +export async function getWorkspaceBlobsSize( + app: INestApplication, + token: string, + workspaceId: string +): Promise { + const res = await request(app.getHttpServer()) + .post(gql) + .auth(token, { type: 'bearer' }) + .send({ + query: ` + query { + workspace(id: "${workspaceId}") { + blobsSize + } + } + `, + }) + .expect(200); + return res.body.data.workspace.blobsSize; +} + +export async function collectAllBlobSizes( + app: INestApplication, + token: string +): Promise { + const res = await request(app.getHttpServer()) + .post(gql) + .auth(token, { type: 'bearer' }) + .send({ + query: ` + query { + collectAllBlobSizes { + size + } + } + `, + }) + .expect(200); + return res.body.data.collectAllBlobSizes.size; +} + +export async function checkBlobSize( + app: INestApplication, + token: string, + workspaceId: string, + size: number +): Promise { + const res = await request(app.getHttpServer()) + .post(gql) + .auth(token, { type: 'bearer' }) + .send({ + query: `query checkBlobSize($workspaceId: String!, $size: Float!) { + checkBlobSize(workspaceId: $workspaceId, size: $size) { + size + } + }`, + variables: { workspaceId, size }, + }) + .expect(200); + return res.body.data.checkBlobSize.size; +} + +export async function setBlob( + app: INestApplication, + token: string, + workspaceId: string, + buffer: Buffer +): Promise { + const res = await request(app.getHttpServer()) + .post(gql) + .auth(token, { type: 'bearer' }) + .set({ 'x-request-id': 'test', 'x-operation-name': 'test' }) + .field( + 'operations', + JSON.stringify({ + name: 'setBlob', + query: `mutation setBlob($blob: Upload!) { + setBlob(workspaceId: "${workspaceId}", blob: $blob) + }`, + variables: { blob: null }, + }) + ) + .field('map', JSON.stringify({ '0': ['variables.blob'] })) + .attach('0', buffer, 'blob.data') + .expect(200); + return res.body.data.setBlob; +} diff --git a/packages/backend/server/tests/utils/common.ts b/packages/backend/server/tests/utils/common.ts new file mode 100644 index 0000000000..7729223676 --- /dev/null +++ b/packages/backend/server/tests/utils/common.ts @@ -0,0 +1 @@ +export const gql = '/graphql'; diff --git a/packages/backend/server/tests/utils/index.ts b/packages/backend/server/tests/utils/index.ts new file mode 100644 index 0000000000..92904d3c35 --- /dev/null +++ b/packages/backend/server/tests/utils/index.ts @@ -0,0 +1,5 @@ +export * from './blobs'; +export * from './invite'; +export * from './user'; +export * from './utils'; +export * from './workspace'; diff --git a/packages/backend/server/tests/utils/invite.ts b/packages/backend/server/tests/utils/invite.ts new file mode 100644 index 0000000000..aeaacb2cec --- /dev/null +++ b/packages/backend/server/tests/utils/invite.ts @@ -0,0 +1,121 @@ +import type { INestApplication } from '@nestjs/common'; +import request from 'supertest'; + +import type { InvitationType } from '../../src/modules/workspaces'; +import { gql } from './common'; + +export async function inviteUser( + app: INestApplication, + token: string, + workspaceId: string, + email: string, + permission: string, + sendInviteMail = false +): Promise { + const res = await request(app.getHttpServer()) + .post(gql) + .auth(token, { type: 'bearer' }) + .set({ 'x-request-id': 'test', 'x-operation-name': 'test' }) + .send({ + query: ` + mutation { + invite(workspaceId: "${workspaceId}", email: "${email}", permission: ${permission}, sendInviteMail: ${sendInviteMail}) + } + `, + }) + .expect(200); + return res.body.data.invite; +} + +export async function acceptInviteById( + app: INestApplication, + workspaceId: string, + inviteId: string, + sendAcceptMail = false +): Promise { + const res = await request(app.getHttpServer()) + .post(gql) + .set({ 'x-request-id': 'test', 'x-operation-name': 'test' }) + .send({ + query: ` + mutation { + acceptInviteById(workspaceId: "${workspaceId}", inviteId: "${inviteId}", sendAcceptMail: ${sendAcceptMail}) + } + `, + }) + .expect(200); + return res.body.data.acceptInviteById; +} + +export async function leaveWorkspace( + app: INestApplication, + token: string, + workspaceId: string, + sendLeaveMail = false +): Promise { + const res = await request(app.getHttpServer()) + .post(gql) + .auth(token, { type: 'bearer' }) + .set({ 'x-request-id': 'test', 'x-operation-name': 'test' }) + .send({ + query: ` + mutation { + leaveWorkspace(workspaceId: "${workspaceId}", workspaceName: "test workspace", sendLeaveMail: ${sendLeaveMail}) + } + `, + }) + .expect(200); + return res.body.data.leaveWorkspace; +} + +export async function revokeUser( + app: INestApplication, + token: string, + workspaceId: string, + userId: string +): Promise { + const res = await request(app.getHttpServer()) + .post(gql) + .auth(token, { type: 'bearer' }) + .set({ 'x-request-id': 'test', 'x-operation-name': 'test' }) + .send({ + query: ` + mutation { + revoke(workspaceId: "${workspaceId}", userId: "${userId}") + } + `, + }) + .expect(200); + return res.body.data.revoke; +} + +export async function getInviteInfo( + app: INestApplication, + token: string, + inviteId: string +): Promise { + const res = await request(app.getHttpServer()) + .post(gql) + .auth(token, { type: 'bearer' }) + .set({ 'x-request-id': 'test', 'x-operation-name': 'test' }) + .send({ + query: ` + query { + getInviteInfo(inviteId: "${inviteId}") { + workspace { + id + name + avatar + } + user { + id + name + avatarUrl + } + } + } + `, + }) + .expect(200); + return res.body.data.getInviteInfo; +} diff --git a/packages/backend/server/tests/utils/user.ts b/packages/backend/server/tests/utils/user.ts new file mode 100644 index 0000000000..64a374e816 --- /dev/null +++ b/packages/backend/server/tests/utils/user.ts @@ -0,0 +1,117 @@ +import type { INestApplication } from '@nestjs/common'; +import request from 'supertest'; + +import type { TokenType } from '../../src/modules/auth'; +import type { UserType } from '../../src/modules/users'; +import { gql } from './common'; + +export async function signUp( + app: INestApplication, + name: string, + email: string, + password: string +): Promise { + const res = await request(app.getHttpServer()) + .post(gql) + .set({ 'x-request-id': 'test', 'x-operation-name': 'test' }) + .send({ + query: ` + mutation { + signUp(name: "${name}", email: "${email}", password: "${password}") { + id, name, email, token { token } + } + } + `, + }) + .expect(200); + return res.body.data.signUp; +} + +export async function currentUser(app: INestApplication, token: string) { + const res = await request(app.getHttpServer()) + .post(gql) + .auth(token, { type: 'bearer' }) + .set({ 'x-request-id': 'test', 'x-operation-name': 'test' }) + .send({ + query: ` + query { + currentUser { + id, name, email, emailVerified, avatarUrl, createdAt, hasPassword, + token { token } + } + } + `, + }) + .expect(200); + return res.body.data.currentUser; +} + +export async function sendChangeEmail( + app: INestApplication, + userToken: string, + email: string, + callbackUrl: string +): Promise { + const res = await request(app.getHttpServer()) + .post(gql) + .auth(userToken, { type: 'bearer' }) + .set({ 'x-request-id': 'test', 'x-operation-name': 'test' }) + .send({ + query: ` + mutation { + sendChangeEmail(email: "${email}", callbackUrl: "${callbackUrl}") + } + `, + }) + .expect(200); + + return res.body.data.sendChangeEmail; +} + +export async function sendVerifyChangeEmail( + app: INestApplication, + userToken: string, + token: string, + email: string, + callbackUrl: string +): Promise { + const res = await request(app.getHttpServer()) + .post(gql) + .auth(userToken, { type: 'bearer' }) + .set({ 'x-request-id': 'test', 'x-operation-name': 'test' }) + .send({ + query: ` + mutation { + sendVerifyChangeEmail(token:"${token}", email: "${email}", callbackUrl: "${callbackUrl}") + } + `, + }) + .expect(200); + + return res.body.data.sendVerifyChangeEmail; +} + +export async function changeEmail( + app: INestApplication, + userToken: string, + token: string +): Promise { + const res = await request(app.getHttpServer()) + .post(gql) + .auth(userToken, { type: 'bearer' }) + .set({ 'x-request-id': 'test', 'x-operation-name': 'test' }) + .send({ + query: ` + mutation { + changeEmail(token: "${token}") { + id + name + avatarUrl + email + } + } + `, + }) + .expect(200); + return res.body.data.changeEmail; +} diff --git a/packages/backend/server/tests/utils/utils.ts b/packages/backend/server/tests/utils/utils.ts new file mode 100644 index 0000000000..0274e63daf --- /dev/null +++ b/packages/backend/server/tests/utils/utils.ts @@ -0,0 +1,82 @@ +import { randomUUID } from 'node:crypto'; + +import type { DynamicModule, FactoryProvider } from '@nestjs/common'; +import { TestingModule } from '@nestjs/testing'; +import { hashSync } from '@node-rs/argon2'; +import { PrismaClient, type User } from '@prisma/client'; + +import { RevertCommand, RunCommand } from '../../src/data/commands/run'; +import { StorageProvide } from '../../src/storage'; + +export async function flushDB() { + const client = new PrismaClient(); + await client.$connect(); + const result: { tablename: string }[] = + await client.$queryRaw`SELECT tablename + FROM pg_catalog.pg_tables + WHERE schemaname != 'pg_catalog' + AND schemaname != 'information_schema'`; + + // remove all table data + await client.$executeRawUnsafe( + `TRUNCATE TABLE ${result + .map(({ tablename }) => tablename) + .filter(name => !name.includes('migrations')) + .join(', ')}` + ); + + await client.$disconnect(); +} + +export class FakePrisma { + fakeUser: User = { + id: randomUUID(), + name: 'Alex Yang', + avatarUrl: '', + email: 'alex.yang@example.org', + password: hashSync('123456'), + emailVerified: new Date(), + createdAt: new Date(), + }; + + get user() { + // eslint-disable-next-line @typescript-eslint/no-this-alias + const prisma = this; + return { + async findFirst() { + return prisma.fakeUser; + }, + async findUnique() { + return this.findFirst(); + }, + async update() { + return this.findFirst(); + }, + }; + } +} + +export class FakeStorageModule { + static forRoot(): DynamicModule { + const storageProvider: FactoryProvider = { + provide: StorageProvide, + useFactory: async () => { + return null; + }, + }; + + return { + global: true, + module: FakeStorageModule, + providers: [storageProvider], + exports: [storageProvider], + }; + } +} + +export async function initFeatureConfigs(module: TestingModule) { + const run = module.get(RunCommand); + const revert = module.get(RevertCommand); + await Promise.allSettled([revert.run(['UserFeaturesInit1698652531198'])]); + await run.runOne('UserFeaturesInit1698652531198'); +} diff --git a/packages/backend/server/tests/utils/workspace.ts b/packages/backend/server/tests/utils/workspace.ts new file mode 100644 index 0000000000..c8d5aa4789 --- /dev/null +++ b/packages/backend/server/tests/utils/workspace.ts @@ -0,0 +1,172 @@ +import type { INestApplication } from '@nestjs/common'; +import request from 'supertest'; + +import type { WorkspaceType } from '../../src/modules/workspaces'; +import { gql } from './common'; + +export async function createWorkspace( + app: INestApplication, + token: string +): Promise { + const res = await request(app.getHttpServer()) + .post(gql) + .auth(token, { type: 'bearer' }) + .set({ 'x-request-id': 'test', 'x-operation-name': 'test' }) + .field( + 'operations', + JSON.stringify({ + name: 'createWorkspace', + query: `mutation createWorkspace($init: Upload!) { + createWorkspace(init: $init) { + id + } + }`, + variables: { init: null }, + }) + ) + .field('map', JSON.stringify({ '0': ['variables.init'] })) + .attach('0', Buffer.from([0, 0]), 'init.data') + .expect(200); + return res.body.data.createWorkspace; +} + +export async function getWorkspacePublicPages( + app: INestApplication, + token: string, + workspaceId: string +) { + const res = await request(app.getHttpServer()) + .post(gql) + .auth(token, { type: 'bearer' }) + .set({ 'x-request-id': 'test', 'x-operation-name': 'test' }) + .send({ + query: ` + query { + workspace(id: "${workspaceId}") { + publicPages { + id + mode + } + } + } + `, + }) + .expect(200); + return res.body.data.workspace.publicPages; +} + +export async function getWorkspace( + app: INestApplication, + token: string, + workspaceId: string, + skip = 0, + take = 8 +): Promise { + const res = await request(app.getHttpServer()) + .post(gql) + .auth(token, { type: 'bearer' }) + .set({ 'x-request-id': 'test', 'x-operation-name': 'test' }) + .send({ + query: ` + query { + workspace(id: "${workspaceId}") { + id, members(skip: ${skip}, take: ${take}) { id, name, email, permission, inviteId } + } + } + `, + }) + .expect(200); + return res.body.data.workspace; +} + +export async function getPublicWorkspace( + app: INestApplication, + workspaceId: string +): Promise { + const res = await request(app.getHttpServer()) + .post(gql) + .set({ 'x-request-id': 'test', 'x-operation-name': 'test' }) + .send({ + query: ` + query { + publicWorkspace(id: "${workspaceId}") { + id + } + } + `, + }) + .expect(200); + return res.body.data.publicWorkspace; +} + +export async function updateWorkspace( + app: INestApplication, + token: string, + workspaceId: string, + isPublic: boolean +): Promise { + const res = await request(app.getHttpServer()) + .post(gql) + .auth(token, { type: 'bearer' }) + .set({ 'x-request-id': 'test', 'x-operation-name': 'test' }) + .send({ + query: ` + mutation { + updateWorkspace(input: { id: "${workspaceId}", public: ${isPublic} }) { + public + } + } + `, + }) + .expect(200); + return res.body.data.updateWorkspace.public; +} + +export async function publishPage( + app: INestApplication, + token: string, + workspaceId: string, + pageId: string +) { + const res = await request(app.getHttpServer()) + .post(gql) + .auth(token, { type: 'bearer' }) + .set({ 'x-request-id': 'test', 'x-operation-name': 'test' }) + .send({ + query: ` + mutation { + publishPage(workspaceId: "${workspaceId}", pageId: "${pageId}") { + id + mode + } + } + `, + }) + .expect(200); + return res.body.errors?.[0]?.message || res.body.data?.publishPage; +} + +export async function revokePublicPage( + app: INestApplication, + token: string, + workspaceId: string, + pageId: string +) { + const res = await request(app.getHttpServer()) + .post(gql) + .auth(token, { type: 'bearer' }) + .set({ 'x-request-id': 'test', 'x-operation-name': 'test' }) + .send({ + query: ` + mutation { + revokePublicPage(workspaceId: "${workspaceId}", pageId: "${pageId}") { + id + mode + public + } + } + `, + }) + .expect(200); + return res.body.errors?.[0]?.message || res.body.data?.revokePublicPage; +} diff --git a/packages/backend/server/tests/workspace-blobs.spec.ts b/packages/backend/server/tests/workspace-blobs.spec.ts index 7db0034ced..af0f683adc 100644 --- a/packages/backend/server/tests/workspace-blobs.spec.ts +++ b/packages/backend/server/tests/workspace-blobs.spec.ts @@ -179,7 +179,7 @@ test('should be able calc quota after switch plan', async t => { ); t.is(size1, 0, 'failed to check free plan blob size'); - quota.switchUserQuota(u1.id, QuotaType.Quota_ProPlanV1); + quota.switchUserQuota(u1.id, QuotaType.ProPlanV1); const size2 = await checkBlobSize( app, diff --git a/packages/backend/server/tests/workspace-usage.spec.ts b/packages/backend/server/tests/workspace-usage.spec.ts index aecf55cbff..ad5623b393 100644 --- a/packages/backend/server/tests/workspace-usage.spec.ts +++ b/packages/backend/server/tests/workspace-usage.spec.ts @@ -4,6 +4,7 @@ import ava, { type TestFn } from 'ava'; import { stub } from 'sinon'; import { AppModule } from '../src/app'; +import { FeatureManagementService } from '../src/modules/features'; import { Quotas } from '../src/modules/quota'; import { UsersService } from '../src/modules/users'; import { PermissionService } from '../src/modules/workspaces/permission'; @@ -59,6 +60,23 @@ test.beforeEach(async t => { }; }, }, + features: { + async findFirst() { + return { + id: 0, + feature: 'free_plan_v1', + version: 1, + type: 1, + configs: { + name: 'Free', + blobLimit: 1, + storageQuota: 1, + historyPeriod: 1, + memberLimit: 3, + }, + }; + }, + }, }) .overrideProvider(PermissionService) .useClass(FakePermission) @@ -70,6 +88,8 @@ test.beforeEach(async t => { return 1024 * 10; }, }) + .overrideProvider(FeatureManagementService) + .useValue({}) .compile(); t.context.app = module.createNestApplication(); t.context.resolver = t.context.app.get(WorkspaceResolver); diff --git a/yarn.lock b/yarn.lock index a52bd1f840..88d7a70ac4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -812,6 +812,7 @@ __metadata: typescript: "npm:^5.3.2" ws: "npm:^8.14.2" yjs: "npm:^13.6.10" + zod: "npm:^3.22.4" bin: run-test: ./scripts/run-test.ts languageName: unknown