v1.0.0 #28

Merged
bizcochito merged 147 commits from develop into main 2024-01-14 19:49:34 +00:00
22 changed files with 4177 additions and 174 deletions

View File

@ -0,0 +1,22 @@
name: Unit Tests with docker compose
on: [push, pull_request]
jobs:
unit-test:
container:
image: docker:dind
volumes:
- /usr/local/share/.cache/yarn:/usr/local/share/.cache/yarn
- /var/lib/docker/image:/var/lib/docker/image
- /var/lib/docker/overlay2:/var/lib/docker/overlay2
steps:
- name: Starting docker daemon
run: docker-init -- dockerd --host=unix:///var/run/docker.sock &
- name: Installing necessary packages
run: apk add yarn git curl bash
- name: Check out repository code
uses: actions/checkout@v3
- name: Install project dependencies
run: yarn install --frozen-lockfile --ignore-scripts
- name: Run docker-compose
run: docker compose down -v && docker compose run bot-api bun test

View File

@ -0,0 +1,52 @@
name: Build image
on:
push:
branches:
- main
- build
tags:
- v*
jobs:
build:
container:
image: docker:dind
volumes:
- /data/.cache/act:/data/.cache/act
- /var/lib/docker/image:/var/lib/docker/image
- /var/lib/docker/overlay2:/var/lib/docker/overlay2
steps:
- name: Starting docker daemon
run: docker-init -- dockerd --host=unix:///var/run/docker.sock &
- name: Installing necessary packages
run: apk add nodejs git curl bash
- name: Checkout
uses: actions/checkout@v3
- name: Docker meta
id: meta
uses: https://github.com/docker/metadata-action@v4
with:
# list of Docker images to use as base name for tags
images: |
git.fai.st/fedi-image-bot/bot-api
# generate Docker tags based on the following events/attributes
tags: |
type=raw,value=latest,enable=${{ github.ref == format('refs/heads/{0}', 'main') }}
type=ref,event=branch
type=semver,pattern={{raw}}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Login to fai.st docker registry
uses: docker/login-action@v2
with:
registry: git.fai.st
username: ${{ secrets.DOCKER_USER }}
password: ${{ secrets.DOCKER_PASS }}
- name: Build and push
uses: docker/build-push-action@v4
with:
platforms: linux/amd64,linux/arm64
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

7
.gitignore vendored
View File

@ -1,2 +1,7 @@
node_modules/
bun.lockb
bun.lockb
.vscode
.env
.editorconfig
.idea
coverage*

View File

@ -5,36 +5,28 @@
FROM oven/bun:1 as base
WORKDIR /usr/src/app
# Generate new bun lock
FROM base as lock
RUN mkdir -p /temp/lock
COPY package.json /temp/lock
RUN cd /temp/lock && bun install
# 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 --from=lock /temp/lock/package.json /temp/lock/bun.lockb /temp/prod/
RUN cd /temp/prod && bun install --production --frozen-lockfile
# 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 /temp/prod/package.json .
COPY ./src ./src
# run the app
USER bun
ENV NODE_ENV production
EXPOSE 8080/tcp
CMD ["bun", "run", "start"]

View File

@ -1,5 +1,52 @@
# 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.
### GET `/images/<id>`
Allows to get an image document.
#### Params
- `id`: the id of the document to be modified.
#### Example
`GET /images/61f7e48f0c651345677b7775` will get the document referenced by the `id` param.
### PUT `/images/<id>`
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`
Allows to insert a new image document.
#### Example
`POST /images` with body `{ "url": "https://my-images.com/foo.jpg", "status": "available", "tags": ["foo", "bar"] }` will insert the image passed on the request body into the database.
### POST `/login`
Generates an access token to use in future requests to authenticated endpoints if a valid App and Secret are provided.
#### Example
`POST /login` with body `{ "app": "example", "secret": "badsecret" }` will return `{ "token": "x.y.z" }` "x.y.z" being a JWT token to use on further requests if the App and Secret are valid.
## Installation
To install dependencies:
```bash
@ -9,13 +56,13 @@ bun install
To run:
```bash
docker compose up
bun run dev
```
For testing, remember:
```bash
docker compose down -v
docker compose run bot-api bun run test
bun run test
```
This project was created using `bun init` in bun v1.0.13. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime.

