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..85f45e4f 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", @@ -36,6 +37,7 @@ "@fastify/type-provider-typebox": "^4.0.0", "@fastify/under-pressure": "^8.3.0", "@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 +49,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..eeaff466 100644 --- a/src/app.ts +++ b/src/app.ts @@ -6,10 +6,20 @@ import path from "node:path"; import fastifyAutoload from "@fastify/autoload"; import { FastifyInstance, FastifyPluginOptions } from "fastify"; +export const options = { + ajv: { + customOptions: { + coerceTypes: "array", + removeAdditional: "all" + } + } +}; + 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/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..8db0749f 100644 --- a/src/routes/api/tasks/index.ts +++ b/src/routes/api/tasks/index.ts @@ -2,7 +2,14 @@ import { FastifyPluginAsyncTypebox, Type } from "@fastify/type-provider-typebox"; -import { TaskSchema } from "../../../schemas/tasks.js"; +import { + TaskSchema, + Task, + CreateTaskSchema, + UpdateTaskSchema, + TaskStatus +} from "../../../schemas/tasks.js"; +import { FastifyReply } from "fastify"; const plugin: FastifyPluginAsyncTypebox = async (fastify) => { fastify.get( @@ -16,9 +23,160 @@ 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.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); } ); + + 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) { + 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..3ab96734 100644 --- a/src/schemas/tasks.ts +++ b/src/schemas/tasks.ts @@ -1,6 +1,36 @@ -import { Type } from "@sinclair/typebox"; +import { Static, Type } from "@sinclair/typebox"; + +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()) }); + diff --git a/test/helper.ts b/test/helper.ts index ce16fe95..dad5f983 100644 --- a/test/helper.ts +++ b/test/helper.ts @@ -1,21 +1,34 @@ -// 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"; +import { options as serverOptions } from "../src/app.js"; + +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 +38,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 +60,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(), serverOptions) 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/routes/api/tasks/tasks.test.ts b/test/routes/api/tasks/tasks.test.ts index 8dc2d33e..ae82067c 100644 --- a/test/routes/api/tasks/tasks.test.ts +++ b/test/routes/api/tasks/tasks.test.ts @@ -1,17 +1,248 @@ -import { test } from "node:test"; +import { describe, it } from "node:test"; import assert from "node:assert"; import { build } from "../../../helper.js"; +import { Task, TaskStatus } 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); - const res = await app.injectWithLogin("basic", { - method: "GET", - url: "/api/tasks" + + +async function createTask(app: FastifyInstance, taskData: Partial) { + return await app.repository.create("tasks", { data: taskData }); +} + +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); + }); }); - assert.strictEqual(res.statusCode, 200); - assert.deepStrictEqual(JSON.parse(res.payload), [ - { id: 1, name: "Do something..." } - ]); -}); + 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('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"); + }); + }); + + 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"); + }); + }); +}) 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"] +}