From 04ca554525c2d10c069e8165e4e9afea25ac6cd8 Mon Sep 17 00:00:00 2001 From: DarkSky Date: Fri, 5 Jan 2024 04:13:47 +0000 Subject: [PATCH] feat: add workspace feature tests (#5501) --- .../migration.sql | 18 +++ packages/backend/server/package.json | 3 +- packages/backend/server/schema.prisma | 2 +- .../server/src/modules/features/management.ts | 8 + packages/backend/server/src/schema.gql | 150 +++++++++--------- packages/backend/server/tests/feature.spec.ts | 137 +++++++++++++--- 6 files changed, 217 insertions(+), 101 deletions(-) create mode 100644 packages/backend/server/migrations/20240103092238_add_workspace_features/migration.sql diff --git a/packages/backend/server/migrations/20240103092238_add_workspace_features/migration.sql b/packages/backend/server/migrations/20240103092238_add_workspace_features/migration.sql new file mode 100644 index 0000000000..212f0c9d67 --- /dev/null +++ b/packages/backend/server/migrations/20240103092238_add_workspace_features/migration.sql @@ -0,0 +1,18 @@ +-- CreateTable +CREATE TABLE "workspace_features" ( + "id" SERIAL NOT NULL, + "workspace_id" VARCHAR(36) NOT NULL, + "feature_id" INTEGER NOT NULL, + "reason" VARCHAR NOT NULL, + "created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "expired_at" TIMESTAMPTZ(6), + "activated" BOOLEAN NOT NULL DEFAULT false, + + CONSTRAINT "workspace_features_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "workspace_features" ADD CONSTRAINT "workspace_features_feature_id_fkey" FOREIGN KEY ("feature_id") REFERENCES "features"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "workspace_features" ADD CONSTRAINT "workspace_features_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/backend/server/package.json b/packages/backend/server/package.json index 19ced215fb..3dc1b7eeb9 100644 --- a/packages/backend/server/package.json +++ b/packages/backend/server/package.json @@ -124,8 +124,7 @@ "node" ], "files": [ - "tests/**/*.spec.ts", - "tests/**/*.e2e.ts" + "tests/**/feature.spec.ts" ], "require": [ "./src/prelude.ts" diff --git a/packages/backend/server/schema.prisma b/packages/backend/server/schema.prisma index 5fd5a72e34..f0b17cd978 100644 --- a/packages/backend/server/schema.prisma +++ b/packages/backend/server/schema.prisma @@ -164,7 +164,7 @@ model WorkspaceFeatures { activated Boolean @default(false) feature Features @relation(fields: [featureId], references: [id], onDelete: Cascade) - workspace Workspace @relation(fields: [workspaceId], references: [id]) + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) @@map("workspace_features") } diff --git a/packages/backend/server/src/modules/features/management.ts b/packages/backend/server/src/modules/features/management.ts index 41535515da..81067841f2 100644 --- a/packages/backend/server/src/modules/features/management.ts +++ b/packages/backend/server/src/modules/features/management.ts @@ -113,9 +113,17 @@ export class FeatureManagementService implements OnModuleInit { return features.map(feature => feature.feature.name); } + async hasWorkspaceFeature(workspaceId: string, feature: FeatureType) { + return this.feature.hasWorkspaceFeature(workspaceId, feature); + } + async removeWorkspaceFeature(workspaceId: string, feature: FeatureType) { return this.feature .removeWorkspaceFeature(workspaceId, feature) .then(c => c > 0); } + + async listFeatureWorkspaces(feature: FeatureType) { + return this.feature.listFeatureWorkspaces(feature); + } } diff --git a/packages/backend/server/src/schema.gql b/packages/backend/server/src/schema.gql index 32223cd389..3ae63243fd 100644 --- a/packages/backend/server/src/schema.gql +++ b/packages/backend/server/src/schema.gql @@ -72,81 +72,6 @@ type RemoveAvatar { success: Boolean! } -type TokenType { - token: String! - refresh: String! - sessionToken: String -} - -type SubscriptionPrice { - type: String! - plan: SubscriptionPlan! - currency: String! - amount: Int! - yearlyAmount: Int! -} - -enum SubscriptionPlan { - Free - Pro - Team - Enterprise - SelfHosted -} - -type UserSubscription { - id: String! - plan: SubscriptionPlan! - recurring: SubscriptionRecurring! - status: SubscriptionStatus! - start: DateTime! - end: DateTime! - trialStart: DateTime - trialEnd: DateTime - nextBillAt: DateTime - canceledAt: DateTime - createdAt: DateTime! - updatedAt: DateTime! -} - -enum SubscriptionRecurring { - Monthly - Yearly -} - -enum SubscriptionStatus { - Active - PastDue - Unpaid - Canceled - Incomplete - Paused - IncompleteExpired - Trialing -} - -type UserInvoice { - id: String! - plan: SubscriptionPlan! - recurring: SubscriptionRecurring! - currency: String! - amount: Int! - status: InvoiceStatus! - reason: String! - lastPaymentError: String - link: String - createdAt: DateTime! - updatedAt: DateTime! -} - -enum InvoiceStatus { - Draft - Open - Void - Paid - Uncollectible -} - type InviteUserType { """User name""" name: String @@ -242,6 +167,81 @@ type InvitationType { invitee: UserType! } +type TokenType { + token: String! + refresh: String! + sessionToken: String +} + +type SubscriptionPrice { + type: String! + plan: SubscriptionPlan! + currency: String! + amount: Int! + yearlyAmount: Int! +} + +enum SubscriptionPlan { + Free + Pro + Team + Enterprise + SelfHosted +} + +type UserSubscription { + id: String! + plan: SubscriptionPlan! + recurring: SubscriptionRecurring! + status: SubscriptionStatus! + start: DateTime! + end: DateTime! + trialStart: DateTime + trialEnd: DateTime + nextBillAt: DateTime + canceledAt: DateTime + createdAt: DateTime! + updatedAt: DateTime! +} + +enum SubscriptionRecurring { + Monthly + Yearly +} + +enum SubscriptionStatus { + Active + PastDue + Unpaid + Canceled + Incomplete + Paused + IncompleteExpired + Trialing +} + +type UserInvoice { + id: String! + plan: SubscriptionPlan! + recurring: SubscriptionRecurring! + currency: String! + amount: Int! + status: InvoiceStatus! + reason: String! + lastPaymentError: String + link: String + createdAt: DateTime! + updatedAt: DateTime! +} + +enum InvoiceStatus { + Draft + Open + Void + Paid + Uncollectible +} + type DocHistoryType { workspaceId: String! id: String! diff --git a/packages/backend/server/tests/feature.spec.ts b/packages/backend/server/tests/feature.spec.ts index ecc73db85c..c1755b2e7e 100644 --- a/packages/backend/server/tests/feature.spec.ts +++ b/packages/backend/server/tests/feature.spec.ts @@ -1,5 +1,6 @@ /// +import { Injectable } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { PrismaClient } from '@prisma/client'; import ava, { type TestFn } from 'ava'; @@ -14,14 +15,43 @@ import { FeatureService, FeatureType, } from '../src/modules/features'; -import { PrismaModule } from '../src/prisma'; +import { UserType } from '../src/modules/users/types'; +import { WorkspaceResolver } from '../src/modules/workspaces/resolvers'; +import { Permission } from '../src/modules/workspaces/types'; +import { PrismaModule, PrismaService } from '../src/prisma'; import { RateLimiterModule } from '../src/throttler'; import { initFeatureConfigs } from './utils'; +@Injectable() +class WorkspaceResolverMock { + constructor(private readonly prisma: PrismaService) {} + + async createWorkspace(user: UserType, _init: null) { + const workspace = await this.prisma.workspace.create({ + data: { + public: false, + permissions: { + create: { + type: Permission.Owner, + user: { + connect: { + id: user.id, + }, + }, + accepted: true, + }, + }, + }, + }); + return workspace; + } +} + const test = ava as TestFn<{ auth: AuthService; feature: FeatureService; - early_access: FeatureManagementService; + workspace: WorkspaceResolver; + management: FeatureManagementService; app: TestingModule; }>; @@ -30,6 +60,7 @@ test.beforeEach(async () => { const client = new PrismaClient(); await client.$connect(); await client.user.deleteMany({}); + await client.workspace.deleteMany({}); await client.$disconnect(); }); @@ -55,12 +86,17 @@ test.beforeEach(async t => { RevertCommand, RunCommand, ], - }).compile(); + providers: [WorkspaceResolver], + }) + .overrideProvider(WorkspaceResolver) + .useClass(WorkspaceResolverMock) + .compile(); t.context.app = module; t.context.auth = module.get(AuthService); t.context.feature = module.get(FeatureService); - t.context.early_access = module.get(FeatureManagementService); + t.context.workspace = module.get(WorkspaceResolver); + t.context.management = module.get(FeatureManagementService); // init features await initFeatureConfigs(module); @@ -70,7 +106,7 @@ test.afterEach.always(async t => { await t.context.app.close(); }); -test('should be able to set feature', async t => { +test('should be able to set user feature', async t => { const { auth, feature } = t.context; const u1 = await auth.signUp('DarkSky', 'darksky@example.org', '123456'); @@ -86,14 +122,14 @@ test('should be able to set feature', async t => { }); test('should be able to check early access', async t => { - const { auth, feature, early_access } = t.context; + const { auth, feature, management } = t.context; const u1 = await auth.signUp('DarkSky', 'darksky@example.org', '123456'); - const f1 = await early_access.canEarlyAccess(u1.email); + const f1 = await management.canEarlyAccess(u1.email); t.false(f1, 'should not have early access'); - await early_access.addEarlyAccess(u1.id); - const f2 = await early_access.canEarlyAccess(u1.email); + await management.addEarlyAccess(u1.id); + const f2 = await management.canEarlyAccess(u1.email); t.true(f2, 'should have early access'); const f3 = await feature.listFeatureUsers(FeatureType.EarlyAccess); @@ -101,24 +137,24 @@ test('should be able to check early access', async t => { t.is(f3[0].id, u1.id, 'should be the same user'); }); -test('should be able revert quota', async t => { - const { auth, feature, early_access } = t.context; +test('should be able revert user feature', async t => { + const { auth, feature, management } = t.context; const u1 = await auth.signUp('DarkSky', 'darksky@example.org', '123456'); - const f1 = await early_access.canEarlyAccess(u1.email); + const f1 = await management.canEarlyAccess(u1.email); t.false(f1, 'should not have early access'); - await early_access.addEarlyAccess(u1.id); - const f2 = await early_access.canEarlyAccess(u1.email); + await management.addEarlyAccess(u1.id); + const f2 = await management.canEarlyAccess(u1.email); t.true(f2, 'should have early access'); - const q1 = await early_access.listEarlyAccess(); + const q1 = await management.listEarlyAccess(); 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); - const f3 = await early_access.canEarlyAccess(u1.email); + await management.removeEarlyAccess(u1.id); + const f3 = await management.canEarlyAccess(u1.email); t.false(f3, 'should not have early access'); - const q2 = await early_access.listEarlyAccess(); + const q2 = await management.listEarlyAccess(); t.is(q2.length, 0, 'should have no user'); const q3 = await feature.getUserFeatures(u1.id); @@ -127,17 +163,72 @@ test('should be able revert quota', async t => { 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; +test('should be same instance after reset the user feature', async t => { + const { auth, feature, management } = t.context; const u1 = await auth.signUp('DarkSky', 'darksky@example.org', '123456'); - await early_access.addEarlyAccess(u1.id); + await management.addEarlyAccess(u1.id); const f1 = (await feature.getUserFeatures(u1.id))[0]; - await early_access.removeEarlyAccess(u1.id); + await management.removeEarlyAccess(u1.id); - await early_access.addEarlyAccess(u1.id); + await management.addEarlyAccess(u1.id); const f2 = (await feature.getUserFeatures(u1.id))[1]; t.is(f1.feature, f2.feature, 'should be same instance'); }); + +test('should be able to set workspace feature', async t => { + const { auth, feature, workspace } = t.context; + + const u1 = await auth.signUp('DarkSky', 'darksky@example.org', '123456'); + const w1 = await workspace.createWorkspace(u1, null); + + const f1 = await feature.getWorkspaceFeatures(w1.id); + t.is(f1.length, 0, 'should be empty'); + + await feature.addWorkspaceFeature(w1.id, FeatureType.Copilot, 1, 'test'); + + const f2 = await feature.getWorkspaceFeatures(w1.id); + t.is(f2.length, 1, 'should have 1 feature'); + t.is(f2[0].feature.name, FeatureType.Copilot, 'should be copilot'); +}); + +test('should be able to check workspace feature', async t => { + const { auth, feature, workspace, management } = t.context; + const u1 = await auth.signUp('DarkSky', 'darksky@example.org', '123456'); + const w1 = await workspace.createWorkspace(u1, null); + + const f1 = await management.hasWorkspaceFeature(w1.id, FeatureType.Copilot); + t.false(f1, 'should not have copilot'); + + await management.addWorkspaceFeatures(w1.id, FeatureType.Copilot, 1, 'test'); + const f2 = await management.hasWorkspaceFeature(w1.id, FeatureType.Copilot); + t.true(f2, 'should have copilot'); + + const f3 = await feature.listFeatureWorkspaces(FeatureType.Copilot); + t.is(f3.length, 1, 'should have 1 workspace'); + t.is(f3[0].id, w1.id, 'should be the same workspace'); +}); + +test('should be able revert workspace feature', async t => { + const { auth, feature, workspace, management } = t.context; + const u1 = await auth.signUp('DarkSky', 'darksky@example.org', '123456'); + const w1 = await workspace.createWorkspace(u1, null); + + const f1 = await management.hasWorkspaceFeature(w1.id, FeatureType.Copilot); + t.false(f1, 'should not have feature'); + + await management.addWorkspaceFeatures(w1.id, FeatureType.Copilot, 1, 'test'); + const f2 = await management.hasWorkspaceFeature(w1.id, FeatureType.Copilot); + t.true(f2, 'should have feature'); + + await management.removeWorkspaceFeature(w1.id, FeatureType.Copilot); + const f3 = await management.hasWorkspaceFeature(w1.id, FeatureType.Copilot); + t.false(f3, 'should not have feature'); + + const q3 = await feature.getWorkspaceFeatures(w1.id); + t.is(q3.length, 1, 'should have 1 feature'); + t.is(q3[0].feature.name, FeatureType.Copilot, 'should be copilot'); + t.is(q3[0].activated, false, 'should be deactivated'); +});