From a59f951500ecf8ef58a66e33bd127ed71cb6a554 Mon Sep 17 00:00:00 2001 From: Alie Date: Sun, 31 Dec 2023 11:11:22 +0000 Subject: [PATCH 1/6] unit testing (#7) Co-authored-by: Sugui Reviewed-on: https://git.fai.st/fedi-image-bot/bot-api/pulls/7 Co-authored-by: Alie Co-committed-by: Alie --- .gitea/workflows/test.yaml | 22 +++++----- compose.yaml | 3 +- mongo-init.js | 30 ------------- package.json | 7 ++-- src/app.ts | 41 ++++++++++++++++++ src/controllers/AuthControler.ts | 2 +- src/controllers/ImageController.ts | 11 ++--- src/index.ts | 46 +++----------------- src/models/AuthModel.ts | 22 ++++++---- src/models/ImageModel.ts | 34 ++++++++------- src/services/AuthService.ts | 2 +- tests/app.test.ts | 67 ++++++++++++++++++++++++++---- tests/auth.test.ts | 21 ---------- tests/populateDatabase.ts | 15 +++++++ 14 files changed, 176 insertions(+), 147 deletions(-) delete mode 100644 mongo-init.js create mode 100644 src/app.ts delete mode 100644 tests/auth.test.ts create mode 100644 tests/populateDatabase.ts diff --git a/.gitea/workflows/test.yaml b/.gitea/workflows/test.yaml index 0cca8ee..7c64ca0 100644 --- a/.gitea/workflows/test.yaml +++ b/.gitea/workflows/test.yaml @@ -1,19 +1,19 @@ -name: Gitea Actions Demo +name: Unit Tests with docker compose run-name: ${{ gitea.actor }} is testing out Gitea Actions 🚀 on: [pull_request] jobs: - Explore-Gitea-Actions: + unit-test: container: - image: oven/bun:1 + image: docker:dind steps: - - run: echo "🎉 The job was automatically triggered by a ${{ gitea.event_name }} event." - - run: echo "🐧 This job is now running on a ${{ runner.os }} server hosted by Gitea!" - - run: echo "🔎 The name of your branch is ${{ gitea.ref }} and your repository is ${{ gitea.repository }}." + - name: Starting docker daemon + run: docker-init -- dockerd --host=unix:///var/run/docker.sock & + - name: Installing necessary packages + run: apk add npm git curl bash - name: Check out repository code uses: actions/checkout@v3 - - run: echo "💡 The ${{ gitea.repository }} repository has been cloned to the runner." - - run: echo "🖥️ The workflow is now ready to test your code on the runner." - - name: List files in the repository - run: | - ls ${{ gitea.workspace }} \ No newline at end of file + - name: Install project dependencies + run: npm install + - name: Run docker-compose + run: docker compose down -v && docker compose run bot-api bun test \ No newline at end of file diff --git a/compose.yaml b/compose.yaml index 964794b..30088de 100644 --- a/compose.yaml +++ b/compose.yaml @@ -2,7 +2,7 @@ version: "3" services: mongodb: - image: mongo + image: mongo:bionic container_name: mongodb ports: - "27017:27017" @@ -12,7 +12,6 @@ services: MONGO_INITDB_DATABASE: bot volumes: - mongodb_data:/data/db - - ./mongo-init.js:/docker-entrypoint-initdb.d/mongo-init.js:ro bot-api: image: oven/bun:1 diff --git a/mongo-init.js b/mongo-init.js deleted file mode 100644 index 67b53c9..0000000 --- a/mongo-init.js +++ /dev/null @@ -1,30 +0,0 @@ -db.createUser({ - user: "root", - pwd: "password", - roles: [ - { - role: "readWrite", - db: "admin", - }, - { - role: "readWrite", - db: "bot", - }, - ], -}); - -db = new Mongo().getDB("bot"); - -db.images.createIndex({ status: 1 }); -db.images.createIndex({ url: 1 }, { unique: true }); -db.images.insert({ - url: "https://example.com", - status: "consumed", - tags: ["2girls", "sleeping"], -}); - -db.authorizations.createIndex({ app: 1 }); -db.authorizations.insert({ - app: "tester", - secret: "test", -}); diff --git a/package.json b/package.json index 2080221..e11f4b4 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,10 @@ "module": "index.ts", "type": "module", "devDependencies": { + "@types/express": "^4.17.21", + "@types/express-list-endpoints": "^6.0.3", "@types/jest": "^29.5.11", + "@types/jsonwebtoken": "^9.0.5", "@types/supertest": "^6.0.1", "bun-types": "latest", "jest": "^29.7.0", @@ -20,10 +23,6 @@ "test": "docker compose down -v && docker compose run bot-api bun test" }, "dependencies": { - "@types/express": "^4.17.21", - "@types/express-list-endpoints": "^6.0.3", - "@types/jsonwebtoken": "^9.0.5", - "@types/mongoose": "^5.11.97", "express": "^4.18.2", "express-list-endpoints": "^6.0.0", "jsonwebtoken": "^9.0.2", diff --git a/src/app.ts b/src/app.ts new file mode 100644 index 0000000..43a3f6f --- /dev/null +++ b/src/app.ts @@ -0,0 +1,41 @@ +import express from "express"; +import listEndpoints from "express-list-endpoints"; +import imageController from "./controllers/ImageController"; +import authControler from "./controllers/AuthControler"; +import mongoose from "mongoose"; + +export const app = express(); + +app.use(express.json()); + +app.get("/", (_, res) => { + const endpoints = listEndpoints(app); + res.json({ endpoints }); +}); + +app.get("/images", imageController.getAllImages); +app.post("/images", authControler.authorize, imageController.addImage); +app.post("/login", authControler.login); + +export const startApp = async () => { + const port = process.env.PORT || 8080; + const mongo_uri: string = process.env.MONGODB_URI || ""; + const mongo_user = process.env.MONGODB_USER; + const mongo_pass = process.env.MONGODB_PASS; + + try { + await mongoose.connect(mongo_uri, { + authSource: "admin", + user: mongo_user, + pass: mongo_pass, + }); + app.listen(port, () => + console.log(`Express server listening on port ${port}`) + ); + } catch (error) { + console.error(error); + process.exit(1); + } +}; + +export default app; diff --git a/src/controllers/AuthControler.ts b/src/controllers/AuthControler.ts index 4f0649c..335e11a 100644 --- a/src/controllers/AuthControler.ts +++ b/src/controllers/AuthControler.ts @@ -13,7 +13,7 @@ class AuthControler { const authenticated = await AuthService.find(app, secret); if (authenticated) { - console.log("Authenticated app ", authenticated.app); + console.log("Authenticated app", authenticated.app); // Generate an access token const accessToken = jwt.sign( { app: authenticated.app }, diff --git a/src/controllers/ImageController.ts b/src/controllers/ImageController.ts index 1b86aea..75fca37 100644 --- a/src/controllers/ImageController.ts +++ b/src/controllers/ImageController.ts @@ -3,12 +3,11 @@ import imageService from "../services/ImageService"; import mongoose, { mongo } from "mongoose"; class ImageController { - async getAllImages(req: Request, res: Response): Promise { + async getAllImages(_: Request, res: Response): Promise { try { const images = await imageService.findAll(); res.json({ images }); } catch (error) { - console.error(error); res.status(500).json({ error: "Internal Server Error" }); } } @@ -23,11 +22,9 @@ class ImageController { } catch (error: any) { if (error instanceof mongo.MongoServerError && error.code === 11000) { // Should return 409 Conflict for existing urls - res - .status(409) - .json({ - error: `the image with URL ${error.keyValue.url} already exists`, - }); + res.status(409).json({ + error: `the image with URL ${error.keyValue.url} already exists`, + }); } else if (error instanceof mongoose.Error.ValidationError) { // Should return 400 Bad request for invalid requests res.status(400).json({ error: error.message }); diff --git a/src/index.ts b/src/index.ts index d74231b..4f8ee9f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,42 +1,8 @@ -import express from "express"; -import mongoose from "mongoose"; -import listEndpoints from "express-list-endpoints"; -import imageController from "./controllers/ImageController"; -import authControler from "./controllers/AuthControler"; +import populateDatabase from "../tests/populateDatabase"; +import { startApp } from "./app"; -export const app = express(); +await startApp(); -app.use(express.json()); - -app.get("/", (_, res) => { - const endpoints = listEndpoints(app); - res.json({ endpoints }); -}); - -app.get("/images", imageController.getAllImages); -app.post("/images", authControler.authorize, imageController.addImage); -app.post("/login", authControler.login); - -const start = async () => { - // Set the default port to 8080, or use the PORT environment variable - const port = process.env.PORT || 8080; - const mongo_uri: string = process.env.MONGODB_URI || ""; - const mongo_user = process.env.MONGODB_USER; - const mongo_pass = process.env.MONGODB_PASS; - - try { - await mongoose.connect(mongo_uri, { - authSource: "admin", - user: mongo_user, - pass: mongo_pass, - }); - app.listen(port, () => - console.log(`Express server listening on port ${port}`) - ); - } catch (error) { - console.error(error); - process.exit(1); - } -}; - -start(); +try { + await populateDatabase(); +} catch {} diff --git a/src/models/AuthModel.ts b/src/models/AuthModel.ts index df71afe..8c4a94f 100644 --- a/src/models/AuthModel.ts +++ b/src/models/AuthModel.ts @@ -5,15 +5,19 @@ export interface Auth extends Document { secret: String; } -const AuthSchema = new mongoose.Schema({ - app: { - type: String, - required: true, +const AuthSchema = new mongoose.Schema( + { + app: { + type: String, + required: true, + index: true, + }, + secret: { + type: String, + required: true, + }, }, - secret: { - type: String, - required: true, - }, -}); + { collection: "authorizations" } +); export default mongoose.model("authorizations", AuthSchema); diff --git a/src/models/ImageModel.ts b/src/models/ImageModel.ts index d91ab18..f2baa49 100644 --- a/src/models/ImageModel.ts +++ b/src/models/ImageModel.ts @@ -6,21 +6,27 @@ export interface Image extends Document { tags?: String[]; } -const ImageSchema = new mongoose.Schema({ - url: { - type: String, - required: true, - }, - status: { - type: String, - enum: { - values: ["consumed", "unavailable", "available"], +const ImageSchema = new mongoose.Schema( + { + url: { + type: String, + required: true, + index: true, + unique: true, + }, + status: { + type: String, + enum: { + values: ["consumed", "unavailable", "available"], + }, + required: true, + index: true, + }, + tags: { + type: [String], }, - required: true, }, - tags: { - type: [String], - }, -}); + { collection: "images" } +); export default mongoose.model("images", ImageSchema); diff --git a/src/services/AuthService.ts b/src/services/AuthService.ts index aed8d12..ac32127 100644 --- a/src/services/AuthService.ts +++ b/src/services/AuthService.ts @@ -2,7 +2,7 @@ import AuthModel, { Auth } from "../models/AuthModel"; class AuthService { async find(app: String, secret: String): Promise { - const auth = await AuthModel.findOne({ app: app, secret: secret }).exec(); + const auth = await AuthModel.findOne({ app: app, secret: secret }); return auth; } } diff --git a/tests/app.test.ts b/tests/app.test.ts index ac40ace..f8f7652 100644 --- a/tests/app.test.ts +++ b/tests/app.test.ts @@ -1,13 +1,29 @@ -import { afterEach, beforeAll, describe, expect, it, mock } from "bun:test"; -import request from "supertest"; -import { app } from "../src"; +import { + afterEach, + beforeAll, + describe, + expect, + it, + mock, +} from "bun:test"; +import request, { Response } from "supertest"; +import app, { startApp } from "../src/app"; import imageService from "../src/services/ImageService"; +import populateDatabase from "./populateDatabase"; const imageServiceOriginal = imageService; -const tok = await request(app) - .post("/login") - .send({ app: "tester", secret: "test" }); -const token = tok.body.token; + +let token: string; + +beforeAll(async () => { + await startApp(); + await populateDatabase(); + + const tok = await request(app) + .post("/login") + .send({ app: "tester", secret: "test" }); + token = tok.body.token; +}); afterEach(() => { mock.restore(); @@ -16,6 +32,28 @@ afterEach(() => { })); }); +describe("/login works as instended", async () => { + let correctRespose: Response; + beforeAll(async () => { + correctRespose = await request(app) + .post("/login") + .send({ app: "tester", secret: "test" }); + }); + + it("should return 200 for correct login", async () => { + expect(correctRespose.status).toBe(200); + }); + + it("should contain a token", () => { + expect(correctRespose.body).toHaveProperty("token"); + }); + + it("should return 403 for invalid credentials", async () => { + const res = await request(app).post("/login").send({}); + expect(res.status).toBe(403); + }); +}); + describe("GET / shows all of the endpoints", async () => { const res = await request(app).get("/"); @@ -38,6 +76,21 @@ describe("GET /images works properly", async () => { it("should return a 200", async () => { expect(res.statusCode).toBe(200); }); + + it("should return 500 for an error on the service", async () => { + mock.module("../src/services/ImageService", () => ({ + default: { + add: () => { + throw new Error("This is an expected testing error"); + }, + }, + })); + + const res = await request(app) + .get("/images"); + + expect(res.status).toBe(500); + }); }); describe("POST /images works properly", () => { diff --git a/tests/auth.test.ts b/tests/auth.test.ts deleted file mode 100644 index bd20cd2..0000000 --- a/tests/auth.test.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { describe, expect, it, mock } from "bun:test"; -import request from "supertest"; -import { app } from "../src"; - -describe("/login", async () => { - const correctRespose = await request(app).post("/login").send({ - app: "tester", - secret: "test", - }); - it("should return 200 for correct login", () => { - expect(correctRespose.status).toBe(200); - }); - it("should contain a token", () => { - expect(correctRespose.body).toHaveProperty("token"); - }); - - it("should return 403 for invalid credentials", async () => { - const res = await request(app).post("/login").send({}); - expect(res.status).toBe(403); - }); -}); diff --git a/tests/populateDatabase.ts b/tests/populateDatabase.ts new file mode 100644 index 0000000..f60c9c4 --- /dev/null +++ b/tests/populateDatabase.ts @@ -0,0 +1,15 @@ +import authModel from "../src/models/AuthModel"; +import imageModel from "../src/models/ImageModel"; + +export default async function () { + await imageModel.create({ + url: "https://example.com", + status: "consumed", + tags: ["2girls", "sleeping"], + }); + + await authModel.create({ + app: "tester", + secret: "test", + }); +} From 106c1b8726bfdedb0fa594505f89f8ba411f1644 Mon Sep 17 00:00:00 2001 From: Alie Date: Sun, 31 Dec 2023 12:29:31 +0100 Subject: [PATCH 2/6] fmt --- tests/app.test.ts | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/tests/app.test.ts b/tests/app.test.ts index f8f7652..4893ae0 100644 --- a/tests/app.test.ts +++ b/tests/app.test.ts @@ -1,11 +1,4 @@ -import { - afterEach, - beforeAll, - describe, - expect, - it, - mock, -} from "bun:test"; +import { afterEach, beforeAll, describe, expect, it, mock } from "bun:test"; import request, { Response } from "supertest"; import app, { startApp } from "../src/app"; import imageService from "../src/services/ImageService"; @@ -86,8 +79,7 @@ describe("GET /images works properly", async () => { }, })); - const res = await request(app) - .get("/images"); + const res = await request(app).get("/images"); expect(res.status).toBe(500); }); From 265761766f3d8542ba095e1bb181aae5913eb597 Mon Sep 17 00:00:00 2001 From: Alie Date: Sun, 31 Dec 2023 13:26:10 +0100 Subject: [PATCH 3/6] made the dockerfile usable to our usecase --- Dockerfile | 21 ++++----------------- src/index.ts | 6 +++++- 2 files changed, 9 insertions(+), 18 deletions(-) diff --git a/Dockerfile b/Dockerfile index 7ecabac..2314c7c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,33 +8,20 @@ WORKDIR /usr/src/app # install dependencies into temp folder # this will cache them and speed up future builds FROM base AS install -RUN mkdir -p /temp/dev -COPY package.json bun.lockb /temp/dev/ -RUN cd /temp/dev && bun install # install with --production (exclude devDependencies) RUN mkdir -p /temp/prod COPY package.json bun.lockb /temp/prod/ RUN cd /temp/prod && bun install --production -# copy node_modules from temp folder -# then copy all (non-ignored) project files into the image -FROM install AS prerelease -COPY --from=install /temp/dev/node_modules node_modules -COPY . . - -# [optional] tests & build -# ENV NODE_ENV=production -# RUN bun test -# RUN bun run build - -# copy production dependencies and source code into final image +# Copy production dependencies and source code into final image FROM base AS release COPY --from=install /temp/prod/node_modules node_modules -COPY --from=prerelease /usr/src/app/index.ts . -COPY --from=prerelease /usr/src/app/package.json . +COPY --from=install /usr/src/app/src ./src +COPY --from=install /usr/src/app/package.json . # run the app USER bun +ENV NODE_ENV=production EXPOSE 8080/tcp CMD ["bun", "run", "start"] diff --git a/src/index.ts b/src/index.ts index 4f8ee9f..0d016b7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,6 +3,10 @@ import { startApp } from "./app"; await startApp(); +// This try carch is to prevent hot reload from making the process die due to coliding entries try { - await populateDatabase(); + // Not insert test data into production + if (process.env.NODE_ENV != "production"){ + await populateDatabase(); + } } catch {} From df99e9470c709ef81a4df4591415d7fa48c4459b Mon Sep 17 00:00:00 2001 From: Alie Date: Wed, 3 Jan 2024 13:58:18 +0100 Subject: [PATCH 4/6] added the images/id endpoint and the tests for it --- src/app.ts | 1 + src/controllers/ImageController.ts | 17 +++++++++++++++++ src/services/ImageService.ts | 4 ++++ tests/app.test.ts | 23 +++++++++++++++++++++++ 4 files changed, 45 insertions(+) diff --git a/src/app.ts b/src/app.ts index 43a3f6f..8ae7edd 100644 --- a/src/app.ts +++ b/src/app.ts @@ -14,6 +14,7 @@ app.get("/", (_, res) => { }); app.get("/images", imageController.getAllImages); +app.get("/images/:id", imageController.getImageById); app.post("/images", authControler.authorize, imageController.addImage); app.post("/login", authControler.login); diff --git a/src/controllers/ImageController.ts b/src/controllers/ImageController.ts index 75fca37..4b2301e 100644 --- a/src/controllers/ImageController.ts +++ b/src/controllers/ImageController.ts @@ -3,6 +3,23 @@ import imageService from "../services/ImageService"; import mongoose, { mongo } from "mongoose"; class ImageController { + async getImageById(req: Request, res: Response) { + try { + const image = await imageService.findById(req.params.id); + if (image) { + res.json({ image }); + } else { + res.status(404).json({ error: "Image not found" }); + } + } catch (error: any) { + if (error instanceof mongoose.Error.CastError) { + res.status(400).json({ error: "Invalid Id" }); + } else { + res.status(500).json({ error: "Internal Server Error" }); + } + } + } + async getAllImages(_: Request, res: Response): Promise { try { const images = await imageService.findAll(); diff --git a/src/services/ImageService.ts b/src/services/ImageService.ts index 8691f7d..8289ed5 100644 --- a/src/services/ImageService.ts +++ b/src/services/ImageService.ts @@ -1,6 +1,10 @@ import imageModel, { Image } from "../models/ImageModel"; class ImageService { + async findById(id: string) { + const image = await imageModel.findOne({ _id: id }); + return image; + } async findAll(): Promise { const allImages = await imageModel.find(); return allImages; diff --git a/tests/app.test.ts b/tests/app.test.ts index 4893ae0..91987dd 100644 --- a/tests/app.test.ts +++ b/tests/app.test.ts @@ -177,3 +177,26 @@ describe("POST /images works properly", () => { expect(res.status).toBe(400); }); }); + +describe("/images/:id works properly", () => { + it("should return 200 for existing ids", async () => { + const list = await request(app).get("/images"); + const id = list.body.images[0]._id; + const res = await request(app).get(`/images/${id}`); + expect(res.status).toBe(200); + }); + + it("should return 404 for non-existing ids", async () => { + const list = await request(app).get("/images"); + const id = "000000000000000000000000"; // this was the least posible to exist ID + if (!(id in list.body.images)) { + const res = await request(app).get(`/images/${id}`); + expect(res.status).toBe(404); + } + }); + + it("should return 400 for malformed ids", async () => { + const res = await request(app).get("/images/98439384"); + expect(res.status).toBe(400); + }); +}); From d9e2cb3917ffac229b0853ade0153891cd3e0fbe Mon Sep 17 00:00:00 2001 From: Alie Date: Sat, 6 Jan 2024 11:23:55 +0100 Subject: [PATCH 5/6] edited workflow --- .gitea/workflows/test.yaml | 1 - 1 file changed, 1 deletion(-) diff --git a/.gitea/workflows/test.yaml b/.gitea/workflows/test.yaml index 7c64ca0..73e2e08 100644 --- a/.gitea/workflows/test.yaml +++ b/.gitea/workflows/test.yaml @@ -1,5 +1,4 @@ name: Unit Tests with docker compose -run-name: ${{ gitea.actor }} is testing out Gitea Actions 🚀 on: [pull_request] jobs: From 26311992d396611c67e6eaef24402646a1a06b74 Mon Sep 17 00:00:00 2001 From: Suguivy Date: Sat, 6 Jan 2024 11:43:21 +0000 Subject: [PATCH 6/6] Documentation for some API endpoints --- README.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/README.md b/README.md index 87f9bbc..d4c7f57 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,33 @@ # bot-api +## Introduction +The function of the API is basically to access the images' metadata stored in the database. + +## Usage +The API exposes some endpoints to interact with the database. + +### GET `/images` +Allows to get a list of image documents. + +#### Query params +- `limit`: an optional parameter, which accepts a non-negative integer that dictates the number of documents that the list will have. If its value is equal to `0`, or if this parameter is missing, the endpoint will return all the image documents in the database. +- `status`: an optional parameter, which accepts the values `consumed`, `available` and `unavailable`. It filters the documents that have only the `status` attribute equal to that indicated in the parameter's value. If the parameter is missing, no filter will be applied to the document. + +#### Example +- `GET /images?limit=5&status=available`: will return 5 documents that have the `available` value in their `status` attribute. + +### PUT `/images/` +Modifies an existing image document. The request must provide a JSON-formatted body, with one or more valid document attributes. The existing document attributes will be replaced with the provided new ones. + +#### Params +- `id`: the id of the document to be modified. + +#### Example +- `PUT /images/61f7e48f0c651345677b7775` with body `{ "status": "consumed" }`: will modify the document referenced by the `id` param, changing their `status` value to `consumed`. + +### POST `/images` +### POST `/login` +## Installation To install dependencies: ```bash