4
bunfig.toml Normal file
View File

@ -0,0 +1,4 @@
[install.lockfile]
# whether to save a non-Bun lockfile alongside bun.lockb
# only "yarn" is supported
print = "yarn"

View File

@ -1,8 +1,8 @@
version: '3'
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
@ -27,6 +26,7 @@ services:
MONGODB_URI: "mongodb://mongodb:27017/bot"
MONGODB_USER: "root"
MONGODB_PASS: "password"
JWTSECRET: "cooljwtsecret"
volumes:
- ./:/usr/src/app:ro

View File

@ -1,24 +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"]
});

View File

@ -1,13 +1,19 @@
{
"name": "fib-api",
"name": "bot-api",
"module": "index.ts",
"type": "module",
"devDependencies": {
"@faker-js/faker": "^8.3.1",
"@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",
"enforce-unique": "^1.2.0",
"jest": "^29.7.0",
"supertest": "^6.3.3",
"@types/compression": "^1.7.5",
"ts-jest": "^29.1.1"
},
"peerDependencies": {
@ -20,9 +26,11 @@
"test": "docker compose down -v && docker compose run bot-api bun test"
},
"dependencies": {
"@types/express": "^4.17.21",
"@types/mongoose": "^5.11.97",
"compression": "^1.7.4",
"express": "^4.18.2",
"mongoose": "^8.0.3"
"express-list-endpoints": "^6.0.0",
"jsonwebtoken": "^9.0.2",
"mongoose": "^8.0.3",
"winston": "^3.11.0"
}
}
}

View File

@ -1,20 +0,0 @@
import mongoose from "mongoose";
const ImageSchema = new mongoose.Schema({
url: {
type: String,
required: true
},
status: {
type: String,
enum: {
values: ["consumed", "unavailable", "available"],
},
required: true
},
tags: {
type: [String]
}
});
export default mongoose.model('images', ImageSchema);

47
src/app.ts Normal file
View File

@ -0,0 +1,47 @@
import express from "express";
import listEndpoints from "express-list-endpoints";
import imageController from "./controllers/ImageController";
import authControler from "./controllers/AuthControler";
import mongoose from "mongoose";
import compression from "compression";
import logger from "./logger";
export const app = express();
app.use(express.json());
app.use(compression());
app.get("/", (_, res) => {
const endpoints = listEndpoints(app);
res.json({ endpoints });
});
app.get("/images", imageController.getAllImages);
app.get("/images/:id", imageController.getImageById);
app.put("/images/:id", authControler.authorize, imageController.editImage);
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,
});
mongoose.set("runValidators", true);
app.listen(port, () =>
logger.info(`Express server listening on port ${port}`)
);
} catch (error) {
logger.error(error);
process.exit(1);
}
};
export default app;

View File

@ -0,0 +1,52 @@
import jwt, { JwtPayload } from "jsonwebtoken";
import AuthService from "../services/AuthService";
import { Request, Response, NextFunction } from "express";
import logger from "../logger";
const authTokenSecret = process.env.JWTSECRET || "badsecret";
class AuthControler {
async login(req: Request, res: Response) {
// Read app and secret from request body
const { app, secret } = req.body;
// Filter app from the apps by app and secret
const authenticated = await AuthService.find(app, secret);
if (authenticated) {
logger.info("Authenticated app", authenticated.app);
// Generate an access token
const token = jwt.sign({ app: authenticated.app }, authTokenSecret, {
expiresIn: "1h",
});
res.json({ token });
} else {
logger.warn("Authentication attempt for", app);
res.status(403).send("Credentials incorrect");
}
}
authorize(req: Request, res: Response, next: NextFunction) {
const authHeader = req.headers.authorization;
if (authHeader) {
const token = authHeader.split(" ")[1];
jwt.verify(token, authTokenSecret, (err, app) => {
if (err) {
return res.status(403).json("Invalid token provided");
}
logger.info(
`Authorization for ${req.method} ${req.url} provided to app ${
(app as JwtPayload).app
} `
);
next();
});
} else {
res.status(401).json("No Authorization header provided");
}
}
}
export default new AuthControler();

