fmt
Unit Tests with docker compose / unit-test (pull_request) Successful in 23s Details
Unit Tests with docker compose / unit-test (push) Successful in 21s Details
Build image / build (push) Failing after 23s Details

This commit is contained in:
Alie 2024-04-17 20:04:43 +02:00
parent 1aacac9c41
commit 642e075721
17 changed files with 208 additions and 193 deletions

View File

@ -38,8 +38,8 @@ services:
environment: environment:
PORT: 8081 PORT: 8081
BOT_API_URI: "http://bot-api:8080" BOT_API_URI: "http://bot-api:8080"
GELBOORU_IMAGES_PER_REQUEST: 100 # Number of images per request, maximum 100 GELBOORU_IMAGES_PER_REQUEST: 100 # Number of images per request, maximum 100
GELBOORU_TAGS: "2girls sleeping" # Tags of the images. The images will have all of these tags GELBOORU_TAGS: "2girls sleeping" # Tags of the images. The images will have all of these tags
volumes: volumes:
- ./:/usr/src/app:ro - ./:/usr/src/app:ro

View File

@ -9,8 +9,8 @@ app.use(express.json());
app.use(compression()); app.use(compression());
app.get("/", (_, res) => { app.get("/", (_, res) => {
const endpoints = listEndpoints(app); const endpoints = listEndpoints(app);
res.json({ endpoints }); res.json({ endpoints });
}); });
app.get("/image", ImageController.get); app.get("/image", ImageController.get);

View File

@ -3,15 +3,15 @@ import logger from "src/logger";
import ImageService from "src/services/ImageService"; import ImageService from "src/services/ImageService";
class ImageController { class ImageController {
async get(_: Request, res: Response) { async get(_: Request, res: Response) {
try { try {
const image = await ImageService.get(); const image = await ImageService.get();
res.json(image); res.json(image);
} catch (error: any) { } catch (error: any) {
logger.error(error) logger.error(error);
res.status(500).json({ "error": `Internal server error: ${error}` }); res.status(500).json({ error: `Internal server error: ${error}` });
}
} }
}
} }
export default new ImageController(); export default new ImageController();

View File

@ -5,12 +5,12 @@ import logger from "src/logger";
const PORT = env.PORT; const PORT = env.PORT;
const server = app.listen(PORT, () => const server = app.listen(PORT, () =>
logger.info(`Express server listening on port ${PORT}`) logger.info(`Express server listening on port ${PORT}`)
); );
process.on("SIGTERM", () => { process.on("SIGTERM", () => {
server.close(() => { server.close(() => {
logger.info('Server closed.'); logger.info("Server closed.");
process.exit(0); process.exit(0);
}); });
}); });

View File

@ -3,22 +3,20 @@ import logger from "src/logger";
import { BotApiResponse } from "src/types/BotApiResponse"; import { BotApiResponse } from "src/types/BotApiResponse";
class BotApiService { class BotApiService {
readonly BOT_API_URI = env.BOT_API_URI; readonly BOT_API_URI = env.BOT_API_URI;
async getAll(): Promise<BotApiResponse> {
const get_url = `${this.BOT_API_URI}/images`;
const response: BotApiResponse = await fetch(get_url)
.then(async res => {
if (!res.ok) {
logger.error(`${res.status}: ${res.statusText}, ${await res.text()}`)
throw new Error("Error fetching images");
} else {
return res.json();
}
}) as BotApiResponse;
return response;
}
async getAll(): Promise<BotApiResponse> {
const get_url = `${this.BOT_API_URI}/images`;
const response: BotApiResponse = (await fetch(get_url).then(async (res) => {
if (!res.ok) {
logger.error(`${res.status}: ${res.statusText}, ${await res.text()}`);
throw new Error("Error fetching images");
} else {
return res.json();
}
})) as BotApiResponse;
return response;
}
} }
export default new BotApiService(); export default new BotApiService();

View File

@ -4,23 +4,29 @@ import GelbooruApiResponse from "src/types/GelbooruApiResponse";
import GelbooruServiceResponse from "src/types/GelbooruServiceResponse"; import GelbooruServiceResponse from "src/types/GelbooruServiceResponse";
class GelbooruApiService { class GelbooruApiService {
async get(): Promise<GelbooruServiceResponse> { async get(): Promise<GelbooruServiceResponse> {
const LIMIT = env.GELBOORU_IMAGES_PER_REQUEST || 100; const LIMIT = env.GELBOORU_IMAGES_PER_REQUEST || 100;
const TAGS = encodeURIComponent(env.GELBOORU_TAGS || ""); const TAGS = encodeURIComponent(env.GELBOORU_TAGS || "");
const url: string = `https://gelbooru.com/index.php?page=dapi&s=post&q=index&limit=${LIMIT}&json=1&tags=${TAGS}`; const url: string = `https://gelbooru.com/index.php?page=dapi&s=post&q=index&limit=${LIMIT}&json=1&tags=${TAGS}`;
const response: GelbooruApiResponse = await fetch(url) const response: GelbooruApiResponse = (await fetch(url).then(
.then(async res => { async (res) => {
if (!res.ok) { if (!res.ok) {
logger.error(`${res.status}: ${res.statusText}, ${await res.text()}`) logger.error(`${res.status}: ${res.statusText}, ${await res.text()}`);
throw new Error("Error fetching images"); throw new Error("Error fetching images");
} else { } else {
return res.json(); return res.json();
} }
}) as GelbooruApiResponse; }
)) as GelbooruApiResponse;
return { posts: response.post.map(post => ({ url: post.file_url, tags: post.tags.split(" ") })) }; return {
} posts: response.post.map((post) => ({
url: post.file_url,
tags: post.tags.split(" "),
})),
};
}
} }
export default new GelbooruApiService(); export default new GelbooruApiService();

