v1.0.0 #28
|
@ -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
|
|
@ -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 }}
|
|
@ -1,2 +1,7 @@
|
|||
node_modules/
|
||||
bun.lockb
|
||||
.vscode
|
||||
.env
|
||||
.editorconfig
|
||||
.idea
|
||||
coverage*
|
||||
|
|
32
Dockerfile
32
Dockerfile
|
@ -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"]
|
||||
|
|
53
README.md
53
README.md
|
@ -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.
|
||||
|
|
|
@ -0,0 +1,4 @@
|
|||
[install.lockfile]
|
||||
# whether to save a non-Bun lockfile alongside bun.lockb
|
||||
# only "yarn" is supported
|
||||
print = "yarn"
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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"]
|
||||
});
|
16
package.json
16
package.json
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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);
|
|
@ -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;
|
|
@ -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();
|
|
@ -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();
|
67
src/index.ts
67
src/index.ts
|
@ -1,61 +1,12 @@
|
|||
import express from "express";
|
||||
import mongoose from "mongoose";
|
||||
import ImageModel from "./ImageModel";
|
||||
import { startApp } from "./app";
|
||||
|
||||
export const app = express();
|
||||
await startApp();
|
||||
|
||||
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 });
|
||||
// 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();
|
||||
}
|
||||
})
|
||||
|
||||
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();
|
||||
|
||||
} catch {}
|
||||
|
|
|
@ -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;
|
|
@ -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);
|
|
@ -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);
|
|
@ -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();
|
|
@ -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();
|
|
@ -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", async () => {
|
||||
expect(Array.isArray(res.body)).toBeTrue();
|
||||
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");
|
||||
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
|
||||
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 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 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);
|
||||
});
|
||||
|
||||
it("should return 201 for new image", async () => {
|
||||
const res = await request(app).post("/images").send({
|
||||
const res = await request(app)
|
||||
.post("/images")
|
||||
.set("authorization", `Bearer ${token}`)
|
||||
.send({
|
||||
url: "https://test.url.com/1",
|
||||
status: "available",
|
||||
tags: ["2girls", "touhou"]
|
||||
tags: ["2girls", "touhou"],
|
||||
});
|
||||
expect(res.status).toSatisfy(status => [201].includes(status));
|
||||
expect(res.status).toBe(201);
|
||||
});
|
||||
|
||||
it("should return 409 for a repeated images", async () => {
|
||||
await request(app).post("/images").send({
|
||||
await request(app)
|
||||
.post("/images")
|
||||
.set("authorization", `Bearer ${token}`)
|
||||
.send({
|
||||
url: "https://test.url.com/2",
|
||||
status: "available",
|
||||
tags: ["2girls", "touhou"]
|
||||
tags: ["2girls", "touhou"],
|
||||
});
|
||||
|
||||
const res = await request(app).post("/images").send({
|
||||
const res = await request(app)
|
||||
.post("/images")
|
||||
.set("authorization", `Bearer ${token}`)
|
||||
.send({
|
||||
url: "https://test.url.com/2",
|
||||
status: "available",
|
||||
tags: ["2girls", "touhou"]
|
||||
tags: ["2girls", "touhou"],
|
||||
});
|
||||
|
||||
expect(res.status).toSatisfy(status => [409].includes(status));
|
||||
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").send({
|
||||
url: "https://test.url.com/3",
|
||||
const res = await request(app)
|
||||
.post("/images")
|
||||
.set("authorization", `Bearer ${token}`)
|
||||
.send({
|
||||
url: "https://test.url.com/4",
|
||||
status: "wrong",
|
||||
tags: ["2girls", "touhou"]
|
||||
tags: ["2girls", "touhou"],
|
||||
});
|
||||
expect(res.status).toSatisfy(status => [400].includes(status));
|
||||
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");
|
||||
});
|
||||
});
|
|
@ -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",
|
||||
});
|
||||
}
|
Loading…
Reference in New Issue