chore(server): data mocking and seeding (#10864)

This commit is contained in:
liuyi
2025-03-17 14:15:34 +08:00
committed by GitHub
parent 81af7a0571
commit 0f83566504
24 changed files with 449 additions and 82 deletions

View File

@@ -40,8 +40,9 @@ yarn affine @affine/server-native build
```sh ```sh
# uncomment all env variables here # uncomment all env variables here
cp packages/backend/server/.env.example packages/backend/server/.env 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 ## Start server
@@ -90,3 +91,9 @@ Now you should be able to start developing affine with server enabled.
# available at http://localhost:5555 # available at http://localhost:5555
yarn affine server prisma studio yarn affine server prisma studio
``` ```
### Seed the db
```
yarn affine server seed -h
```

View File

@@ -16,6 +16,8 @@
"test:coverage": "c8 ava --concurrency 1 --serial", "test:coverage": "c8 ava --concurrency 1 --serial",
"test:copilot:coverage": "c8 ava --timeout=5m \"src/__tests__/**/copilot-*.spec.ts\"", "test:copilot:coverage": "c8 ava --timeout=5m \"src/__tests__/**/copilot-*.spec.ts\"",
"data-migration": "cross-env NODE_ENV=development r ./src/data/index.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", "predeploy": "yarn prisma migrate deploy && node --import ./scripts/register.js ./dist/data/index.js run",
"postinstall": "prisma generate" "postinstall": "prisma generate"
}, },
@@ -108,6 +110,7 @@
"@affine-tools/cli": "workspace:*", "@affine-tools/cli": "workspace:*",
"@affine-tools/utils": "workspace:*", "@affine-tools/utils": "workspace:*",
"@affine/server-native": "workspace:*", "@affine/server-native": "workspace:*",
"@faker-js/faker": "^9.6.0",
"@nestjs/testing": "^10.4.15", "@nestjs/testing": "^10.4.15",
"@types/cookie-parser": "^1.4.8", "@types/cookie-parser": "^1.4.8",
"@types/express": "^4.17.21", "@types/express": "^4.17.21",

View File