View File

@ -5,32 +5,35 @@ import GelbooruApiService from "src/services/GelbooruApiService";
import logger from "src/logger"; import logger from "src/logger";
class ImageService { class ImageService {
postsQueue: Image[] = []; postsQueue: Image[] = [];
async get(): Promise<Image> { async get(): Promise<Image> {
while (this.postsQueue.length === 0) { while (this.postsQueue.length === 0) {
const validPosts = await this.getNewValidImages(); const validPosts = await this.getNewValidImages();
this.postsQueue = validPosts; this.postsQueue = validPosts;
logger.info(`Got ${validPosts.length} images from remote`) logger.info(`Got ${validPosts.length} images from remote`);
}
return this.postsQueue.pop() as Image;
} }
return this.postsQueue.pop() as Image;
}
private async getNewValidImages(): Promise<Image[]> { private async getNewValidImages(): Promise<Image[]> {
const gelbooruResponse: GelbooruServiceResponse = await GelbooruApiService.get(); const gelbooruResponse: GelbooruServiceResponse =
const posts = gelbooruResponse.posts; await GelbooruApiService.get();
const posts = gelbooruResponse.posts;
const botResponse = await BotApiService.getAll(); const botResponse = await BotApiService.getAll();
const imagesUrls = botResponse.images.map(image => image.url); const imagesUrls = botResponse.images.map((image) => image.url);
const validPosts = Promise.all(posts const validPosts = Promise.all(
.filter(post => !imagesUrls.some(url => url === post.url)) posts
.map((post): Image => { .filter((post) => !imagesUrls.some((url) => url === post.url))
return { url: post.url, tags: post.tags }; .map((post): Image => {
})); return { url: post.url, tags: post.tags };
})
);
return validPosts; return validPosts;
} }
} }
export default new ImageService(); export default new ImageService();

View File

@ -1,5 +1,5 @@
import BotImage from "src/types/BotImage"; import BotImage from "src/types/BotImage";
export interface BotApiResponse { export interface BotApiResponse {
images: BotImage[] images: BotImage[];
} }

View File

@ -1,7 +1,7 @@
export default interface BotImage { export default interface BotImage {
_id: string _id: string;
url: string url: string;
status: string status: string;
tags: string[] tags: string[];
__v: number __v: number;
} }

View File

@ -1,42 +1,42 @@
export default interface GelbooruApiResponse { export default interface GelbooruApiResponse {
"@attributes": Attributes "@attributes": Attributes;
post: Post[] post: Post[];
} }
export interface Attributes { export interface Attributes {
limit: number limit: number;
offset: number offset: number;
count: number count: number;
} }
export interface Post { export interface Post {
id: number id: number;
created_at: string created_at: string;
score: number score: number;
width: number width: number;
height: number height: number;
md5: string md5: string;
directory: string directory: string;
image: string image: string;
rating: string rating: string;
source: string source: string;
change: number change: number;
owner: string owner: string;
creator_id: number creator_id: number;
parent_id: number parent_id: number;
sample: number sample: number;
preview_height: number preview_height: number;
preview_width: number preview_width: number;
tags: string tags: string;
title: string title: string;
has_notes: string has_notes: string;
has_comments: string has_comments: string;
file_url: string file_url: string;
preview_url: string preview_url: string;
sample_url: string sample_url: string;
sample_height: number sample_height: number;
sample_width: number sample_width: number;
status: string status: string;
post_locked: number post_locked: number;
has_children: string has_children: string;
} }

View File

@ -1,4 +1,4 @@
export default interface GelbooruPost { export default interface GelbooruPost {
url: string; url: string;
tags: string[]; tags: string[];
} }

View File

@ -1,5 +1,5 @@
import GelbooruPost from "src/types/GelbooruPost"; import GelbooruPost from "src/types/GelbooruPost";
export default interface GelbooruServiceResponse { export default interface GelbooruServiceResponse {
posts: GelbooruPost[]; posts: GelbooruPost[];
} }

View File

@ -1,4 +1,4 @@
export default interface Image { export default interface Image {
url: string; url: string;
tags: string[]; tags: string[];
} }

View File