View File

@ -0,0 +1,104 @@
import { Request, Response } from "express";
import imageService from "../services/ImageService";
import mongoose, { mongo } from "mongoose";
import { Image } from "../models/ImageModel";
class ImageController {
async editImage(req: Request, res: Response) {
try {
const change: Image = req.body;
const result = await imageService.replaceOne(req.params.id, change);
if (result.matchedCount > 0) {
res.status(204).json();
} 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 if (error instanceof mongoose.Error.ValidationError) {
// Should return 400 Bad request for invalid requests
res.status(400).json({ error: error.message });
} else 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`,
});
} else {
res.status(500).json({ error: "Internal Server Error" });
}
}
}
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(req: Request, res: Response): Promise<void> {
try {
if (
req.query.status !== undefined &&
!["consumed", "unavailable", "available"].includes(
req.query.status as string
)
) {
throw TypeError(
"if present, `status` should have the values `consumed`, `unavailable`, or `available`"
);
}
const limit = req.query.limit ? Number(req.query.limit) : undefined;
const status = req.query.status
? (req.query.status as Image["status"])
: undefined;
const images = await imageService.findAll(limit, status);
res.json({ images });
} catch (error) {
if (error instanceof TypeError) {
res.status(400).json({ error });
} else {
res.status(500).json({ error: "Internal Server Error" });
}
}
}
async addImage(req: Request, res: Response): Promise<void> {
try {
// Should add auth here before doing stuff
// Thowing a 401 if not auth provided
// Throwing a 403 for incorrect auth
const image = await imageService.add(req.body);
res.status(201).json({ image });
} 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`,
});
} else if (error instanceof mongoose.Error.ValidationError) {
// Should return 400 Bad request for invalid requests
res.status(400).json({ error: error.message });
} else {
// Return 500 in other case
res.status(500).json({ error });
}
}
}
}
export default new ImageController();

View File

@ -1,61 +1,12 @@
import express from "express";
import mongoose from "mongoose";
import ImageModel from "./ImageModel";
import { startApp } from "./app";
export const app = express();
app.use(express.json());
app.get("/", (_, res) => {
res.json({ message: "Blazing fast 🚀" });
});
app.get("/images", async (req, res) => {
try {
const allImages = await ImageModel.find();
res.json(allImages);
} catch (error) {
res.status(500).json({ message: error });
}
})
app.post("/images", async (req, res) => {
try {
// Should add auth here before doing stuff
// Thowing a 401 if not auth provided
// Throwing a 403 for incorrect auth
const image = await ImageModel.create(req.body);
res.status(201).json(image);
} catch (error: any) {
if (error.code == 11000){
// Should return 409 Conflict for existing urls
res.status(409).json({ message: "Existing URL" });
}
// Should return 400 Bad request for invalid requests
res.status(400).json({ message: error });
}
})
// Set the default port to 8080, or use the PORT environment variable
const start = 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);
}
}
start();
await startApp();
// This try carch is to prevent hot reload from making the process die due to coliding entries
try {
// Not insert test data into production
if (process.env.NODE_ENV != "production") {
const populateDatabase = require("../tests/populateDatabase");
await populateDatabase();
}
} catch {}

17
src/logger.ts Normal file
View File

@ -0,0 +1,17 @@
import winston, { format } from "winston";
const logger = winston.createLogger({
format: format.combine(
format.timestamp(),
format.align(),
format.colorize({ all: true }),
format.printf((info) => `${info.timestamp} ${info.level}:${info.message}`)
),
});
const files = new winston.transports.File({ filename: "/tmp/express.log" });
const console = new winston.transports.Console();
logger.clear().add(files).add(console);
export default logger;

23
src/models/AuthModel.ts Normal file
View File

