Skip to content

Commit

Permalink
Tasks: Initial implementation (#224)
Browse files Browse the repository at this point in the history
* save

* bump

* start and stop tasks

* save

* fix: fitness data not awaited

* enh: Added more tasks

* add single task endpoint

* added some tests

Signed-off-by: Henry Brink <[email protected]>

* added further tests

Signed-off-by: Henry Brink <[email protected]>

* fix: TaskLogs would already be considered started

* make api compliant to docs

* fix: enums are not correct in api doc

* linter & ghas errors

---------

Signed-off-by: Henry Brink <[email protected]>
  • Loading branch information
henrybrink authored Jun 14, 2024
1 parent 3ef0e6d commit ca4d7b0
Show file tree
Hide file tree
Showing 21 changed files with 670 additions and 8 deletions.
11 changes: 11 additions & 0 deletions backend/prisma/migrations/20240611203212_/migration.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
-- CreateTable
CREATE TABLE "TaskLog" (
"userId" TEXT NOT NULL,
"task" TEXT NOT NULL,
"start" DATETIME NOT NULL,
"end" DATETIME,
"status" TEXT NOT NULL,
"metadata" TEXT,

PRIMARY KEY ("userId", "task")
);
11 changes: 11 additions & 0 deletions backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,17 @@ model FitnessProviderCredential {
providerUserId String
}

model TaskLog {
userId String
task String
start DateTime
end DateTime?
status String
metadata String?
@@id([userId, task])
}

model Points {
userId String
owner User @relation(fields: [userId], references: [id])
Expand Down
2 changes: 2 additions & 0 deletions backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { ConfigModule } from '@nestjs/config';
import FitnessModule from './integration/fitness/fitness.module';
import configuration from './config/configuration';
import { NotificationModule } from './notification/notification.module';
import { TaskModule } from './app/tasks/task.module';
import { StreakModule } from './app/streaks/streak.module';

@Module({
Expand All @@ -20,6 +21,7 @@ import { StreakModule } from './app/streaks/streak.module';
}),
FitnessModule,
NotificationModule,
TaskModule,
StreakModule,
],
controllers: [AppController, UserController],
Expand Down
105 changes: 105 additions & 0 deletions backend/src/app/tasks/task.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import {
Body,
Controller,
Get,
Param,
Put,
Req,
Res,
UseGuards,
} from '@nestjs/common';
import {
ConcurrentTaskError,
FitnessDataNotAvailable,
TaskAlreadyCompleted,
TaskNotAvailableError,
TaskService,
} from './task.service';
import { AutoGuard } from '../../auth/auto.guard';
import { NestRequest } from '../../types/request.type';
import { Response } from 'express';

@Controller('task')
export class TaskController {
constructor(private taskService: TaskService) {}

@Get()
@UseGuards(AutoGuard)
public async getTasks(@Req() req: NestRequest) {
return (await this.taskService.getTasks(req.user.id)).map((t) =>
t.getInfo(),
);
}

@Put('/:id')
@UseGuards(AutoGuard)
public async putTask(
@Req() req: NestRequest,
@Param('id') id: string,
@Body('action') action: string,
@Res() response: Response,
) {
if (action == 'start') {
return this.startTask(req, id, response);
} else if (action == 'stop') {
return this.stopTask(req, id, response);
} else {
response
.status(500)
.json({ error: 'action must be either start or stop' });
}
}

private async startTask(req: NestRequest, id: string, response: Response) {
try {
await this.taskService.startTask(req.user.id, id);
const task = await this.taskService.getTask(req.user.id, id);

return response.json(task?.getInfo());
} catch (e) {
if (e instanceof TaskNotAvailableError) {
return response.status(400).json({ error: 'Invalid task' });
} else if (e instanceof FitnessDataNotAvailable) {
return response
.status(400)
.json({ error: 'No fitness provider connected.' });
}

if (e instanceof ConcurrentTaskError) {
return response
.status(400)
.json({ error: 'You already have a running task' });
} else if (e instanceof TaskAlreadyCompleted) {
return response
.status(400)
.json({ error: 'You already completed this task' });
}

response.status(500).json({ error: 'Unknown error' });
}
}

private async stopTask(req: NestRequest, id: string, response: Response) {
await this.taskService.stopTask(req.user.id, id);

const task = await this.taskService.getTask(req.user.id, id);

response.status(200).json(task?.getInfo());
}

@Get('/:id')
@UseGuards(AutoGuard)
public async getTask(
@Req() req: NestRequest,
@Param('id') id: string,
@Res() response: Response,
) {
const task = await this.taskService.getTask(req.user.id, id);

if (!task) {
return response.status(404).json({ error: 'Task not found' });
}

return response.status(200).json(task.getInfo());
}
}
12 changes: 12 additions & 0 deletions backend/src/app/tasks/task.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from '../../db/prisma.module';
import { TaskService } from './task.service';
import { TaskController } from './task.controller';
import FitnessModule from '../../integration/fitness/fitness.module';

@Module({
imports: [PrismaModule, FitnessModule],
providers: [TaskService],
controllers: [TaskController],
})
export class TaskModule {}
116 changes: 116 additions & 0 deletions backend/src/app/tasks/task.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { Test } from '@nestjs/testing';
import { TaskService } from './task.service';
import { PrismaModule } from '../../db/prisma.module';
import { DeepMockProxy, mockDeep } from 'jest-mock-extended';
import { TaskRepository } from '../../db/repositories/task.repository';
import { FitnessService } from '../../integration/fitness/fitness.service';
import FitnessModule from '../../integration/fitness/fitness.module';
import { TestConstants } from '../../../test/lib/constants';
import { MockProvider } from '../../integration/fitness/providers/mock.provider';

describe('task service tests', () => {
let taskService: TaskService;
let taskRepository: DeepMockProxy<TaskRepository>;
let fitnessService: DeepMockProxy<FitnessService>;

beforeAll(async () => {
const testModule = await Test.createTestingModule({
imports: [PrismaModule, FitnessModule],
providers: [TaskService],
})
.overrideProvider(TaskRepository)
.useValue(mockDeep<TaskRepository>())
.overrideProvider(FitnessService)
.useValue(mockDeep<FitnessService>())
.compile();

taskService = testModule.get(TaskService);
taskRepository = testModule.get(TaskRepository);
fitnessService = testModule.get(FitnessService);
});

it('it should return all available tasks including user info', async () => {
const logs = [
TestConstants.database.taskLogs.task1,
TestConstants.database.taskLogs.task2,
];

taskRepository.getTaskLogsForUser.mockResolvedValue(logs);

const tasks = await taskService.getTasks(
TestConstants.database.users.exampleUser.id,
);

expect(tasks.length).toBeGreaterThan(0);

const task1Info = tasks[0].getInfo();
expect(task1Info.id).toBe('1');
expect(task1Info.status).toBe('pending');

const task2Info = tasks[1].getInfo();
expect(task2Info.id).toBe('2');
expect(task2Info.status).toBe('failed');

const task3Info = tasks[2].getInfo();
expect(task3Info.id).toBe('3');
expect(task3Info.status).toBe('not started');
});

it('it should return all a specific including user info', async () => {
taskRepository.getTaskLog.mockResolvedValue(
TestConstants.database.taskLogs.task1,
);

const task = await taskService.getTask(
TestConstants.database.users.exampleUser.id,
TestConstants.database.taskLogs.task1.task,
);

expect(task).toBeDefined();

const task1Info = task!.getInfo();
expect(task1Info.id).toBe('1');
expect(task1Info.status).toBe('pending');
});

it('should be able to start a task', async () => {
taskRepository.getStartedTasksForUser.mockResolvedValue([]);

fitnessService.getDatasourcesForUser.mockResolvedValue([
new MockProvider(),
]);

taskRepository.saveTaskLog.mockResolvedValue(
TestConstants.database.taskLogs.task3,
);

taskRepository.getTaskLog.mockResolvedValue(null);

const log = await taskService.startTask(
TestConstants.database.users.exampleUser.id,
TestConstants.database.taskLogs.task3.task,
);

expect(log).toBeDefined();
expect(log.task).toBe('3');
expect(log.status).toBe('in progress');
});

it('should be able to stop a running task', async () => {
taskRepository.getTaskLog.mockResolvedValue(
TestConstants.database.taskLogs.task3,
);

taskRepository.updateTaskLog.mockResolvedValue(
TestConstants.database.taskLogs.task3,
);

const log = await taskService.stopTask(
TestConstants.database.users.exampleUser.id,
TestConstants.database.taskLogs.task3.task,
);

expect(log).toBeDefined();
expect(log.task).toBe('3');
});
});
Loading

0 comments on commit ca4d7b0

Please sign in to comment.