From 0f8356650457cfd7d7231833baa92e9c0c7e1618 Mon Sep 17 00:00:00 2001 From: liuyi Date: Mon, 17 Mar 2025 14:15:34 +0800 Subject: [PATCH] chore(server): data mocking and seeding (#10864) --- docs/developing-server.md | 11 ++- packages/backend/server/package.json | 3 + .../server/src/__tests__/auth/auth.e2e.ts | 8 +- .../src/__tests__/auth/controller.spec.ts | 6 +- .../server/src/__tests__/copilot.e2e.ts | 6 +- .../server/src/__tests__/mailer.spec.ts | 4 +- .../server/src/__tests__/mocks/factory.ts | 90 +++++++++++++++++++ .../server/src/__tests__/mocks/index.ts | 14 +++ .../__tests__/mocks/team-workspace.mock.ts | 57 ++++++++++++ .../server/src/__tests__/mocks/user.mock.ts | 54 +++++++++++ .../src/__tests__/mocks/workspace.mock.ts | 32 +++++++ .../src/__tests__/nestjs/throttler.spec.ts | 16 ++-- .../backend/server/src/__tests__/team.e2e.ts | 14 +-- .../backend/server/src/__tests__/user.e2e.ts | 4 +- .../server/src/__tests__/user/user.e2e.ts | 8 +- .../server/src/__tests__/utils/testing-app.ts | 24 ++++- .../src/__tests__/utils/testing-module.ts | 7 ++ .../src/__tests__/workspace-invite.e2e.ts | 26 +++--- .../server/src/__tests__/workspace.e2e.ts | 14 +-- .../src/__tests__/workspace/blobs.e2e.ts | 16 ++-- .../__tests__/workspace/controller.spec.ts | 2 +- .../notification/__tests__/resolver.e2e.ts | 24 ++--- packages/backend/server/src/seed/index.ts | 88 ++++++++++++++++++ yarn.lock | 3 +- 24 files changed, 449 insertions(+), 82 deletions(-) create mode 100644 packages/backend/server/src/__tests__/mocks/factory.ts create mode 100644 packages/backend/server/src/__tests__/mocks/index.ts create mode 100644 packages/backend/server/src/__tests__/mocks/team-workspace.mock.ts create mode 100644 packages/backend/server/src/__tests__/mocks/user.mock.ts create mode 100644 packages/backend/server/src/__tests__/mocks/workspace.mock.ts create mode 100644 packages/backend/server/src/seed/index.ts diff --git a/docs/developing-server.md b/docs/developing-server.md index ba7f5176ea..2bfb8a971f 100644 --- a/docs/developing-server.md +++ b/docs/developing-server.md @@ -40,8 +40,9 @@ yarn affine @affine/server-native build ```sh # uncomment all env variables here cp packages/backend/server/.env.example packages/backend/server/.env -yarn affine server prisma db push -yarn affine server data-migration run + +# everytime there are new migrations, init command should runned again +yarn affine server init ``` ## Start server @@ -90,3 +91,9 @@ Now you should be able to start developing affine with server enabled. # available at http://localhost:5555 yarn affine server prisma studio ``` + +### Seed the db + +``` +yarn affine server seed -h +``` diff --git a/packages/backend/server/package.json b/packages/backend/server/package.json index a31366fd0b..7ad8a6ae07 100644 --- a/packages/backend/server/package.json +++ b/packages/backend/server/package.json @@ -16,6 +16,8 @@ "test:coverage": "c8 ava --concurrency 1 --serial", "test:copilot:coverage": "c8 ava --timeout=5m \"src/__tests__/**/copilot-*.spec.ts\"", "data-migration": "cross-env NODE_ENV=development r ./src/data/index.ts", + "init": "yarn prisma migrate dev && yarn data-migration run", + "seed": "r ./src/seed/index.ts", "predeploy": "yarn prisma migrate deploy && node --import ./scripts/register.js ./dist/data/index.js run", "postinstall": "prisma generate" }, @@ -108,6 +110,7 @@ "@affine-tools/cli": "workspace:*", "@affine-tools/utils": "workspace:*", "@affine/server-native": "workspace:*", + "@faker-js/faker": "^9.6.0", "@nestjs/testing": "^10.4.15", "@types/cookie-parser": "^1.4.8", "@types/express": "^4.17.21", diff --git a/packages/backend/server/src/__tests__/auth/auth.e2e.ts b/packages/backend/server/src/__tests__/auth/auth.e2e.ts index 2e1355a44c..f1aad7a23e 100644 --- a/packages/backend/server/src/__tests__/auth/auth.e2e.ts +++ b/packages/backend/server/src/__tests__/auth/auth.e2e.ts @@ -41,7 +41,7 @@ test('change email', async t => { const u1Email = 'u1@affine.pro'; const u2Email = 'u2@affine.pro'; - await app.signup(u1Email); + await app.signupV1(u1Email); const primitiveMailCount = await getCurrentMailMessageCount(); await sendChangeEmail(app, u1Email, 'affine.pro'); @@ -101,7 +101,7 @@ test('set and change password', async t => { if (mail.hasConfigured()) { const u1Email = 'u1@affine.pro'; - const u1 = await app.signup(u1Email); + const u1 = await app.signupV1(u1Email); const primitiveMailCount = await getCurrentMailMessageCount(); @@ -153,7 +153,7 @@ test('should revoke token after change user identify', async t => { const u1Email = 'u1@affine.pro'; const u2Email = 'u2@affine.pro'; - const u1 = await app.signup(u1Email); + const u1 = await app.signupV1(u1Email); { const user = await currentUser(app); @@ -190,7 +190,7 @@ test('should revoke token after change user identify', async t => { const u3Email = 'u3333@affine.pro'; await app.logout(); - const u3 = await app.signup(u3Email); + const u3 = await app.signupV1(u3Email); { const user = await currentUser(app); diff --git a/packages/backend/server/src/__tests__/auth/controller.spec.ts b/packages/backend/server/src/__tests__/auth/controller.spec.ts index 30623caf36..f61f53cf36 100644 --- a/packages/backend/server/src/__tests__/auth/controller.spec.ts +++ b/packages/backend/server/src/__tests__/auth/controller.spec.ts @@ -164,7 +164,7 @@ test('should be able to sign out', async t => { test('should be able to correct user id cookie', async t => { const { app } = t.context; - const u1 = await app.signup('u1@affine.pro'); + const u1 = await app.signupV1('u1@affine.pro'); const req = app.GET('/api/auth/session'); let cookies = req.get('cookie') as unknown as string[]; @@ -229,8 +229,8 @@ test('should be able to sign in another account in one session', async t => { test('should be able to sign out multiple accounts in one session', async t => { const { app } = t.context; - const u1 = await app.signup('u1@affine.pro'); - const u2 = await app.signup('u2@affine.pro'); + const u1 = await app.signupV1('u1@affine.pro'); + const u2 = await app.signupV1('u2@affine.pro'); // sign out u2 await app.GET(`/api/auth/sign-out?user_id=${u2.id}`).expect(200); diff --git a/packages/backend/server/src/__tests__/copilot.e2e.ts b/packages/backend/server/src/__tests__/copilot.e2e.ts index e10d26b3fa..0f402e8cfd 100644 --- a/packages/backend/server/src/__tests__/copilot.e2e.ts +++ b/packages/backend/server/src/__tests__/copilot.e2e.ts @@ -110,7 +110,7 @@ test.beforeEach(async t => { const { app, prompt } = t.context; await app.initTestingDB(); await prompt.onModuleInit(); - t.context.u1 = await app.signup('u1@affine.pro'); + t.context.u1 = await app.signupV1('u1@affine.pro'); unregisterCopilotProvider(OpenAIProvider.type); unregisterCopilotProvider(FalProvider.type); @@ -223,7 +223,7 @@ test('should update session correctly', async t => { } { - await app.signup('test@affine.pro'); + await app.signupV1('test@affine.pro'); const u2 = await app.createUser('u2@affine.pro'); const { id: workspaceId } = await createWorkspace(app); const inviteId = await inviteUser(app, workspaceId, u2.email); @@ -309,7 +309,7 @@ test('should fork session correctly', async t => { } { - const u2 = await app.signup('u2@affine.pro'); + const u2 = await app.signupV1('u2@affine.pro'); await assertForkSession(id, sessionId, randomUUID(), '', async x => { await t.throwsAsync( x, diff --git a/packages/backend/server/src/__tests__/mailer.spec.ts b/packages/backend/server/src/__tests__/mailer.spec.ts index f72097fed2..b91ff4dd96 100644 --- a/packages/backend/server/src/__tests__/mailer.spec.ts +++ b/packages/backend/server/src/__tests__/mailer.spec.ts @@ -31,8 +31,8 @@ test('should send invite email', async t => { const { mail, app } = t.context; if (mail.hasConfigured()) { - const u2 = await app.signup('u2@affine.pro'); - const u1 = await app.signup('u1@affine.pro'); + const u2 = await app.signupV1('u2@affine.pro'); + const u1 = await app.signupV1('u1@affine.pro'); const stub = Sinon.stub(mail, 'send'); const workspace = await createWorkspace(app); diff --git a/packages/backend/server/src/__tests__/mocks/factory.ts b/packages/backend/server/src/__tests__/mocks/factory.ts new file mode 100644 index 0000000000..12a3570a17 --- /dev/null +++ b/packages/backend/server/src/__tests__/mocks/factory.ts @@ -0,0 +1,90 @@ +import { Type } from '@nestjs/common'; +import { PrismaClient } from '@prisma/client'; + +export abstract class Mocker { + // NOTE(@forehalo): + // The reason why we don't inject [Models] to Mocker for more easier data creation with built in logic is, + // the method in [Models] may introduce side effects like 'events', + // which may break the tests with event emitting asserts. + protected db!: PrismaClient; + + abstract create(input?: Partial): Promise; +} + +type MockerConstructor = Type>; +type MockerInput> = + Ctor extends MockerConstructor ? In : never; +type MockerOutput> = + Ctor extends MockerConstructor ? Out : never; + +const FACTORIES = new Map>(); + +interface FactoryOptions { + logger: ((val: any) => void) | boolean; +} + +export function createFactory( + db: PrismaClient, + opts: FactoryOptions = { logger: false } +) { + const log = (val: any) => { + if (typeof opts.logger === 'function') { + opts.logger(val); + } else if (opts.logger) { + console.log(val); + } + }; + + class Inner { + static create>( + Factory: Ctor, + overrides?: Partial> + ): Promise>; + static create>( + Factory: Ctor, + count: number + ): Promise[]>; + static create>( + Factory: Ctor, + overrides: Partial>, + count: number + ): Promise[]>; + static async create>( + Factory: Ctor, + overridesOrCount?: Partial> | number, + count?: number + ): Promise | MockerOutput[]> { + let factory = FACTORIES.get(Factory.name); + + if (!factory) { + factory = new Factory(); + // @ts-expect-error private + factory.db = db; + FACTORIES.set(Factory.name, factory); + } + + let overrides: Partial> | undefined = undefined; + if (typeof overridesOrCount === 'number') { + count = overridesOrCount; + } else { + overrides = overridesOrCount; + } + + if (typeof count === 'number') { + return await Promise.all( + Array.from({ length: count }).map(async () => { + const row = await factory.create(overrides); + log(row); + return row; + }) + ); + } + + const row = await factory.create(overrides); + log(row); + return row; + } + } + + return Inner.create; +} diff --git a/packages/backend/server/src/__tests__/mocks/index.ts b/packages/backend/server/src/__tests__/mocks/index.ts new file mode 100644 index 0000000000..212b716342 --- /dev/null +++ b/packages/backend/server/src/__tests__/mocks/index.ts @@ -0,0 +1,14 @@ +export { createFactory } from './factory'; +export * from './team-workspace.mock'; +export * from './user.mock'; +export * from './workspace.mock'; + +import { MockTeamWorkspace } from './team-workspace.mock'; +import { MockUser } from './user.mock'; +import { MockWorkspace } from './workspace.mock'; + +export const Mockers = { + User: MockUser, + Workspace: MockWorkspace, + TeamWorkspace: MockTeamWorkspace, +}; diff --git a/packages/backend/server/src/__tests__/mocks/team-workspace.mock.ts b/packages/backend/server/src/__tests__/mocks/team-workspace.mock.ts new file mode 100644 index 0000000000..3e84f28117 --- /dev/null +++ b/packages/backend/server/src/__tests__/mocks/team-workspace.mock.ts @@ -0,0 +1,57 @@ +import { faker } from '@faker-js/faker'; + +import { Feature } from '../../models'; +import { Mocker } from './factory'; + +interface MockTeamWorkspaceInput { + id: string; + quantity: number; +} + +export class MockTeamWorkspace extends Mocker< + MockTeamWorkspaceInput, + { id: string } +> { + override async create(input?: Partial) { + const id = input?.id ?? faker.string.uuid(); + const quantity = input?.quantity ?? 10; + + await this.db.subscription.create({ + data: { + targetId: id, + plan: 'team', + recurring: 'monthly', + status: 'active', + start: faker.date.past(), + nextBillAt: faker.date.future(), + quantity, + }, + }); + + const feature = await this.db.feature.findFirst({ + where: { + name: Feature.TeamPlan, + }, + }); + + if (!feature) { + throw new Error( + `Feature ${Feature.TeamPlan} does not exist in DB. You might forgot to run data-migration first.` + ); + } + + await this.db.workspaceFeature.create({ + data: { + workspaceId: id, + featureId: feature.id, + reason: 'test', + activated: true, + configs: { + memberLimit: quantity, + }, + }, + }); + + return { id }; + } +} diff --git a/packages/backend/server/src/__tests__/mocks/user.mock.ts b/packages/backend/server/src/__tests__/mocks/user.mock.ts new file mode 100644 index 0000000000..5f760756a6 --- /dev/null +++ b/packages/backend/server/src/__tests__/mocks/user.mock.ts @@ -0,0 +1,54 @@ +import { faker } from '@faker-js/faker'; +import { hashSync } from '@node-rs/argon2'; +import type { Prisma, User } from '@prisma/client'; + +import type { UserFeatureName } from '../../models'; +import { Mocker } from './factory'; + +export type MockUserInput = Prisma.UserCreateInput & { + feature?: UserFeatureName; +}; + +export type MockedUser = Omit & { + password: string; +}; + +export class MockUser extends Mocker { + override async create(input?: Partial) { + const password = input?.password ?? faker.internet.password(); + const user = await this.db.user.create({ + data: { + email: faker.internet.email(), + name: faker.person.fullName(), + password: password ? hashSync(password) : undefined, + ...input, + }, + }); + + if (input?.feature) { + const feature = await this.db.feature.findFirst({ + where: { + name: input.feature, + }, + }); + + if (!feature) { + throw new Error( + `Feature ${input.feature} does not exist in DB. You might forgot to run data-migration first.` + ); + } + + await this.db.userFeature.create({ + data: { + userId: user.id, + featureId: feature.id, + reason: 'test', + activated: true, + }, + }); + } + + // return raw password for later usage, for example 'signIn' + return { ...user, password }; + } +} diff --git a/packages/backend/server/src/__tests__/mocks/workspace.mock.ts b/packages/backend/server/src/__tests__/mocks/workspace.mock.ts new file mode 100644 index 0000000000..06a3769eef --- /dev/null +++ b/packages/backend/server/src/__tests__/mocks/workspace.mock.ts @@ -0,0 +1,32 @@ +import { faker } from '@faker-js/faker'; +import type { Prisma, Workspace } from '@prisma/client'; + +import { WorkspaceRole } from '../../models'; +import { Mocker } from './factory'; + +export type MockWorkspaceInput = Prisma.WorkspaceCreateInput & { + owner?: { id: string }; +}; + +export type MockedWorkspace = Workspace; + +export class MockWorkspace extends Mocker { + override async create(input?: Partial) { + return await this.db.workspace.create({ + data: { + name: faker.animal.cat(), + public: false, + ...input, + permissions: input?.owner + ? { + create: { + userId: 'id' in input.owner ? input.owner.id : input.owner, + type: WorkspaceRole.Owner, + status: 'Accepted', + }, + } + : undefined, + }, + }); + } +} diff --git a/packages/backend/server/src/__tests__/nestjs/throttler.spec.ts b/packages/backend/server/src/__tests__/nestjs/throttler.spec.ts index 97adc3f75c..4b01b07647 100644 --- a/packages/backend/server/src/__tests__/nestjs/throttler.spec.ts +++ b/packages/backend/server/src/__tests__/nestjs/throttler.spec.ts @@ -191,7 +191,7 @@ test('should use specified throttler for unauthenticated user', async t => { test('should not protect unspecified routes', async t => { const { app } = t.context; - await app.signup('u1@affine.pro'); + await app.signupV1('u1@affine.pro'); const res = await app.GET('/nonthrottled/default').expect(200); const headers = rateLimitHeaders(res); @@ -204,7 +204,7 @@ test('should not protect unspecified routes', async t => { test('should use default throttler for authenticated user when not specified', async t => { const { app } = t.context; - await app.signup('u1@affine.pro'); + await app.signupV1('u1@affine.pro'); const res = await app.GET('/throttled/default').expect(200); const headers = rateLimitHeaders(res); @@ -216,7 +216,7 @@ test('should use default throttler for authenticated user when not specified', a test('should use same throttler for multiple routes', async t => { const { app } = t.context; - await app.signup('u1@affine.pro'); + await app.signupV1('u1@affine.pro'); let res = await app.GET('/throttled/default').expect(200); let headers = rateLimitHeaders(res); @@ -235,7 +235,7 @@ test('should use same throttler for multiple routes', async t => { test('should use different throttler if specified', async t => { const { app } = t.context; - await app.signup('u1@affine.pro'); + await app.signupV1('u1@affine.pro'); let res = await app.GET('/throttled/default').expect(200); let headers = rateLimitHeaders(res); @@ -254,7 +254,7 @@ test('should use different throttler if specified', async t => { test('should skip throttler for authenticated if `authenticated` throttler used', async t => { const { app } = t.context; - await app.signup('u1@affine.pro'); + await app.signupV1('u1@affine.pro'); const res = await app.GET('/throttled/authenticated').expect(200); const headers = rateLimitHeaders(res); @@ -278,7 +278,7 @@ test('should apply `default` throttler for unauthenticated user if `authenticate test('should skip throttler for authenticated user when specified', async t => { const { app } = t.context; - await app.signup('u1@affine.pro'); + await app.signupV1('u1@affine.pro'); const res = await app.GET('/throttled/skip').expect(200); const headers = rateLimitHeaders(res); @@ -291,7 +291,7 @@ test('should skip throttler for authenticated user when specified', async t => { test('should use specified throttler for authenticated user', async t => { const { app } = t.context; - await app.signup('u1@affine.pro'); + await app.signupV1('u1@affine.pro'); const res = await app.GET('/throttled/strict').expect(200); const headers = rateLimitHeaders(res); @@ -306,7 +306,7 @@ test('should separate anonymous and authenticated user throttlers', async t => { const unauthenticatedUserRes = await app .GET('/nonthrottled/default') .expect(200); - await app.signup('u1@affine.pro'); + await app.signupV1('u1@affine.pro'); const authenticatedUserRes = await app.GET('/throttled/default').expect(200); const authenticatedResHeaders = rateLimitHeaders(authenticatedUserRes); diff --git a/packages/backend/server/src/__tests__/team.e2e.ts b/packages/backend/server/src/__tests__/team.e2e.ts index 1420ed892b..b5cd982fab 100644 --- a/packages/backend/server/src/__tests__/team.e2e.ts +++ b/packages/backend/server/src/__tests__/team.e2e.ts @@ -82,7 +82,7 @@ const init = async ( memberLimit = 10, prefix = randomUUID() ) => { - const owner = await app.signup(`${prefix}owner@affine.pro`); + const owner = await app.signupV1(`${prefix}owner@affine.pro`); const models = app.get(Models); { await models.userFeature.add(owner.id, 'pro_plan_v1', 'test'); @@ -101,7 +101,7 @@ const init = async ( permission: WorkspaceRole = WorkspaceRole.Collaborator, shouldSendEmail: boolean = false ) => { - const member = await app.signup(email); + const member = await app.signupV1(email); { // normal workspace @@ -140,7 +140,7 @@ const init = async ( ) => { const members = []; for (const email of emails) { - const member = await app.signup(email); + const member = await app.signupV1(email); members.push(member); } @@ -161,7 +161,7 @@ const init = async ( return [ inviteId, async (email: string, shouldSendEmail: boolean = false) => { - const member = await app.signup(email); + const member = await app.signupV1(email); await acceptInviteById(app, ws.id, inviteId, shouldSendEmail); return member; }, @@ -221,8 +221,8 @@ test('should be able to invite multiple users', async t => { { // manager - const m1 = await app.signup('m1@affine.pro'); - const m2 = await app.signup('m2@affine.pro'); + const m1 = await app.signupV1('m1@affine.pro'); + const m2 = await app.signupV1('m2@affine.pro'); app.switchUser(owner); t.is( (await inviteUsers(app, ws.id, [m1.email])).length, @@ -483,7 +483,7 @@ test('should be able to approve team member', async t => { const { link } = await createInviteLink(app, tws.id, 'OneDay'); const inviteId = link.split('/').pop()!; - const member = await app.signup('newmember@affine.pro'); + const member = await app.signupV1('newmember@affine.pro'); t.true( await acceptInviteById(app, tws.id, inviteId, false), 'should be able to accept invite' diff --git a/packages/backend/server/src/__tests__/user.e2e.ts b/packages/backend/server/src/__tests__/user.e2e.ts index f921cbd9b5..5be745ca28 100644 --- a/packages/backend/server/src/__tests__/user.e2e.ts +++ b/packages/backend/server/src/__tests__/user.e2e.ts @@ -25,7 +25,7 @@ test.after.always(async () => { test.skip('should register a user', () => {}); test('should get current user', async t => { - const user = await app.signup('u1@affine.pro'); + const user = await app.signupV1('u1@affine.pro'); const currUser = await currentUser(app); t.is(currUser.id, user.id, 'user.id is not valid'); t.is(currUser.name, user.name, 'user.name is not valid'); @@ -34,7 +34,7 @@ test('should get current user', async t => { }); test('should be able to delete user', async t => { - await app.signup('u1@affine.pro'); + await app.signupV1('u1@affine.pro'); const deleted = await deleteAccount(app); t.true(deleted); const currUser = await currentUser(app); diff --git a/packages/backend/server/src/__tests__/user/user.e2e.ts b/packages/backend/server/src/__tests__/user/user.e2e.ts index 8e74bde4e6..38a96ce15d 100644 --- a/packages/backend/server/src/__tests__/user/user.e2e.ts +++ b/packages/backend/server/src/__tests__/user/user.e2e.ts @@ -19,10 +19,6 @@ test.before(async t => { t.context.app = app; }); -test.beforeEach(async t => { - await t.context.app.initTestingDB(); -}); - test.after.always(async t => { await t.context.app.close(); }); @@ -30,7 +26,7 @@ test.after.always(async t => { test('should be able to upload user avatar', async t => { const { app } = t.context; - await app.signup('u1@affine.pro'); + await app.signup(); const avatar = Buffer.from('test'); const res = await updateAvatar(app, avatar); @@ -46,7 +42,7 @@ test('should be able to upload user avatar', async t => { test('should be able to update user avatar, and invalidate old avatar url', async t => { const { app } = t.context; - await app.signup('u1@affine.pro'); + await app.signup(); const avatar = Buffer.from('test'); let res = await updateAvatar(app, avatar); diff --git a/packages/backend/server/src/__tests__/utils/testing-app.ts b/packages/backend/server/src/__tests__/utils/testing-app.ts index 06865f1820..695d7d6735 100644 --- a/packages/backend/server/src/__tests__/utils/testing-app.ts +++ b/packages/backend/server/src/__tests__/utils/testing-app.ts @@ -3,7 +3,7 @@ import { randomUUID } from 'node:crypto'; import { INestApplication, ModuleMetadata } from '@nestjs/common'; import type { NestExpressApplication } from '@nestjs/platform-express'; import { TestingModuleBuilder } from '@nestjs/testing'; -import { User } from '@prisma/client'; +import { PrismaClient, User } from '@prisma/client'; import cookieParser from 'cookie-parser'; import graphqlUploadExpress from 'graphql-upload/graphqlUploadExpress.mjs'; import supertest from 'supertest'; @@ -11,6 +11,7 @@ import supertest from 'supertest'; import { AFFiNELogger, ApplyType, GlobalExceptionFilter } from '../../base'; import { AuthService } from '../../core/auth'; import { UserModel } from '../../models'; +import { createFactory, MockedUser, MockUser, MockUserInput } from '../mocks'; import { createTestingModule } from './testing-module'; import { initTestingDB, TEST_LOG_LEVEL } from './utils'; @@ -80,6 +81,8 @@ export class TestingApp extends ApplyType() { private currentUserCookie: string | null = null; private readonly userCookies: Set = new Set(); + readonly create!: ReturnType; + [Symbol.asyncDispose](): Promise { return this.close(); } @@ -188,6 +191,9 @@ export class TestingApp extends ApplyType() { return `test-${randomUUID()}@affine.pro`; } + /** + * @deprecated use `create(MockUser)` + */ async createUser( email?: string, override?: Partial @@ -209,13 +215,22 @@ export class TestingApp extends ApplyType() { return user as Omit & { password: string }; } - async signup(email?: string, override?: Partial) { + /** + * @deprecated use `signup` + */ + async signupV1(email?: string, override?: Partial) { const user = await this.createUser(email ?? this.randomEmail(), override); await this.login(user); return user; } - async login(user: TestUser) { + async signup(overrides?: Partial) { + const user = await this.create(MockUser, overrides); + await this.login(user); + return user; + } + + async login(user: MockedUser) { await this.POST('/api/auth/sign-in') .send({ email: user.email, @@ -263,6 +278,9 @@ export class TestingApp extends ApplyType() { function makeTestingApp(app: INestApplication): TestingApp { const testingApp = new TestingApp(); + // @ts-expect-error allow + testingApp.create = createFactory(app.get(PrismaClient, { strict: false })); + return new Proxy(testingApp, { get(target, prop) { // @ts-expect-error override diff --git a/packages/backend/server/src/__tests__/utils/testing-module.ts b/packages/backend/server/src/__tests__/utils/testing-module.ts index a1dac9bd8e..c02e517750 100644 --- a/packages/backend/server/src/__tests__/utils/testing-module.ts +++ b/packages/backend/server/src/__tests__/utils/testing-module.ts @@ -6,12 +6,14 @@ import { TestingModule as BaseTestingModule, TestingModuleBuilder, } from '@nestjs/testing'; +import { PrismaClient } from '@prisma/client'; import { AppModule, FunctionalityModules } from '../../app.module'; import { AFFiNELogger, Runtime } from '../../base'; import { GqlModule } from '../../base/graphql'; import { AuthGuard, AuthModule } from '../../core/auth'; import { ModelsModule } from '../../models'; +import { createFactory } from '../mocks'; import { initTestingDB, TEST_LOG_LEVEL } from './utils'; interface TestingModuleMeatdata extends ModuleMetadata { @@ -20,6 +22,7 @@ interface TestingModuleMeatdata extends ModuleMetadata { export interface TestingModule extends BaseTestingModule { initTestingDB(): Promise; + create: ReturnType; [Symbol.asyncDispose](): Promise; } @@ -91,6 +94,10 @@ export async function createTestingModule( await runtime.set('auth/password.min', 1); }; + testingModule.create = createFactory( + module.get(PrismaClient, { strict: false }) + ); + testingModule[Symbol.asyncDispose] = async () => { await module.close(); }; diff --git a/packages/backend/server/src/__tests__/workspace-invite.e2e.ts b/packages/backend/server/src/__tests__/workspace-invite.e2e.ts index 824b19e0b1..8135215800 100644 --- a/packages/backend/server/src/__tests__/workspace-invite.e2e.ts +++ b/packages/backend/server/src/__tests__/workspace-invite.e2e.ts @@ -47,8 +47,8 @@ test.after.always(async t => { test('should invite a user', async t => { const { app } = t.context; - const u2 = await app.signup('u2@affine.pro'); - await app.signup('u1@affine.pro'); + const u2 = await app.signupV1('u2@affine.pro'); + await app.signupV1('u1@affine.pro'); const workspace = await createWorkspace(app); @@ -58,8 +58,8 @@ test('should invite a user', async t => { test('should leave a workspace', async t => { const { app } = t.context; - const u2 = await app.signup('u2@affine.pro'); - await app.signup('u1@affine.pro'); + const u2 = await app.signupV1('u2@affine.pro'); + await app.signupV1('u1@affine.pro'); const workspace = await createWorkspace(app); const invite = await inviteUser(app, workspace.id, u2.email); @@ -74,8 +74,8 @@ test('should leave a workspace', async t => { test('should revoke a user', async t => { const { app } = t.context; - const u2 = await app.signup('u2@affine.pro'); - await app.signup('u1@affine.pro'); + const u2 = await app.signupV1('u2@affine.pro'); + await app.signupV1('u1@affine.pro'); const workspace = await createWorkspace(app); await inviteUser(app, workspace.id, u2.email); @@ -89,7 +89,7 @@ test('should revoke a user', async t => { test('should create user if not exist', async t => { const { app, models } = t.context; - await app.signup('u1@affine.pro'); + await app.signupV1('u1@affine.pro'); const workspace = await createWorkspace(app); @@ -102,8 +102,8 @@ test('should create user if not exist', async t => { test('should invite a user by link', async t => { const { app } = t.context; - const u2 = await app.signup('u2@affine.pro'); - const u1 = await app.signup('u1@affine.pro'); + const u2 = await app.signupV1('u2@affine.pro'); + const u1 = await app.signupV1('u1@affine.pro'); const workspace = await createWorkspace(app); @@ -127,8 +127,8 @@ test('should invite a user by link', async t => { test('should send email', async t => { const { mail, app } = t.context; if (mail.hasConfigured()) { - const u2 = await app.signup('u2@affine.pro'); - await app.signup('u1@affine.pro'); + const u2 = await app.signupV1('u2@affine.pro'); + await app.signupV1('u1@affine.pro'); const workspace = await createWorkspace(app); const primitiveMailCount = await getCurrentMailMessageCount(); @@ -193,7 +193,7 @@ test('should send email', async t => { test('should support pagination for member', async t => { const { app } = t.context; - await app.signup('u1@affine.pro'); + await app.signupV1('u1@affine.pro'); const workspace = await createWorkspace(app); await inviteUser(app, workspace.id, 'u2@affine.pro'); @@ -207,7 +207,7 @@ test('should support pagination for member', async t => { test('should limit member count correctly', async t => { const { app } = t.context; - await app.signup('u1@affine.pro'); + await app.signupV1('u1@affine.pro'); const workspace = await createWorkspace(app); await Promise.allSettled( diff --git a/packages/backend/server/src/__tests__/workspace.e2e.ts b/packages/backend/server/src/__tests__/workspace.e2e.ts index 7affaac35c..5135e6e956 100644 --- a/packages/backend/server/src/__tests__/workspace.e2e.ts +++ b/packages/backend/server/src/__tests__/workspace.e2e.ts @@ -37,7 +37,7 @@ test.after.always(async t => { test('should create a workspace', async t => { const { app } = t.context; - await app.signup('u1@affine.pro'); + await app.signupV1('u1@affine.pro'); const workspace = await createWorkspace(app); t.is(typeof workspace.id, 'string', 'workspace.id is not a string'); @@ -45,7 +45,7 @@ test('should create a workspace', async t => { test('should be able to publish workspace', async t => { const { app } = t.context; - await app.signup('u1@affine.pro'); + await app.signupV1('u1@affine.pro'); const workspace = await createWorkspace(app); const isPublic = await updateWorkspace(app, workspace.id, true); @@ -58,7 +58,7 @@ test('should be able to publish workspace', async t => { test('should visit public page', async t => { const { app } = t.context; - await app.signup('u1@affine.pro'); + await app.signupV1('u1@affine.pro'); const workspace = await createWorkspace(app); const share = await publishDoc(app, workspace.id, 'doc1'); @@ -104,7 +104,7 @@ test('should visit public page', async t => { test('should not be able to public not permitted doc', async t => { const { app } = t.context; - await app.signup('u2@affine.pro'); + await app.signupV1('u2@affine.pro'); await t.throwsAsync(publishDoc(app, 'not_exists_ws', 'doc2'), { message: @@ -119,8 +119,8 @@ test('should not be able to public not permitted doc', async t => { test('should be able to get workspace doc', async t => { const { app } = t.context; - const u1 = await app.signup('u1@affine.pro'); - const u2 = await app.signup('u2@affine.pro'); + const u1 = await app.signupV1('u1@affine.pro'); + const u2 = await app.signupV1('u2@affine.pro'); app.switchUser(u1.id); const workspace = await createWorkspace(app); @@ -167,7 +167,7 @@ test('should be able to get workspace doc', async t => { test('should be able to get public workspace doc', async t => { const { app } = t.context; - await app.signup('u1@affine.pro'); + await app.signupV1('u1@affine.pro'); const workspace = await createWorkspace(app); const isPublic = await updateWorkspace(app, workspace.id, true); diff --git a/packages/backend/server/src/__tests__/workspace/blobs.e2e.ts b/packages/backend/server/src/__tests__/workspace/blobs.e2e.ts index 5e09a41d36..f3e35b0677 100644 --- a/packages/backend/server/src/__tests__/workspace/blobs.e2e.ts +++ b/packages/backend/server/src/__tests__/workspace/blobs.e2e.ts @@ -40,7 +40,7 @@ test.after.always(async () => { }); test('should set blobs', async t => { - await app.signup('u1@affine.pro'); + await app.signupV1('u1@affine.pro'); const workspace = await createWorkspace(app); @@ -63,7 +63,7 @@ test('should set blobs', async t => { }); test('should list blobs', async t => { - await app.signup('u1@affine.pro'); + await app.signupV1('u1@affine.pro'); const workspace = await createWorkspace(app); const blobs = await listBlobs(app, workspace.id); @@ -81,7 +81,7 @@ test('should list blobs', async t => { }); test('should auto delete blobs when workspace is deleted', async t => { - await app.signup('u1@affine.pro'); + await app.signupV1('u1@affine.pro'); const workspace = await createWorkspace(app); const buffer1 = Buffer.from([0, 0]); @@ -100,7 +100,7 @@ test('should auto delete blobs when workspace is deleted', async t => { }); test('should calc blobs size', async t => { - await app.signup('u1@affine.pro'); + await app.signupV1('u1@affine.pro'); const workspace = await createWorkspace(app); @@ -114,7 +114,7 @@ test('should calc blobs size', async t => { }); test('should calc all blobs size', async t => { - await app.signup('u1@affine.pro'); + await app.signupV1('u1@affine.pro'); const workspace1 = await createWorkspace(app); @@ -135,7 +135,7 @@ test('should calc all blobs size', async t => { }); test('should reject blob exceeded limit', async t => { - await app.signup('u1@affine.pro'); + await app.signupV1('u1@affine.pro'); const workspace1 = await createWorkspace(app); await model.add(workspace1.id, 'team_plan_v1', 'test', RESTRICTED_QUOTA); @@ -147,7 +147,7 @@ test('should reject blob exceeded limit', async t => { }); test('should reject blob exceeded quota', async t => { - await app.signup('u1@affine.pro'); + await app.signupV1('u1@affine.pro'); const workspace = await createWorkspace(app); await model.add(workspace.id, 'team_plan_v1', 'test', RESTRICTED_QUOTA); @@ -159,7 +159,7 @@ test('should reject blob exceeded quota', async t => { }); test('should accept blob even storage out of quota if workspace has unlimited feature', async t => { - await app.signup('u1@affine.pro'); + await app.signupV1('u1@affine.pro'); const workspace = await createWorkspace(app); await model.add(workspace.id, 'team_plan_v1', 'test', RESTRICTED_QUOTA); diff --git a/packages/backend/server/src/__tests__/workspace/controller.spec.ts b/packages/backend/server/src/__tests__/workspace/controller.spec.ts index 0db6d6cb5a..c8ffacd4d3 100644 --- a/packages/backend/server/src/__tests__/workspace/controller.spec.ts +++ b/packages/backend/server/src/__tests__/workspace/controller.spec.ts @@ -31,7 +31,7 @@ test.before(async t => { const db = app.get(PrismaClient); - t.context.u1 = await app.signup('u1@affine.pro'); + t.context.u1 = await app.signupV1('u1@affine.pro'); t.context.db = db; t.context.app = app; t.context.storage = app.get(WorkspaceBlobStorage); diff --git a/packages/backend/server/src/core/notification/__tests__/resolver.e2e.ts b/packages/backend/server/src/core/notification/__tests__/resolver.e2e.ts index 3b326c951b..ca3f232783 100644 --- a/packages/backend/server/src/core/notification/__tests__/resolver.e2e.ts +++ b/packages/backend/server/src/core/notification/__tests__/resolver.e2e.ts @@ -29,8 +29,8 @@ test.after.always(async () => { }); test('should mention user in a doc', async t => { - const member = await app.signup(); - const owner = await app.signup(); + const member = await app.signupV1(); + const owner = await app.signupV1(); await app.switchUser(owner); const workspace = await createWorkspace(app); @@ -161,8 +161,8 @@ test('should mention doc mode support string value', async t => { }); test('should throw error when mention user has no Doc.Read role', async t => { - const member = await app.signup(); - const owner = await app.signup(); + const member = await app.signupV1(); + const owner = await app.signupV1(); await app.switchUser(owner); const workspace = await createWorkspace(app); @@ -187,7 +187,7 @@ test('should throw error when mention user has no Doc.Read role', async t => { }); test('should throw error when mention a not exists user', async t => { - const owner = await app.signup(); + const owner = await app.signupV1(); const workspace = await createWorkspace(app); await app.switchUser(owner); const docId = randomUUID(); @@ -209,7 +209,7 @@ test('should throw error when mention a not exists user', async t => { }); test('should not mention user oneself', async t => { - const owner = await app.signup(); + const owner = await app.signupV1(); const workspace = await createWorkspace(app); await app.switchUser(owner); await t.throwsAsync( @@ -230,8 +230,8 @@ test('should not mention user oneself', async t => { }); test('should mark notification as read', async t => { - const member = await app.signup(); - const owner = await app.signup(); + const member = await app.signupV1(); + const owner = await app.signupV1(); await app.switchUser(owner); const workspace = await createWorkspace(app); @@ -273,8 +273,8 @@ test('should mark notification as read', async t => { }); test('should throw error when read the other user notification', async t => { - const member = await app.signup(); - const owner = await app.signup(); + const member = await app.signupV1(); + const owner = await app.signupV1(); await app.switchUser(owner); const workspace = await createWorkspace(app); @@ -356,8 +356,8 @@ test('should throw error when mention mode value is invalid', async t => { }); test('should list and count notifications', async t => { - const member = await app.signup(); - const owner = await app.signup(); + const member = await app.signupV1(); + const owner = await app.signupV1(); { await app.switchUser(member); diff --git a/packages/backend/server/src/seed/index.ts b/packages/backend/server/src/seed/index.ts new file mode 100644 index 0000000000..c92d0aba5b --- /dev/null +++ b/packages/backend/server/src/seed/index.ts @@ -0,0 +1,88 @@ +import '../prelude'; + +import { PrismaClient } from '@prisma/client'; + +import { createFactory, Mockers } from '../__tests__/mocks'; + +const client = new PrismaClient(); + +const args = process.argv.slice(2); + +if (!args.length || args.includes('-h') || args.includes('--help')) { + console.log(` +seed [Entity] [count] [[field]=[val]] + +Checkout [server/src/__tests__/mocks/*.mock.ts] for all available Entities and Inputs + +examples: + +$ seed User Create an User +$ seed User 3 Create 3 Users +$ seed User feature=pro_plan_v1 Create an User with Pro feature +$ seed TeamWorkspace id=xxx Seed a workspace with Team feature +$ seed Workspace id=xxx public=true Seed with boolean property +$ seed TeamWorkspace id=xxx quantity=10n Seed with numberic property, use \`={num}n\` suffix +`); + process.exit(0); +} + +const name = args.shift() as keyof typeof Mockers; +const Mocker = Mockers[name]; + +if (!name || !Mocker) { + throw new Error( + 'First argument must be one of: ' + JSON.stringify(Object.keys(Mockers)) + ); +} + +const create = createFactory(client, { + logger: (val: any) => { + console.log(`${name} ${JSON.stringify(val)}`); + }, +}); + +function parseArgs(args: string[]) { + if (!args.length) { + return { count: 1 }; + } + + const overrides: Record = {}; + let count: number = 1; + + args.forEach(arg => { + let kvSep = arg.indexOf('='); + if (kvSep) { + const key = arg.slice(0, kvSep); + const val = arg.slice(kvSep + 1); + + if (/[\d]+n$/.test(val)) { + const num = Number(val.slice(0, -1)); + if (Number.isNaN(num)) { + throw new Error(`Invalid numeric parameter: ${arg}`); + } + overrides[key] = num; + } else if (val.length === 4 && val.toLowerCase() === 'true') { + overrides[key] = true; + } else if (val.length === 5 && val.toLowerCase() === 'false') { + overrides[key] = false; + } else { + overrides[key] = val; + } + } else { + const maybeCount = parseInt(arg); + if (!maybeCount || Number.isNaN(maybeCount)) { + console.warn(`Invalid parameter: ${arg}`); + return; + } + count = maybeCount; + } + }); + + return { + overrides, + count, + }; +} + +const { overrides, count } = parseArgs(args); +await create(Mocker, overrides as any, count); diff --git a/yarn.lock b/yarn.lock index e7523fee99..f7694871d3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -834,6 +834,7 @@ __metadata: "@affine/server-native": "workspace:*" "@apollo/server": "npm:^4.11.2" "@aws-sdk/client-s3": "npm:^3.709.0" + "@faker-js/faker": "npm:^9.6.0" "@fal-ai/serverless-client": "npm:^0.15.0" "@google-cloud/opentelemetry-cloud-monitoring-exporter": "npm:^0.20.0" "@google-cloud/opentelemetry-cloud-trace-exporter": "npm:^2.4.1" @@ -5105,7 +5106,7 @@ __metadata: languageName: node linkType: hard -"@faker-js/faker@npm:^9.3.0": +"@faker-js/faker@npm:^9.3.0, @faker-js/faker@npm:^9.6.0": version: 9.6.0 resolution: "@faker-js/faker@npm:9.6.0" checksum: 10/f53aeca972a16e7cbb26024c457cea7e1c6bff9dd60561f942a48eb3233863ed7b5fbb1392eb98a0901cb5bea23cf7dbb0793a30c1655478c6a76a43ebb6c360