test(server): move tests out of src folder (#4366)

This commit is contained in:
LongYinan
2023-09-15 00:34:14 -07:00
committed by GitHub
parent b5e8fecfd0
commit 1aec1ce7d0
36 changed files with 233 additions and 159 deletions

View File

@@ -0,0 +1,209 @@
import { ok } from 'node:assert';
import { randomUUID } from 'node:crypto';
import { Transformer } from '@napi-rs/image';
import type { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import { hashSync } from '@node-rs/argon2';
import { User } from '@prisma/client';
import ava, { TestFn } from 'ava';
import { Express } from 'express';
import graphqlUploadExpress from 'graphql-upload/graphqlUploadExpress.mjs';
import request from 'supertest';
import { AppModule } from '../src/app';
import { PrismaService } from '../src/prisma/service';
const gql = '/graphql';
const test = ava as TestFn<{
app: INestApplication;
}>;
class FakePrisma {
fakeUser: User = {
id: randomUUID(),
name: 'Alex Yang',
avatarUrl: '',
email: 'alex.yang@example.org',
password: hashSync('123456'),
emailVerified: new Date(),
createdAt: new Date(),
};
get user() {
// eslint-disable-next-line @typescript-eslint/no-this-alias
const prisma = this;
return {
async findFirst() {
return prisma.fakeUser;
},
async findUnique() {
return this.findFirst();
},
async update() {
return this.findFirst();
},
};
}
}
test.beforeEach(async t => {
const module = await Test.createTestingModule({
imports: [AppModule],
})
.overrideProvider(PrismaService)
.useClass(FakePrisma)
.compile();
t.context.app = module.createNestApplication({
cors: true,
bodyParser: true,
});
t.context.app.use(
graphqlUploadExpress({
maxFileSize: 10 * 1024 * 1024,
maxFiles: 5,
})
);
await t.context.app.init();
});
test.afterEach.always(async t => {
await t.context.app.close();
});
test('should init app', async t => {
t.is(typeof t.context.app, 'object');
await request(t.context.app.getHttpServer())
.post(gql)
.send({
query: `
query {
error
}
`,
})
.expect(400);
const { token } = await createToken(t.context.app);
await request(t.context.app.getHttpServer())
.post(gql)
.auth(token, { type: 'bearer' })
.send({
query: `
query {
__typename
}
`,
})
.expect(200)
.expect(res => {
t.is(res.body.data.__typename, 'Query');
});
});
test('should find default user', async t => {
const { token } = await createToken(t.context.app);
await request(t.context.app.getHttpServer())
.post(gql)
.auth(token, { type: 'bearer' })
.send({
query: `
query {
user(email: "alex.yang@example.org") {
email
avatarUrl
}
}
`,
})
.expect(200)
.expect(res => {
t.is(res.body.data.user.email, 'alex.yang@example.org');
});
});
test('should be able to upload avatar and remove it', async t => {
const { token, id } = await createToken(t.context.app);
const png = await Transformer.fromRgbaPixels(
Buffer.alloc(400 * 400 * 4).fill(255),
400,
400
).png();
await request(t.context.app.getHttpServer())
.post(gql)
.auth(token, { type: 'bearer' })
.field(
'operations',
JSON.stringify({
name: 'uploadAvatar',
query: `mutation uploadAvatar($avatar: Upload!) {
uploadAvatar(avatar: $avatar) {
id
name
avatarUrl
email
}
}
`,
variables: { id, avatar: null },
})
)
.field('map', JSON.stringify({ '0': ['variables.avatar'] }))
.attach('0', png, 'avatar.png')
.expect(200)
.expect(res => {
t.is(res.body.data.uploadAvatar.id, id);
});
await request(t.context.app.getHttpServer())
.post(gql)
.auth(token, { type: 'bearer' })
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
.send({
query: `
mutation removeAvatar {
removeAvatar {
success
}
}
`,
})
.expect(200)
.expect(res => {
t.is(res.body.data.removeAvatar.success, true);
});
});
async function createToken(app: INestApplication<Express>): Promise<{
id: string;
token: string;
}> {
let token;
let id;
await request(app.getHttpServer())
.post(gql)
.send({
query: `
mutation {
signIn(email: "alex.yang@example.org", password: "123456") {
id
token {
token
}
}
}
`,
})
.expect(200)
.expect(res => {
id = res.body.data.signIn.id;
ok(
typeof res.body.data.signIn.token.token === 'string',
'res.body.data.signIn.token.token is not a string'
);
token = res.body.data.signIn.token.token;
});
return { token: token!, id: id! };
}

View File

@@ -0,0 +1,134 @@
import {
getCurrentMailMessageCount,
getLatestMailMessage,
} from '@affine-test/kit/utils/cloud';
import type { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import { PrismaClient } from '@prisma/client';
import ava, { TestFn } from 'ava';
import graphqlUploadExpress from 'graphql-upload/graphqlUploadExpress.mjs';
import { AppModule } from '../src/app';
import { MailService } from '../src/modules/auth/mailer';
import { AuthService } from '../src/modules/auth/service';
import {
changeEmail,
createWorkspace,
sendChangeEmail,
sendVerifyChangeEmail,
signUp,
} from './utils';
const test = ava as TestFn<{
app: INestApplication;
client: PrismaClient;
auth: AuthService;
mail: MailService;
}>;
test.beforeEach(async t => {
const client = new PrismaClient();
t.context.client = client;
await client.$connect();
await client.user.deleteMany({});
await client.snapshot.deleteMany({});
await client.update.deleteMany({});
await client.workspace.deleteMany({});
await client.$disconnect();
const module = await Test.createTestingModule({
imports: [AppModule],
}).compile();
const app = module.createNestApplication();
app.use(
graphqlUploadExpress({
maxFileSize: 10 * 1024 * 1024,
maxFiles: 5,
})
);
await app.init();
const auth = module.get(AuthService);
const mail = module.get(MailService);
t.context.app = app;
t.context.auth = auth;
t.context.mail = mail;
});
test.afterEach(async t => {
await t.context.app.close();
});
test('change email', async t => {
const { mail, app } = t.context;
if (mail.hasConfigured()) {
const u1Email = 'u1@affine.pro';
const u2Email = 'u2@affine.pro';
const tokenRegex = /token=3D([^"&\s]+)/;
const u1 = await signUp(app, 'u1', u1Email, '1');
await createWorkspace(app, u1.token.token);
const primitiveMailCount = await getCurrentMailMessageCount();
await sendChangeEmail(app, u1.token.token, u1Email, 'affine.pro');
const afterSendChangeMailCount = await getCurrentMailMessageCount();
t.is(
primitiveMailCount + 1,
afterSendChangeMailCount,
'failed to send change email'
);
const changeEmailContent = await getLatestMailMessage();
const changeTokenMatch = changeEmailContent.Content.Body.match(tokenRegex);
const changeEmailToken = changeTokenMatch
? decodeURIComponent(changeTokenMatch[1].replace(/=3D/g, '='))
: null;
t.not(
changeEmailToken,
null,
'fail to get change email token from email content'
);
await sendVerifyChangeEmail(
app,
u1.token.token,
changeEmailToken as string,
u2Email,
'affine.pro'
);
const afterSendVerifyMailCount = await getCurrentMailMessageCount();
t.is(
afterSendChangeMailCount + 1,
afterSendVerifyMailCount,
'failed to send verify email'
);
const verifyEmailContent = await getLatestMailMessage();
const verifyTokenMatch = verifyEmailContent.Content.Body.match(tokenRegex);
const verifyEmailToken = verifyTokenMatch
? decodeURIComponent(verifyTokenMatch[1].replace(/=3D/g, '='))
: null;
t.not(
verifyEmailToken,
null,
'fail to get verify change email token from email content'
);
await changeEmail(app, u1.token.token, verifyEmailToken as string);
const afterNotificationMailCount = await getCurrentMailMessageCount();
t.is(
afterSendVerifyMailCount + 1,
afterNotificationMailCount,
'failed to send notification email'
);
}
t.pass();
});

View File

@@ -0,0 +1,178 @@
/// <reference types="../src/global.d.ts" />
import { Test, TestingModule } from '@nestjs/testing';
import { PrismaClient } from '@prisma/client';
import test from 'ava';
import { ConfigModule } from '../src/config';
import { GqlModule } from '../src/graphql.module';
import { MetricsModule } from '../src/metrics';
import { AuthModule } from '../src/modules/auth';
import { AuthResolver } from '../src/modules/auth/resolver';
import { AuthService } from '../src/modules/auth/service';
import { PrismaModule } from '../src/prisma';
import { RateLimiterModule } from '../src/throttler';
let authService: AuthService;
let authResolver: AuthResolver;
let module: TestingModule;
// cleanup database before each test
test.beforeEach(async () => {
const client = new PrismaClient();
await client.$connect();
await client.user.deleteMany({});
await client.$disconnect();
});
test.beforeEach(async () => {
module = await Test.createTestingModule({
imports: [
ConfigModule.forRoot({
auth: {
accessTokenExpiresIn: 1,
refreshTokenExpiresIn: 1,
leeway: 1,
},
host: 'example.org',
https: true,
}),
PrismaModule,
GqlModule,
AuthModule,
MetricsModule,
RateLimiterModule,
],
}).compile();
authService = module.get(AuthService);
authResolver = module.get(AuthResolver);
});
test.afterEach.always(async () => {
await module.close();
});
test('should be able to register and signIn', async t => {
await authService.signUp('Alex Yang', 'alexyang@example.org', '123456');
await authService.signIn('alexyang@example.org', '123456');
t.pass();
});
test('should be able to verify', async t => {
await authService.signUp('Alex Yang', 'alexyang@example.org', '123456');
await authService.signIn('alexyang@example.org', '123456');
const date = new Date();
const user = {
id: '1',
name: 'Alex Yang',
email: 'alexyang@example.org',
emailVerified: date,
createdAt: date,
avatarUrl: '',
};
{
const token = await authService.sign(user);
const claim = await authService.verify(token);
t.is(claim.id, '1');
t.is(claim.name, 'Alex Yang');
t.is(claim.email, 'alexyang@example.org');
t.is(claim.emailVerified?.toISOString(), date.toISOString());
t.is(claim.createdAt.toISOString(), date.toISOString());
}
{
const token = await authService.refresh(user);
const claim = await authService.verify(token);
t.is(claim.id, '1');
t.is(claim.name, 'Alex Yang');
t.is(claim.email, 'alexyang@example.org');
t.is(claim.emailVerified?.toISOString(), date.toISOString());
t.is(claim.createdAt.toISOString(), date.toISOString());
}
});
test('should not be able to return token if user is invalid', async t => {
const date = new Date();
const user = {
id: '1',
name: 'Alex Yang',
email: 'alexyang@example.org',
emailVerified: date,
createdAt: date,
avatarUrl: '',
};
const anotherUser = {
id: '2',
name: 'Alex Yang 2',
email: 'alexyang@example.org',
emailVerified: date,
createdAt: date,
avatarUrl: '',
};
await t.throwsAsync(
authResolver.token(
{
req: {
headers: {
referer: 'https://example.org',
host: 'example.org',
},
} as any,
},
user,
anotherUser
),
{
message: 'Invalid user',
}
);
});
test('should not return sessionToken if request headers is invalid', async t => {
const date = new Date();
const user = {
id: '1',
name: 'Alex Yang',
email: 'alexyang@example.org',
emailVerified: date,
createdAt: date,
avatarUrl: '',
};
const result = await authResolver.token(
{
req: {
headers: {},
} as any,
},
user,
user
);
t.is(result.sessionToken, undefined);
});
test('should return valid sessionToken if request headers valid', async t => {
const date = new Date();
const user = {
id: '1',
name: 'Alex Yang',
email: 'alexyang@example.org',
emailVerified: date,
createdAt: date,
avatarUrl: '',
};
const result = await authResolver.token(
{
req: {
headers: {
referer: 'https://example.org/open-app/test',
host: 'example.org',
},
cookies: {
'next-auth.session-token': '123456',
},
} as any,
},
user,
user
);
t.is(result.sessionToken, '123456');
});

View File

@@ -0,0 +1,35 @@
import { Test, TestingModule } from '@nestjs/testing';
import test from 'ava';
import { Config, ConfigModule } from '../src/config';
let config: Config;
let module: TestingModule;
test.beforeEach(async () => {
module = await Test.createTestingModule({
imports: [ConfigModule.forRoot()],
}).compile();
config = module.get(Config);
});
test.afterEach.always(async () => {
await module.close();
});
test('should be able to get config', t => {
t.true(typeof config.host === 'string');
t.is(config.env, 'test');
});
test('should be able to override config', async t => {
const module = await Test.createTestingModule({
imports: [
ConfigModule.forRoot({
host: 'testing',
}),
],
}).compile();
const config = module.get(Config);
t.is(config.host, 'testing');
});

View File

@@ -0,0 +1,160 @@
import { mock } from 'node:test';
import { INestApplication } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import test from 'ava';
import { register } from 'prom-client';
import * as Sinon from 'sinon';
import { Doc as YDoc, encodeStateAsUpdate } from 'yjs';
import { Config, ConfigModule } from '../src/config';
import { MetricsModule } from '../src/metrics';
import { DocManager, DocModule } from '../src/modules/doc';
import { PrismaModule, PrismaService } from '../src/prisma';
import { flushDB } from './utils';
const createModule = () => {
return Test.createTestingModule({
imports: [
PrismaModule,
MetricsModule,
ConfigModule.forRoot(),
DocModule.forRoot(),
],
}).compile();
};
let app: INestApplication;
let m: TestingModule;
let timer: Sinon.SinonFakeTimers;
// cleanup database before each test
test.beforeEach(async () => {
timer = Sinon.useFakeTimers({
toFake: ['setInterval'],
});
await flushDB();
m = await createModule();
app = m.createNestApplication();
app.enableShutdownHooks();
await app.init();
});
test.afterEach.always(async () => {
await app.close();
await m.close();
timer.restore();
});
test('should setup update poll interval', async t => {
register.clear();
const m = await createModule();
const manager = m.get(DocManager);
const fake = mock.method(manager, 'setup');
await m.createNestApplication().init();
t.is(fake.mock.callCount(), 1);
// @ts-expect-error private member
t.truthy(manager.job);
});
test('should be able to stop poll', async t => {
const manager = m.get(DocManager);
const fake = mock.method(manager, 'destroy');
await app.close();
t.is(fake.mock.callCount(), 1);
// @ts-expect-error private member
t.is(manager.job, null);
});
test('should poll when intervel due', async t => {
const manager = m.get(DocManager);
const interval = m.get(Config).doc.manager.updatePollInterval;
let resolve: any;
const fake = mock.method(manager, 'apply', () => {
return new Promise(_resolve => {
resolve = _resolve;
});
});
timer.tick(interval);
t.is(fake.mock.callCount(), 1);
// busy
timer.tick(interval);
// @ts-expect-error private member
t.is(manager.busy, true);
t.is(fake.mock.callCount(), 1);
resolve();
await timer.tickAsync(1);
// @ts-expect-error private member
t.is(manager.busy, false);
timer.tick(interval);
t.is(fake.mock.callCount(), 2);
});
test('should merge update when intervel due', async t => {
const db = m.get(PrismaService);
const manager = m.get(DocManager);
const doc = new YDoc();
const text = doc.getText('content');
text.insert(0, 'hello');
const update = encodeStateAsUpdate(doc);
const ws = await db.workspace.create({
data: {
id: '1',
public: false,
},
});
await db.update.createMany({
data: [
{
id: '1',
workspaceId: '1',
blob: Buffer.from([0, 0]),
},
{
id: '1',
workspaceId: '1',
blob: Buffer.from(update),
},
],
});
await manager.apply();
t.deepEqual(
(await manager.getLatestUpdate(ws.id, '1'))?.toString('hex'),
Buffer.from(update.buffer).toString('hex')
);
let appendUpdate = Buffer.from([]);
doc.on('update', update => {
appendUpdate = Buffer.from(update);
});
text.insert(5, 'world');
await db.update.create({
data: {
workspaceId: ws.id,
id: '1',
blob: appendUpdate,
},
});
await manager.apply();
t.deepEqual(
(await manager.getLatestUpdate(ws.id, '1'))?.toString('hex'),
Buffer.from(encodeStateAsUpdate(doc)).toString('hex')
);
});

View File

@@ -0,0 +1,86 @@
import { Controller, Get, INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import test from 'ava';
import graphqlUploadExpress from 'graphql-upload/graphqlUploadExpress.mjs';
import request from 'supertest';
import { AppModule } from '../src/app';
import { ExceptionLogger } from '../src/middleware/exception-logger';
import { PrismaService } from '../src/prisma';
const gql = '/graphql';
const rest = '/rest';
let app: INestApplication;
class FakePrisma {
get workspace() {
return {
async findUnique() {
throw Error('exception from graphql');
},
};
}
}
@Controller('rest')
export class MockController {
@Get()
test(): string {
throw new Error('exception from rest api');
}
}
test.beforeEach(async () => {
const module = await Test.createTestingModule({
imports: [AppModule],
controllers: [MockController],
})
.overrideProvider(PrismaService)
.useClass(FakePrisma)
.compile();
app = module.createNestApplication({
cors: true,
bodyParser: true,
});
app.useGlobalFilters(new ExceptionLogger());
app.use(
graphqlUploadExpress({
maxFileSize: 10 * 1024 * 1024,
maxFiles: 5,
})
);
await app.init();
});
test.afterEach.always(async () => {
await app.close();
});
test('should get response from graphql', async t => {
const id = 'workspace';
const response = await request(app.getHttpServer())
.post(gql)
.send({
name: 'getPublicWorkspace',
query: `
query getPublicWorkspace($id: String!) {
publicWorkspace(id: $id) {
id
}
}
`,
variables: { id },
});
t.is(response.status, 200);
t.is(response.body.errors[0].message, 'exception from graphql');
});
test('should get response from rest api', async t => {
const response = await request(app.getHttpServer()).get(rest);
t.is(response.status, 500);
t.is(response.body.error, 'exception from rest api');
});

View File

@@ -0,0 +1,78 @@
/// <reference types="../src/global.d.ts" />
// This test case is for testing the mailer service.
// Please use local SMTP server for testing.
// See: https://github.com/mailhog/MailHog
import {
getCurrentMailMessageCount,
getLatestMailMessage,
} from '@affine-test/kit/utils/cloud';
import { Test, TestingModule } from '@nestjs/testing';
import { PrismaClient } from '@prisma/client';
import ava, { TestFn } from 'ava';
import { ConfigModule } from '../src/config';
import { GqlModule } from '../src/graphql.module';
import { MetricsModule } from '../src/metrics';
import { AuthModule } from '../src/modules/auth';
import { AuthService } from '../src/modules/auth/service';
import { PrismaModule } from '../src/prisma';
import { RateLimiterModule } from '../src/throttler';
const test = ava as TestFn<{
auth: AuthService;
module: TestingModule;
skip: boolean;
}>;
// cleanup database before each test
test.beforeEach(async () => {
const client = new PrismaClient();
await client.$connect();
await client.user.deleteMany({});
});
test.beforeEach(async t => {
t.context.module = await Test.createTestingModule({
imports: [
ConfigModule.forRoot({
auth: {
accessTokenExpiresIn: 1,
refreshTokenExpiresIn: 1,
leeway: 1,
},
}),
PrismaModule,
GqlModule,
AuthModule,
MetricsModule,
RateLimiterModule,
],
}).compile();
t.context.auth = t.context.module.get(AuthService);
});
test.afterEach.always(async t => {
await t.context.module.close();
});
test('should include callbackUrl in sending email', async t => {
const { auth } = t.context;
await auth.signUp('Alex Yang', 'alexyang@example.org', '123456');
for (const fn of [
'sendSetPasswordEmail',
'sendChangeEmail',
'sendChangePasswordEmail',
'sendVerifyChangeEmail',
] as const) {
const prev = await getCurrentMailMessageCount();
await auth[fn]('alexyang@example.org', 'https://test.com/callback');
const current = await getCurrentMailMessageCount();
const mail = await getLatestMailMessage();
t.regex(
mail?.Content?.Body,
/https:\/\/test.com\/callback/,
`should include callbackUrl when calling ${fn}`
);
t.is(current, prev + 1, `calling ${fn}`);
}
});

View File

@@ -0,0 +1,158 @@
import { randomUUID } from 'node:crypto';
import type { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import { hashSync } from '@node-rs/argon2';
import { User } from '@prisma/client';
import ava, { TestFn } from 'ava';
import graphqlUploadExpress from 'graphql-upload/graphqlUploadExpress.mjs';
import { AppModule } from '../src/app';
import { MailService } from '../src/modules/auth/mailer';
import { PrismaService } from '../src/prisma';
import { createWorkspace, getInviteInfo, inviteUser, signUp } from './utils';
const FakePrisma = {
fakeUser: {
id: randomUUID(),
name: 'Alex Yang',
avatarUrl: '',
email: 'alex.yang@example.org',
password: hashSync('123456'),
emailVerified: new Date(),
createdAt: new Date(),
} satisfies User,
get user() {
// eslint-disable-next-line @typescript-eslint/no-this-alias
const prisma = this;
return {
async findFirst() {
return null;
},
async create({ data }: any) {
return {
...prisma.fakeUser,
...data,
};
},
async findUnique() {
return prisma.fakeUser;
},
};
},
get workspace() {
return {
id: randomUUID(),
async create({ data }: any) {
return {
id: this.id,
public: data.public ?? false,
createdAt: new Date(),
};
},
};
},
snapshot: {
id: randomUUID(),
async create() {},
async findFirstOrThrow() {
return { id: this.id, blob: Buffer.from([0, 0]) };
},
},
get userWorkspacePermission() {
return {
id: randomUUID(),
prisma: this,
async count() {
return 1;
},
async create() {
return { id: this.id };
},
async findUniqueOrThrow() {
return { id: this.id, workspaceId: this.prisma.workspace.id };
},
async findFirst() {
return { id: this.id };
},
async findFirstOrThrow() {
return { id: this.id, user: this.prisma.fakeUser };
},
async userWorkspacePermission() {
return {
id: randomUUID(),
createdAt: new Date(),
};
},
};
},
};
const test = ava as TestFn<{
app: INestApplication;
mail: MailService;
}>;
test.beforeEach(async t => {
const module = await Test.createTestingModule({
imports: [AppModule],
})
.overrideProvider(PrismaService)
.useValue(FakePrisma)
.compile();
const app = module.createNestApplication();
app.use(
graphqlUploadExpress({
maxFileSize: 10 * 1024 * 1024,
maxFiles: 5,
})
);
await app.init();
const mail = module.get(MailService);
t.context.app = app;
t.context.mail = mail;
});
test.afterEach.always(async t => {
await t.context.app.close();
});
test('should send invite email', async t => {
const { mail, app } = t.context;
if (mail.hasConfigured()) {
const u1 = await signUp(app, 'u1', 'u1@affine.pro', '1');
const u2 = await signUp(app, 'u2', 'u2@affine.pro', '1');
const workspace = await createWorkspace(app, u1.token.token);
const inviteId = await inviteUser(
app,
u1.token.token,
workspace.id,
u2.email,
'Admin'
);
const inviteInfo = await getInviteInfo(app, u1.token.token, inviteId);
const resp = await mail.sendInviteEmail(
'production@toeverything.info',
inviteId,
{
workspace: {
id: inviteInfo.workspace.id,
name: inviteInfo.workspace.name,
avatar: '',
},
user: {
avatar: inviteInfo.user?.avatarUrl || '',
name: inviteInfo.user?.name || '',
},
}
);
t.is(resp.accepted.length, 1, 'failed to send invite email');
}
t.pass();
});

View File

@@ -0,0 +1,79 @@
import { Test, TestingModule } from '@nestjs/testing';
import test from 'ava';
import { register } from 'prom-client';
import { MetricsModule } from '../src/metrics';
import { Metrics } from '../src/metrics/metrics';
import { PrismaModule } from '../src/prisma';
let metrics: Metrics;
let module: TestingModule;
test.beforeEach(async () => {
module = await Test.createTestingModule({
imports: [MetricsModule, PrismaModule],
}).compile();
metrics = module.get(Metrics);
});
test.afterEach.always(async () => {
await module.close();
});
test('should be able to increment counter', async t => {
metrics.socketIOEventCounter(1, { event: 'client-handshake' });
const socketIOCounterMetric =
await register.getSingleMetric('socket_io_counter');
t.truthy(socketIOCounterMetric);
t.truthy(
JSON.stringify((await socketIOCounterMetric!.get()).values) ===
'[{"value":1,"labels":{"event":"client-handshake"}}]'
);
t.pass();
});
test('should be able to timer', async t => {
let minimum: number;
{
const endTimer = metrics.socketIOEventTimer({ event: 'client-handshake' });
const a = performance.now();
await new Promise(resolve => setTimeout(resolve, 50));
const b = performance.now();
minimum = b - a;
endTimer();
}
let maximum: number;
{
const a = performance.now();
const endTimer = metrics.socketIOEventTimer({ event: 'client-handshake' });
await new Promise(resolve => setTimeout(resolve, 100));
endTimer();
const b = performance.now();
maximum = b - a;
}
const socketIOTimerMetric = register.getSingleMetric('socket_io_timer');
t.truthy(socketIOTimerMetric);
const observations = (await socketIOTimerMetric!.get()).values;
for (const observation of observations) {
if (
observation.labels.event === 'client-handshake' &&
'quantile' in observation.labels
) {
t.truthy(
observation.value >= minimum / 1000,
'observation.value should be greater than minimum'
);
t.truthy(
observation.value <= maximum / 1000,
'observation.value should be less than maximum'
);
}
}
t.pass();
});

View File

@@ -0,0 +1,46 @@
/// <reference types="../src/global.d.ts" />
import { Test, TestingModule } from '@nestjs/testing';
import ava, { TestFn } from 'ava';
import { ConfigModule } from '../src/config';
import { SessionModule, SessionService } from '../src/session';
const test = ava as TestFn<{
session: SessionService;
app: TestingModule;
}>;
test.beforeEach(async t => {
const module = await Test.createTestingModule({
imports: [
ConfigModule.forRoot({
redis: {
enabled: false,
},
}),
SessionModule,
],
}).compile();
const session = module.get(SessionService);
t.context.app = module;
t.context.session = session;
});
test.afterEach.always(async t => {
await t.context.app.close();
});
test('should be able to set session', async t => {
const { session } = t.context;
await session.set('test', 'value');
t.is(await session.get('test'), 'value');
});
test('should be expired by ttl', async t => {
const { session } = t.context;
await session.set('test', 'value', 100);
t.is(await session.get('test'), 'value');
await new Promise(resolve => setTimeout(resolve, 500));
t.is(await session.get('test'), undefined);
});

View File

@@ -0,0 +1,21 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"composite": true,
"target": "ESNext",
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"rootDir": ".",
"outDir": "../dist/tests"
},
"references": [
{
"path": "../tsconfig.json"
},
{
"path": "../../../tests/kit/tsconfig.json"
}
],
"include": ["."],
"exclude": []
}

View File

@@ -0,0 +1,72 @@
import type { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import { PrismaClient } from '@prisma/client';
import test from 'ava';
import graphqlUploadExpress from 'graphql-upload/graphqlUploadExpress.mjs';
import request from 'supertest';
import { AppModule } from '../src/app';
import { currentUser, signUp } from './utils';
let app: INestApplication;
// cleanup database before each test
test.beforeEach(async () => {
const client = new PrismaClient();
await client.$connect();
await client.user.deleteMany({});
await client.$disconnect();
});
test.beforeEach(async () => {
const module = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = module.createNestApplication();
app.use(
graphqlUploadExpress({
maxFileSize: 10 * 1024 * 1024,
maxFiles: 5,
})
);
await app.init();
});
test.afterEach.always(async () => {
await app.close();
});
test('should register a user', async t => {
const user = await signUp(app, 'u1', 'u1@affine.pro', '123456');
t.is(typeof user.id, 'string', 'user.id is not a string');
t.is(user.name, 'u1', 'user.name is not valid');
t.is(user.email, 'u1@affine.pro', 'user.email is not valid');
});
test('should get current user', async t => {
const user = await signUp(app, 'u1', 'u1@affine.pro', '123456');
const currUser = await currentUser(app, user.token.token);
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.email, user.email, 'user.email is not valid');
t.true(currUser.hasPassword, 'currUser.hasPassword is not valid');
});
test('should be able to delete user', async t => {
const user = await signUp(app, 'u1', 'u1@affine.pro', '123456');
await request(app.getHttpServer())
.post('/graphql')
.auth(user.token.token, { type: 'bearer' })
.send({
query: `
mutation {
deleteAccount {
success
}
}
`,
})
.expect(200);
await t.throwsAsync(() => currentUser(app, user.token.token));
t.pass();
});

598
apps/server/tests/utils.ts Normal file
View File

@@ -0,0 +1,598 @@
import { randomUUID } from 'node:crypto';
import type { INestApplication } from '@nestjs/common';
import { hashSync } from '@node-rs/argon2';
import { PrismaClient, User } from '@prisma/client';
import request from 'supertest';
import type { TokenType } from '../src/modules/auth';
import type { UserType } from '../src/modules/users';
import type { InvitationType, WorkspaceType } from '../src/modules/workspaces';
const gql = '/graphql';
async function signUp(
app: INestApplication,
name: string,
email: string,
password: string
): Promise<UserType & { token: TokenType }> {
const res = await request(app.getHttpServer())
.post(gql)
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
.send({
query: `
mutation {
signUp(name: "${name}", email: "${email}", password: "${password}") {
id, name, email, token { token }
}
}
`,
})
.expect(200);
return res.body.data.signUp;
}
async function currentUser(app: INestApplication, token: string) {
const res = await request(app.getHttpServer())
.post(gql)
.auth(token, { type: 'bearer' })
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
.send({
query: `
query {
currentUser {
id, name, email, emailVerified, avatarUrl, createdAt, hasPassword,
token { token }
}
}
`,
})
.expect(200);
return res.body.data.currentUser;
}
async function createWorkspace(
app: INestApplication,
token: string
): Promise<WorkspaceType> {
const res = await request(app.getHttpServer())
.post(gql)
.auth(token, { type: 'bearer' })
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
.field(
'operations',
JSON.stringify({
name: 'createWorkspace',
query: `mutation createWorkspace($init: Upload!) {
createWorkspace(init: $init) {
id
}
}`,
variables: { init: null },
})
)
.field('map', JSON.stringify({ '0': ['variables.init'] }))
.attach('0', Buffer.from([0, 0]), 'init.data')
.expect(200);
return res.body.data.createWorkspace;
}
export async function getWorkspaceSharedPages(
app: INestApplication,
token: string,
workspaceId: string
): Promise<string[]> {
const res = await request(app.getHttpServer())
.post(gql)
.auth(token, { type: 'bearer' })
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
.send({
query: `
query {
workspace(id: "${workspaceId}") {
sharedPages
}
}
`,
})
.expect(200);
return res.body.data.workspace.sharedPages;
}
async function getWorkspace(
app: INestApplication,
token: string,
workspaceId: string,
skip = 0,
take = 8
): Promise<WorkspaceType> {
const res = await request(app.getHttpServer())
.post(gql)
.auth(token, { type: 'bearer' })
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
.send({
query: `
query {
workspace(id: "${workspaceId}") {
id, members(skip: ${skip}, take: ${take}) { id, name, email, permission, inviteId }
}
}
`,
})
.expect(200);
return res.body.data.workspace;
}
async function getPublicWorkspace(
app: INestApplication,
workspaceId: string
): Promise<WorkspaceType> {
const res = await request(app.getHttpServer())
.post(gql)
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
.send({
query: `
query {
publicWorkspace(id: "${workspaceId}") {
id
}
}
`,
})
.expect(200);
return res.body.data.publicWorkspace;
}
async function updateWorkspace(
app: INestApplication,
token: string,
workspaceId: string,
isPublic: boolean
): Promise<boolean> {
const res = await request(app.getHttpServer())
.post(gql)
.auth(token, { type: 'bearer' })
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
.send({
query: `
mutation {
updateWorkspace(input: { id: "${workspaceId}", public: ${isPublic} }) {
public
}
}
`,
})
.expect(200);
return res.body.data.updateWorkspace.public;
}
async function inviteUser(
app: INestApplication,
token: string,
workspaceId: string,
email: string,
permission: string,
sendInviteMail = false
): Promise<string> {
const res = await request(app.getHttpServer())
.post(gql)
.auth(token, { type: 'bearer' })
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
.send({
query: `
mutation {
invite(workspaceId: "${workspaceId}", email: "${email}", permission: ${permission}, sendInviteMail: ${sendInviteMail})
}
`,
})
.expect(200);
return res.body.data.invite;
}
async function acceptInviteById(
app: INestApplication,
workspaceId: string,
inviteId: string,
sendAcceptMail = false
): Promise<boolean> {
const res = await request(app.getHttpServer())
.post(gql)
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
.send({
query: `
mutation {
acceptInviteById(workspaceId: "${workspaceId}", inviteId: "${inviteId}", sendAcceptMail: ${sendAcceptMail})
}
`,
})
.expect(200);
return res.body.data.acceptInviteById;
}
async function acceptInvite(
app: INestApplication,
token: string,
workspaceId: string
): Promise<boolean> {
const res = await request(app.getHttpServer())
.post(gql)
.auth(token, { type: 'bearer' })
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
.send({
query: `
mutation {
acceptInvite(workspaceId: "${workspaceId}")
}
`,
})
.expect(200);
return res.body.data.acceptInvite;
}
async function leaveWorkspace(
app: INestApplication,
token: string,
workspaceId: string,
sendLeaveMail = false
): Promise<boolean> {
const res = await request(app.getHttpServer())
.post(gql)
.auth(token, { type: 'bearer' })
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
.send({
query: `
mutation {
leaveWorkspace(workspaceId: "${workspaceId}", workspaceName: "test workspace", sendLeaveMail: ${sendLeaveMail})
}
`,
})
.expect(200);
return res.body.data.leaveWorkspace;
}
async function revokeUser(
app: INestApplication,
token: string,
workspaceId: string,
userId: string
): Promise<boolean> {
const res = await request(app.getHttpServer())
.post(gql)
.auth(token, { type: 'bearer' })
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
.send({
query: `
mutation {
revoke(workspaceId: "${workspaceId}", userId: "${userId}")
}
`,
})
.expect(200);
return res.body.data.revoke;
}
async function sharePage(
app: INestApplication,
token: string,
workspaceId: string,
pageId: string
): Promise<boolean | string> {
const res = await request(app.getHttpServer())
.post(gql)
.auth(token, { type: 'bearer' })
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
.send({
query: `
mutation {
sharePage(workspaceId: "${workspaceId}", pageId: "${pageId}")
}
`,
})
.expect(200);
return res.body.errors?.[0]?.message || res.body.data?.sharePage;
}
async function revokePage(
app: INestApplication,
token: string,
workspaceId: string,
pageId: string
): Promise<boolean | string> {
const res = await request(app.getHttpServer())
.post(gql)
.auth(token, { type: 'bearer' })
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
.send({
query: `
mutation {
revokePage(workspaceId: "${workspaceId}", pageId: "${pageId}")
}
`,
})
.expect(200);
return res.body.errors?.[0]?.message || res.body.data?.revokePage;
}
async function listBlobs(
app: INestApplication,
token: string,
workspaceId: string
): Promise<string[]> {
const res = await request(app.getHttpServer())
.post(gql)
.auth(token, { type: 'bearer' })
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
.send({
query: `
query {
listBlobs(workspaceId: "${workspaceId}")
}
`,
})
.expect(200);
return res.body.data.listBlobs;
}
async function collectBlobSizes(
app: INestApplication,
token: string,
workspaceId: string
): Promise<number> {
const res = await request(app.getHttpServer())
.post(gql)
.auth(token, { type: 'bearer' })
.send({
query: `
query {
collectBlobSizes(workspaceId: "${workspaceId}") {
size
}
}
`,
})
.expect(200);
return res.body.data.collectBlobSizes.size;
}
async function collectAllBlobSizes(
app: INestApplication,
token: string
): Promise<number> {
const res = await request(app.getHttpServer())
.post(gql)
.auth(token, { type: 'bearer' })
.send({
query: `
query {
collectAllBlobSizes {
size
}
}
`,
})
.expect(200);
return res.body.data.collectAllBlobSizes.size;
}
async function checkBlobSize(
app: INestApplication,
token: string,
workspaceId: string,
size: number
): Promise<number> {
const res = await request(app.getHttpServer())
.post(gql)
.auth(token, { type: 'bearer' })
.send({
query: `query checkBlobSize($workspaceId: String!, $size: Float!) {
checkBlobSize(workspaceId: $workspaceId, size: $size) {
size
}
}`,
variables: { workspaceId, size },
})
.expect(200);
return res.body.data.checkBlobSize.size;
}
async function setBlob(
app: INestApplication,
token: string,
workspaceId: string,
buffer: Buffer
): Promise<string> {
const res = await request(app.getHttpServer())
.post(gql)
.auth(token, { type: 'bearer' })
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
.field(
'operations',
JSON.stringify({
name: 'setBlob',
query: `mutation setBlob($blob: Upload!) {
setBlob(workspaceId: "${workspaceId}", blob: $blob)
}`,
variables: { blob: null },
})
)
.field('map', JSON.stringify({ '0': ['variables.blob'] }))
.attach('0', buffer, 'blob.data')
.expect(200);
return res.body.data.setBlob;
}
async function flushDB() {
const client = new PrismaClient();
await client.$connect();
const result: { tablename: string }[] =
await client.$queryRaw`SELECT tablename
FROM pg_catalog.pg_tables
WHERE schemaname != 'pg_catalog'
AND schemaname != 'information_schema'`;
// remove all table data
await client.$executeRawUnsafe(
`TRUNCATE TABLE ${result
.map(({ tablename }) => tablename)
.filter(name => !name.includes('migrations'))
.join(', ')}`
);
await client.$disconnect();
}
async function getInviteInfo(
app: INestApplication,
token: string,
inviteId: string
): Promise<InvitationType> {
const res = await request(app.getHttpServer())
.post(gql)
.auth(token, { type: 'bearer' })
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
.send({
query: `
query {
getInviteInfo(inviteId: "${inviteId}") {
workspace {
id
name
avatar
}
user {
id
name
avatarUrl
}
}
}
`,
})
.expect(200);
return res.body.data.getInviteInfo;
}
async function sendChangeEmail(
app: INestApplication,
userToken: string,
email: string,
callbackUrl: string
): Promise<boolean> {
const res = await request(app.getHttpServer())
.post(gql)
.auth(userToken, { type: 'bearer' })
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
.send({
query: `
mutation {
sendChangeEmail(email: "${email}", callbackUrl: "${callbackUrl}")
}
`,
})
.expect(200);
return res.body.data.sendChangeEmail;
}
async function sendVerifyChangeEmail(
app: INestApplication,
userToken: string,
token: string,
email: string,
callbackUrl: string
): Promise<boolean> {
const res = await request(app.getHttpServer())
.post(gql)
.auth(userToken, { type: 'bearer' })
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
.send({
query: `
mutation {
sendVerifyChangeEmail(token:"${token}", email: "${email}", callbackUrl: "${callbackUrl}")
}
`,
})
.expect(200);
return res.body.data.sendVerifyChangeEmail;
}
async function changeEmail(
app: INestApplication,
userToken: string,
token: string
): Promise<UserType & { token: TokenType }> {
const res = await request(app.getHttpServer())
.post(gql)
.auth(userToken, { type: 'bearer' })
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
.send({
query: `
mutation {
changeEmail(token: "${token}") {
id
name
avatarUrl
email
}
}
`,
})
.expect(200);
return res.body.data.changeEmail;
}
export class FakePrisma {
fakeUser: User = {
id: randomUUID(),
name: 'Alex Yang',
avatarUrl: '',
email: 'alex.yang@example.org',
password: hashSync('123456'),
emailVerified: new Date(),
createdAt: new Date(),
};
get user() {
// eslint-disable-next-line @typescript-eslint/no-this-alias
const prisma = this;
return {
async findFirst() {
return prisma.fakeUser;
},
async findUnique() {
return this.findFirst();
},
async update() {
return this.findFirst();
},
};
}
}
export {
acceptInvite,
acceptInviteById,
changeEmail,
checkBlobSize,
collectAllBlobSizes,
collectBlobSizes,
createWorkspace,
currentUser,
flushDB,
getInviteInfo,
getPublicWorkspace,
getWorkspace,
inviteUser,
leaveWorkspace,
listBlobs,
revokePage,
revokeUser,
sendChangeEmail,
sendVerifyChangeEmail,
setBlob,
sharePage,
signUp,
updateWorkspace,
};

View File

@@ -0,0 +1,145 @@
import type { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import { PrismaClient } from '@prisma/client';
import test from 'ava';
import graphqlUploadExpress from 'graphql-upload/graphqlUploadExpress.mjs';
import request from 'supertest';
import { AppModule } from '../src/app';
import {
checkBlobSize,
collectAllBlobSizes,
collectBlobSizes,
createWorkspace,
listBlobs,
setBlob,
signUp,
} from './utils';
let app: INestApplication;
const client = new PrismaClient();
// cleanup database before each test
test.beforeEach(async () => {
await client.$connect();
await client.user.deleteMany({});
await client.snapshot.deleteMany({});
await client.update.deleteMany({});
await client.workspace.deleteMany({});
await client.$disconnect();
});
test.beforeEach(async () => {
const module = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = module.createNestApplication();
app.use(
graphqlUploadExpress({
maxFileSize: 10 * 1024 * 1024,
maxFiles: 5,
})
);
await app.init();
});
test.afterEach.always(async () => {
await app.close();
});
test('should set blobs', async t => {
const u1 = await signUp(app, 'u1', 'u1@affine.pro', '1');
const workspace = await createWorkspace(app, u1.token.token);
const buffer1 = Buffer.from([0, 0]);
const hash1 = await setBlob(app, u1.token.token, workspace.id, buffer1);
const buffer2 = Buffer.from([0, 1]);
const hash2 = await setBlob(app, u1.token.token, workspace.id, buffer2);
const server = app.getHttpServer();
const response1 = await request(server)
.get(`/api/workspaces/${workspace.id}/blobs/${hash1}`)
.auth(u1.token.token, { type: 'bearer' })
.buffer();
t.deepEqual(response1.body, buffer1, 'failed to get blob');
const response2 = await request(server)
.get(`/api/workspaces/${workspace.id}/blobs/${hash2}`)
.auth(u1.token.token, { type: 'bearer' })
.buffer();
t.deepEqual(response2.body, buffer2, 'failed to get blob');
});
test('should list blobs', async t => {
const u1 = await signUp(app, 'u1', 'u1@affine.pro', '1');
const workspace = await createWorkspace(app, u1.token.token);
const blobs = await listBlobs(app, u1.token.token, workspace.id);
t.is(blobs.length, 0, 'failed to list blobs');
const buffer1 = Buffer.from([0, 0]);
const hash1 = await setBlob(app, u1.token.token, workspace.id, buffer1);
const buffer2 = Buffer.from([0, 1]);
const hash2 = await setBlob(app, u1.token.token, workspace.id, buffer2);
const ret = await listBlobs(app, u1.token.token, workspace.id);
t.is(ret.length, 2, 'failed to list blobs');
t.is(ret[0], hash1, 'failed to list blobs');
t.is(ret[1], hash2, 'failed to list blobs');
});
test('should calc blobs size', async t => {
const u1 = await signUp(app, 'u1', 'u1@affine.pro', '1');
const workspace = await createWorkspace(app, u1.token.token);
const buffer1 = Buffer.from([0, 0]);
await setBlob(app, u1.token.token, workspace.id, buffer1);
const buffer2 = Buffer.from([0, 1]);
await setBlob(app, u1.token.token, workspace.id, buffer2);
const size = await collectBlobSizes(app, u1.token.token, workspace.id);
t.is(size, 4, 'failed to collect blob sizes');
});
test('should calc all blobs size', async t => {
const u1 = await signUp(app, 'u1', 'u1@affine.pro', '1');
const workspace1 = await createWorkspace(app, u1.token.token);
const buffer1 = Buffer.from([0, 0]);
await setBlob(app, u1.token.token, workspace1.id, buffer1);
const buffer2 = Buffer.from([0, 1]);
await setBlob(app, u1.token.token, workspace1.id, buffer2);
const workspace2 = await createWorkspace(app, u1.token.token);
const buffer3 = Buffer.from([0, 0]);
await setBlob(app, u1.token.token, workspace2.id, buffer3);
const buffer4 = Buffer.from([0, 1]);
await setBlob(app, u1.token.token, workspace2.id, buffer4);
const size = await collectAllBlobSizes(app, u1.token.token);
t.is(size, 8, 'failed to collect all blob sizes');
const size1 = await checkBlobSize(
app,
u1.token.token,
workspace1.id,
10 * 1024 * 1024 * 1024 - 8
);
t.is(size1, 0, 'failed to check blob size');
const size2 = await checkBlobSize(
app,
u1.token.token,
workspace1.id,
10 * 1024 * 1024 * 1024 - 7
);
t.is(size2, -1, 'failed to check blob size');
});

View File

@@ -0,0 +1,278 @@
import {
getCurrentMailMessageCount,
getLatestMailMessage,
} from '@affine-test/kit/utils/cloud';
import type { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import { PrismaClient } from '@prisma/client';
import ava, { TestFn } from 'ava';
import graphqlUploadExpress from 'graphql-upload/graphqlUploadExpress.mjs';
import { AppModule } from '../src/app';
import { MailService } from '../src/modules/auth/mailer';
import { AuthService } from '../src/modules/auth/service';
import {
acceptInvite,
acceptInviteById,
createWorkspace,
getWorkspace,
inviteUser,
leaveWorkspace,
revokeUser,
signUp,
} from './utils';
const test = ava as TestFn<{
app: INestApplication;
client: PrismaClient;
auth: AuthService;
mail: MailService;
}>;
test.beforeEach(async t => {
const client = new PrismaClient();
t.context.client = client;
await client.$connect();
await client.user.deleteMany({});
await client.snapshot.deleteMany({});
await client.update.deleteMany({});
await client.workspace.deleteMany({});
await client.$disconnect();
const module = await Test.createTestingModule({
imports: [AppModule],
}).compile();
const app = module.createNestApplication();
app.use(
graphqlUploadExpress({
maxFileSize: 10 * 1024 * 1024,
maxFiles: 5,
})
);
await app.init();
const auth = module.get(AuthService);
const mail = module.get(MailService);
t.context.app = app;
t.context.auth = auth;
t.context.mail = mail;
});
test.afterEach.always(async t => {
await t.context.app.close();
});
test('should invite a user', async t => {
const { app } = t.context;
const u1 = await signUp(app, 'u1', 'u1@affine.pro', '1');
const u2 = await signUp(app, 'u2', 'u2@affine.pro', '1');
const workspace = await createWorkspace(app, u1.token.token);
const invite = await inviteUser(
app,
u1.token.token,
workspace.id,
u2.email,
'Admin'
);
t.truthy(invite, 'failed to invite user');
});
test('should accept an invite', async t => {
const { app } = t.context;
const u1 = await signUp(app, 'u1', 'u1@affine.pro', '1');
const u2 = await signUp(app, 'u2', 'u2@affine.pro', '1');
const workspace = await createWorkspace(app, u1.token.token);
await inviteUser(app, u1.token.token, workspace.id, u2.email, 'Admin');
const accept = await acceptInvite(app, u2.token.token, workspace.id);
t.is(accept, true, 'failed to accept invite');
const currWorkspace = await getWorkspace(app, u1.token.token, workspace.id);
const currMember = currWorkspace.members.find(u => u.email === u2.email);
t.not(currMember, undefined, 'failed to invite user');
t.is(currMember!.id, u2.id, 'failed to invite user');
t.true(!currMember!.accepted, 'failed to invite user');
t.pass();
});
test('should leave a workspace', async t => {
const { app } = t.context;
const u1 = await signUp(app, 'u1', 'u1@affine.pro', '1');
const u2 = await signUp(app, 'u2', 'u2@affine.pro', '1');
const workspace = await createWorkspace(app, u1.token.token);
await inviteUser(app, u1.token.token, workspace.id, u2.email, 'Admin');
await acceptInvite(app, u2.token.token, workspace.id);
const leave = await leaveWorkspace(app, u2.token.token, workspace.id);
t.pass();
t.true(leave, 'failed to leave workspace');
});
test('should revoke a user', async t => {
const { app } = t.context;
const u1 = await signUp(app, 'u1', 'u1@affine.pro', '1');
const u2 = await signUp(app, 'u2', 'u2@affine.pro', '1');
const workspace = await createWorkspace(app, u1.token.token);
await inviteUser(app, u1.token.token, workspace.id, u2.email, 'Admin');
const currWorkspace = await getWorkspace(app, u1.token.token, workspace.id);
t.is(currWorkspace.members.length, 2, 'failed to invite user');
const revoke = await revokeUser(app, u1.token.token, workspace.id, u2.id);
t.true(revoke, 'failed to revoke user');
});
test('should create user if not exist', async t => {
const { app, auth } = t.context;
const u1 = await signUp(app, 'u1', 'u1@affine.pro', '1');
const workspace = await createWorkspace(app, u1.token.token);
await inviteUser(app, u1.token.token, workspace.id, 'u2@affine.pro', 'Admin');
const user = await auth.getUserByEmail('u2@affine.pro');
t.not(user, undefined, 'failed to create user');
t.is(user?.name, 'Unnamed', 'failed to create user');
});
test('should invite a user by link', async t => {
const { app } = t.context;
const u1 = await signUp(app, 'u1', 'u1@affine.pro', '1');
const u2 = await signUp(app, 'u2', 'u2@affine.pro', '1');
const workspace = await createWorkspace(app, u1.token.token);
const invite = await inviteUser(
app,
u1.token.token,
workspace.id,
u2.email,
'Admin'
);
const accept = await acceptInviteById(app, workspace.id, invite);
t.true(accept, 'failed to accept invite');
const invite1 = await inviteUser(
app,
u1.token.token,
workspace.id,
u2.email,
'Admin'
);
t.is(invite, invite1, 'repeat the invitation must return same id');
const currWorkspace = await getWorkspace(app, u1.token.token, workspace.id);
const currMember = currWorkspace.members.find(u => u.email === u2.email);
t.not(currMember, undefined, 'failed to invite user');
t.is(currMember?.inviteId, invite, 'failed to check invite id');
});
test('should send email', async t => {
const { mail, app } = t.context;
if (mail.hasConfigured()) {
const u1 = await signUp(app, 'u1', 'u1@affine.pro', '1');
const u2 = await signUp(app, 'test', 'production@toeverything.info', '1');
const workspace = await createWorkspace(app, u1.token.token);
const primitiveMailCount = await getCurrentMailMessageCount();
const invite = await inviteUser(
app,
u1.token.token,
workspace.id,
u2.email,
'Admin',
true
);
const afterInviteMailCount = await getCurrentMailMessageCount();
t.is(
primitiveMailCount + 1,
afterInviteMailCount,
'failed to send invite email'
);
const inviteEmailContent = await getLatestMailMessage();
t.not(
inviteEmailContent.To.find((item: any) => {
return item.Mailbox === 'production';
}),
undefined,
'invite email address was incorrectly sent'
);
const accept = await acceptInviteById(app, workspace.id, invite, true);
t.true(accept, 'failed to accept invite');
const afterAcceptMailCount = await getCurrentMailMessageCount();
t.is(
afterInviteMailCount + 1,
afterAcceptMailCount,
'failed to send accepted email to owner'
);
const acceptEmailContent = await getLatestMailMessage();
t.not(
acceptEmailContent.To.find((item: any) => {
return item.Mailbox === 'u1';
}),
undefined,
'accept email address was incorrectly sent'
);
await leaveWorkspace(app, u2.token.token, workspace.id, true);
const afterLeaveMailCount = await getCurrentMailMessageCount();
t.is(
afterAcceptMailCount + 1,
afterLeaveMailCount,
'failed to send leave email to owner'
);
const leaveEmailContent = await getLatestMailMessage();
t.not(
leaveEmailContent.To.find((item: any) => {
return item.Mailbox === 'u1';
}),
undefined,
'leave email address was incorrectly sent'
);
}
t.pass();
});
test('should support pagination for member', async t => {
const { app } = t.context;
const u1 = await signUp(app, 'u1', 'u1@affine.pro', '1');
const u2 = await signUp(app, 'u2', 'u2@affine.pro', '1');
const u3 = await signUp(app, 'u3', 'u3@affine.pro', '1');
const workspace = await createWorkspace(app, u1.token.token);
await inviteUser(app, u1.token.token, workspace.id, u2.email, 'Admin');
await inviteUser(app, u1.token.token, workspace.id, u3.email, 'Admin');
await acceptInvite(app, u2.token.token, workspace.id);
await acceptInvite(app, u3.token.token, workspace.id);
const firstPageWorkspace = await getWorkspace(
app,
u1.token.token,
workspace.id,
0,
2
);
t.is(firstPageWorkspace.members.length, 2, 'failed to check invite id');
const secondPageWorkspace = await getWorkspace(
app,
u1.token.token,
workspace.id,
2,
2
);
t.is(secondPageWorkspace.members.length, 1, 'failed to check invite id');
});

View File

@@ -0,0 +1,76 @@
import type { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import ava, { TestFn } from 'ava';
import { stub } from 'sinon';
import { AppModule } from '../src/app';
import { UsersService } from '../src/modules/users';
import { PermissionService } from '../src/modules/workspaces/permission';
import { WorkspaceResolver } from '../src/modules/workspaces/resolver';
import { PrismaService } from '../src/prisma';
import { StorageProvide } from '../src/storage';
import { FakePrisma } from './utils';
class FakePermission {
async tryCheck() {
return true;
}
async getWorkspaceOwner() {
return {
user: new FakePrisma().fakeUser,
};
}
}
const fakeUserService = {
getStorageQuotaById: stub(),
};
const test = ava as TestFn<{
app: INestApplication;
resolver: WorkspaceResolver;
}>;
test.beforeEach(async t => {
const module = await Test.createTestingModule({
imports: [AppModule],
})
.overrideProvider(PrismaService)
.useValue({
userWorkspacePermission: {
async findMany() {
return [];
},
},
})
.overrideProvider(PermissionService)
.useClass(FakePermission)
.overrideProvider(UsersService)
.useValue(fakeUserService)
.overrideProvider(StorageProvide)
.useValue({
blobsSize() {
return 1024 * 10;
},
})
.compile();
t.context.app = module.createNestApplication();
t.context.resolver = t.context.app.get(WorkspaceResolver);
await t.context.app.init();
});
test.afterEach.always(async t => {
await t.context.app.close();
});
test('should get blob size limit', async t => {
const { resolver } = t.context;
fakeUserService.getStorageQuotaById.returns(
Promise.resolve(100 * 1024 * 1024 * 1024)
);
const res = await resolver.checkBlobSize(new FakePrisma().fakeUser, '', 100);
t.not(res, false);
// @ts-expect-error
t.is(typeof res.size, 'number');
fakeUserService.getStorageQuotaById.reset();
});

View File

@@ -0,0 +1,234 @@
import type { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import { PrismaClient } from '@prisma/client';
import ava, { TestFn } from 'ava';
import graphqlUploadExpress from 'graphql-upload/graphqlUploadExpress.mjs';
import request from 'supertest';
import { AppModule } from '../src/app';
import {
acceptInvite,
createWorkspace,
currentUser,
getPublicWorkspace,
getWorkspaceSharedPages,
inviteUser,
revokePage,
sharePage,
signUp,
updateWorkspace,
} from './utils';
const test = ava as TestFn<{
app: INestApplication;
client: PrismaClient;
}>;
test.beforeEach(async t => {
const client = new PrismaClient();
await client.$connect();
await client.user.deleteMany({});
await client.update.deleteMany({});
await client.snapshot.deleteMany({});
await client.workspace.deleteMany({});
await client.$disconnect();
const module = await Test.createTestingModule({
imports: [AppModule],
}).compile();
const app = module.createNestApplication();
app.use(
graphqlUploadExpress({
maxFileSize: 10 * 1024 * 1024,
maxFiles: 5,
})
);
await app.init();
t.context.client = client;
t.context.app = app;
});
test.afterEach.always(async t => {
await t.context.app.close();
});
test('should register a user', async t => {
const user = await signUp(t.context.app, 'u1', 'u1@affine.pro', '123456');
t.is(typeof user.id, 'string', 'user.id is not a string');
t.is(user.name, 'u1', 'user.name is not valid');
t.is(user.email, 'u1@affine.pro', 'user.email is not valid');
});
test.skip('should be throttled at call signUp', async t => {
const { app } = t.context;
let token = '';
for (let i = 0; i < 10; i++) {
token = (await signUp(app, `u${i}`, `u${i}@affine.pro`, `${i}`)).token
.token;
// throttles are applied to each endpoint separately
await currentUser(app, token);
}
await t.throwsAsync(() => signUp(app, 'u11', 'u11@affine.pro', '11'));
await t.throwsAsync(() => currentUser(app, token));
});
test('should create a workspace', async t => {
const { app } = t.context;
const user = await signUp(app, 'u1', 'u1@affine.pro', '1');
const workspace = await createWorkspace(app, user.token.token);
t.is(typeof workspace.id, 'string', 'workspace.id is not a string');
});
test('should can publish workspace', async t => {
const { app } = t.context;
const user = await signUp(app, 'u1', 'u1@affine.pro', '1');
const workspace = await createWorkspace(app, user.token.token);
const isPublic = await updateWorkspace(
app,
user.token.token,
workspace.id,
true
);
t.true(isPublic, 'failed to publish workspace');
const isPrivate = await updateWorkspace(
app,
user.token.token,
workspace.id,
false
);
t.false(isPrivate, 'failed to unpublish workspace');
});
test('should can read published workspace', async t => {
const { app } = t.context;
const user = await signUp(app, 'u1', 'u1@affine.pro', '1');
const workspace = await createWorkspace(app, user.token.token);
await t.throwsAsync(() => getPublicWorkspace(app, 'not_exists_ws'));
await t.throwsAsync(() => getPublicWorkspace(app, workspace.id));
await updateWorkspace(app, user.token.token, workspace.id, true);
const publicWorkspace = await getPublicWorkspace(app, workspace.id);
t.is(publicWorkspace.id, workspace.id, 'failed to get public workspace');
});
test('should share a page', async t => {
const { app } = t.context;
const u1 = await signUp(app, 'u1', 'u1@affine.pro', '1');
const u2 = await signUp(app, 'u2', 'u2@affine.pro', '1');
const workspace = await createWorkspace(app, u1.token.token);
const share = await sharePage(app, u1.token.token, workspace.id, 'page1');
t.true(share, 'failed to share page');
const pages = await getWorkspaceSharedPages(
app,
u1.token.token,
workspace.id
);
t.is(pages.length, 1, 'failed to get shared pages');
t.is(pages[0], 'page1', 'failed to get shared page: page1');
const msg1 = await sharePage(app, u2.token.token, 'not_exists_ws', 'page2');
t.is(msg1, 'Permission denied', 'unauthorized user can share page');
const msg2 = await revokePage(app, u2.token.token, 'not_exists_ws', 'page2');
t.is(msg2, 'Permission denied', 'unauthorized user can share page');
await inviteUser(app, u1.token.token, workspace.id, u2.email, 'Admin');
await acceptInvite(app, u2.token.token, workspace.id);
const invited = await sharePage(app, u2.token.token, workspace.id, 'page2');
t.true(invited, 'failed to share page');
const revoke = await revokePage(app, u1.token.token, workspace.id, 'page1');
t.true(revoke, 'failed to revoke page');
const pages2 = await getWorkspaceSharedPages(
app,
u1.token.token,
workspace.id
);
t.is(pages2.length, 1, 'failed to get shared pages');
t.is(pages2[0], 'page2', 'failed to get shared page: page2');
const msg3 = await revokePage(app, u1.token.token, workspace.id, 'page3');
t.false(msg3, 'can revoke non-exists page');
const msg4 = await revokePage(app, u1.token.token, workspace.id, 'page2');
t.true(msg4, 'failed to revoke page');
const page3 = await getWorkspaceSharedPages(
app,
u1.token.token,
workspace.id
);
t.is(page3.length, 0, 'failed to get shared pages');
});
test('should can get workspace doc', async t => {
const { app } = t.context;
const u1 = await signUp(app, 'u1', 'u1@affine.pro', '1');
const u2 = await signUp(app, 'u2', 'u2@affine.pro', '2');
const workspace = await createWorkspace(app, u1.token.token);
const res1 = await request(app.getHttpServer())
.get(`/api/workspaces/${workspace.id}/docs/${workspace.id}`)
.auth(u1.token.token, { type: 'bearer' })
.expect(200)
.type('application/octet-stream');
t.deepEqual(
res1.body,
Buffer.from([0, 0]),
'failed to get doc with u1 token'
);
await request(app.getHttpServer())
.get(`/api/workspaces/${workspace.id}/docs/${workspace.id}`)
.expect(403);
await request(app.getHttpServer())
.get(`/api/workspaces/${workspace.id}/docs/${workspace.id}`)
.auth(u2.token.token, { type: 'bearer' })
.expect(403);
await inviteUser(app, u1.token.token, workspace.id, u2.email, 'Admin');
await request(app.getHttpServer())
.get(`/api/workspaces/${workspace.id}/docs/${workspace.id}`)
.auth(u2.token.token, { type: 'bearer' })
.expect(403);
await acceptInvite(app, u2.token.token, workspace.id);
const res2 = await request(app.getHttpServer())
.get(`/api/workspaces/${workspace.id}/docs/${workspace.id}`)
.auth(u2.token.token, { type: 'bearer' })
.expect(200)
.type('application/octet-stream');
t.deepEqual(
res2.body,
Buffer.from([0, 0]),
'failed to get doc with u2 token'
);
});
test('should be able to get public workspace doc', async t => {
const { app } = t.context;
const user = await signUp(app, 'u1', 'u1@affine.pro', '1');
const workspace = await createWorkspace(app, user.token.token);
const isPublic = await updateWorkspace(
app,
user.token.token,
workspace.id,
true
);
t.true(isPublic, 'failed to publish workspace');
const res = await request(app.getHttpServer())
.get(`/api/workspaces/${workspace.id}/docs/${workspace.id}`)
.expect(200)
.type('application/octet-stream');
t.deepEqual(res.body, Buffer.from([0, 0]), 'failed to get public doc');
});