diff --git a/.github/workflows/github-actions.yml b/.github/workflows/github-actions.yml index 28ae551..fa4971d 100644 --- a/.github/workflows/github-actions.yml +++ b/.github/workflows/github-actions.yml @@ -2,48 +2,64 @@ name: Frontend/Backend pipeline run-name: Frontend/Backend pipeline on: [push] jobs: - fe-pipeline: - name: Frontend Pipeline - runs-on: ubuntu-latest - defaults: - run: - working-directory: ./frontend/core - steps: - - uses: actions/checkout@v4 - - name: Use Node.js - uses: actions/setup-node@v3 - with: - node-version: '20.x' - - name: Cache node_modules - id: cache-npm - uses: actions/cache@v3 - env: - cache-name: cache-fe-node-modules - with: - # npm cache files are stored in `~/.npm` on Linux/macOS - path: ~/.npm - key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json') }} - restore-keys: | - ${{ runner.os }}-build-${{ env.cache-name }}- - ${{ runner.os }}-build- - ${{ runner.os }}- - - if: ${{ steps.cache-npm.outputs.cache-hit != 'true' }} - name: List the state of node modules - continue-on-error: true - run: npm list - - name: Install dependencies - run: npm install - - name: Lint - run: npm run lint - - name: Compile - run: npm run tsc - - name: Test - run: npm run test - - name: Build - run: npm run build + # fe-pipeline: + # name: Frontend Pipeline + # runs-on: ubuntu-latest + # defaults: + # run: + # working-directory: ./frontend/core + # steps: + # - uses: actions/checkout@v4 + # - name: Use Node.js + # uses: actions/setup-node@v3 + # with: + # node-version: '20.x' + # - name: Cache node_modules + # id: cache-npm + # uses: actions/cache@v3 + # env: + # cache-name: cache-fe-node-modules + # with: + # # npm cache files are stored in `~/.npm` on Linux/macOS + # path: ~/.npm + # key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json') }} + # restore-keys: | + # ${{ runner.os }}-build-${{ env.cache-name }}- + # ${{ runner.os }}-build- + # ${{ runner.os }}- + # - if: ${{ steps.cache-npm.outputs.cache-hit != 'true' }} + # name: List the state of node modules + # continue-on-error: true + # run: npm list + # - name: Install dependencies + # run: npm install + # - name: Lint + # run: npm run lint + # - name: Compile + # run: npm run tsc + # - name: Test + # run: npm run test + # - name: Build + # run: npm run build be-pipeline: name: Backend Pipeline runs-on: ubuntu-latest + services: + mysql: + image: mysql:8.0 + env: + MYSQL_DATABASE: test_todo + MYSQL_ROOT_PASSWORD: root + ports: + - 3306:3306 + options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3 + env: + AUTH_SECRET: testsecret + AUTH_SUGAR: testsugar + DATABASE_USERNAME: root + DATABASE_PASSWORD: root + DATABASE_HOST: localhost + NODE_ENV: test defaults: run: working-directory: ./api @@ -70,6 +86,8 @@ jobs: name: List the state of node modules continue-on-error: true run: npm list + - name: MySQL + run: which mysql - name: Install dependencies run: npm install - name: Lint @@ -80,4 +98,9 @@ jobs: run: npm run build - name: Test run: npm run test - + # - name: E2E Test - Start Database Service + # run: sudo systemctl start mysql.service + - name: E2E Test - Setup Database & Run + run: | + npm run db:test:setup + npm run test:e2e diff --git a/api/package.json b/api/package.json index 3a733f2..a532312 100644 --- a/api/package.json +++ b/api/package.json @@ -14,11 +14,11 @@ "start:prod": "node dist/main", "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", "tsc": "tsc --project tsconfig.json", - "test": "jest --passWithNoTests", + "test": "jest", "test:watch": "jest --watch", "test:cov": "jest --coverage", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", - "test:e2e": "jest --config ./test/jest-e2e.json", + "test:e2e": "NODE_ENV=test jest --config ./test/jest-e2e.json", "typeorm": "typeorm-ts-node-commonjs", "migration:create": "typeorm-ts-node-commonjs migration:create", "migration:run": "typeorm-ts-node-commonjs migration:run -d src/db/config.ts", @@ -26,7 +26,12 @@ "db:migrate": "ts-node src/db/cli/index.ts migrate", "db:seed": "ts-node src/db/cli/index.ts seed", "db:reset": "ts-node src/db/cli/index.ts reset", - "db:setup": "npm run db:reset && npm run db:migrate && npm run db:seed" + "db:setup": "npm run db:reset && npm run db:seed", + "db:test:create": "NODE_ENV=test ts-node src/db/cli/index.ts create", + "db:test:migrate": "NODE_ENV=test ts-node src/db/cli/index.ts migrate", + "db:test:seed": "NODE_ENV=test ts-node src/db/cli/index.ts seed", + "db:test:reset": "NODE_ENV=test ts-node src/db/cli/index.ts reset", + "db:test:setup": "npm run db:test:reset && npm run db:test:seed" }, "dependencies": { "@nestjs/common": "^10.0.0", diff --git a/api/src/app.controller.ts b/api/src/app.controller.ts index b19d60c..3ef41aa 100644 --- a/api/src/app.controller.ts +++ b/api/src/app.controller.ts @@ -10,7 +10,6 @@ export class AppController { @Get() index(): { version: string } { const res = this.appService.index(); - console.log(res); return res; } } diff --git a/api/src/app.module.ts b/api/src/app.module.ts index 4a10ef1..8d9dd37 100644 --- a/api/src/app.module.ts +++ b/api/src/app.module.ts @@ -18,11 +18,13 @@ import { LoggerModule } from './lib/modules/logger/logger.module'; const config = appConfig(); +const envFilePath = [`.env.${process.env.NODE_ENV || 'development'}`]; + @Module({ imports: [ ConfigModule.forRoot({ load: [appConfig], - envFilePath: ['.env.development.local', '.env.development'], + envFilePath, validationSchema: Joi.object({ NODE_ENV: Joi.string() .valid('development', 'production', 'test', 'provision') diff --git a/api/src/db/cli/index.ts b/api/src/db/cli/index.ts index 66cef5c..09c2a89 100644 --- a/api/src/db/cli/index.ts +++ b/api/src/db/cli/index.ts @@ -1,12 +1,12 @@ +import { DataSource } from 'typeorm'; import { NestFactory } from '@nestjs/core'; import { INestApplication } from '@nestjs/common'; -import { DataSource } from 'typeorm'; -import { AppModule } from '../../app.module'; -import { AppDataSource } from '../config'; +import { AppDataSource, RootDBDataSource } from '../config'; import { seed } from './seeds'; -import { LoggerService } from '../../lib/modules/logger/logger.service'; +import { Logger } from '../../lib/modules/logger/logger.service'; +import { AppModule } from '../../app.module'; -interface Logger { +interface ILogger { info(...msg: any[]); debug(...msg: any[]); error(...msg: any[]); @@ -14,31 +14,47 @@ interface Logger { type Context = { dataSource: DataSource; - logger: Logger; - app: INestApplication; + logger: ILogger; + appFactory: () => Promise; }; const commands: { [key: string]: (ctx: Context) => Promise } = { + create: async ({ dataSource, logger }: Context) => { + logger.info('create database: ', dataSource.options.database); + await RootDBDataSource.initialize(); + await RootDBDataSource.manager.query( + 'create database if not exists ' + dataSource.options.database, + ); + await RootDBDataSource.close(); + }, reset: async ({ dataSource, logger }: Context) => { + await dataSource.initialize(); logger.info('drop database: ', dataSource.options.database); await dataSource.dropDatabase(); logger.info('migration run'); await dataSource.runMigrations(); + await dataSource.close(); }, migrate: async ({ dataSource, logger }: Context) => { + await dataSource.initialize(); logger.info('migration run'); await dataSource.runMigrations(); + await dataSource.close(); + }, + seed: async (context: Context) => { + const { dataSource } = context; + await dataSource.initialize(); + await seed(context); + await dataSource.close(); }, - seed, }; const main = async (command: keyof typeof commands) => { - const app = await NestFactory.create(AppModule); - const logger = app.get(LoggerService).logger; + const appFactory = () => NestFactory.create(AppModule); + const logger = new Logger({ level: 'info' }); try { logger.info(`start command: ${command}`); - await AppDataSource.initialize(); const cmd = commands[command]; if (!cmd) { logger.error(`${cmd} is undefined`); @@ -46,7 +62,7 @@ const main = async (command: keyof typeof commands) => { return; } - await cmd({ app, dataSource: AppDataSource, logger } as Context); + await cmd({ appFactory, dataSource: AppDataSource, logger } as Context); logger.info(`end command.`); process.exit(); } catch (e) { diff --git a/api/src/db/cli/seeds/index.ts b/api/src/db/cli/seeds/index.ts index 5715242..0239480 100644 --- a/api/src/db/cli/seeds/index.ts +++ b/api/src/db/cli/seeds/index.ts @@ -2,20 +2,20 @@ import { EntityManager, DeepPartial } from 'typeorm'; import dayjs from 'dayjs'; import { UsersService } from '../../../domains/users/users.service'; import { User } from '../../../domains/users/user.entity'; -import { Project } from '../../../domains/projects/project.entity'; +import { Project, StatusType } from '../../../domains/projects/project.entity'; import { Task } from '../../../domains/tasks/task.entity'; import { Tag } from '../../../domains/tags/tag.entity'; -export const seed = async ({ app, dataSource, logger }) => { +export const seed = async ({ appFactory, dataSource, logger }) => { + const app = await appFactory(); const usersService = app.get(UsersService); const now = dayjs(); - console.log('connection is establised'); + logger.info('connection is establised'); - console.log('users ========'); const users = []; await dataSource.transaction(async (manager: EntityManager) => { + logger.info('[START] users ========'); for (let i = 1; i <= 10; i++) { - logger.info(`seeding for a user. index: ${i}`); const timestamp = new Date(); const user = manager.create(User, { username: `user ${i}`, @@ -30,8 +30,9 @@ export const seed = async ({ app, dataSource, logger }) => { await manager.save(user); users.push(user); } + logger.info('[END]', 'count:', users.length); - console.log('tags ========'); + logger.info('[START] tags ========'); const user = users[0]; const tags = []; await Promise.all( @@ -62,14 +63,16 @@ export const seed = async ({ app, dataSource, logger }) => { tags.push(tag); }), ); + logger.info('[END]', 'count:', tags.length); - console.log('projects ========'); + logger.info('[START] projects ========'); const projects = []; await Promise.all( [ { userId: user.id, name: 'プログラミング', + uuid: '9a1b53d8-4afc-4630-a26e-3634a10bf619', slug: 'programming', goal: '期限日までにフロントエンドエンジニアとして就職する。', shouldbe: 'エンジニアとしての学習習慣を身につけて生活する。', @@ -78,6 +81,7 @@ export const seed = async ({ app, dataSource, logger }) => { { userId: user.id, name: '英語', + uuid: 'ee9f5f2e-fc8c-4830-985a-a44e96e96ffe', slug: 'english', goal: 'IELTS Over All 7.0', shouldbe: '英語に浸る', @@ -86,6 +90,7 @@ export const seed = async ({ app, dataSource, logger }) => { { userId: user.id, name: 'プライベート', + uuid: '75cda72f-9883-4570-b3b5-66d389d5b1a9', slug: 'private', goal: '長期休みに海外旅行する', status: 'active' as const, @@ -104,12 +109,17 @@ export const seed = async ({ app, dataSource, logger }) => { ); Promise.all( new Array(20 - projects.length).fill('').map(async (_, index) => { + let status: StatusType = 'active' as const; + if (index >= 14) { + status = 'archived' as const; + } + const it: DeepPartial = { userId: user.id, name: `ダミープロジェクト ${index + 1}`, slug: `dummy-projec-${index + 1}`, goal: `ダミープロジェクト ${index + 1}`, - status: 'active' as const, + status, }; it.deadline = dayjs() @@ -127,22 +137,80 @@ export const seed = async ({ app, dataSource, logger }) => { projects.push(project); }), ); - console.log('tasks ========'); + logger.info('[END]', 'count:', projects.length); + + logger.info('[START] tasks ========'); const args = [ { project: projects[0], + premadeTasks: [ + { + userId: user.id, + projectId: projects[0].id, + uuid: '067176b2-baaf-4936-b7d4-6d202ab72639', + kind: 'task', + status: 'scheduled', + }, + ], + premadeMilestones: [ + { + userId: user.id, + uuid: '67331996-7671-4cce-87a4-18b46adbd230', + projectId: projects[0].id, + title: `milestone 0`, + kind: 'milestone' as const, + status: 'scheduled' as const, + }, + ], tasks: [], count: 100, baseDate: dayjs().add(1, 'year'), }, { project: projects[1], + premadeTasks: [ + { + userId: user.id, + projectId: projects[1].id, + uuid: 'c185e0c2-842e-46f0-a1e8-41d598569431', + kind: 'task', + status: 'scheduled', + }, + ], + premadeMilestones: [ + { + userId: user.id, + uuid: 'be36a559-ec88-4817-b98e-c084a0d51616', + projectId: projects[1].id, + title: `milestone 0`, + kind: 'milestone' as const, + status: 'scheduled' as const, + }, + ], tasks: [], count: 30, baseDate: dayjs().add(1, 'year'), }, { project: projects[2], + premadeTasks: [ + { + userId: user.id, + uuid: '1afdd678-1690-4567-9b9f-053cbbec286d', + projectId: projects[2].id, + kind: 'task', + status: 'scheduled', + }, + ], + premadeMilestones: [ + { + userId: user.id, + projectId: projects[2].id, + title: `milestone 0`, + kind: 'milestone' as const, + status: 'scheduled' as const, + }, + ], tasks: [], count: 10, baseDate: dayjs().add(1, 'year'), @@ -150,15 +218,24 @@ export const seed = async ({ app, dataSource, logger }) => { ]; await Promise.all( - args.map(async (arg, index) => { - console.log('tasks ========', index); - const { project, tasks, count, baseDate } = arg; - for (let i = 0; i < count; i++) { + args.map(async (arg) => { + const { project, premadeTasks, tasks, count, baseDate } = arg; + premadeTasks.forEach((it) => tasks.push(it)); + + const _count = count - premadeTasks.length; + for (let i = 0; i < _count; i++) { + let status = 'scheduled'; + if (i >= arg.count - 3) { + status = 'archived'; + } else if (i >= arg.count - 6) { + status = 'completed'; + } + tasks.push({ userId: user.id, projectId: project.id, kind: 'task' as const, - status: 'scheduled' as const, + status, }); } @@ -178,13 +255,16 @@ export const seed = async ({ app, dataSource, logger }) => { let head = 0; for (let i = 0; i < 5; i++) { - const milestone = { - userId: user.id, - projectId: project.id, - title: `milestone ${i}`, - kind: 'milestone' as const, - status: 'scheduled' as const, - }; + const milestone = + i === 0 + ? arg.premadeMilestones[0] + : { + userId: user.id, + projectId: project.id, + title: `milestone ${i}`, + kind: 'milestone' as const, + status: 'scheduled' as const, + }; const m = manager.create(Task, milestone); const tail = head + Math.ceil((tasks.length - 1 - head) / 2); @@ -201,5 +281,9 @@ export const seed = async ({ app, dataSource, logger }) => { } }), ); + const allTaskCount = args.reduce((acc, it) => { + return acc + it.tasks.length; + }, 0); + logger.info('[END]', 'count:', allTaskCount); }); }; diff --git a/api/src/db/config.ts b/api/src/db/config.ts index a56202f..cb7a939 100644 --- a/api/src/db/config.ts +++ b/api/src/db/config.ts @@ -6,20 +6,32 @@ import { Tag } from '../domains/tags/tag.entity'; import { DataSource, DataSourceOptions } from 'typeorm'; import migrations from '../db/migrations'; +const database = + process.env['DATABASE_NAME'] || + `todo_${process.env.NODE_ENV || 'development'}`; + export const dataSourceOptions = { type: 'mysql', host: process.env['DATABASE_HOST'] || 'localhost', port: 3306, username: process.env['DATABASE_USERNAME'] || 'root', password: process.env['DATABASE_PASSWORD'], - database: process.env['DATABASE_NAME'] || 'todo_development', + database, entities: [User, Project, Task, TagTask, Tag], migrations, synchronize: false, - logging: true, + logging: process.env.NODE_ENV !== 'test', autoLoadEntities: true, }; -export const AppDataSource = new DataSource( - dataSourceOptions as DataSourceOptions, -); +export const RootDBDataSource = (function () { + return new DataSource({ + ...dataSourceOptions, + database: 'mysql', + } as DataSourceOptions); +})(); + +export const AppDataSource = (function () { + console.log('[db]: database name', dataSourceOptions.database); + return new DataSource(dataSourceOptions as DataSourceOptions); +})(); diff --git a/api/src/domains/bulk/tasks.controller.ts b/api/src/domains/bulk/tasks.controller.ts index 4e8d2c5..4809ae0 100644 --- a/api/src/domains/bulk/tasks.controller.ts +++ b/api/src/domains/bulk/tasks.controller.ts @@ -12,9 +12,10 @@ export class TasksController { @DUser() user: User, @Body('ids') ids: string[], ): Promise> { - const result = await this.tasksService.archive(user.id, ids); + await this.tasksService.archive(user.id, ids); + const data = await this.tasksService.findAll({ userId: user.id, ids }); - return { data: result }; + return { data }; } @Put('complete') @@ -22,9 +23,10 @@ export class TasksController { @DUser() user: User, @Body('ids') ids: string[], ): Promise> { - const result = await this.tasksService.complete(user.id, ids); + await this.tasksService.complete(user.id, ids); + const data = await this.tasksService.findAll({ userId: user.id, ids }); - return { data: result }; + return { data }; } @Put('reopen') @@ -32,8 +34,9 @@ export class TasksController { @DUser() user: User, @Body('ids') ids: string[], ): Promise> { - const result = await this.tasksService.reopen(user.id, ids); + await this.tasksService.reopen(user.id, ids); + const data = await this.tasksService.findAll({ userId: user.id, ids }); - return { data: result }; + return { data }; } } diff --git a/api/src/domains/projects/projects.module.ts b/api/src/domains/projects/projects.module.ts index 273a8b8..7eeb623 100644 --- a/api/src/domains/projects/projects.module.ts +++ b/api/src/domains/projects/projects.module.ts @@ -4,13 +4,12 @@ import { TasksModule } from '../tasks/tasks.module'; import { Task } from '../tasks/task.entity'; import { Project } from './project.entity'; import { ProjectsService } from './projects.service'; -import { TasksController } from './tasks.controller'; import { MilestonesController } from './milestones.controller'; @Module({ imports: [TypeOrmModule.forFeature([Project, Task]), TasksModule], providers: [ProjectsService], exports: [ProjectsService], - controllers: [TasksController, MilestonesController], + controllers: [MilestonesController], }) export class ProjectsModule {} diff --git a/api/src/domains/projects/projects.service.ts b/api/src/domains/projects/projects.service.ts index 643c1d2..3aaf86d 100644 --- a/api/src/domains/projects/projects.service.ts +++ b/api/src/domains/projects/projects.service.ts @@ -77,14 +77,17 @@ export class ProjectsService { }); const projectIds = projects.map((it) => it.id); - const stats = await this.tasksService.statics(projectIds); + let stats = {}; + if (projectIds.length > 0) { + stats = await this.tasksService.statics(projectIds); + } projects.forEach((p: Project) => { p.stats = stats[p.id] || initialStat(); }); const result = new Pagination({ data: projects, - limit, + limit: take, page: page || 1, totalCount, ...sortOptions, diff --git a/api/src/domains/projects/tasks.controller.ts b/api/src/domains/projects/tasks.controller.ts deleted file mode 100644 index a071731..0000000 --- a/api/src/domains/projects/tasks.controller.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { Controller, Get, Param, Query } from '@nestjs/common'; -import { TasksService } from '../tasks/tasks.service'; -import { User as DUser } from '../users/user.decorator'; -import { User } from '../users/user.entity'; -import { Dto } from '../../entities/dto.entity'; -import { TaskStatuses } from '../tasks/task.entity'; -import { - IsNumberString, - IsIn, - IsArray, - IsOptional, - IsDefined, -} from 'class-validator'; - -class TaskIndexDto extends Dto { - @IsOptional() - @IsIn(['deadline', 'createdAt', 'updatedAt']) - sortType?: 'deadline' | 'createdAt' | 'updatedAt'; - @IsOptional() - @IsIn(['asc', 'desc']) - sortOrder?: 'asc' | 'desc'; - @IsOptional() - @IsNumberString() - limit?: number; - @IsOptional() - @IsNumberString() - page?: number; - @IsDefined() - @IsArray() - status: TaskStatuses[]; -} - -@Controller('projects') -export class TasksController { - constructor(private readonly tasksService: TasksService) {} - - @Get(':slug/tasks') - async index( - @DUser() user: User, - @Param('slug') slug: string, - @Query() query: TaskIndexDto, - ): Promise> { - const result = await this.tasksService.search({ - slug, - user, - ...query, - }); - - return result.serialize; - } -} diff --git a/api/src/domains/tasks/tasks.service.ts b/api/src/domains/tasks/tasks.service.ts index 0734936..d68f2f7 100644 --- a/api/src/domains/tasks/tasks.service.ts +++ b/api/src/domains/tasks/tasks.service.ts @@ -8,7 +8,9 @@ import { FindOperator, In, IsNull, + LessThanOrEqual, Like, + MoreThanOrEqual, Repository, } from 'typeorm'; import { Task, TaskKinds } from './task.entity'; @@ -92,6 +94,21 @@ export class TasksService { }); } + async findAll({ ids, userId }: { ids: string[]; userId: number }) { + return await this.tasksRepository.find({ + relations: { + user: true, + project: true, + parent: true, + children: true, + }, + where: { + uuid: In(ids), + userId, + }, + }); + } + async orphans({ userId, slug, @@ -155,7 +172,6 @@ export class TasksService { user, search, projectId, - slug, sortType, sortOrder, dateType, @@ -180,39 +196,15 @@ export class TasksService { }; const { take, skip } = options; - const whereBase: WhereParams = { - userId: user.id, - status: In(options.status), - kind: 'task' as const, - project: { status: In(['active']) }, - }; - - if (projectId) { - whereBase.project.uuid = projectId; - } - - if (slug) { - whereBase.project.slug = slug; - } - - if (search) { - whereBase.title = Like(`%${search}%`); - } - - if (dateType && (dateFrom || dateTo)) { - whereBase[dateType] = Between(dateFrom, dateTo); - } - - const where = [ - { - ...whereBase, - parent: { status: In(['scheduled']) }, - }, - { - ...whereBase, - parent: IsNull(), - }, - ]; + const where = this.buildWhere({ + user, + status: options.status, + projectId, + search, + dateFrom, + dateTo, + dateType, + }); const [tasks, totalCount] = await this.tasksRepository.findAndCount({ where, @@ -242,7 +234,7 @@ export class TasksService { .createQueryBuilder('tasks') .select('projectId, kind, count(*) as count') .where('tasks.projectId IN(:id)', { id: ids }) - .groupBy('projectId') + .groupBy('projectId, kind') .addGroupBy('kind') .getRawMany(); @@ -440,4 +432,58 @@ export class TasksService { options, ); } + + private buildWhere({ + user, + status, + search, + projectId, + dateType, + dateFrom, + dateTo, + }: { + user: User; + status: TaskStatuses[]; + search: string; + projectId: string; + dateType: string; + dateFrom: string; + dateTo: string; + }) { + const base: WhereParams = { + userId: user.id, + status: In(status), + kind: 'task' as const, + project: { status: In(['active']) }, + }; + + if (projectId) { + base.project.uuid = projectId; + } + + if (search) { + base.title = Like(`%${search}%`); + } + + if (dateType && dateFrom && dateTo) { + base[dateType] = Between(dateFrom, dateTo); + } else if (dateType && dateFrom) { + base[dateType] = MoreThanOrEqual(dateFrom); + } else if (dateType && dateTo) { + base[dateType] = LessThanOrEqual(dateTo); + } + + const where = [ + { + ...base, + parent: { status: In(['scheduled']) }, + }, + { + ...base, + parent: IsNull(), + }, + ]; + + return where; + } } diff --git a/api/src/domains/users/projects.controller.ts b/api/src/domains/users/projects.controller.ts index d47cf29..56c7134 100644 --- a/api/src/domains/users/projects.controller.ts +++ b/api/src/domains/users/projects.controller.ts @@ -3,7 +3,6 @@ import { Get, Patch, Post, - Delete, Query, Param, Body, @@ -15,15 +14,11 @@ import { ProjectsService } from '../projects/projects.service'; import { Dto } from '../../entities/dto.entity'; import { User as DUser } from './user.decorator'; import { User } from './user.entity'; -import { TaskKind, TaskKinds } from '../tasks/task.entity'; class TaskDto extends Dto { @IsNotEmpty() title: string; - @IsIn(Object.keys(TaskKind)) - kind: TaskKinds; - @IsDate() @IsNotEmpty() deadline: Date; @@ -83,7 +78,7 @@ export class ProjectsController { user, limit, page, - statuses, + statuses: statuses && !Array.isArray(statuses) ? [statuses] : statuses, }); return result.serialize; @@ -105,10 +100,14 @@ export class ProjectsController { @DUser() user: User, @Param('slug') slug: string, ): Promise> { - return await this.projectsService.findOne({ + const data = await this.projectsService.findOne({ user, slug, }); + + return { + data, + }; } @Patch(':slug') @@ -121,17 +120,6 @@ export class ProjectsController { await this.projectsService.update(slug, user.id, body); } - @Delete(':slug') - async delete( - @DUser() user: User, - @Param('slug') slug: string, - ): Promise> { - return await this.projectsService.findOne({ - user, - slug, - }); - } - @Patch(':slug/archive') async archive( @DUser() user: User, diff --git a/api/src/domains/users/tasks.controller.ts b/api/src/domains/users/tasks.controller.ts index bfa6461..334e39e 100644 --- a/api/src/domains/users/tasks.controller.ts +++ b/api/src/domains/users/tasks.controller.ts @@ -7,6 +7,8 @@ import { Body, Query, Param, + HttpCode, + HttpStatus, } from '@nestjs/common'; import { OrderType, SortType, TasksService } from '../tasks/tasks.service'; import { User as DUser } from './user.decorator'; @@ -58,6 +60,7 @@ export class TasksController { } @Post('') + @HttpCode(HttpStatus.CREATED) async create( @DUser() user: User, @Body() body: CreateParams, @@ -99,7 +102,6 @@ export class TasksController { 'title', 'projectId', 'status', - 'kind', 'deadline', 'finishedAt', 'startingAt', @@ -119,33 +121,36 @@ export class TasksController { return { data: result }; } - @Put(':taskId/archive') + @Put(':id/archive') async archive( @DUser() user: User, - @Param('taskId') taskId: string, + @Param('id') id: string, ): Promise> { - const result = await this.tasksService.archive(user.id, [taskId]); + await this.tasksService.archive(user.id, [id]); + const task = await this.tasksService.find({ id, userId: user.id }); - return { data: result }; + return { data: task }; } - @Put(':taskId/complete') + @Put(':id/complete') async complete( @DUser() user: User, - @Param('taskId') taskId: string, + @Param('id') id: string, ): Promise> { - const result = await this.tasksService.complete(user.id, [taskId]); + await this.tasksService.complete(user.id, [id]); + const task = await this.tasksService.find({ id, userId: user.id }); - return { data: result }; + return { data: task }; } - @Put(':taskId/reopen') + @Put(':id/reopen') async reopen( @DUser() user: User, - @Param('taskId') taskId: string, + @Param('id') id: string, ): Promise> { - const result = await this.tasksService.reopen(user.id, [taskId]); + await this.tasksService.reopen(user.id, [id]); + const task = await this.tasksService.find({ id, userId: user.id }); - return { data: result }; + return { data: task }; } } diff --git a/api/src/entities/pagination.entity.ts b/api/src/entities/pagination.entity.ts index 32b65d3..e6b310e 100644 --- a/api/src/entities/pagination.entity.ts +++ b/api/src/entities/pagination.entity.ts @@ -25,13 +25,13 @@ export class Pagination { return { data, pageInfo: { - page, - limit, + page: Number(page), + limit: Number(limit), sortOrder, sortType, hasNext: totalCount > limit * page, - hasPrevious: page > 1, - totalCount, + hasPrevious: Number(page) > 1, + totalCount: Number(totalCount), }, }; } diff --git a/api/src/lib/modules/logger/logger.service.ts b/api/src/lib/modules/logger/logger.service.ts index b5785db..ac42de5 100644 --- a/api/src/lib/modules/logger/logger.service.ts +++ b/api/src/lib/modules/logger/logger.service.ts @@ -5,7 +5,7 @@ type LogLevel = 'debug' | 'info' | 'warn' | 'error'; const levelOrder: LogLevel[] = ['debug', 'info', 'warn', 'error']; -class Logger { +export class Logger { private readonly level: LogLevel; constructor({ level }) { this.level = level; @@ -43,6 +43,24 @@ class Logger { console.error('[ERROR]', ...args); } + async time( + cb: () => Promise, + { + onStart, + onEnd, + }: { onStart?: () => void; onEnd?: (ms: number) => void } = {}, + ) { + onStart?.(); + const before = new Date(); + await cb(); + const after = new Date(); + const ms = + after.getSeconds() * 1000 + + after.getMilliseconds() - + (before.getSeconds() * 1000 + before.getMilliseconds()); + onEnd?.(ms); + } + private shouldOuptut(level: LogLevel) { const baseIndex = levelOrder.findIndex((l) => l === this.level); const targetIndex = levelOrder.findIndex((l) => l === level); diff --git a/api/src/lib/utils/index.spec.ts b/api/src/lib/utils/index.spec.ts new file mode 100644 index 0000000..44af7aa --- /dev/null +++ b/api/src/lib/utils/index.spec.ts @@ -0,0 +1,10 @@ +import { toMap } from './'; + +describe('toMap', () => { + it(`array to map`, () => { + expect(toMap([{ id: 1 }, { id: 1 }, { id: 2 }])).toEqual({ + 1: [{ id: 1 }, { id: 1 }], + 2: [{ id: 2 }], + }); + }); +}); diff --git a/api/test/app.e2e-spec.ts b/api/test/app.e2e-spec.ts index 2ccb6f5..0778719 100644 --- a/api/test/app.e2e-spec.ts +++ b/api/test/app.e2e-spec.ts @@ -1,3 +1,4 @@ +import request from 'supertest'; import { Test, TestingModule } from '@nestjs/testing'; import { INestApplication } from '@nestjs/common'; import { AppModule } from './../src/app.module'; @@ -13,4 +14,14 @@ describe('AppController (e2e)', () => { app = moduleFixture.createNestApplication(); await app.init(); }); + + it(`Get /index`, () => { + return request(app.getHttpServer()).get('').expect(200).expect({ + version: 'v1', + }); + }); + + afterAll(async () => { + await app.close(); + }); }); diff --git a/api/test/bulk/tasks.e2e-spec.ts b/api/test/bulk/tasks.e2e-spec.ts new file mode 100644 index 0000000..c244789 --- /dev/null +++ b/api/test/bulk/tasks.e2e-spec.ts @@ -0,0 +1,147 @@ +import { INestApplication } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { checkNoAuthBehavior, prepareApp } from '../helper'; +import { withCleanup } from '../helper/databse'; +import request from '../helper/request'; + +const jwtService = { verifyAsync: () => ({ sub: 'user 1' }) }; + +describe('Bulk/TasksController (e2e)', () => { + let app: INestApplication; + let server: any; + + beforeAll(async () => { + app = await prepareApp([ + { + provider: JwtService, + value: jwtService, + }, + ]); + server = app.getHttpServer(); + + return; + }); + + describe('Put /bulk/tasks/archive', () => { + const uuid = [ + '067176b2-baaf-4936-b7d4-6d202ab72639', + 'c185e0c2-842e-46f0-a1e8-41d598569431', + '36b6ee02-e4c7-40c3-8df3-f7bf4f443183', + ]; + const subject = () => request(server).put(`/bulk/tasks/archive`); + it(...checkNoAuthBehavior(subject)); + + it('archive task', async () => { + await withCleanup(app, async () => { + const response = await subject().withAuth().send({ + ids: uuid, + }); + expect(response.status).toEqual(200); + expect(response.body).toMatchObject({ + data: [ + { + id: expect.any(Number), + title: 'Task 0', + uuid: uuid[0], + status: 'archived', + }, + { + id: expect.any(Number), + title: 'Task 0', + uuid: uuid[2], + status: 'archived', + }, + { + id: expect.any(Number), + title: 'Task 0', + uuid: uuid[1], + status: 'archived', + }, + ], + }); + }); + }); + }); + + describe('Put /users/tasks/:id/complete', () => { + const uuid = [ + '067176b2-baaf-4936-b7d4-6d202ab72639', + 'c185e0c2-842e-46f0-a1e8-41d598569431', + '36b6ee02-e4c7-40c3-8df3-f7bf4f443183', + ]; + const subject = () => request(server).put(`/bulk/tasks/complete`); + it(...checkNoAuthBehavior(subject)); + + it('complete task', async () => { + await withCleanup(app, async () => { + const response = await subject().withAuth().send({ ids: uuid }); + expect(response.status).toEqual(200); + expect(response.body).toMatchObject({ + data: [ + { + id: expect.any(Number), + title: 'Task 0', + uuid: uuid[0], + status: 'completed', + }, + { + id: expect.any(Number), + title: 'Task 0', + uuid: uuid[2], + status: 'completed', + }, + { + id: expect.any(Number), + title: 'Task 0', + uuid: uuid[1], + status: 'completed', + }, + ], + }); + }); + }); + }); + + describe('Put /users/tasks/:id/reopen', () => { + const uuid = [ + '067176b2-baaf-4936-b7d4-6d202ab72639', + 'c185e0c2-842e-46f0-a1e8-41d598569431', + '36b6ee02-e4c7-40c3-8df3-f7bf4f443183', + ]; + const subject = () => request(server).put(`/bulk/tasks/reopen`); + it(...checkNoAuthBehavior(subject)); + + it('reopen task', async () => { + await withCleanup(app, async () => { + const response = await subject().withAuth().send({ ids: uuid }); + expect(response.status).toEqual(200); + expect(response.body).toMatchObject({ + data: [ + { + id: expect.any(Number), + title: 'Task 0', + uuid: uuid[0], + status: 'scheduled', + }, + { + id: expect.any(Number), + title: 'Task 0', + uuid: uuid[2], + status: 'scheduled', + }, + { + id: expect.any(Number), + title: 'Task 0', + uuid: uuid[1], + status: 'scheduled', + }, + ], + }); + }); + }); + }); + + afterAll(async () => { + return await app.close(); + }); +}); diff --git a/api/test/helper/databse.ts b/api/test/helper/databse.ts new file mode 100644 index 0000000..c29343e --- /dev/null +++ b/api/test/helper/databse.ts @@ -0,0 +1,34 @@ +import { INestApplication } from '@nestjs/common'; +import { seed } from '../../src/db/cli/seeds'; +import { getDataSourceToken, getRepositoryToken } from '@nestjs/typeorm'; + +const noOutputLogger = { + info: () => {}, + error: () => {}, + warn: () => {}, +}; + +// FIXME: +// this solution to clean db after test looks lazy a bit, since it recreate database including seed data and slow. +// Injectinting EntityManager and rollback after test looks better way. +export const withCleanup = async ( + app: INestApplication, + cb: () => Promise, +) => { + const dataSource = app.get(getDataSourceToken()); + try { + await cb(); + } finally { + await dataSource.dropDatabase(); + await dataSource.runMigrations(); + await seed({ + appFactory: async () => app, + dataSource, + logger: noOutputLogger, + }); + } +}; + +export const getRepository = (app: INestApplication, token: any) => { + return app.get(getRepositoryToken(token)); +}; diff --git a/api/test/helper/index.ts b/api/test/helper/index.ts new file mode 100644 index 0000000..6a7f2fb --- /dev/null +++ b/api/test/helper/index.ts @@ -0,0 +1,45 @@ +import { ConfigModule } from '@nestjs/config'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Test as NestTest } from '@nestjs/testing'; +import { dataSourceOptions } from '../../src/db/config'; +import appConfig from '../../src/config/app.config'; +import { AppModule } from '../../src/app.module'; +import { Test } from './request'; + +export const checkNoAuthBehavior = (test: () => Test): [string, () => Test] => [ + 'no auth token, return 401', + () => { + return test().expect(401); + }, +]; + +interface OverrideProvier { + provider: any; + value: any; +} + +export const prepareApp = async (providers: OverrideProvier[]) => { + let moduleFixture = NestTest.createTestingModule({ + imports: [ + ConfigModule.forRoot({ + load: [appConfig], + envFilePath: ['.env.test'], + isGlobal: true, + }), + TypeOrmModule.forRoot(dataSourceOptions as any), + AppModule, + ], + }); + + providers.forEach((it) => { + moduleFixture = moduleFixture + .overrideProvider(it.provider) + .useValue(it.value); + }); + + const module = await moduleFixture.compile(); + const app = module.createNestApplication(); + await app.init(); + + return app; +}; diff --git a/api/test/helper/request.ts b/api/test/helper/request.ts new file mode 100644 index 0000000..1d11e2d --- /dev/null +++ b/api/test/helper/request.ts @@ -0,0 +1,56 @@ +import supertest from 'supertest'; + +export class ExtendedTest { + readonly _test: supertest.Test; + + constructor(test: supertest.Test) { + this._test = test; + } + + withAuth() { + return this._test.set('Authorization', 'Bearer [token]'); + } +} + +const handler = { + get: (receiver: ExtendedTest, prop: keyof ExtendedTest) => { + if (prop in receiver) { + return receiver[prop]; + } + + return receiver._test[prop]; + }, +}; + +export type Test = ExtendedTest & supertest.Test; + +const testFactory = (test: supertest.Test): Test => + new Proxy(new ExtendedTest(test), handler) as Test; + +class ExtendedRequest { + _request: supertest.SuperTest; + + constructor(app: any) { + this._request = supertest(app); + } + + get(path: string) { + return testFactory(this._request.get(path)); + } + + post(path: string) { + return testFactory(this._request.post(path)); + } + + patch(path: string) { + return testFactory(this._request.patch(path)); + } + + put(path: string) { + return testFactory(this._request.put(path)); + } +} + +export default function (app: any) { + return new ExtendedRequest(app); +} diff --git a/api/test/projects/milestones.e2e-spec.ts b/api/test/projects/milestones.e2e-spec.ts new file mode 100644 index 0000000..5868b81 --- /dev/null +++ b/api/test/projects/milestones.e2e-spec.ts @@ -0,0 +1,70 @@ +import { INestApplication } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { getRepository, withCleanup } from '../helper/databse'; +import { checkNoAuthBehavior, prepareApp } from '../helper'; +import request from '../helper/request'; +import { Task } from '../../src/domains/tasks/task.entity'; + +const jwtService = { verifyAsync: () => ({ sub: 'user 1' }) }; + +describe('Projects/MilestonesController (e2e)', () => { + let app: INestApplication; + let server: any; + let repo: any; + + beforeAll(async () => { + app = await prepareApp([ + { + provider: JwtService, + value: jwtService, + }, + ]); + server = app.getHttpServer(); + repo = getRepository(app, Task); + + return; + }); + + describe('Get /projects/:slug/milestones', () => { + const slug = 'programming'; + const subject = () => request(server).get(`/projects/${slug}/milestones`); + + it(...checkNoAuthBehavior(subject)); + + it('page params, retrun expected list', async () => { + const response = await subject().withAuth(); + expect(response.status).toEqual(200); + expect(response.body).toMatchObject({ + data: { + milestones: expect.any(Array), + orphans: [], + }, + }); + }); + }); + + describe('Put /projects/:slug/milestones/:id/archive', () => { + const uuid = '67331996-7671-4cce-87a4-18b46adbd230'; + const slug = 'programming'; + const subject = () => + request(server).put(`/projects/${slug}/milestones/${uuid}/archive`); + + it(...checkNoAuthBehavior(subject)); + + it('archive milestone', async () => { + await withCleanup(app, async () => { + await subject().withAuth().expect(200); + + const milestone = await repo.findOne({ where: { uuid } }); + expect(milestone).toMatchObject({ + uuid, + status: 'archived', + }); + }); + }); + }); + + afterAll(async () => { + return await app.close(); + }); +}); diff --git a/api/test/users/projects.e2e-spec.ts b/api/test/users/projects.e2e-spec.ts new file mode 100644 index 0000000..c05e6fd --- /dev/null +++ b/api/test/users/projects.e2e-spec.ts @@ -0,0 +1,387 @@ +import { INestApplication } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import dayjs from 'dayjs'; +import { Project } from '../../src/domains/projects/project.entity'; +import { getRepository, withCleanup } from '../helper/databse'; +import { checkNoAuthBehavior, prepareApp } from '../helper'; +import request from '../helper/request'; + +const jwtService = { verifyAsync: () => ({ sub: 'user 1' }) }; + +describe('ProjectsController (e2e)', () => { + let app: INestApplication; + let server: any; + let repo: any; + + beforeAll(async () => { + app = await prepareApp([ + { + provider: JwtService, + value: jwtService, + }, + ]); + server = app.getHttpServer(); + repo = getRepository(app, Project); + + return; + }); + + describe('Get /users/projects', () => { + const subject = () => request(server).get('/users/projects'); + + it(...checkNoAuthBehavior(subject)); + + describe('data', () => { + it('expected list', async () => { + const response = await subject() + .withAuth() + .query({ + status: ['active'], + limit: 3, + }); + expect(response.status).toEqual(200); + expect(response.body).toMatchObject({ + data: [ + { + archivedAt: null, + uuid: '9a1b53d8-4afc-4630-a26e-3634a10bf619', + createdAt: expect.any(String), + deadline: expect.any(String), + finishedAt: null, + goal: '期限日までにフロントエンドエンジニアとして就職する。', + id: 1, + milestones: [], + name: 'プログラミング', + slug: 'programming', + startedAt: null, + startingAt: null, + stats: { + kinds: { + milestone: 5, + task: 100, + }, + states: { + archived: 2, + completed: 3, + scheduled: 100, + }, + total: 105, + }, + status: 'active', + userId: 1, + }, + { + name: '英語', + slug: 'english', + goal: 'IELTS Over All 7.0', + id: 2, + uuid: 'ee9f5f2e-fc8c-4830-985a-a44e96e96ffe', + milestones: [], + stats: { + kinds: { + milestone: 5, + task: 30, + }, + states: { + archived: 2, + completed: 3, + scheduled: 30, + }, + total: 35, + }, + userId: 1, + status: 'active', + }, + { + goal: '長期休みに海外旅行する', + id: 3, + uuid: '75cda72f-9883-4570-b3b5-66d389d5b1a9', + name: 'プライベート', + slug: 'private', + stats: { + kinds: { + milestone: 5, + task: 10, + }, + states: { + archived: 2, + completed: 3, + scheduled: 10, + }, + total: 15, + }, + status: 'active', + userId: 1, + }, + ], + pageInfo: { + hasNext: true, + hasPrevious: false, + limit: 3, + page: 1, + sortOrder: 'asc', + sortType: 'deadline', + totalCount: 17, + }, + }); + }); + }); + + describe('status', () => { + it('status is active', async () => { + const r1 = await subject() + .withAuth() + .query({ + status: ['active'], + }); + expect(r1.status).toEqual(200); + expect(r1.body).toEqual({ + data: expect.any(Array), + pageInfo: { + hasNext: true, + hasPrevious: false, + limit: 5, + page: 1, + sortOrder: 'asc', + sortType: 'deadline', + totalCount: 17, + }, + }); + }); + + it('status is archived', async () => { + const r1 = await subject() + .withAuth() + .query({ + status: ['archived'], + }); + expect(r1.status).toEqual(200); + expect(r1.body).toEqual({ + data: expect.any(Array), + pageInfo: { + hasNext: false, + hasPrevious: false, + limit: 5, + page: 1, + sortOrder: 'asc', + sortType: 'deadline', + totalCount: 3, + }, + }); + }); + + it('status is active and archived', async () => { + const r1 = await subject() + .withAuth() + .query({ + status: ['archived', 'active'], + }); + expect(r1.status).toEqual(200); + expect(r1.body).toEqual({ + data: expect.any(Array), + pageInfo: { + hasNext: true, + hasPrevious: false, + limit: 5, + page: 1, + sortOrder: 'asc', + sortType: 'deadline', + totalCount: 20, + }, + }); + }); + }); + + describe('pagination', () => { + it('page params, retrun expected list', async () => { + const r1 = await subject().withAuth().query({ + limit: 6, + }); + expect(r1.status).toEqual(200); + expect(r1.body).toEqual({ + data: expect.any(Array), + pageInfo: { + hasNext: true, + hasPrevious: false, + limit: 6, + page: 1, + sortOrder: 'asc', + sortType: 'deadline', + totalCount: 17, + }, + }); + + const r2 = await subject().withAuth().query({ + limit: 6, + page: 2, + }); + expect(r2.status).toEqual(200); + expect(r2.body).toEqual({ + data: expect.any(Array), + pageInfo: { + hasNext: true, + hasPrevious: true, + limit: 6, + page: 2, + sortOrder: 'asc', + sortType: 'deadline', + totalCount: 17, + }, + }); + + const r3 = await subject().withAuth().query({ + limit: 6, + page: 3, + }); + expect(r3.status).toEqual(200); + expect(r3.body).toEqual({ + data: expect.any(Array), + pageInfo: { + hasNext: false, + hasPrevious: true, + limit: 6, + page: 3, + sortOrder: 'asc', + sortType: 'deadline', + totalCount: 17, + }, + }); + }); + }); + }); + + describe('Post /users/projects', () => { + const subject = () => request(server).post('/users/projects'); + + it(...checkNoAuthBehavior(subject)); + it('created project normally', async () => { + await withCleanup(app, async () => { + const before = await repo.count(); + await subject() + .withAuth() + .send({ + userId: 1, + name: 'テスト', + slug: 'test', + goal: 'Goal', + shouldbe: 'Should Be', + status: 'active' as const, + deadline: dayjs().add(1, 'year').add(3, 'day').format('YYYY-MM-DD'), + milestones: [ + { + title: 'Test Milestone 1', + deadline: dayjs() + .add(1, 'year') + .add(1, 'day') + .format('YYYY-MM-DD'), + }, + { + title: 'Test Milestone 2', + deadline: dayjs() + .add(1, 'year') + .add(2, 'day') + .format('YYYY-MM-DD'), + }, + { + title: 'Test Milestone 3', + deadline: dayjs() + .add(1, 'year') + .add(3, 'day') + .format('YYYY-MM-DD'), + }, + ], + }) + .expect(201); + const after = await repo.count(); + + expect(after - before).toEqual(1); + }); + }); + }); + + describe('Get /users/projects/:slug', () => { + const slug = 'programming'; + const subject = () => request(server).get(`/users/projects/${slug}`); + + it(...checkNoAuthBehavior(subject)); + + it('get project', async () => { + const response = await subject().withAuth(); + expect(response.status).toEqual(200); + expect(response.body).toMatchObject({ + data: { + userId: 1, + name: 'プログラミング', + uuid: '9a1b53d8-4afc-4630-a26e-3634a10bf619', + slug, + milestones: [], + }, + }); + }); + }); + + describe('Patch /users/tasks/:slug', () => { + const slug = 'programming'; + const subject = () => request(server).patch(`/users/projects/${slug}`); + + it(...checkNoAuthBehavior(subject)); + + it('update project', async () => { + await withCleanup(app, async () => { + await subject() + .withAuth() + .send({ + name: 'Test Project', + slug: 'test', + deadline: '2022-02-02', + goal: 'Test Goal', + shouldbe: 'Test Should Be', + }) + .expect(200); + const project = await repo.findOne({ where: { slug: 'test' } }); + expect(project).toMatchObject({ + name: 'Test Project', + slug: 'test', + deadline: dayjs('2022-02-02').toDate(), + goal: 'Test Goal', + shouldbe: 'Test Should Be', + }); + }); + }); + }); + + describe('Patch /users/tasks/:slug/archive', () => { + const slug = 'programming'; + const subject = () => + request(server).patch(`/users/projects/${slug}/archive`); + + it('archive project', async () => { + await withCleanup(app, async () => { + await subject().withAuth().expect(200); + const project = await repo.findOne({ where: { slug } }); + expect(project).toMatchObject({ + status: 'archived', + }); + }); + }); + }); + describe('Patch /users/tasks/:slug/reopen', () => { + const slug = 'programming'; + const subject = () => + request(server).patch(`/users/projects/${slug}/reopen`); + + it('reopen project', async () => { + await withCleanup(app, async () => { + await subject().withAuth().expect(200); + const project = await repo.findOne({ where: { slug } }); + expect(project).toMatchObject({ + status: 'active', + }); + }); + }); + }); + + afterAll(async () => { + return await app.close(); + }); +}); diff --git a/api/test/users/tasks.e2e-spec.ts b/api/test/users/tasks.e2e-spec.ts new file mode 100644 index 0000000..68ec455 --- /dev/null +++ b/api/test/users/tasks.e2e-spec.ts @@ -0,0 +1,572 @@ +import { INestApplication } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import dayjs from 'dayjs'; +import { Task } from '../../src/domains/tasks/task.entity'; +import { checkNoAuthBehavior, prepareApp } from '../helper'; +import { withCleanup, getRepository } from '../helper/databse'; +import request from '../helper/request'; + +const jwtService = { verifyAsync: () => ({ sub: 'user 1' }) }; +const today = dayjs(); + +const projectId = '9a1b53d8-4afc-4630-a26e-3634a10bf619'; + +describe('TasksController (e2e)', () => { + let app: INestApplication; + let server: any; + + beforeAll(async () => { + app = await prepareApp([ + { + provider: JwtService, + value: jwtService, + }, + ]); + server = app.getHttpServer(); + + return; + }); + + describe('Get /users/tasks', () => { + const subject = () => request(server).get('/users/tasks'); + + it(...checkNoAuthBehavior(subject)); + + it('witout any query parasm, return empty list', async () => { + const response = await subject().withAuth(); + expect(response.status).toEqual(200); + expect(response.body).toEqual({ + data: [], + pageInfo: { + hasNext: false, + hasPrevious: false, + limit: 20, + page: 1, + sortOrder: 'asc', + sortType: 'deadline', + totalCount: 0, + }, + }); + }); + + describe('with status', () => { + it('fetched scheduled task, return tasks list', async () => { + const response = await subject() + .withAuth() + .query({ 'status[]': ['scheduled'] }); + expect(response.status).toEqual(200); + expect(response.body).toMatchObject({ + data: expect.any(Array), + pageInfo: { + hasNext: true, + hasPrevious: false, + limit: 20, + page: 1, + sortOrder: 'asc', + sortType: 'deadline', + totalCount: 125, + }, + }); + }); + + it('fetched scheduled and completed task, return tasks list', async () => { + const response = await subject() + .withAuth() + .query({ 'status[]': ['scheduled', 'completed'] }); + expect(response.status).toEqual(200); + expect(response.body).toMatchObject({ + data: expect.any(Array), + pageInfo: { + hasNext: true, + hasPrevious: false, + limit: 20, + page: 1, + sortOrder: 'asc', + sortType: 'deadline', + totalCount: 134, + }, + }); + }); + + it('fetched scheduled, completed and archived task, return tasks list', async () => { + const response = await subject() + .withAuth() + .query({ 'status[]': ['scheduled', 'completed', 'archived'] }); + expect(response.status).toEqual(200); + expect(response.body).toMatchObject({ + data: expect.any(Array), + pageInfo: { + hasNext: true, + hasPrevious: false, + limit: 20, + page: 1, + sortOrder: 'asc', + sortType: 'deadline', + totalCount: 140, + }, + }); + }); + }); + + describe('with date params', () => { + it('with full params, return expeceted tasks list', async () => { + const response = await subject() + .withAuth() + .query({ + 'status[]': ['scheduled'], + dateFrom: today.add(1, 'year').add(-7, 'day').format('YYYY-MM-DD'), + dateTo: today.add(1, 'year').format('YYYY-MM-DD'), + dateType: 'deadline', + }); + expect(response.status).toEqual(200); + expect(response.body).toMatchObject({ + data: expect.any(Array), + pageInfo: { + hasNext: false, + hasPrevious: false, + limit: 20, + page: 1, + sortOrder: 'asc', + sortType: 'deadline', + totalCount: 18, + }, + }); + }); + + it('with only date from, return expeceted tasks list', async () => { + const response = await subject() + .withAuth() + .query({ + 'status[]': ['scheduled'], + dateFrom: today.add(1, 'year').add(-7, 'day').format('YYYY-MM-DD'), + dateType: 'deadline', + }); + expect(response.status).toEqual(200); + expect(response.body).toMatchObject({ + data: expect.any(Array), + pageInfo: { + hasNext: true, + hasPrevious: false, + limit: 20, + page: 1, + sortOrder: 'asc', + sortType: 'deadline', + totalCount: 21, + }, + }); + }); + + it('with only date to, return expeceted tasks list', async () => { + const response = await subject() + .withAuth() + .query({ + 'status[]': ['scheduled'], + dateTo: today.add(1, 'year').add(-7, 'day').format('YYYY-MM-DD'), + dateType: 'deadline', + }); + expect(response.status).toEqual(200); + expect(response.body).toMatchObject({ + data: expect.any(Array), + pageInfo: { + hasNext: true, + hasPrevious: false, + limit: 20, + page: 1, + sortOrder: 'asc', + sortType: 'deadline', + totalCount: 104, + }, + }); + }); + }); + + describe('with search params', () => { + it('with search, return expeceted tasks list', async () => { + const response = await subject() + .withAuth() + .query({ + 'status[]': ['scheduled'], + search: 'Task 1', + }); + expect(response.status).toEqual(200); + expect(response.body).toMatchObject({ + data: expect.any(Array), + pageInfo: { + hasNext: true, + hasPrevious: false, + limit: 20, + page: 1, + sortOrder: 'asc', + sortType: 'deadline', + totalCount: 23, + }, + }); + }); + }); + + describe('with limit and page', () => { + it('return expeceted tasks list', async () => { + const page1 = await subject() + .withAuth() + .query({ + 'status[]': ['scheduled'], + limit: 50, + page: 1, + }); + expect(page1.status).toEqual(200); + expect(page1.body).toMatchObject({ + data: expect.any(Array), + pageInfo: { + hasNext: true, + hasPrevious: false, + limit: 50, + page: 1, + sortOrder: 'asc', + sortType: 'deadline', + totalCount: 125, + }, + }); + + const page2 = await subject() + .withAuth() + .query({ + 'status[]': ['scheduled'], + limit: 50, + page: 2, + }); + expect(page2.status).toEqual(200); + expect(page2.body).toMatchObject({ + data: expect.any(Array), + pageInfo: { + hasNext: true, + hasPrevious: true, + limit: 50, + page: 2, + sortOrder: 'asc', + sortType: 'deadline', + totalCount: 125, + }, + }); + + const page3 = await subject() + .withAuth() + .query({ + 'status[]': ['scheduled'], + limit: 50, + page: 3, + }); + expect(page3.status).toEqual(200); + expect(page3.body).toMatchObject({ + data: expect.any(Array), + pageInfo: { + hasNext: false, + hasPrevious: true, + limit: 50, + page: 3, + sortOrder: 'asc', + sortType: 'deadline', + totalCount: 125, + }, + }); + }); + }); + + describe('with projectId', () => { + it('return tasks only in the project', async () => { + const response = await subject() + .withAuth() + .query({ + 'status[]': ['scheduled'], + projectId, + limit: 20, + page: 1, + }); + expect(response.status).toEqual(200); + expect(response.body).toMatchObject({ + data: expect.any(Array), + pageInfo: { + hasNext: true, + hasPrevious: false, + limit: 20, + page: 1, + sortOrder: 'asc', + sortType: 'deadline', + totalCount: 95, + }, + }); + }); + }); + + describe('with sortOrder and sortType', () => { + it('deadline, desc', async () => { + const response = await subject() + .withAuth() + .query({ + 'status[]': ['scheduled'], + projectId, + sortOrder: 'desc', + limit: 3, + page: 1, + }); + expect(response.status).toEqual(200); + expect(response.body).toMatchObject({ + data: [ + { + id: expect.any(Number), + title: 'Task 0', + }, + { + id: expect.any(Number), + title: 'Task 1', + }, + { + id: expect.any(Number), + title: 'Task 2', + }, + ], + pageInfo: { + hasNext: true, + hasPrevious: false, + limit: 3, + page: 1, + sortOrder: 'desc', + sortType: 'deadline', + totalCount: 95, + }, + }); + }); + + it('createdAt, asc', async () => { + const response = await subject() + .withAuth() + .query({ + 'status[]': ['scheduled'], + projectId, + sortType: 'createdAt', + limit: 3, + page: 1, + }); + expect(response.status).toEqual(200); + expect(response.body).toMatchObject({ + data: [ + { + id: expect.any(Number), + title: 'Task 0', + }, + { + id: expect.any(Number), + title: 'Task 1', + }, + { + id: expect.any(Number), + title: 'Task 2', + }, + ], + pageInfo: { + hasNext: true, + hasPrevious: false, + limit: 3, + page: 1, + sortOrder: 'asc', + sortType: 'createdAt', + totalCount: 95, + }, + }); + }); + + it('createdAt, desc', async () => { + const response = await subject() + .withAuth() + .query({ + 'status[]': ['scheduled'], + projectId, + sortOrder: 'desc', + sortType: 'createdAt', + limit: 3, + page: 1, + }); + expect(response.status).toEqual(200); + expect(response.body).toMatchObject({ + data: [ + { + id: expect.any(Number), + title: 'Task 94', + }, + { + id: expect.any(Number), + title: 'Task 93', + }, + { + id: expect.any(Number), + title: 'Task 92', + }, + ], + pageInfo: { + hasNext: true, + hasPrevious: false, + limit: 3, + page: 1, + sortOrder: 'desc', + sortType: 'createdAt', + totalCount: 95, + }, + }); + }); + }); + }); + + describe('Post /users/tasks', () => { + const subject = () => request(server).post(`/users/tasks`); + it(...checkNoAuthBehavior(subject)); + it('create task', async () => { + await withCleanup(app, async () => { + const repo = getRepository(app, Task); + const before = await repo.count(); + await subject() + .withAuth() + .send({ + title: 'Task XXX', + projectId, + deadline: '2026-01-01', + startingAt: undefined, + status: 'scheduled', + kind: 'task', + }) + .expect(201); + const after = await repo.count(); + expect(after - before).toEqual(1); + }); + }); + }); + + describe('Get /users/tasks/:id', () => { + const uuid = '067176b2-baaf-4936-b7d4-6d202ab72639'; + const subject = () => request(server).get(`/users/tasks/${uuid}`); + it(...checkNoAuthBehavior(subject)); + + it('not found', () => { + const uuid = '[not found uuid]'; + request(server).get(`/users/tasks/${uuid}`).expect(404); + }); + + it('fetch task', async () => { + const response = await subject() + .withAuth() + .query({ + 'status[]': ['scheduled'], + projectId, + sortOrder: 'desc', + sortType: 'createdAt', + limit: 3, + page: 1, + }); + expect(response.status).toEqual(200); + expect(response.body).toMatchObject({ + data: { + id: expect.any(Number), + uuid, + status: 'scheduled', + }, + }); + }); + }); + + describe('Patch /users/tasks/:id', () => { + const uuid = '067176b2-baaf-4936-b7d4-6d202ab72639'; + const subject = () => request(server).patch(`/users/tasks/${uuid}`); + it(...checkNoAuthBehavior(subject)); + + it('update task', async () => { + await withCleanup(app, async () => { + const response = await subject().withAuth().send({ + title: 'Updated', + projectId: 'ee9f5f2e-fc8c-4830-985a-a44e96e96ffe', + status: 'archived', + deadline: '2022-01-01', + finishedAt: '2022-01-01', + startingAt: '2022-01-01', + }); + expect(response.status).toEqual(200); + expect(response.body).toMatchObject({ + data: { + id: expect.any(Number), + uuid, + title: 'Updated', + projectId: 2, + status: 'archived', + deadline: '2022-01-01', + finishedAt: '2022-01-01', + startingAt: '2022-01-01', + }, + }); + }); + }); + }); + + describe('Put /users/tasks/:id/archive', () => { + const uuid = '067176b2-baaf-4936-b7d4-6d202ab72639'; + const subject = () => request(server).put(`/users/tasks/${uuid}/archive`); + it(...checkNoAuthBehavior(subject)); + + it('archive task', async () => { + await withCleanup(app, async () => { + const response = await subject().withAuth(); + expect(response.status).toEqual(200); + expect(response.body).toMatchObject({ + data: { + id: expect.any(Number), + title: 'Task 0', + uuid, + status: 'archived', + }, + }); + }); + }); + }); + + describe('Put /users/tasks/:id/complete', () => { + const uuid = '067176b2-baaf-4936-b7d4-6d202ab72639'; + const subject = () => request(server).put(`/users/tasks/${uuid}/complete`); + it(...checkNoAuthBehavior(subject)); + + it('complete task', async () => { + await withCleanup(app, async () => { + const response = await subject().withAuth(); + expect(response.status).toEqual(200); + expect(response.body).toMatchObject({ + data: { + id: expect.any(Number), + title: 'Task 0', + uuid, + status: 'completed', + }, + }); + }); + }); + }); + + describe('Put /users/tasks/:id/reopen', () => { + const uuid = '067176b2-baaf-4936-b7d4-6d202ab72639'; + const subject = () => request(server).put(`/users/tasks/${uuid}/reopen`); + it(...checkNoAuthBehavior(subject)); + + it('complete task', async () => { + await withCleanup(app, async () => { + const response = await subject().withAuth(); + expect(response.status).toEqual(200); + expect(response.body).toMatchObject({ + data: { + id: expect.any(Number), + title: 'Task 0', + uuid, + status: 'scheduled', + }, + }); + }); + }); + }); + + afterAll(async () => { + return await app.close(); + }); +}); diff --git a/api/test/users/users.e2e-spec.ts b/api/test/users/users.e2e-spec.ts new file mode 100644 index 0000000..6986814 --- /dev/null +++ b/api/test/users/users.e2e-spec.ts @@ -0,0 +1,53 @@ +import { INestApplication } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { checkNoAuthBehavior, prepareApp } from '../helper'; +import request from '../helper/request'; + +describe('UsersController (e2e)', () => { + let app: INestApplication; + let server: any; + const jwtService = { verifyAsync: () => ({ sub: 'user 1' }) }; + + beforeAll(async () => { + app = await prepareApp([ + { + provider: JwtService, + value: jwtService, + }, + ]); + + server = app?.getHttpServer(); + return; + }); + + describe('Get /users/me', () => { + const subject = () => request(server).get('/users/me'); + + it(...checkNoAuthBehavior(subject)); + + it(`with auth token, return 200`, async () => { + const response = await subject().withAuth(); + + expect(response.status).toEqual(200); + expect(response.body).toMatchObject({ + data: { + id: 1, + uuid: expect.any(String), + createdAt: expect.any(String), + updatedAt: expect.any(String), + username: 'user 1', + email: 'user.1@example.com', + password: expect.any(String), + refreshToken: expect.any(String), + status: 'active', + }, + }); + }); + + return; + }); + + afterAll(async () => { + await app.close(); + }); +}); diff --git a/frontend/core/src/app/main/projects/[slug]/page.tsx b/frontend/core/src/app/main/projects/[slug]/page.tsx index 3c80894..f9ed15b 100644 --- a/frontend/core/src/app/main/projects/[slug]/page.tsx +++ b/frontend/core/src/app/main/projects/[slug]/page.tsx @@ -65,7 +65,7 @@ export default function Project({ params: { slug } }: Props) { const fetch = useCallback( async ({ slug }: { slug: string }) => { const res = await api.fetchProject({ slug }); - const item = factory.project(res.data); + const item = factory.project(res.data.data); setProject(item); },