@ -0,0 +1,23 @@
import mongoose, { Document } from "mongoose";
export interface Auth extends Document {
app: String;
secret: String;
}
const AuthSchema = new mongoose.Schema(
{
app: {
type: String,
required: true,
index: true,
},
secret: {
type: String,
required: true,
},
},
{ collection: "authorizations" }
);
export default mongoose.model("authorizations", AuthSchema);

32
src/models/ImageModel.ts Normal file
View File

@ -0,0 +1,32 @@
import mongoose, { Document } from "mongoose";
export interface Image extends Document {
url: string;
status: "consumed" | "unavailable" | "available";
tags?: string[];
}
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],
},
},
{ collection: "images" }
);
export default mongoose.model<Image>("images", ImageSchema);

View File

@ -0,0 +1,10 @@
import AuthModel, { Auth } from "../models/AuthModel";
class AuthService {
async find(app: String, secret: String): Promise<Auth | null> {
const auth = await AuthModel.findOne({ app: app, secret: secret });
return auth;
}
}
export default new AuthService();

View File

@ -0,0 +1,37 @@
import imageModel, { Image } from "../models/ImageModel";
class ImageService {
async replaceOne(id: string, newimage: Image) {
const result = await imageModel.updateOne({ _id: id }, newimage);
return result;
}
async findById(id: string) {
const image = await imageModel.findOne({ _id: id });
return image;
}
async findAll(limit?: number, status?: Image["status"]): Promise<Image[]> {
const typeError = TypeError(
"if present, `limit` must be a non-negative integer"
);
const filter = status !== undefined ? { status } : {};
let query = imageModel.find(filter);
if (limit !== undefined) {
if (Number.isInteger(limit)) {
if (limit > 0) query = query.limit(limit);
else if (limit < 0) throw typeError;
} else {
throw typeError;
}
}
const allImages = await query;
return allImages;
}
async add(image: Image): Promise<Image> {
const newImage = await imageModel.create(image);
return newImage;
}
}
export default new ImageService();

View File