@ -6,31 +6,31 @@ import request from "supertest";
const imageServiceOriginal = ImageService; const imageServiceOriginal = ImageService;
afterEach(() => { afterEach(() => {
mock.restore(); mock.restore();
mock.module("src/services/ImageService", () => ({ mock.module("src/services/ImageService", () => ({
default: imageServiceOriginal, default: imageServiceOriginal,
})); }));
}) });
describe("endpoint returns the correct status codes", () => { describe("endpoint returns the correct status codes", () => {
it("should return 200 if successful", async () => { it("should return 200 if successful", async () => {
// Can't mock ImageService partially because of bun incomplete implementation of testing :( // Can't mock ImageService partially because of bun incomplete implementation of testing :(
// It goes to the network directly to test // It goes to the network directly to test
const res = await request(app).get("/image"); const res = await request(app).get("/image");
expect(res.status).toBe(200); expect(res.status).toBe(200);
}); });
it("should return 500 if any error happens", async () => { it("should return 500 if any error happens", async () => {
mock.module("src/services/ImageService", () => { mock.module("src/services/ImageService", () => {
return { return {
default: { default: {
get: () => { get: () => {
throw new Error("Controlled error"); throw new Error("Controlled error");
} },
} },
} };
});
const res = await request(app).get("/image");
expect(res.status).toBe(500);
}); });
}) const res = await request(app).get("/image");
expect(res.status).toBe(500);
});
});

View File

@ -11,50 +11,58 @@ const gelbooruApiServiceOriginal = GelbooruApiService;
const botApiServiceOriginal = BotApiService; const botApiServiceOriginal = BotApiService;
afterEach(() => { afterEach(() => {
mock.restore(); mock.restore();
mock.module("src/services/ImageService", () => ({ mock.module("src/services/ImageService", () => ({
default: imageServiceOriginal, default: imageServiceOriginal,
})); }));
mock.module("src/services/GelbooruApiService", () => ({ mock.module("src/services/GelbooruApiService", () => ({
default: gelbooruApiServiceOriginal, default: gelbooruApiServiceOriginal,
})); }));
mock.module("src/services/BotApiService", () => ({ mock.module("src/services/BotApiService", () => ({
default: botApiServiceOriginal default: botApiServiceOriginal,
})); }));
}) });
describe("endpoint gets a non repeated image", () => { describe("endpoint gets a non repeated image", () => {
it("should return an image that is not in the response of the /images endpoint of the bot API", async () => { it("should return an image that is not in the response of the /images endpoint of the bot API", async () => {
const REPEATED_URL = "https://fastly.picsum.photos/id/1/10/20.jpg?hmac=gY6PvUXFacKfYpBpTTVcNLxumpyMmoCamM-J5DOPwNc"; const REPEATED_URL =
const UNIQUE_URL = "https://fastly.picsum.photos/id/2/10/20.jpg?hmac=zy6lz21CuRIstr9ETx9h5AuoH50s_L2uIEct3dROpY8"; "https://fastly.picsum.photos/id/1/10/20.jpg?hmac=gY6PvUXFacKfYpBpTTVcNLxumpyMmoCamM-J5DOPwNc";
const UNIQUE_URL =
"https://fastly.picsum.photos/id/2/10/20.jpg?hmac=zy6lz21CuRIstr9ETx9h5AuoH50s_L2uIEct3dROpY8";
mock.module("src/services/GelbooruApiService", () => { mock.module("src/services/GelbooruApiService", () => {
let alreadyCalled = false; let alreadyCalled = false;
return { return {
default: { default: {
get: (): GelbooruApiResponse => { get: (): GelbooruApiResponse => {
if (alreadyCalled) { if (alreadyCalled) {
return { posts: [{ url: UNIQUE_URL, tags: [] }] }; return { posts: [{ url: UNIQUE_URL, tags: [] }] };
} else { } else {
alreadyCalled = true; alreadyCalled = true;
return { posts: [{ url: REPEATED_URL, tags: [] }] }; return { posts: [{ url: REPEATED_URL, tags: [] }] };
}
}
}
} }
}); },
},
mock.module("src/services/BotApiService", () => ({ };
default: {
getAll: (): BotApiResponse => ({
images: [
{ _id: "0", url: REPEATED_URL, status: "consumed", tags: ["pokemon", "computer"], "__v": 0 }
]
})
}
}));
const image: Image = await ImageService.get();
expect(image.url).not.toBe(REPEATED_URL);
}); });
mock.module("src/services/BotApiService", () => ({
default: {
getAll: (): BotApiResponse => ({
images: [
{
_id: "0",
url: REPEATED_URL,
status: "consumed",
tags: ["pokemon", "computer"],
__v: 0,
},
],
}),
},
}));
const image: Image = await ImageService.get();
expect(image.url).not.toBe(REPEATED_URL);
});
}); });

View File

@ -18,6 +18,6 @@
"types": [ "types": [
"bun-types" // add Bun global "bun-types" // add Bun global
], ],
"baseUrl": "./", "baseUrl": "./"
} }
} }