From 4bf50ea3fbd56cbba4b06fa84663c5eccc25e012 Mon Sep 17 00:00:00 2001 From: jean Date: Sun, 25 Aug 2024 14:58:20 +0200 Subject: [PATCH 1/5] feat: crud task according to workflow --- README.md | 5 - package.json | 13 +- src/app.ts | 1 + src/plugins/custom/workflow.ts | 33 +++ src/plugins/external/{1-env.ts => env.ts} | 0 src/routes/api/tasks/index.ts | 169 ++++++++++++++- src/schemas/tasks.ts | 53 ++++- test/helper.ts | 45 ++-- test/plugins/repository.test.ts | 2 +- test/plugins/scrypt.test.ts | 2 +- test/plugins/workflow.test.ts | 86 ++++++++ test/routes/api/tasks/tasks.test.ts | 247 +++++++++++++++++++++- test/tsconfig.json | 8 + 13 files changed, 624 insertions(+), 40 deletions(-) create mode 100644 src/plugins/custom/workflow.ts rename src/plugins/external/{1-env.ts => env.ts} (100%) create mode 100644 test/plugins/workflow.test.ts create mode 100644 test/tsconfig.json diff --git a/README.md b/README.md index b7afec18..d5301a6e 100644 --- a/README.md +++ b/README.md @@ -41,11 +41,6 @@ To build the project: npm run build ``` -In dev mode, you can use: -```bash -npm run watch -``` - ### Start the server In dev mode: ```bash diff --git a/package.json b/package.json index c962b482..f70144ee 100644 --- a/package.json +++ b/package.json @@ -8,11 +8,12 @@ "test": "test" }, "scripts": { - "build": "rm -rf dist && tsc", - "watch": "npm run build -- --watch", + "start": "npm run build && fastify start -l info dist/app.js", + "build": "tsc", + "watch": "tsc -w", + "dev": "npm run build && concurrently -k -p \"[{name}]\" -n \"TypeScript,App\" -c \"yellow.bold,cyan.bold\" \"npm:watch\" \"npm:dev:start\"", + "dev:start": "fastify start --ignore-watch=.ts$ -w -l info -P dist/app.js", "test": "npm run db:seed && tap --jobs=1 test/**/*", - "start": "fastify start -l info dist/app.js", - "dev": "fastify start -w -l info -P dist/app.js", "standalone": "node --env-file=.env dist/server.js", "lint": "eslint --ignore-pattern=dist", "lint:fix": "npm run lint -- --fix", @@ -35,7 +36,9 @@ "@fastify/swagger-ui": "^4.0.1", "@fastify/type-provider-typebox": "^4.0.0", "@fastify/under-pressure": "^8.3.0", + "@jean-michelet/workflow": "^1.0.7", "@sinclair/typebox": "^0.33.7", + "concurrently": "^8.2.2", "fastify": "^4.26.1", "fastify-cli": "^6.1.1", "fastify-plugin": "^4.0.0", @@ -47,7 +50,7 @@ "fastify-tsconfig": "^2.0.0", "mysql2": "^3.10.1", "neostandard": "^0.7.0", - "tap": "^19.2.2", + "tap": "^21.0.1", "typescript": "^5.4.5" } } diff --git a/src/app.ts b/src/app.ts index 8cf458a0..23734d02 100644 --- a/src/app.ts +++ b/src/app.ts @@ -10,6 +10,7 @@ export default async function serviceApp( fastify: FastifyInstance, opts: FastifyPluginOptions ) { + delete opts.skipOverride // This option only serves testing purpose // This loads all external plugins defined in plugins/external // those should be registered first as your custom plugins might depend on them await fastify.register(fastifyAutoload, { diff --git a/src/plugins/custom/workflow.ts b/src/plugins/custom/workflow.ts new file mode 100644 index 00000000..b79ac1e8 --- /dev/null +++ b/src/plugins/custom/workflow.ts @@ -0,0 +1,33 @@ +import fp from 'fastify-plugin'; +import { MultiOriginTransition, Transition, Workflow } from '@jean-michelet/workflow'; +import { Task, TaskStatus, TaskTransitions } from '../../schemas/tasks.js'; + +declare module "fastify" { + export interface FastifyInstance { + taskWorkflow: ReturnType; + } + } + +function createTaskWorkflow() { + const wf = new Workflow({ + stateProperty: 'status' + }) + + wf.addTransition(TaskTransitions.Start, new Transition(TaskStatus.New, TaskStatus.InProgress)); + wf.addTransition(TaskTransitions.Complete, new Transition(TaskStatus.InProgress, TaskStatus.Completed)); + wf.addTransition(TaskTransitions.Hold, new Transition(TaskStatus.InProgress, TaskStatus.OnHold)); + wf.addTransition(TaskTransitions.Resume, new Transition(TaskStatus.OnHold, TaskStatus.InProgress)); + wf.addTransition(TaskTransitions.Cancel, new MultiOriginTransition( + [TaskStatus.New, TaskStatus.InProgress, TaskStatus.OnHold], + TaskStatus.Canceled + )); + wf.addTransition(TaskTransitions.Archive, new Transition(TaskStatus.Completed, TaskStatus.Archived)); + + return wf +} + +export default fp(async (fastify) => { + fastify.decorate('taskWorkflow', createTaskWorkflow()); +}, { + name: 'workflow' +}); diff --git a/src/plugins/external/1-env.ts b/src/plugins/external/env.ts similarity index 100% rename from src/plugins/external/1-env.ts rename to src/plugins/external/env.ts diff --git a/src/routes/api/tasks/index.ts b/src/routes/api/tasks/index.ts index 2d8f29b5..09c9ef9c 100644 --- a/src/routes/api/tasks/index.ts +++ b/src/routes/api/tasks/index.ts @@ -2,7 +2,15 @@ import { FastifyPluginAsyncTypebox, Type } from "@fastify/type-provider-typebox"; -import { TaskSchema } from "../../../schemas/tasks.js"; +import { + TaskSchema, + Task, + CreateTaskSchema, + UpdateTaskSchema, + TaskStatus, + PatchTaskTransitionSchema +} from "../../../schemas/tasks.js"; +import { FastifyReply } from "fastify"; const plugin: FastifyPluginAsyncTypebox = async (fastify) => { fastify.get( @@ -16,9 +24,166 @@ const plugin: FastifyPluginAsyncTypebox = async (fastify) => { } }, async function () { - return [{ id: 1, name: "Do something..." }]; + const tasks = await fastify.repository.findMany("tasks"); + + return tasks; + } + ); + + fastify.get( + "/:id", + { + schema: { + params: Type.Object({ + id: Type.Number() + }), + response: { + 200: TaskSchema, + 404: Type.Object({ message: Type.String() }) + }, + tags: ["Tasks"] + } + }, + async function (request, reply) { + const { id } = request.params; + const task = await fastify.repository.find("tasks", { where: { id } }); + + if (!task) { + return notFound(reply); + } + + return task; + } + ); + + fastify.post( + "/", + { + schema: { + body: CreateTaskSchema, + response: { + 201: { + id: Type.Number() + } + }, + tags: ["Tasks"] + } + }, + async function (request, reply) { + const id = await fastify.repository.create("tasks", { data: {...request.body, status: TaskStatus.New} }); + reply.code(201); + + return { + id + }; + } + ); + + fastify.patch( + "/:id", + { + schema: { + params: Type.Object({ + id: Type.Number() + }), + body: UpdateTaskSchema, + response: { + 200: TaskSchema, + 404: Type.Object({ message: Type.String() }) + }, + tags: ["Tasks"] + } + }, + async function (request, reply) { + const { id } = request.params; + const affectedRows = await fastify.repository.update("tasks", { + data: request.body, + where: { id } + }); + + if (affectedRows === 0) { + return notFound(reply) + } + + const task = await fastify.repository.find("tasks", { where: { id } }); + + return task as Task; + } + ); + + fastify.patch( + "/:id/transition", + { + schema: { + params: Type.Object({ + id: Type.Number() + }), + body: PatchTaskTransitionSchema, + response: { + 200: Type.Object({ message: Type.String() }), + 404: Type.Object({ message: Type.String() }) + }, + tags: ["Tasks"] + } + }, + async function (request, reply) { + const { id } = request.params; + const { transition } = request.body; + + const task = await fastify.repository.find("tasks", { select: 'status', where: { id } }); + + if (!task) { + return notFound(reply); + } + + if (!fastify.taskWorkflow.can(transition, task)) { + reply.status(400) + return { message: `Transition "${transition}" can not be applied to task with status "${task.status}"` } + } + + fastify.taskWorkflow.apply(transition, task) + + await fastify.repository.update("tasks", { + data: { status: task.status }, + where: { id } + }); + + return { + message: "Status changed" + }; + } + ); + + fastify.delete( + "/:id", + { + schema: { + params: Type.Object({ + id: Type.Number() + }), + response: { + 204: Type.Null(), + 404: Type.Object({ message: Type.String() }) + }, + tags: ["Tasks"] + } + }, + async function (request, reply) { + const { id } = request.params; + const affectedRows = await fastify.repository.delete("tasks", { id }); + + if (affectedRows === 0) { + return notFound(reply) + } + + reply.code(204).send(null); } ); }; +function notFound(reply: FastifyReply) { + reply.code(404) + return { message: "Task not found" } +} + export default plugin; diff --git a/src/schemas/tasks.ts b/src/schemas/tasks.ts index 6f506c5c..3aed1ddf 100644 --- a/src/schemas/tasks.ts +++ b/src/schemas/tasks.ts @@ -1,6 +1,55 @@ -import { Type } from "@sinclair/typebox"; +import { Static, Type } from "@sinclair/typebox"; + +export const TaskTransitions = { + Start: "start", + Complete: "complete", + Hold: "hold", + Resume: "resume", + Cancel: "cancel", + Archive: "archive" +} as const; + +export const TaskStatus = { + New: 'new', + InProgress: 'in-progress', + OnHold: 'on-hold', + Completed: 'completed', + Canceled: 'canceled', + Archived: 'archived' +} as const; + +export type TaskStatusType = typeof TaskStatus[keyof typeof TaskStatus]; export const TaskSchema = Type.Object({ id: Type.Number(), - name: Type.String() + name: Type.String(), + author_id: Type.Number(), + assigned_user_id: Type.Optional(Type.Number()), + status: Type.String(), + created_at: Type.String({ format: "date-time" }), + updated_at: Type.String({ format: "date-time" }) +}); + +export interface Task extends Static {} + +export const CreateTaskSchema = Type.Object({ + name: Type.String(), + author_id: Type.Number(), + assigned_user_id: Type.Optional(Type.Number()) +}); + +export const UpdateTaskSchema = Type.Object({ + name: Type.Optional(Type.String()), + assigned_user_id: Type.Optional(Type.Number()) +}); + +export const PatchTaskTransitionSchema = Type.Object({ + transition: Type.Union([ + Type.Literal(TaskTransitions.Start), + Type.Literal(TaskTransitions.Complete), + Type.Literal(TaskTransitions.Hold), + Type.Literal(TaskTransitions.Resume), + Type.Literal(TaskTransitions.Cancel), + Type.Literal(TaskTransitions.Archive) + ]) }); diff --git a/test/helper.ts b/test/helper.ts index ce16fe95..14c00f3c 100644 --- a/test/helper.ts +++ b/test/helper.ts @@ -1,21 +1,36 @@ // This file contains code that we reuse // between our tests. -import { InjectOptions } from "fastify"; +import { FastifyInstance, InjectOptions } from "fastify"; import { build as buildApplication } from "fastify-cli/helper.js"; import path from "node:path"; import { TestContext } from "node:test"; +declare module "fastify" { + interface FastifyInstance { + login: typeof login; + injectWithLogin: typeof injectWithLogin + } +} + + const AppPath = path.join(import.meta.dirname, "../src/app.ts"); // Fill in this config with all the configurations // needed for testing the application export function config() { - return {}; + return { + skipOverride: "true" // Register our application with fastify-plugin + }; } +const tokens: Record = {} // We will create different users with different roles -async function login(username: string) { +async function login(this: FastifyInstance, username: string) { + if (tokens[username]) { + return tokens[username] + } + const res = await this.inject({ method: "POST", url: "/api/auth/login", @@ -25,9 +40,20 @@ async function login(username: string) { } }); - return JSON.parse(res.payload).token; + tokens[username] = JSON.parse(res.payload).token; + + return tokens[username] } +async function injectWithLogin(this: FastifyInstance, username: string, opts: InjectOptions) { + opts.headers = { + ...opts.headers, + Authorization: `Bearer ${await this.login(username)}` + }; + + return this.inject(opts); +}; + // automatically build and tear down our instance export async function build(t: TestContext) { // you can set all the options supported by the fastify CLI command @@ -36,18 +62,11 @@ export async function build(t: TestContext) { // fastify-plugin ensures that all decorators // are exposed for testing purposes, this is // different from the production setup - const app = await buildApplication(argv, config()); + const app = await buildApplication(argv, config()) as FastifyInstance; app.login = login; - app.injectWithLogin = async (username: string, opts: InjectOptions) => { - opts.headers = { - ...opts.headers, - Authorization: `Bearer ${await app.login(username)}` - }; - - return app.inject(opts); - }; + app.injectWithLogin = injectWithLogin // close the app after we are done t.after(() => app.close()); diff --git a/test/plugins/repository.test.ts b/test/plugins/repository.test.ts index 2f143a78..4244f636 100644 --- a/test/plugins/repository.test.ts +++ b/test/plugins/repository.test.ts @@ -3,7 +3,7 @@ import assert from "node:assert"; import { execSync } from "child_process"; import Fastify from "fastify"; import repository from "../../src/plugins/custom/repository.js"; -import * as envPlugin from "../../src/plugins/external/1-env.js"; +import * as envPlugin from "../../src/plugins/external/env.js"; import * as mysqlPlugin from "../../src/plugins/external/mysql.js"; import { Auth } from '../../src/schemas/auth.js'; diff --git a/test/plugins/scrypt.test.ts b/test/plugins/scrypt.test.ts index 5b8e6ac7..2d2f6d62 100644 --- a/test/plugins/scrypt.test.ts +++ b/test/plugins/scrypt.test.ts @@ -1,6 +1,6 @@ import { test } from "tap"; import Fastify from "fastify"; -import scryptPlugin from "../../src/plugins/custom/scrypt.ts"; +import scryptPlugin from "../../src/plugins/custom/scrypt.js"; test("scrypt works standalone", async t => { const app = Fastify(); diff --git a/test/plugins/workflow.test.ts b/test/plugins/workflow.test.ts new file mode 100644 index 00000000..355e9035 --- /dev/null +++ b/test/plugins/workflow.test.ts @@ -0,0 +1,86 @@ +import Fastify, { FastifyInstance } from "fastify"; +import { Task, TaskStatus, TaskTransitions } from "../../src/schemas/tasks.js"; +import workflowPlugin from "../../src/plugins/custom/workflow.js"; +import assert from "assert"; +import { after, before, describe, it } from "node:test"; + +describe("workflow", () => { + let app: FastifyInstance; + + before(async () => { + app = Fastify(); + app.register(workflowPlugin); + + await app.ready() + }); + + after(() => app.close()); + + it("Start transition", async () => { + const task = { status: TaskStatus.New } as Task; + assert.ok(app.taskWorkflow.can(TaskTransitions.Start, task)); + assert.ok(app.taskWorkflow.can(TaskTransitions.Cancel, task)); + + assert.ok(!app.taskWorkflow.can(TaskTransitions.Complete, task)); + assert.ok(!app.taskWorkflow.can(TaskTransitions.Hold, task)); + assert.ok(!app.taskWorkflow.can(TaskTransitions.Resume, task)); + assert.ok(!app.taskWorkflow.can(TaskTransitions.Archive, task)); + }); + + it("In Progress transitions", async () => { + const task = { status: TaskStatus.InProgress } as Task; + assert.ok(app.taskWorkflow.can(TaskTransitions.Complete, task)); + assert.ok(app.taskWorkflow.can(TaskTransitions.Hold, task)); + assert.ok(app.taskWorkflow.can(TaskTransitions.Cancel, task)); + + assert.ok(!app.taskWorkflow.can(TaskTransitions.Start, task)); + assert.ok(!app.taskWorkflow.can(TaskTransitions.Resume, task)); + assert.ok(!app.taskWorkflow.can(TaskTransitions.Archive, task)); + }); + + it("On Hold transitions", async () => { + + const task = { status: TaskStatus.OnHold } as Task; + assert.ok(app.taskWorkflow.can(TaskTransitions.Resume, task)); + assert.ok(app.taskWorkflow.can(TaskTransitions.Cancel, task)); + + assert.ok(!app.taskWorkflow.can(TaskTransitions.Start, task)); + assert.ok(!app.taskWorkflow.can(TaskTransitions.Complete, task)); + assert.ok(!app.taskWorkflow.can(TaskTransitions.Hold, task)); + assert.ok(!app.taskWorkflow.can(TaskTransitions.Archive, task)); + }); + + it("Completed transitions", async () => { + + const task = { status: TaskStatus.Completed } as Task; + assert.ok(app.taskWorkflow.can(TaskTransitions.Archive, task)); + + assert.ok(!app.taskWorkflow.can(TaskTransitions.Start, task)); + assert.ok(!app.taskWorkflow.can(TaskTransitions.Complete, task)); + assert.ok(!app.taskWorkflow.can(TaskTransitions.Hold, task)); + assert.ok(!app.taskWorkflow.can(TaskTransitions.Resume, task)); + assert.ok(!app.taskWorkflow.can(TaskTransitions.Cancel, task)); + }); + + it("Canceled transitions", async () => { + + const task = { status: TaskStatus.Canceled } as Task; + + assert.ok(!app.taskWorkflow.can(TaskTransitions.Start, task)); + assert.ok(!app.taskWorkflow.can(TaskTransitions.Complete, task)); + assert.ok(!app.taskWorkflow.can(TaskTransitions.Hold, task)); + assert.ok(!app.taskWorkflow.can(TaskTransitions.Resume, task)); + assert.ok(!app.taskWorkflow.can(TaskTransitions.Archive, task)); + }); + + it("Archived transitions", async () => { + + const task = { status: TaskStatus.Archived } as Task; + + assert.ok(!app.taskWorkflow.can(TaskTransitions.Start, task)); + assert.ok(!app.taskWorkflow.can(TaskTransitions.Complete, task)); + assert.ok(!app.taskWorkflow.can(TaskTransitions.Hold, task)); + assert.ok(!app.taskWorkflow.can(TaskTransitions.Resume, task)); + assert.ok(!app.taskWorkflow.can(TaskTransitions.Cancel, task)); + }); +}); diff --git a/test/routes/api/tasks/tasks.test.ts b/test/routes/api/tasks/tasks.test.ts index 8dc2d33e..ab5a4dc3 100644 --- a/test/routes/api/tasks/tasks.test.ts +++ b/test/routes/api/tasks/tasks.test.ts @@ -1,17 +1,242 @@ -import { test } from "node:test"; +import { describe, it } from "node:test"; import assert from "node:assert"; import { build } from "../../../helper.js"; +import { Task, TaskStatus, TaskTransitions } from "../../../../src/schemas/tasks.js"; +import { FastifyInstance } from "fastify"; -test("GET /api/tasks with valid JWT Token should return 200", async (t) => { - const app = await build(t); +async function createTask(app: FastifyInstance, taskData: Partial) { + return await app.repository.create("tasks", { data: taskData }); +} - const res = await app.injectWithLogin("basic", { - method: "GET", - url: "/api/tasks" +describe('Tasks api (logged user only)', () => { + describe('GET /api/tasks', () => { + it("should return a list of tasks", async (t) => { + const app = await build(t); + + const taskData = { + name: "New Task", + author_id: 1, + status: TaskStatus.New + }; + + const newTaskId = await app.repository.create("tasks", { data: taskData }); + + const res = await app.injectWithLogin("basic", { + method: "GET", + url: "/api/tasks" + }); + + assert.strictEqual(res.statusCode, 200); + const tasks = JSON.parse(res.payload) as Task[]; + const createdTask = tasks.find((task) => task.id === newTaskId); + assert.ok(createdTask, "Created task should be in the response"); + + assert.deepStrictEqual(taskData.name, createdTask.name); + assert.strictEqual(taskData.author_id, createdTask.author_id); + assert.strictEqual(taskData.status, createdTask.status); + }); + }) + + describe('GET /api/tasks/:id', () => { + it("should return a task", async (t) => { + const app = await build(t); + + const taskData = { + name: "Single Task", + author_id: 1, + status: TaskStatus.New + }; + + const newTaskId = await createTask(app, taskData); + + const res = await app.injectWithLogin("basic", { + method: "GET", + url: `/api/tasks/${newTaskId}` + }); + + assert.strictEqual(res.statusCode, 200); + const task = JSON.parse(res.payload) as Task; + assert.equal(task.id, newTaskId); + }); + + it("should return 404 if task is not found", async (t) => { + const app = await build(t); + + const res = await app.injectWithLogin("basic", { + method: "GET", + url: "/api/tasks/9999" + }); + + assert.strictEqual(res.statusCode, 404); + const payload = JSON.parse(res.payload); + assert.strictEqual(payload.message, "Task not found"); + }); + }); + + describe('POST /api/tasks', () => { + it("should create a new task", async (t) => { + const app = await build(t); + + const taskData = { + name: "New Task", + author_id: 1 + }; + + const res = await app.injectWithLogin("basic", { + method: "POST", + url: "/api/tasks", + payload: taskData + }); + + assert.strictEqual(res.statusCode, 201); + const { id } = JSON.parse(res.payload); + + const createdTask = await app.repository.find("tasks", { select: 'name', where: { id } }) as Task; + assert.equal(createdTask.name, taskData.name); + }); + }); + + describe('PATCH /api/tasks/:id', () => { + it("should update an existing task", async (t) => { + const app = await build(t); + + const taskData = { + name: "Task to Update", + author_id: 1, + status: TaskStatus.New + }; + const newTaskId = await createTask(app, taskData); + + const updatedData = { + name: "Updated Task" + }; + + const res = await app.injectWithLogin("basic", { + method: "PATCH", + url: `/api/tasks/${newTaskId}`, + payload: updatedData + }); + + assert.strictEqual(res.statusCode, 200); + const updatedTask = await app.repository.find("tasks", { where: { id: newTaskId } }) as Task; + assert.equal(updatedTask.name, updatedData.name); + }); + + it("should return 404 if task is not found for update", async (t) => { + const app = await build(t); + + const updatedData = { + name: "Updated Task" + }; + + const res = await app.injectWithLogin("basic", { + method: "PATCH", + url: "/api/tasks/9999", + payload: updatedData + }); + + assert.strictEqual(res.statusCode, 404); + const payload = JSON.parse(res.payload); + assert.strictEqual(payload.message, "Task not found"); + }); + }); + + describe('PATCH /api/tasks/:id/transition', () => { + it("should apply transition if valid", async (t) => { + const app = await build(t); + + const taskData = { + name: "Task to Patch", + author_id: 1, + status: TaskStatus.New + }; + const newTaskId = await createTask(app, taskData); + + const patchData = { transition: TaskTransitions.Start}; + + const res = await app.injectWithLogin("basic", { + method: "PATCH", + url: `/api/tasks/${newTaskId}/transition`, + payload: patchData + }); + + assert.strictEqual(res.statusCode, 200); + const message = JSON.parse(res.payload).message; + assert.strictEqual(message, "Status changed"); + + const patchedTask = await app.repository.find("tasks", { where: { id: newTaskId } }) as Task; + assert.strictEqual(patchedTask.status, TaskStatus.InProgress); + + const res2 = await app.injectWithLogin("basic", { + method: "PATCH", + url: `/api/tasks/${newTaskId}/transition`, + payload: { ...patchData, transition: 'invalid' } + }); + + assert.strictEqual(res2.statusCode, 400); + + const res3 = await app.injectWithLogin("basic", { + method: "PATCH", + url: `/api/tasks/${newTaskId}/transition`, + payload: { ...patchData, transition: TaskTransitions.Start } + }); + + assert.strictEqual(res3.statusCode, 400); + assert.deepStrictEqual(JSON.parse(res3.body), { + message: `Transition "${TaskTransitions.Start}" can not be applied to task with status "in-progress"` + }) + }); + + it("should return 404 if task is not found", async (t) => { + const app = await build(t); + + const patchData = { transition: TaskTransitions.Start }; + + const res = await app.injectWithLogin("basic", { + method: "PATCH", + url: "/api/tasks/9999/transition", + payload: patchData + }); + + assert.strictEqual(res.statusCode, 404); + const payload = JSON.parse(res.payload); + assert.strictEqual(payload.message, "Task not found"); + }); }); - assert.strictEqual(res.statusCode, 200); - assert.deepStrictEqual(JSON.parse(res.payload), [ - { id: 1, name: "Do something..." } - ]); -}); + describe('DELETE /api/tasks/:id', () => { + it("should delete an existing task", async (t) => { + const app = await build(t); + + const taskData = { + name: "Task to Delete", + author_id: 1, + status: TaskStatus.New + }; + const newTaskId = await createTask(app, taskData); + + const res = await app.injectWithLogin("basic", { + method: "DELETE", + url: `/api/tasks/${newTaskId}` + }); + + assert.strictEqual(res.statusCode, 204); + + const deletedTask = await app.repository.find("tasks", { where: { id: newTaskId } }); + assert.strictEqual(deletedTask, null); + }); + + it("should return 404 if task is not found for deletion", async (t) => { + const app = await build(t); + + const res = await app.injectWithLogin("basic", { + method: "DELETE", + url: "/api/tasks/9999" + }); + + assert.strictEqual(res.statusCode, 404); + const payload = JSON.parse(res.payload); + assert.strictEqual(payload.message, "Task not found"); + }); + }); +}) diff --git a/test/tsconfig.json b/test/tsconfig.json new file mode 100644 index 00000000..66246dfe --- /dev/null +++ b/test/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "baseUrl": ".", + "noEmit": false + }, + "include": ["@types", "../src/**/*.ts", "**/*.ts"] +} From 3cdc6248a7ce19cf4c5469c0014864dbf79f9aaf Mon Sep 17 00:00:00 2001 From: jean Date: Tue, 10 Sep 2024 22:17:08 +0200 Subject: [PATCH 2/5] refactor: remove workflow --- package.json | 1 - src/plugins/custom/workflow.ts | 33 ----------- src/routes/api/tasks/index.ts | 46 +-------------- src/schemas/tasks.ts | 19 ------- test/helper.ts | 2 - test/plugins/workflow.test.ts | 86 ----------------------------- test/routes/api/tasks/tasks.test.ts | 65 +--------------------- 7 files changed, 2 insertions(+), 250 deletions(-) delete mode 100644 src/plugins/custom/workflow.ts delete mode 100644 test/plugins/workflow.test.ts diff --git a/package.json b/package.json index f70144ee..85f45e4f 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,6 @@ "@fastify/swagger-ui": "^4.0.1", "@fastify/type-provider-typebox": "^4.0.0", "@fastify/under-pressure": "^8.3.0", - "@jean-michelet/workflow": "^1.0.7", "@sinclair/typebox": "^0.33.7", "concurrently": "^8.2.2", "fastify": "^4.26.1", diff --git a/src/plugins/custom/workflow.ts b/src/plugins/custom/workflow.ts deleted file mode 100644 index b79ac1e8..00000000 --- a/src/plugins/custom/workflow.ts +++ /dev/null @@ -1,33 +0,0 @@ -import fp from 'fastify-plugin'; -import { MultiOriginTransition, Transition, Workflow } from '@jean-michelet/workflow'; -import { Task, TaskStatus, TaskTransitions } from '../../schemas/tasks.js'; - -declare module "fastify" { - export interface FastifyInstance { - taskWorkflow: ReturnType; - } - } - -function createTaskWorkflow() { - const wf = new Workflow({ - stateProperty: 'status' - }) - - wf.addTransition(TaskTransitions.Start, new Transition(TaskStatus.New, TaskStatus.InProgress)); - wf.addTransition(TaskTransitions.Complete, new Transition(TaskStatus.InProgress, TaskStatus.Completed)); - wf.addTransition(TaskTransitions.Hold, new Transition(TaskStatus.InProgress, TaskStatus.OnHold)); - wf.addTransition(TaskTransitions.Resume, new Transition(TaskStatus.OnHold, TaskStatus.InProgress)); - wf.addTransition(TaskTransitions.Cancel, new MultiOriginTransition( - [TaskStatus.New, TaskStatus.InProgress, TaskStatus.OnHold], - TaskStatus.Canceled - )); - wf.addTransition(TaskTransitions.Archive, new Transition(TaskStatus.Completed, TaskStatus.Archived)); - - return wf -} - -export default fp(async (fastify) => { - fastify.decorate('taskWorkflow', createTaskWorkflow()); -}, { - name: 'workflow' -}); diff --git a/src/routes/api/tasks/index.ts b/src/routes/api/tasks/index.ts index 09c9ef9c..660a76a7 100644 --- a/src/routes/api/tasks/index.ts +++ b/src/routes/api/tasks/index.ts @@ -7,8 +7,7 @@ import { Task, CreateTaskSchema, UpdateTaskSchema, - TaskStatus, - PatchTaskTransitionSchema + TaskStatus } from "../../../schemas/tasks.js"; import { FastifyReply } from "fastify"; @@ -111,49 +110,6 @@ const plugin: FastifyPluginAsyncTypebox = async (fastify) => { } ); - fastify.patch( - "/:id/transition", - { - schema: { - params: Type.Object({ - id: Type.Number() - }), - body: PatchTaskTransitionSchema, - response: { - 200: Type.Object({ message: Type.String() }), - 404: Type.Object({ message: Type.String() }) - }, - tags: ["Tasks"] - } - }, - async function (request, reply) { - const { id } = request.params; - const { transition } = request.body; - - const task = await fastify.repository.find("tasks", { select: 'status', where: { id } }); - - if (!task) { - return notFound(reply); - } - - if (!fastify.taskWorkflow.can(transition, task)) { - reply.status(400) - return { message: `Transition "${transition}" can not be applied to task with status "${task.status}"` } - } - - fastify.taskWorkflow.apply(transition, task) - - await fastify.repository.update("tasks", { - data: { status: task.status }, - where: { id } - }); - - return { - message: "Status changed" - }; - } - ); - fastify.delete( "/:id", { diff --git a/src/schemas/tasks.ts b/src/schemas/tasks.ts index 3aed1ddf..3ab96734 100644 --- a/src/schemas/tasks.ts +++ b/src/schemas/tasks.ts @@ -1,14 +1,5 @@ import { Static, Type } from "@sinclair/typebox"; -export const TaskTransitions = { - Start: "start", - Complete: "complete", - Hold: "hold", - Resume: "resume", - Cancel: "cancel", - Archive: "archive" -} as const; - export const TaskStatus = { New: 'new', InProgress: 'in-progress', @@ -43,13 +34,3 @@ export const UpdateTaskSchema = Type.Object({ assigned_user_id: Type.Optional(Type.Number()) }); -export const PatchTaskTransitionSchema = Type.Object({ - transition: Type.Union([ - Type.Literal(TaskTransitions.Start), - Type.Literal(TaskTransitions.Complete), - Type.Literal(TaskTransitions.Hold), - Type.Literal(TaskTransitions.Resume), - Type.Literal(TaskTransitions.Cancel), - Type.Literal(TaskTransitions.Archive) - ]) -}); diff --git a/test/helper.ts b/test/helper.ts index 14c00f3c..88013907 100644 --- a/test/helper.ts +++ b/test/helper.ts @@ -1,5 +1,3 @@ -// This file contains code that we reuse -// between our tests. import { FastifyInstance, InjectOptions } from "fastify"; import { build as buildApplication } from "fastify-cli/helper.js"; diff --git a/test/plugins/workflow.test.ts b/test/plugins/workflow.test.ts deleted file mode 100644 index 355e9035..00000000 --- a/test/plugins/workflow.test.ts +++ /dev/null @@ -1,86 +0,0 @@ -import Fastify, { FastifyInstance } from "fastify"; -import { Task, TaskStatus, TaskTransitions } from "../../src/schemas/tasks.js"; -import workflowPlugin from "../../src/plugins/custom/workflow.js"; -import assert from "assert"; -import { after, before, describe, it } from "node:test"; - -describe("workflow", () => { - let app: FastifyInstance; - - before(async () => { - app = Fastify(); - app.register(workflowPlugin); - - await app.ready() - }); - - after(() => app.close()); - - it("Start transition", async () => { - const task = { status: TaskStatus.New } as Task; - assert.ok(app.taskWorkflow.can(TaskTransitions.Start, task)); - assert.ok(app.taskWorkflow.can(TaskTransitions.Cancel, task)); - - assert.ok(!app.taskWorkflow.can(TaskTransitions.Complete, task)); - assert.ok(!app.taskWorkflow.can(TaskTransitions.Hold, task)); - assert.ok(!app.taskWorkflow.can(TaskTransitions.Resume, task)); - assert.ok(!app.taskWorkflow.can(TaskTransitions.Archive, task)); - }); - - it("In Progress transitions", async () => { - const task = { status: TaskStatus.InProgress } as Task; - assert.ok(app.taskWorkflow.can(TaskTransitions.Complete, task)); - assert.ok(app.taskWorkflow.can(TaskTransitions.Hold, task)); - assert.ok(app.taskWorkflow.can(TaskTransitions.Cancel, task)); - - assert.ok(!app.taskWorkflow.can(TaskTransitions.Start, task)); - assert.ok(!app.taskWorkflow.can(TaskTransitions.Resume, task)); - assert.ok(!app.taskWorkflow.can(TaskTransitions.Archive, task)); - }); - - it("On Hold transitions", async () => { - - const task = { status: TaskStatus.OnHold } as Task; - assert.ok(app.taskWorkflow.can(TaskTransitions.Resume, task)); - assert.ok(app.taskWorkflow.can(TaskTransitions.Cancel, task)); - - assert.ok(!app.taskWorkflow.can(TaskTransitions.Start, task)); - assert.ok(!app.taskWorkflow.can(TaskTransitions.Complete, task)); - assert.ok(!app.taskWorkflow.can(TaskTransitions.Hold, task)); - assert.ok(!app.taskWorkflow.can(TaskTransitions.Archive, task)); - }); - - it("Completed transitions", async () => { - - const task = { status: TaskStatus.Completed } as Task; - assert.ok(app.taskWorkflow.can(TaskTransitions.Archive, task)); - - assert.ok(!app.taskWorkflow.can(TaskTransitions.Start, task)); - assert.ok(!app.taskWorkflow.can(TaskTransitions.Complete, task)); - assert.ok(!app.taskWorkflow.can(TaskTransitions.Hold, task)); - assert.ok(!app.taskWorkflow.can(TaskTransitions.Resume, task)); - assert.ok(!app.taskWorkflow.can(TaskTransitions.Cancel, task)); - }); - - it("Canceled transitions", async () => { - - const task = { status: TaskStatus.Canceled } as Task; - - assert.ok(!app.taskWorkflow.can(TaskTransitions.Start, task)); - assert.ok(!app.taskWorkflow.can(TaskTransitions.Complete, task)); - assert.ok(!app.taskWorkflow.can(TaskTransitions.Hold, task)); - assert.ok(!app.taskWorkflow.can(TaskTransitions.Resume, task)); - assert.ok(!app.taskWorkflow.can(TaskTransitions.Archive, task)); - }); - - it("Archived transitions", async () => { - - const task = { status: TaskStatus.Archived } as Task; - - assert.ok(!app.taskWorkflow.can(TaskTransitions.Start, task)); - assert.ok(!app.taskWorkflow.can(TaskTransitions.Complete, task)); - assert.ok(!app.taskWorkflow.can(TaskTransitions.Hold, task)); - assert.ok(!app.taskWorkflow.can(TaskTransitions.Resume, task)); - assert.ok(!app.taskWorkflow.can(TaskTransitions.Cancel, task)); - }); -}); diff --git a/test/routes/api/tasks/tasks.test.ts b/test/routes/api/tasks/tasks.test.ts index ab5a4dc3..50495092 100644 --- a/test/routes/api/tasks/tasks.test.ts +++ b/test/routes/api/tasks/tasks.test.ts @@ -1,7 +1,7 @@ import { describe, it } from "node:test"; import assert from "node:assert"; import { build } from "../../../helper.js"; -import { Task, TaskStatus, TaskTransitions } from "../../../../src/schemas/tasks.js"; +import { Task, TaskStatus } from "../../../../src/schemas/tasks.js"; import { FastifyInstance } from "fastify"; async function createTask(app: FastifyInstance, taskData: Partial) { @@ -141,69 +141,6 @@ describe('Tasks api (logged user only)', () => { }); }); - describe('PATCH /api/tasks/:id/transition', () => { - it("should apply transition if valid", async (t) => { - const app = await build(t); - - const taskData = { - name: "Task to Patch", - author_id: 1, - status: TaskStatus.New - }; - const newTaskId = await createTask(app, taskData); - - const patchData = { transition: TaskTransitions.Start}; - - const res = await app.injectWithLogin("basic", { - method: "PATCH", - url: `/api/tasks/${newTaskId}/transition`, - payload: patchData - }); - - assert.strictEqual(res.statusCode, 200); - const message = JSON.parse(res.payload).message; - assert.strictEqual(message, "Status changed"); - - const patchedTask = await app.repository.find("tasks", { where: { id: newTaskId } }) as Task; - assert.strictEqual(patchedTask.status, TaskStatus.InProgress); - - const res2 = await app.injectWithLogin("basic", { - method: "PATCH", - url: `/api/tasks/${newTaskId}/transition`, - payload: { ...patchData, transition: 'invalid' } - }); - - assert.strictEqual(res2.statusCode, 400); - - const res3 = await app.injectWithLogin("basic", { - method: "PATCH", - url: `/api/tasks/${newTaskId}/transition`, - payload: { ...patchData, transition: TaskTransitions.Start } - }); - - assert.strictEqual(res3.statusCode, 400); - assert.deepStrictEqual(JSON.parse(res3.body), { - message: `Transition "${TaskTransitions.Start}" can not be applied to task with status "in-progress"` - }) - }); - - it("should return 404 if task is not found", async (t) => { - const app = await build(t); - - const patchData = { transition: TaskTransitions.Start }; - - const res = await app.injectWithLogin("basic", { - method: "PATCH", - url: "/api/tasks/9999/transition", - payload: patchData - }); - - assert.strictEqual(res.statusCode, 404); - const payload = JSON.parse(res.payload); - assert.strictEqual(payload.message, "Task not found"); - }); - }); - describe('DELETE /api/tasks/:id', () => { it("should delete an existing task", async (t) => { const app = await build(t); From 3e0bc1073ad3eaf83aa9c72a8b0c131b3b841e11 Mon Sep 17 00:00:00 2001 From: jean Date: Tue, 10 Sep 2024 23:01:26 +0200 Subject: [PATCH 3/5] feat: add endpoint to assign/unassign task --- src/routes/api/tasks/index.ts | 37 +++++++++++++++ test/routes/api/tasks/tasks.test.ts | 71 +++++++++++++++++++++++++++++ 2 files changed, 108 insertions(+) diff --git a/src/routes/api/tasks/index.ts b/src/routes/api/tasks/index.ts index 660a76a7..8db0749f 100644 --- a/src/routes/api/tasks/index.ts +++ b/src/routes/api/tasks/index.ts @@ -135,6 +135,43 @@ const plugin: FastifyPluginAsyncTypebox = async (fastify) => { reply.code(204).send(null); } ); + + fastify.post( + "/:id/assign", + { + schema: { + params: Type.Object({ + id: Type.Number() + }), + body: Type.Object({ + userId: Type.Optional(Type.Number()) + }), + response: { + 200: TaskSchema, + 404: Type.Object({ message: Type.String() }) + }, + tags: ["Tasks"] + } + }, + async function (request, reply) { + const { id } = request.params; + const { userId } = request.body; + + const task = await fastify.repository.find("tasks", { where: { id } }); + if (!task) { + return notFound(reply); + } + + await fastify.repository.update("tasks", { + data: { assigned_user_id: userId }, + where: { id } + }); + + task.assigned_user_id = userId + + return task; + } + ) }; function notFound(reply: FastifyReply) { diff --git a/test/routes/api/tasks/tasks.test.ts b/test/routes/api/tasks/tasks.test.ts index 50495092..1a89bfbe 100644 --- a/test/routes/api/tasks/tasks.test.ts +++ b/test/routes/api/tasks/tasks.test.ts @@ -4,6 +4,9 @@ import { build } from "../../../helper.js"; import { Task, TaskStatus } from "../../../../src/schemas/tasks.js"; import { FastifyInstance } from "fastify"; + + + async function createTask(app: FastifyInstance, taskData: Partial) { return await app.repository.create("tasks", { data: taskData }); } @@ -176,4 +179,72 @@ describe('Tasks api (logged user only)', () => { assert.strictEqual(payload.message, "Task not found"); }); }); + + describe('POST /api/tasks/:id/assign', () => { + + it("should assign a task to a user and persist the changes", async (t) => { + const app = await build(t); + + const taskData = { + name: "Task to Assign", + author_id: 1, + status: TaskStatus.New + }; + const newTaskId = await createTask(app, taskData); + + const res = await app.injectWithLogin("basic", { + method: "POST", + url: `/api/tasks/${newTaskId}/assign`, + payload: { + userId: 2 + } + }); + + assert.strictEqual(res.statusCode, 200); + + const updatedTask = await app.repository.find("tasks", { where: { id: newTaskId } }) as Task + assert.strictEqual(updatedTask.assigned_user_id, 2); + }); + + it("should unassign a task from a user and persist the changes", async (t) => { + const app = await build(t); + + const taskData = { + name: "Task to Unassign", + author_id: 1, + assigned_user_id: 2, + status: TaskStatus.New + }; + const newTaskId = await createTask(app, taskData); + + const res = await app.injectWithLogin("basic", { + method: "POST", + url: `/api/tasks/${newTaskId}/assign`, + payload: {} + }); + + assert.strictEqual(res.statusCode, 200); + + const updatedTask = await app.repository.find("tasks", { where: { id: newTaskId } }) as Task; + assert.strictEqual(updatedTask.assigned_user_id, null); + }); + + it("should return 404 if task is not found", async (t) => { + const app = await build(t); + + const res = await app.injectWithLogin("basic", { + method: "POST", + url: "/api/tasks/9999/assign", + payload: { + userId: 2 + } + }); + + assert.strictEqual(res.statusCode, 404); + const payload = JSON.parse(res.payload); + assert.strictEqual(payload.message, "Task not found"); + }); + + }); + }) From f17f4efbce212b2d8befdbc1981fd76f9fbc445c Mon Sep 17 00:00:00 2001 From: jean Date: Tue, 10 Sep 2024 23:20:37 +0200 Subject: [PATCH 4/5] fix: remove additional properties by default --- src/app.ts | 10 ++++++++++ test/helper.ts | 4 ++-- test/routes/api/tasks/tasks.test.ts | 2 -- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/app.ts b/src/app.ts index 23734d02..f6f663d0 100644 --- a/src/app.ts +++ b/src/app.ts @@ -6,6 +6,16 @@ import path from "node:path"; import fastifyAutoload from "@fastify/autoload"; import { FastifyInstance, FastifyPluginOptions } from "fastify"; +export const options = { + ajv: { + customOptions: { + coerceTypes: "array", + removeAdditional: "all", + allErrors: true + } + } +}; + export default async function serviceApp( fastify: FastifyInstance, opts: FastifyPluginOptions diff --git a/test/helper.ts b/test/helper.ts index 88013907..dad5f983 100644 --- a/test/helper.ts +++ b/test/helper.ts @@ -3,6 +3,7 @@ import { FastifyInstance, InjectOptions } from "fastify"; import { build as buildApplication } from "fastify-cli/helper.js"; import path from "node:path"; import { TestContext } from "node:test"; +import { options as serverOptions } from "../src/app.js"; declare module "fastify" { interface FastifyInstance { @@ -11,7 +12,6 @@ declare module "fastify" { } } - const AppPath = path.join(import.meta.dirname, "../src/app.ts"); // Fill in this config with all the configurations @@ -60,7 +60,7 @@ export async function build(t: TestContext) { // fastify-plugin ensures that all decorators // are exposed for testing purposes, this is // different from the production setup - const app = await buildApplication(argv, config()) as FastifyInstance; + const app = await buildApplication(argv, config(), serverOptions) as FastifyInstance; app.login = login; diff --git a/test/routes/api/tasks/tasks.test.ts b/test/routes/api/tasks/tasks.test.ts index 1a89bfbe..ae82067c 100644 --- a/test/routes/api/tasks/tasks.test.ts +++ b/test/routes/api/tasks/tasks.test.ts @@ -244,7 +244,5 @@ describe('Tasks api (logged user only)', () => { const payload = JSON.parse(res.payload); assert.strictEqual(payload.message, "Task not found"); }); - }); - }) From 345353e9cdf28e3014dac7805465f760a813929b Mon Sep 17 00:00:00 2001 From: jean Date: Fri, 13 Sep 2024 17:50:33 +0200 Subject: [PATCH 5/5] fix: disable allErrors ajv --- src/app.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/app.ts b/src/app.ts index f6f663d0..eeaff466 100644 --- a/src/app.ts +++ b/src/app.ts @@ -10,8 +10,7 @@ export const options = { ajv: { customOptions: { coerceTypes: "array", - removeAdditional: "all", - allErrors: true + removeAdditional: "all" } } };