@ -1,51 +1,382 @@
import { beforeAll, describe, expect, it } 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";
import { Image } from "../src/models/ImageModel";
import { faker } from "@faker-js/faker";
const imageServiceOriginal = imageService;
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();
mock.module("../src/services/ImageService", () => ({
default: imageServiceOriginal,
}));
});
describe("/login works as intended", 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("/");
it("should have the endpoints in the `endpoints` property, as an array", async () => {
expect(res.body).toHaveProperty("endpoints");
expect(Array.isArray(res.body.endpoints)).toBeTrue();
});
});
describe("GET /images works properly", async () => {
const res = await request(app).get("/images");
it("should be an array", () => {
expect(Array.isArray(res.body.images)).toBeTrue();
});
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: {
findAll: () => {
throw new Error("This is an expected testing error");
},
},
}));
const res = await request(app).get("/images");
it("should be an array", async () => {
expect(Array.isArray(res.body)).toBeTrue();
});
expect(res.status).toBe(500);
});
it("should return a 200", async () => {
expect(res.statusCode).toBe(200);
});
it("should return 400 for an invalid status param value", async () => {
const res = await request(app).get("/images?status=foo");
expect(res.statusCode).toBe(400);
});
it("should return 200 for a request with valid status param", async () => {
const status = faker.helpers.arrayElement([
"consumed",
"available",
"unavailable",
]);
const res = await request(app).get(`/images?status=${status}`);
expect(res.statusCode).toBe(200);
});
it("should only have the requested status in the images", async () => {
const status = faker.helpers.arrayElement([
"consumed",
"available",
"unavailable",
]);
const res = await request(app).get(`/images?status=${status}`);
expect(
res.body.images
.map((image: Image) => image.status)
.every((imageStatus: string) => imageStatus === status)
).toBeTrue();
});
it("should return 400 for a floating point value in limit value", async () => {
const res = await request(app).get("/images?limit=2.4");
expect(res.statusCode).toBe(400);
});
it("should return 400 for a negative value in limit value", async () => {
const res = await request(app).get("/images?limit=-1");
expect(res.statusCode).toBe(400);
});
it("should return 400 for a NaN limit value", async () => {
const res = await request(app).get("/images?limit=foo");
expect(res.statusCode).toBe(400);
});
it("should return 200 for a request with valid limit param", async () => {
const limit = faker.number.int({ min: 5, max: 50 });
const res = await request(app).get(`/images?limit=${limit}`);
expect(res.statusCode).toBe(200);
});
it("should return 200 for a request with valid limit param", async () => {
const limit = faker.number.int({ min: 5, max: 50 });
const res = await request(app).get(`/images?limit=${limit}`);
expect(res.body.images.length).toBeLessThanOrEqual(limit);
});
it("should return 200 for a request with both valid params", async () => {
const res = await request(app).get("/images?limit=3&status=available");
expect(res.statusCode).toBe(200);
});
it("should return the same ids using limit=0 and using no limit parameter", async () => {
const resLimit0 = await request(app).get("/images?limit=0");
const resNoLimit = await request(app).get("/images");
const ids1 = resNoLimit.body.images.map((image: Image) => image._id);
const ids2 = resLimit0.body.images.map((image: Image) => image._id);
expect(ids1.length).toBe(ids2.length);
ids1.forEach((id: string) => expect(ids2).toContain(id));
});
});
describe("POST /images works properly", () => {
it("should return 201 for new image", async () => {
const res = await request(app).post("/images").send({
url: "https://test.url.com/1",
status: "available",
tags: ["2girls", "touhou"]
});
expect(res.status).toSatisfy(status => [201].includes(status));
});
it("should return 401 for unauthenticated requests", async () => {
const res = await request(app)
.post("/images")
.send({
url: "https://test.url.com/0",
status: "available",
tags: ["2girls", "touhou"],
});
expect(res.status).toBe(401);
});
it("should return 409 for a repeated images", async () => {
await request(app).post("/images").send({
url: "https://test.url.com/2",
status: "available",
tags: ["2girls", "touhou"]
});
it("should return 403 for invalid tokens", async () => {
const res = await request(app)
.post("/images")
.set("authorization", `Bearer token`)
.send({
url: "https://test.url.com/0",
status: "available",
tags: ["2girls", "touhou"],
});
expect(res.status).toBe(403);
});
const res = await request(app).post("/images").send({
url: "https://test.url.com/2",
status: "available",
tags: ["2girls", "touhou"]
});
it("should return 201 for new image", async () => {
const res = await request(app)
.post("/images")
.set("authorization", `Bearer ${token}`)
.send({
url: "https://test.url.com/1",
status: "available",
tags: ["2girls", "touhou"],
});
expect(res.status).toBe(201);
});
expect(res.status).toSatisfy(status => [409].includes(status));
});
it("should return 409 for a repeated images", async () => {
await request(app)
.post("/images")
.set("authorization", `Bearer ${token}`)
.send({
url: "https://test.url.com/2",
status: "available",
tags: ["2girls", "touhou"],
});
it("should return 400 for malformed requests", async () => {
const res = await request(app).post("/images").send({
url: "https://test.url.com/3",
status: "wrong",
tags: ["2girls", "touhou"]
});
expect(res.status).toSatisfy(status => [400].includes(status));
});
});
const res = await request(app)
.post("/images")
.set("authorization", `Bearer ${token}`)
.send({
url: "https://test.url.com/2",
status: "available",
tags: ["2girls", "touhou"],
});
expect(res.status).toBe(409);
});
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)
.post("/images")
.set("authorization", `Bearer ${token}`)
.send({
url: "https://test.url.com/3",
status: "available",
tags: ["2girls", "touhou"],
});
expect(res.status).toBe(500);
});
it("should return 400 for malformed requests", async () => {
const res = await request(app)
.post("/images")
.set("authorization", `Bearer ${token}`)
.send({
url: "https://test.url.com/4",
status: "wrong",
tags: ["2girls", "touhou"],
});
expect(res.status).toBe(400);
});
});
describe("GET /images/<id> works properly", () => {
let id: string;
let list: Image[];
beforeAll(async () => {
const res = await request(app).get("/images");
list = res.body.images;
id = list[0]._id;
});
it("should return 200 for existing ids", async () => {
const res = await request(app).get(`/images/${id}`);
expect(res.status).toBe(200);
});
it("should return 404 for non-existing ids", async () => {
const id = "000000000000000000000000"; // this was the least posible to exist ID
if (!(id in list)) {
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);
});
});
describe("PUT /images/<id> works properly", () => {
let id: string;
let list: Image[];
beforeAll(async () => {
const res = await request(app).get("/images");
list = res.body.images;
id = list[0]._id;
});
it("should return 204 for valid modifications", async () => {
const res = await request(app)
.put(`/images/${id}`)
.set("authorization", `Bearer ${token}`)
.send({ status: "available" });
expect(res.status).toBe(204);
});
it("should return 404 for non-existing ids", async () => {
const id = "000000000000000000000000"; // this was the least posible to exist ID
if (!(id in list)) {
const res = await request(app)
.put(`/images/${id}`)
.set("authorization", `Bearer ${token}`)
.send({ status: "available" });
expect(res.status).toBe(404);
}
});
it("should return 400 for malformed ids", async () => {
const res = await request(app)
.put("/images/98439384")
.set("authorization", `Bearer ${token}`)
.send({ status: "available" });
expect(res.status).toBe(400);
expect(res.body).toEqual({ error: "Invalid Id" });
});
it("should return 400 for malformed requests", async () => {
const res = await request(app)
.put(`/images/${id}`)
.set("authorization", `Bearer ${token}`)
.send({ status: "wrong" });
expect(res.status).toBe(400);
});
it("should return 409 if we try to modify the url into an existing one", async () => {
const res = await request(app)
.put(`/images/${id}`)
.set("authorization", `Bearer ${token}`)
.send({ url: "https://test.url.com/2" });
expect(res.status).toBe(409);
});
it("should return 500 for an error on the service", async () => {
mock.module("../src/services/ImageService", () => ({
default: {
replaceOne: () => {
throw new Error("This is an expected testing error");
},
},
}));
const res = await request(app)
.put(`/images/${id}`)
.set("authorization", `Bearer ${token}`)
.send({ status: "available" });
expect(res.status).toBe(500);
});
it("should return 401 for unauthenticated requests", async () => {
const res = await request(app)
.put(`/images/${id}`)
.send({ status: "available" });
expect(res.status).toBe(401);
});
it("should return 403 for invalid tokens", async () => {
const res = await request(app)
.put(`/images/${id}`)
.set("authorization", `Bearer token`)
.send({ status: "available" });
expect(res.status).toBe(403);
});
it("should have its changes be reflected onto the DB", async () => {
const image = await request(app)
.post("/images")
.set("authorization", `Bearer ${token}`)
.send({
url: "https://test.url.com/5",
status: "available",
tags: ["2girls", "touhou"],
});
const id = image.body.image._id;
await request(app)
.put(`/images/${id}`)
.set("authorization", `Bearer ${token}`)
.send({ status: "consumed" });
const res = await request(app).get(`/images/${id}`);
expect(res.body.image).toHaveProperty("status", "consumed");
});
});

43
tests/populateDatabase.ts Normal file
View File

@ -0,0 +1,43 @@
import authModel from "../src/models/AuthModel";
import imageModel from "../src/models/ImageModel";
import { faker } from "@faker-js/faker";
import { UniqueEnforcer } from "enforce-unique";
const uniqueEnforcer = new UniqueEnforcer();
export default async function () {
const images = faker.helpers.multiple(
() => ({
url: uniqueEnforcer.enforce(faker.internet.url),
status: faker.helpers.arrayElement([
"available",
"unavailable",
"consumed",
]),
tags: faker.helpers.arrayElements(
[
"2girls",
"sleeping",
"touhou",
"pokemon",
"closed_eyes",
"yume_nikki",
"waifu",
"computer",
"party",
"yuru_camp",
],
{ min: 0, max: 5 }
),
}),
{ count: { min: 5, max: 50 } }
);
// Wait until all images are inserted
await Promise.allSettled(images.map((image) => imageModel.create(image)));
await authModel.create({
app: "tester",
secret: "test",
});
}

3270
yarn.lock Normal file

File diff suppressed because it is too large Load Diff