@@ -41,7 +41,7 @@ test('change email', async t => {
const u1Email = 'u1@affine.pro'; const u1Email = 'u1@affine.pro';
const u2Email = 'u2@affine.pro'; const u2Email = 'u2@affine.pro';
await app.signup(u1Email); await app.signupV1(u1Email);
const primitiveMailCount = await getCurrentMailMessageCount(); const primitiveMailCount = await getCurrentMailMessageCount();
await sendChangeEmail(app, u1Email, 'affine.pro'); await sendChangeEmail(app, u1Email, 'affine.pro');
@@ -101,7 +101,7 @@ test('set and change password', async t => {
if (mail.hasConfigured()) { if (mail.hasConfigured()) {
const u1Email = 'u1@affine.pro'; const u1Email = 'u1@affine.pro';
const u1 = await app.signup(u1Email); const u1 = await app.signupV1(u1Email);
const primitiveMailCount = await getCurrentMailMessageCount(); const primitiveMailCount = await getCurrentMailMessageCount();
@@ -153,7 +153,7 @@ test('should revoke token after change user identify', async t => {
const u1Email = 'u1@affine.pro'; const u1Email = 'u1@affine.pro';
const u2Email = 'u2@affine.pro'; const u2Email = 'u2@affine.pro';
const u1 = await app.signup(u1Email); const u1 = await app.signupV1(u1Email);
{ {
const user = await currentUser(app); const user = await currentUser(app);
@@ -190,7 +190,7 @@ test('should revoke token after change user identify', async t => {
const u3Email = 'u3333@affine.pro'; const u3Email = 'u3333@affine.pro';
await app.logout(); await app.logout();
const u3 = await app.signup(u3Email); const u3 = await app.signupV1(u3Email);
{ {
const user = await currentUser(app); const user = await currentUser(app);

View File

@@ -164,7 +164,7 @@ test('should be able to sign out', async t => {
test('should be able to correct user id cookie', async t => { test('should be able to correct user id cookie', async t => {
const { app } = t.context; 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'); const req = app.GET('/api/auth/session');
let cookies = req.get('cookie') as unknown as string[]; 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 => { test('should be able to sign out multiple accounts in one session', async t => {
const { app } = t.context; const { app } = t.context;
const u1 = await app.signup('u1@affine.pro'); const u1 = await app.signupV1('u1@affine.pro');
const u2 = await app.signup('u2@affine.pro'); const u2 = await app.signupV1('u2@affine.pro');
// sign out u2 // sign out u2
await app.GET(`/api/auth/sign-out?user_id=${u2.id}`).expect(200); await app.GET(`/api/auth/sign-out?user_id=${u2.id}`).expect(200);

View File

@@ -110,7 +110,7 @@ test.beforeEach(async t => {
const { app, prompt } = t.context; const { app, prompt } = t.context;
await app.initTestingDB(); await app.initTestingDB();
await prompt.onModuleInit(); 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(OpenAIProvider.type);
unregisterCopilotProvider(FalProvider.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 u2 = await app.createUser('u2@affine.pro');
const { id: workspaceId } = await createWorkspace(app); const { id: workspaceId } = await createWorkspace(app);
const inviteId = await inviteUser(app, workspaceId, u2.email); 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 assertForkSession(id, sessionId, randomUUID(), '', async x => {
await t.throwsAsync( await t.throwsAsync(
x, x,

View File

@@ -31,8 +31,8 @@ test('should send invite email', async t => {
const { mail, app } = t.context; const { mail, app } = t.context;
if (mail.hasConfigured()) { if (mail.hasConfigured()) {
const u2 = await app.signup('u2@affine.pro'); const u2 = await app.signupV1('u2@affine.pro');
const u1 = await app.signup('u1@affine.pro'); const u1 = await app.signupV1('u1@affine.pro');
const stub = Sinon.stub(mail, 'send'); const stub = Sinon.stub(mail, 'send');
const workspace = await createWorkspace(app); const workspace = await createWorkspace(app);

View File

@@ -0,0 +1,90 @@
import { Type } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
export abstract class Mocker<In, Out> {
// 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<In>): Promise<Out>;
}
type MockerConstructor<In, Out> = Type<Mocker<In, Out>>;
type MockerInput<Ctor extends MockerConstructor<any, any>> =
Ctor extends MockerConstructor<infer In, any> ? In : never;
type MockerOutput<Ctor extends MockerConstructor<any, any>> =
Ctor extends MockerConstructor<any, infer Out> ? Out : never;
const FACTORIES = new Map<string, Mocker<any, any>>();
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<Ctor extends MockerConstructor<any, any>>(
Factory: Ctor,
overrides?: Partial<MockerInput<Ctor>>
): Promise<MockerOutput<Ctor>>;
static create<Ctor extends MockerConstructor<any, any>>(
Factory: Ctor,
count: number
): Promise<MockerOutput<Ctor>[]>;
static create<Ctor extends MockerConstructor<any, any>>(
Factory: Ctor,
overrides: Partial<MockerInput<Ctor>>,
count: number
): Promise<MockerOutput<Ctor>[]>;
static async create<Ctor extends MockerConstructor<any, any>>(
Factory: Ctor,
overridesOrCount?: Partial<MockerInput<Ctor>> | number,
count?: number
): Promise<MockerOutput<Ctor> | MockerOutput<Ctor>[]> {
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<MockerInput<Ctor>> | 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;
}

View File

@@ -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,
};

View File

@@ -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<MockTeamWorkspaceInput>) {
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 };
}
}

View File

@@ -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<User, 'password'> & {
password: string;
};
export class MockUser extends Mocker<MockUserInput, MockedUser> {
override async create(input?: Partial<MockUserInput>) {
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 };
}
}

View File

@@ -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<MockWorkspaceInput, MockedWorkspace> {
override async create(input?: Partial<MockWorkspaceInput>) {
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,
},
});
}
}

View File

@@ -191,7 +191,7 @@ test('should use specified throttler for unauthenticated user', async t => {
test('should not protect unspecified routes', async t => { test('should not protect unspecified routes', async t => {
const { app } = t.context; 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 res = await app.GET('/nonthrottled/default').expect(200);
const headers = rateLimitHeaders(res); 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 => { test('should use default throttler for authenticated user when not specified', async t => {
const { app } = t.context; 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 res = await app.GET('/throttled/default').expect(200);
const headers = rateLimitHeaders(res); 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 => { test('should use same throttler for multiple routes', async t => {
const { app } = t.context; 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 res = await app.GET('/throttled/default').expect(200);
let headers = rateLimitHeaders(res); 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 => { test('should use different throttler if specified', async t => {
const { app } = t.context; 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 res = await app.GET('/throttled/default').expect(200);
let headers = rateLimitHeaders(res); 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 => { test('should skip throttler for authenticated if `authenticated` throttler used', async t => {
const { app } = t.context; 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 res = await app.GET('/throttled/authenticated').expect(200);
const headers = rateLimitHeaders(res); 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 => { test('should skip throttler for authenticated user when specified', async t => {
const { app } = t.context; 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 res = await app.GET('/throttled/skip').expect(200);
const headers = rateLimitHeaders(res); 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 => { test('should use specified throttler for authenticated user', async t => {
const { app } = t.context; 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 res = await app.GET('/throttled/strict').expect(200);
const headers = rateLimitHeaders(res); const headers = rateLimitHeaders(res);
@@ -306,7 +306,7 @@ test('should separate anonymous and authenticated user throttlers', async t => {
const unauthenticatedUserRes = await app const unauthenticatedUserRes = await app
.GET('/nonthrottled/default') .GET('/nonthrottled/default')
.expect(200); .expect(200);
await app.signup('u1@affine.pro'); await app.signupV1('u1@affine.pro');
const authenticatedUserRes = await app.GET('/throttled/default').expect(200); const authenticatedUserRes = await app.GET('/throttled/default').expect(200);
const authenticatedResHeaders = rateLimitHeaders(authenticatedUserRes); const authenticatedResHeaders = rateLimitHeaders(authenticatedUserRes);

View File

@@ -82,7 +82,7 @@ const init = async (
memberLimit = 10, memberLimit = 10,
prefix = randomUUID() 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); const models = app.get(Models);
{ {
await models.userFeature.add(owner.id, 'pro_plan_v1', 'test'); await models.userFeature.add(owner.id, 'pro_plan_v1', 'test');
@@ -101,7 +101,7 @@ const init = async (
permission: WorkspaceRole = WorkspaceRole.Collaborator, permission: WorkspaceRole = WorkspaceRole.Collaborator,
shouldSendEmail: boolean = false shouldSendEmail: boolean = false
) => { ) => {
const member = await app.signup(email); const member = await app.signupV1(email);
{ {
// normal workspace // normal workspace
@@ -140,7 +140,7 @@ const init = async (
) => { ) => {
const members = []; const members = [];
for (const email of emails) { for (const email of emails) {
const member = await app.signup(email); const member = await app.signupV1(email);
members.push(member); members.push(member);
} }
@@ -161,7 +161,7 @@ const init = async (
return [ return [
inviteId, inviteId,
async (email: string, shouldSendEmail: boolean = false) => { 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); await acceptInviteById(app, ws.id, inviteId, shouldSendEmail);
return member; return member;
}, },
@@ -221,8 +221,8 @@ test('should be able to invite multiple users', async t => {
{ {
// manager // manager
const m1 = await app.signup('m1@affine.pro'); const m1 = await app.signupV1('m1@affine.pro');
const m2 = await app.signup('m2@affine.pro'); const m2 = await app.signupV1('m2@affine.pro');
app.switchUser(owner); app.switchUser(owner);
t.is( t.is(
(await inviteUsers(app, ws.id, [m1.email])).length, (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 { link } = await createInviteLink(app, tws.id, 'OneDay');
const inviteId = link.split('/').pop()!; const inviteId = link.split('/').pop()!;
const member = await app.signup('newmember@affine.pro'); const member = await app.signupV1('newmember@affine.pro');
t.true( t.true(
await acceptInviteById(app, tws.id, inviteId, false), await acceptInviteById(app, tws.id, inviteId, false),
'should be able to accept invite' 'should be able to accept invite'

View File

@@ -25,7 +25,7 @@ test.after.always(async () => {
test.skip('should register a user', () => {}); test.skip('should register a user', () => {});
test('should get current user', async t => { 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); const currUser = await currentUser(app);
t.is(currUser.id, user.id, 'user.id is not valid'); t.is(currUser.id, user.id, 'user.id is not valid');
t.is(currUser.name, user.name, 'user.name 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 => { 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); const deleted = await deleteAccount(app);
t.true(deleted); t.true(deleted);
const currUser = await currentUser(app); const currUser = await currentUser(app);

View File

@@ -19,10 +19,6 @@ test.before(async t => {
t.context.app = app; t.context.app = app;
}); });
test.beforeEach(async t => {
await t.context.app.initTestingDB();
});
test.after.always(async t => { test.after.always(async t => {
await t.context.app.close(); await t.context.app.close();
}); });
@@ -30,7 +26,7 @@ test.after.always(async t => {
test('should be able to upload user avatar', async t => { test('should be able to upload user avatar', async t => {
const { app } = t.context; const { app } = t.context;
await app.signup('u1@affine.pro'); await app.signup();
const avatar = Buffer.from('test'); const avatar = Buffer.from('test');
const res = await updateAvatar(app, avatar); 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 => { test('should be able to update user avatar, and invalidate old avatar url', async t => {
const { app } = t.context; const { app } = t.context;
await app.signup('u1@affine.pro'); await app.signup();
const avatar = Buffer.from('test'); const avatar = Buffer.from('test');
let res = await updateAvatar(app, avatar); let res = await updateAvatar(app, avatar);

View File

@@ -3,7 +3,7 @@ import { randomUUID } from 'node:crypto';
import { INestApplication, ModuleMetadata } from '@nestjs/common'; import { INestApplication, ModuleMetadata } from '@nestjs/common';
import type { NestExpressApplication } from '@nestjs/platform-express'; import type { NestExpressApplication } from '@nestjs/platform-express';
import { TestingModuleBuilder } from '@nestjs/testing'; import { TestingModuleBuilder } from '@nestjs/testing';
import { User } from '@prisma/client'; import { PrismaClient, User } from '@prisma/client';
import cookieParser from 'cookie-parser'; import cookieParser from 'cookie-parser';
import graphqlUploadExpress from 'graphql-upload/graphqlUploadExpress.mjs'; import graphqlUploadExpress from 'graphql-upload/graphqlUploadExpress.mjs';
import supertest from 'supertest'; import supertest from 'supertest';
@@ -11,6 +11,7 @@ import supertest from 'supertest';
import { AFFiNELogger, ApplyType, GlobalExceptionFilter } from '../../base'; import { AFFiNELogger, ApplyType, GlobalExceptionFilter } from '../../base';
import { AuthService } from '../../core/auth'; import { AuthService } from '../../core/auth';
import { UserModel } from '../../models'; import { UserModel } from '../../models';
import { createFactory, MockedUser, MockUser, MockUserInput } from '../mocks';
import { createTestingModule } from './testing-module'; import { createTestingModule } from './testing-module';
import { initTestingDB, TEST_LOG_LEVEL } from './utils'; import { initTestingDB, TEST_LOG_LEVEL } from './utils';
@@ -80,6 +81,8 @@ export class TestingApp extends ApplyType<INestApplication>() {
private currentUserCookie: string | null = null; private currentUserCookie: string | null = null;
private readonly userCookies: Set<string> = new Set(); private readonly userCookies: Set<string> = new Set();
readonly create!: ReturnType<typeof createFactory>;
[Symbol.asyncDispose](): Promise<void> { [Symbol.asyncDispose](): Promise<void> {
return this.close(); return this.close();
} }
@@ -188,6 +191,9 @@ export class TestingApp extends ApplyType<INestApplication>() {
return `test-${randomUUID()}@affine.pro`; return `test-${randomUUID()}@affine.pro`;
} }
/**
* @deprecated use `create(MockUser)`
*/
async createUser( async createUser(
email?: string, email?: string,
override?: Partial<User> override?: Partial<User>
@@ -209,13 +215,22 @@ export class TestingApp extends ApplyType<INestApplication>() {
return user as Omit<User, 'password'> & { password: string }; return user as Omit<User, 'password'> & { password: string };
} }
async signup(email?: string, override?: Partial<User>) { /**
* @deprecated use `signup`
*/
async signupV1(email?: string, override?: Partial<User>) {
const user = await this.createUser(email ?? this.randomEmail(), override); const user = await this.createUser(email ?? this.randomEmail(), override);
await this.login(user); await this.login(user);
return user; return user;
} }
async login(user: TestUser) { async signup(overrides?: Partial<MockUserInput>) {
const user = await this.create(MockUser, overrides);
await this.login(user);
return user;
}
async login(user: MockedUser) {
await this.POST('/api/auth/sign-in') await this.POST('/api/auth/sign-in')
.send({ .send({
email: user.email, email: user.email,
@@ -263,6 +278,9 @@ export class TestingApp extends ApplyType<INestApplication>() {
function makeTestingApp(app: INestApplication): TestingApp { function makeTestingApp(app: INestApplication): TestingApp {
const testingApp = new TestingApp(); const testingApp = new TestingApp();
// @ts-expect-error allow
testingApp.create = createFactory(app.get(PrismaClient, { strict: false }));
return new Proxy(testingApp, { return new Proxy(testingApp, {
get(target, prop) { get(target, prop) {
// @ts-expect-error override // @ts-expect-error override

View File

@@ -6,12 +6,14 @@ import {
TestingModule as BaseTestingModule, TestingModule as BaseTestingModule,
TestingModuleBuilder, TestingModuleBuilder,
} from '@nestjs/testing'; } from '@nestjs/testing';
import { PrismaClient } from '@prisma/client';
import { AppModule, FunctionalityModules } from '../../app.module'; import { AppModule, FunctionalityModules } from '../../app.module';
import { AFFiNELogger, Runtime } from '../../base'; import { AFFiNELogger, Runtime } from '../../base';
import { GqlModule } from '../../base/graphql'; import { GqlModule } from '../../base/graphql';
import { AuthGuard, AuthModule } from '../../core/auth'; import { AuthGuard, AuthModule } from '../../core/auth';
import { ModelsModule } from '../../models'; import { ModelsModule } from '../../models';
import { createFactory } from '../mocks';
import { initTestingDB, TEST_LOG_LEVEL } from './utils'; import { initTestingDB, TEST_LOG_LEVEL } from './utils';
interface TestingModuleMeatdata extends ModuleMetadata { interface TestingModuleMeatdata extends ModuleMetadata {
@@ -20,6 +22,7 @@ interface TestingModuleMeatdata extends ModuleMetadata {
export interface TestingModule extends BaseTestingModule { export interface TestingModule extends BaseTestingModule {
initTestingDB(): Promise<void>; initTestingDB(): Promise<void>;
create: ReturnType<typeof createFactory>;
[Symbol.asyncDispose](): Promise<void>; [Symbol.asyncDispose](): Promise<void>;
} }
@@ -91,6 +94,10 @@ export async function createTestingModule(
await runtime.set('auth/password.min', 1); await runtime.set('auth/password.min', 1);
}; };
testingModule.create = createFactory(
module.get(PrismaClient, { strict: false })
);
testingModule[Symbol.asyncDispose] = async () => { testingModule[Symbol.asyncDispose] = async () => {
await module.close(); await module.close();
}; };

View File

@@ -47,8 +47,8 @@ test.after.always(async t => {
test('should invite a user', async t => { test('should invite a user', async t => {
const { app } = t.context; const { app } = t.context;
const u2 = await app.signup('u2@affine.pro'); const u2 = await app.signupV1('u2@affine.pro');
await app.signup('u1@affine.pro'); await app.signupV1('u1@affine.pro');
const workspace = await createWorkspace(app); const workspace = await createWorkspace(app);
@@ -58,8 +58,8 @@ test('should invite a user', async t => {
test('should leave a workspace', async t => { test('should leave a workspace', async t => {
const { app } = t.context; const { app } = t.context;
const u2 = await app.signup('u2@affine.pro'); const u2 = await app.signupV1('u2@affine.pro');
await app.signup('u1@affine.pro'); await app.signupV1('u1@affine.pro');
const workspace = await createWorkspace(app); const workspace = await createWorkspace(app);
const invite = await inviteUser(app, workspace.id, u2.email); 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 => { test('should revoke a user', async t => {
const { app } = t.context; const { app } = t.context;
const u2 = await app.signup('u2@affine.pro'); const u2 = await app.signupV1('u2@affine.pro');
await app.signup('u1@affine.pro'); await app.signupV1('u1@affine.pro');
const workspace = await createWorkspace(app); const workspace = await createWorkspace(app);
await inviteUser(app, workspace.id, u2.email); 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 => { test('should create user if not exist', async t => {
const { app, models } = t.context; const { app, models } = t.context;
await app.signup('u1@affine.pro'); await app.signupV1('u1@affine.pro');
const workspace = await createWorkspace(app); 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 => { test('should invite a user by link', async t => {
const { app } = t.context; const { app } = t.context;
const u2 = await app.signup('u2@affine.pro'); const u2 = await app.signupV1('u2@affine.pro');
const u1 = await app.signup('u1@affine.pro'); const u1 = await app.signupV1('u1@affine.pro');
const workspace = await createWorkspace(app); const workspace = await createWorkspace(app);
@@ -127,8 +127,8 @@ test('should invite a user by link', async t => {
test('should send email', async t => { test('should send email', async t => {
const { mail, app } = t.context; const { mail, app } = t.context;
if (mail.hasConfigured()) { if (mail.hasConfigured()) {
const u2 = await app.signup('u2@affine.pro'); const u2 = await app.signupV1('u2@affine.pro');
await app.signup('u1@affine.pro'); await app.signupV1('u1@affine.pro');
const workspace = await createWorkspace(app); const workspace = await createWorkspace(app);
const primitiveMailCount = await getCurrentMailMessageCount(); const primitiveMailCount = await getCurrentMailMessageCount();
@@ -193,7 +193,7 @@ test('should send email', async t => {
test('should support pagination for member', async t => { test('should support pagination for member', async t => {
const { app } = t.context; const { app } = t.context;
await app.signup('u1@affine.pro'); await app.signupV1('u1@affine.pro');
const workspace = await createWorkspace(app); const workspace = await createWorkspace(app);
await inviteUser(app, workspace.id, 'u2@affine.pro'); 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 => { test('should limit member count correctly', async t => {
const { app } = t.context; const { app } = t.context;
await app.signup('u1@affine.pro'); await app.signupV1('u1@affine.pro');
const workspace = await createWorkspace(app); const workspace = await createWorkspace(app);
await Promise.allSettled( await Promise.allSettled(

View File

@@ -37,7 +37,7 @@ test.after.always(async t => {
test('should create a workspace', async t => { test('should create a workspace', async t => {
const { app } = t.context; const { app } = t.context;
await app.signup('u1@affine.pro'); await app.signupV1('u1@affine.pro');
const workspace = await createWorkspace(app); const workspace = await createWorkspace(app);
t.is(typeof workspace.id, 'string', 'workspace.id is not a string'); 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 => { test('should be able to publish workspace', async t => {
const { app } = t.context; const { app } = t.context;
await app.signup('u1@affine.pro'); await app.signupV1('u1@affine.pro');
const workspace = await createWorkspace(app); const workspace = await createWorkspace(app);
const isPublic = await updateWorkspace(app, workspace.id, true); 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 => { test('should visit public page', async t => {
const { app } = t.context; const { app } = t.context;
await app.signup('u1@affine.pro'); await app.signupV1('u1@affine.pro');
const workspace = await createWorkspace(app); const workspace = await createWorkspace(app);
const share = await publishDoc(app, workspace.id, 'doc1'); 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 => { test('should not be able to public not permitted doc', async t => {
const { app } = t.context; 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'), { await t.throwsAsync(publishDoc(app, 'not_exists_ws', 'doc2'), {
message: 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 => { test('should be able to get workspace doc', async t => {
const { app } = t.context; const { app } = t.context;
const u1 = await app.signup('u1@affine.pro'); const u1 = await app.signupV1('u1@affine.pro');
const u2 = await app.signup('u2@affine.pro'); const u2 = await app.signupV1('u2@affine.pro');
app.switchUser(u1.id); app.switchUser(u1.id);
const workspace = await createWorkspace(app); 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 => { test('should be able to get public workspace doc', async t => {
const { app } = t.context; const { app } = t.context;
await app.signup('u1@affine.pro'); await app.signupV1('u1@affine.pro');
const workspace = await createWorkspace(app); const workspace = await createWorkspace(app);
const isPublic = await updateWorkspace(app, workspace.id, true); const isPublic = await updateWorkspace(app, workspace.id, true);

View File

@@ -40,7 +40,7 @@ test.after.always(async () => {
}); });
test('should set blobs', async t => { test('should set blobs', async t => {
await app.signup('u1@affine.pro'); await app.signupV1('u1@affine.pro');
const workspace = await createWorkspace(app); const workspace = await createWorkspace(app);
@@ -63,7 +63,7 @@ test('should set blobs', async t => {
}); });
test('should list 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 workspace = await createWorkspace(app);
const blobs = await listBlobs(app, workspace.id); 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 => { 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 workspace = await createWorkspace(app);
const buffer1 = Buffer.from([0, 0]); 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 => { test('should calc blobs size', async t => {
await app.signup('u1@affine.pro'); await app.signupV1('u1@affine.pro');
const workspace = await createWorkspace(app); const workspace = await createWorkspace(app);
@@ -114,7 +114,7 @@ test('should calc blobs size', async t => {
}); });
test('should calc all 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); 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 => { 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); const workspace1 = await createWorkspace(app);
await model.add(workspace1.id, 'team_plan_v1', 'test', RESTRICTED_QUOTA); 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 => { 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); const workspace = await createWorkspace(app);
await model.add(workspace.id, 'team_plan_v1', 'test', RESTRICTED_QUOTA); 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 => { 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); const workspace = await createWorkspace(app);
await model.add(workspace.id, 'team_plan_v1', 'test', RESTRICTED_QUOTA); await model.add(workspace.id, 'team_plan_v1', 'test', RESTRICTED_QUOTA);

View File

@@ -31,7 +31,7 @@ test.before(async t => {
const db = app.get(PrismaClient); 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.db = db;
t.context.app = app; t.context.app = app;
t.context.storage = app.get(WorkspaceBlobStorage); t.context.storage = app.get(WorkspaceBlobStorage);

View File

@@ -29,8 +29,8 @@ test.after.always(async () => {
}); });
test('should mention user in a doc', async t => { test('should mention user in a doc', async t => {
const member = await app.signup(); const member = await app.signupV1();
const owner = await app.signup(); const owner = await app.signupV1();
await app.switchUser(owner); await app.switchUser(owner);
const workspace = await createWorkspace(app); 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 => { test('should throw error when mention user has no Doc.Read role', async t => {
const member = await app.signup(); const member = await app.signupV1();
const owner = await app.signup(); const owner = await app.signupV1();
await app.switchUser(owner); await app.switchUser(owner);
const workspace = await createWorkspace(app); 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 => { 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); const workspace = await createWorkspace(app);
await app.switchUser(owner); await app.switchUser(owner);
const docId = randomUUID(); 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 => { test('should not mention user oneself', async t => {
const owner = await app.signup(); const owner = await app.signupV1();
const workspace = await createWorkspace(app); const workspace = await createWorkspace(app);
await app.switchUser(owner); await app.switchUser(owner);
await t.throwsAsync( await t.throwsAsync(
@@ -230,8 +230,8 @@ test('should not mention user oneself', async t => {
}); });
test('should mark notification as read', async t => { test('should mark notification as read', async t => {
const member = await app.signup(); const member = await app.signupV1();
const owner = await app.signup(); const owner = await app.signupV1();
await app.switchUser(owner); await app.switchUser(owner);
const workspace = await createWorkspace(app); 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 => { test('should throw error when read the other user notification', async t => {
const member = await app.signup(); const member = await app.signupV1();
const owner = await app.signup(); const owner = await app.signupV1();
await app.switchUser(owner); await app.switchUser(owner);
const workspace = await createWorkspace(app); 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 => { test('should list and count notifications', async t => {
const member = await app.signup(); const member = await app.signupV1();
const owner = await app.signup(); const owner = await app.signupV1();
{ {
await app.switchUser(member); await app.switchUser(member);

View File

@@ -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<string, any> = {};
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);

View File

@@ -834,6 +834,7 @@ __metadata:
"@affine/server-native": "workspace:*" "@affine/server-native": "workspace:*"
"@apollo/server": "npm:^4.11.2" "@apollo/server": "npm:^4.11.2"
"@aws-sdk/client-s3": "npm:^3.709.0" "@aws-sdk/client-s3": "npm:^3.709.0"
"@faker-js/faker": "npm:^9.6.0"
"@fal-ai/serverless-client": "npm:^0.15.0" "@fal-ai/serverless-client": "npm:^0.15.0"
"@google-cloud/opentelemetry-cloud-monitoring-exporter": "npm:^0.20.0" "@google-cloud/opentelemetry-cloud-monitoring-exporter": "npm:^0.20.0"
"@google-cloud/opentelemetry-cloud-trace-exporter": "npm:^2.4.1" "@google-cloud/opentelemetry-cloud-trace-exporter": "npm:^2.4.1"
@@ -5105,7 +5106,7 @@ __metadata:
languageName: node languageName: node
linkType: hard 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 version: 9.6.0
resolution: "@faker-js/faker@npm:9.6.0" resolution: "@faker-js/faker@npm:9.6.0"
checksum: 10/f53aeca972a16e7cbb26024c457cea7e1c6bff9dd60561f942a48eb3233863ed7b5fbb1392eb98a0901cb5bea23cf7dbb0793a30c1655478c6a76a43ebb6c360 checksum: 10/f53aeca972a16e7cbb26024c457cea7e1c6bff9dd60561f942a48eb3233863ed7b5fbb1392eb98a0901cb5bea23cf7dbb0793a30c1655478c6a76a43ebb6c360