From ea296f8aa385ca78fe7aab91c925c55e17bb6644 Mon Sep 17 00:00:00 2001 From: = <=> Date: Tue, 21 Nov 2023 19:13:37 +0100 Subject: [PATCH] feat(articles): get one article by id route done --- back/__tests__/controllers/articles.test.ts | 46 ++++++++++++++++--- .../migration.sql | 38 +++++++++++++++ back/prisma/schema.prisma | 6 +-- back/prisma/seedingOperatons.ts | 30 ++++++------ back/src/apiErrors.ts | 11 +++++ back/src/controllers/articles.ts | 13 +++++- back/src/database/articles.ts | 10 +++- back/src/middlewares.ts | 11 +++-- back/src/types/dto/articles/OneArticleDTO.ts | 7 +++ 9 files changed, 140 insertions(+), 32 deletions(-) create mode 100644 back/prisma/migrations/20231121160201_article_feed_id_from_string_to_int/migration.sql create mode 100644 back/src/types/dto/articles/OneArticleDTO.ts diff --git a/back/__tests__/controllers/articles.test.ts b/back/__tests__/controllers/articles.test.ts index e9a5cab..a9c415d 100644 --- a/back/__tests__/controllers/articles.test.ts +++ b/back/__tests__/controllers/articles.test.ts @@ -7,6 +7,8 @@ import { import { database } from 'src/lucia'; import request from 'supertest'; import app from '../../src/app'; +import HttpStatusCode from '#types/HttpStatusCode'; +import ApiErrors from '~apiErrors'; describe('Articles router tests', () => { describe('GET all articles', () => { @@ -17,6 +19,7 @@ describe('Articles router tests', () => { test('GET all articles without keywords', async () => { const res = await request(app).get('/api/articles'); + expect(res.statusCode).toStrictEqual(HttpStatusCode.OK_200); expect(JSON.parse(JSON.stringify(res.body))).toStrictEqual( JSON.parse(JSON.stringify(exampleArticles)) ); @@ -26,6 +29,7 @@ describe('Articles router tests', () => { const res = await request(app).get( '/api/articles?date=2023-10-10&orderBy=desc' ); + expect(res.statusCode).toStrictEqual(HttpStatusCode.OK_200); expect(JSON.parse(JSON.stringify(res.body))).toStrictEqual( JSON.parse(JSON.stringify(exampleArticles)) ); @@ -33,6 +37,7 @@ describe('Articles router tests', () => { test('GET all articles with empty keywords', async () => { const res = await request(app).get('/api/articles?keywords='); + expect(res.statusCode).toStrictEqual(HttpStatusCode.OK_200); expect(JSON.parse(JSON.stringify(res.body))).toStrictEqual( JSON.parse(JSON.stringify(exampleArticles)) ); @@ -40,11 +45,12 @@ describe('Articles router tests', () => { test('GET all articles with one existing keyword', async () => { const res = await request(app).get('/api/articles?keywords=bitcoin'); + expect(res.statusCode).toStrictEqual(HttpStatusCode.OK_200); expect(JSON.parse(JSON.stringify(res.body))).toStrictEqual( JSON.parse( JSON.stringify( exampleArticles.filter( - (article) => article.id === '2' || article.id === '3' + (article) => article.id === 2 || article.id === 3 ) ) ) @@ -55,20 +61,17 @@ describe('Articles router tests', () => { const res = await request(app).get( '/api/articles?keywords=bitcoin;wallet' ); + expect(res.statusCode).toStrictEqual(HttpStatusCode.OK_200); expect(JSON.parse(JSON.stringify(res.body))).toStrictEqual( JSON.parse( - JSON.stringify( - exampleArticles.filter( - (article) => - article.id === '1' || article.id === '2' || article.id === '3' - ) - ) + JSON.stringify(exampleArticles.filter((article) => article.id < 4)) ) ); }); test('GET all articles with non existing keywords', async () => { const res = await request(app).get('/api/articles?keywords=alexismoins'); + expect(res.statusCode).toStrictEqual(HttpStatusCode.OK_200); expect(res.body).toStrictEqual([]); }); @@ -76,4 +79,33 @@ describe('Articles router tests', () => { await deleteAllFeeds(database); }); }); + + describe('GET one article', () => { + beforeAll(async () => { + await populateFeeds(database); + await populateArticles(database); + }); + + test('GET one article with wrong id format', async () => { + const res = await request(app).get('/api/articles/three'); + expect(res.statusCode).toStrictEqual(HttpStatusCode.BAD_REQUEST_400); + }); + + test('GET one article with unexisting id', async () => { + const res = await request(app).get('/api/articles/-1'); + expect(res.statusCode).toStrictEqual(HttpStatusCode.NOT_FOUND_404); + }); + + test('GET one existing article', async () => { + const res = await request(app).get('/api/articles/1'); + expect(res.statusCode).toStrictEqual(HttpStatusCode.OK_200); + expect(JSON.parse(JSON.stringify(res.body))).toStrictEqual( + JSON.parse(JSON.stringify(exampleArticles[0])) + ); + }); + + afterAll(async () => { + await deleteAllFeeds(database); + }); + }); }); diff --git a/back/prisma/migrations/20231121160201_article_feed_id_from_string_to_int/migration.sql b/back/prisma/migrations/20231121160201_article_feed_id_from_string_to_int/migration.sql new file mode 100644 index 0000000..c842804 --- /dev/null +++ b/back/prisma/migrations/20231121160201_article_feed_id_from_string_to_int/migration.sql @@ -0,0 +1,38 @@ +/* + Warnings: + + - The primary key for the `Article` table will be changed. If it partially fails, the table could be left without primary key constraint. + - The `id` column on the `Article` table would be dropped and recreated. This will lead to data loss if there is data in the column. + - The primary key for the `Feed` table will be changed. If it partially fails, the table could be left without primary key constraint. + - The `id` column on the `Feed` table would be dropped and recreated. This will lead to data loss if there is data in the column. + - Changed the type of `source_feed_id` on the `Article` table. No cast exists, the column would be dropped and recreated, which cannot be done if there is data, since the column is required. + +*/ +-- DropForeignKey +ALTER TABLE "Article" DROP CONSTRAINT "Article_source_feed_id_fkey"; + +-- DropIndex +DROP INDEX "Article_id_key"; + +-- DropIndex +DROP INDEX "Feed_id_key"; + +-- AlterTable +ALTER TABLE "Article" DROP CONSTRAINT "Article_pkey", +DROP COLUMN "id", +ADD COLUMN "id" SERIAL NOT NULL, +DROP COLUMN "source_feed_id", +ADD COLUMN "source_feed_id" INTEGER NOT NULL, +ADD CONSTRAINT "Article_pkey" PRIMARY KEY ("id"); + +-- AlterTable +ALTER TABLE "Feed" DROP CONSTRAINT "Feed_pkey", +DROP COLUMN "id", +ADD COLUMN "id" SERIAL NOT NULL, +ADD CONSTRAINT "Feed_pkey" PRIMARY KEY ("id"); + +-- CreateIndex +CREATE INDEX "Article_source_feed_id_idx" ON "Article"("source_feed_id"); + +-- AddForeignKey +ALTER TABLE "Article" ADD CONSTRAINT "Article_source_feed_id_fkey" FOREIGN KEY ("source_feed_id") REFERENCES "Feed"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/back/prisma/schema.prisma b/back/prisma/schema.prisma index e4ddd50..168cb72 100644 --- a/back/prisma/schema.prisma +++ b/back/prisma/schema.prisma @@ -39,19 +39,19 @@ model Key { } model Feed { - id String @id @unique + id Int @id @default(autoincrement()) url String @unique articles Article[] } model Article { - id String @id @unique + id Int @id @default(autoincrement()) title String url String @unique image_url String? content String published DateTime - source_feed_id String + source_feed_id Int source_feed Feed @relation(references: [id], fields: [source_feed_id], onDelete: Cascade) @@index([source_feed_id]) diff --git a/back/prisma/seedingOperatons.ts b/back/prisma/seedingOperatons.ts index 60ce75f..f520ccf 100644 --- a/back/prisma/seedingOperatons.ts +++ b/back/prisma/seedingOperatons.ts @@ -10,91 +10,91 @@ const exampleUsers: User[] = [ email: 'alexis.moins@epitech.eu', username: 'Alexis Moins', currency: 'EUR', - is_admin: true + is_admin: true, }, { id: '2', email: 'alexandre.sparton@epitech.eu', username: 'Alexandre Sparton', currency: 'EUR', - is_admin: false + is_admin: false, }, { id: '3', email: 'amaury.bourget@epitech.eu', username: 'Amaury Bourget', currency: 'EUR', - is_admin: true + is_admin: true, }, { id: '4', email: 'medhi@epitech.eu', username: 'Medhi', currency: 'EUR', - is_admin: false + is_admin: false, }, ]; const exampleFeeds: Feed[] = [ { - id: '1', + id: 1, url: 'https://cointelegraph.com/rss/tag/bitcoin', }, { - id: '2', + id: 2, url: 'https://cointelegraph.com/rss/tag/altcoin', }, { - id: '3', + id: 3, url: 'https://cointelegraph.com/rss/tag/ethereum', }, ]; export const exampleArticles: Article[] = [ { - id: '1', + id: 1, title: 'Atomic Wallet asks to toss suit over $100M hack, saying it has ‘no US ties’', url: 'https://cointelegraph.com/news/atomic-wallet-dimissal-of-hack-suit-no-us-ties', content: '

The Estonia-based firm noted that only one plaintiff in the class action lawsuit is actually based in Colorado, where the suit was filed.

', published: new Date('Mon, 20 Nov 2023 06:33:35 +0000'), - source_feed_id: '2', + source_feed_id: 2, image_url: 'https://images.cointelegraph.com/cdn-cgi/image/format=auto,onerror=redirect,quality=90,width=840/https://s3.cointelegraph.com/uploads/2023-11/7d575f59-4dc0-40f9-9591-8ca0fead6890.jpg', }, { - id: '2', + id: 2, title: 'SOL, LINK, NEAR and THETA flash bullish as Bitcoin takes a breather', url: 'https://cointelegraph.com/news/sol-link-near-and-theta-flash-bullish-as-bitcoin-takes-a-breather', content: '

Bitcoin price range trades as SOL, LINK, NEAR and THETA play catch up.

', published: new Date('Sun, 19 Nov 2023 19:45:01 +0000'), - source_feed_id: '2', + source_feed_id: 2, image_url: 'https://images.cointelegraph.com/cdn-cgi/image/format=auto,onerror=redirect,quality=90,width=840/https://s3.cointelegraph.com/uploads/2023-11/797d2bb6-95f2-45ee-a44d-a0da64c4bad2.jpg', }, { - id: '3', + id: 3, title: 'ARK, 21Shares update spot Bitcoin ETF application as next SEC deadline looms', url: 'https://cointelegraph.com/news/cathie-wood-ark-spot-bitcoin-etf-amend', content: '

The latest update is the third amendment to the Bitcoin ETF prospectus by ARK and 21Shares after the firms first filed for a spot Bitcoin ETF in April 2023.

', published: new Date('Mon, 20 Nov 2023 15:03:43 +0000'), - source_feed_id: '1', + source_feed_id: 1, image_url: null, }, { - id: '4', + id: 4, title: 'OpenAI’s Sam Altman ousted, BlackRock and Fidelity seek Ether ETF, and more: Hodler’s Digest, Nov. 12-18', url: 'https://cointelegraph.com/magazine/openais-sam-altman-ousted-blackrock-and-fidelity-seek-ether-etf-and-more-hodlers-digest-nov-12-18', content: '

Bitcoin price range trades as SOL, LINK, NEAR and THETA play catch up.

', published: new Date('Sat, 18 Nov 2023 21:30:10 +0000'), - source_feed_id: '3', + source_feed_id: 3, image_url: 'https://cointelegraph.com/magazine/wp-content/uploads/2023/11/nov-18-scaled.jpg', }, diff --git a/back/src/apiErrors.ts b/back/src/apiErrors.ts index fb1f83d..9756582 100644 --- a/back/src/apiErrors.ts +++ b/back/src/apiErrors.ts @@ -1,3 +1,5 @@ +import HttpStatusCode from '#types/HttpStatusCode'; + enum ApiErrors { /** * Raised when the email given to the login route is not used by a user @@ -43,4 +45,13 @@ enum ApiErrors { RESOURCE_NOT_FOUND = 'RESOURCE_NOT_FOUND', } +export class APIError extends Error { + public errorCode: HttpStatusCode; + + constructor(type: ApiErrors, errorCode: HttpStatusCode) { + super(type); + this.errorCode = errorCode; + } +} + export default ApiErrors; diff --git a/back/src/controllers/articles.ts b/back/src/controllers/articles.ts index bd6a419..04ffa98 100644 --- a/back/src/controllers/articles.ts +++ b/back/src/controllers/articles.ts @@ -1,7 +1,9 @@ -import express, { query } from 'express'; -import { findArticles } from '../database/articles'; +import express from 'express'; +import ApiErrors, { APIError } from '~apiErrors'; import HttpStatusCode from '#types/HttpStatusCode'; import AllArticlesDTO from '#types/dto/articles/AllArticlesDTO'; +import OneArticleDTO from '#types/dto/articles/OneArticleDTO'; +import { findArticleById, findArticles } from '../database/articles'; const controller = express.Router(); @@ -15,6 +17,13 @@ controller.get('/', async (req, res) => { return res.status(HttpStatusCode.OK_200).send(articles); }); +controller.get('/:id', async (req, res) => { + const urlParams = OneArticleDTO.parse(req.params); + const article = await findArticleById(urlParams.id); + if (article === null) throw new APIError(ApiErrors.RESOURCE_NOT_FOUND, 404); + return res.status(HttpStatusCode.OK_200).send(article); +}); + function getKeywordsFromQueryParam(keywordsParam: string): string[] { return keywordsParam.split(';'); } diff --git a/back/src/database/articles.ts b/back/src/database/articles.ts index d1f2f79..a988e6a 100644 --- a/back/src/database/articles.ts +++ b/back/src/database/articles.ts @@ -1,5 +1,5 @@ -import { database } from '~lucia'; import { Article, Prisma } from '@prisma/client'; +import { database } from '~lucia'; export async function findArticles(keywords: string[]): Promise { // Build keyword filter query @@ -21,3 +21,11 @@ export async function findArticles(keywords: string[]): Promise { return await database.article.findMany(query); } + +export async function findArticleById(id: number): Promise
{ + return await database.article.findUnique({ + where: { + id: id, + }, + }); +} diff --git a/back/src/middlewares.ts b/back/src/middlewares.ts index 47b6727..d8f33ea 100644 --- a/back/src/middlewares.ts +++ b/back/src/middlewares.ts @@ -1,12 +1,12 @@ -import { LuciaError } from 'lucia'; import { NextFunction, Request, Response } from 'express'; +import { LuciaError } from 'lucia'; -import ApiErrors from '~apiErrors'; +import ApiErrors, { APIError } from '~apiErrors'; import HttpStatusCode from '#types/HttpStatusCode'; -import { auth } from '~lucia'; -import { ZodError } from 'zod'; import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library'; +import { ZodError } from 'zod'; +import { auth } from '~lucia'; /** * Log the incoming request on the command line. @@ -96,6 +96,9 @@ export function errorHandler( .send(ApiErrors.UNEXPECTED_SERVER_ERROR); break; } + } else if (err instanceof APIError) { + console.log('[error] %s', err); + res.status(err.errorCode).send(err.message); } else { console.log('[error] %s', err); res diff --git a/back/src/types/dto/articles/OneArticleDTO.ts b/back/src/types/dto/articles/OneArticleDTO.ts new file mode 100644 index 0000000..b6b819b --- /dev/null +++ b/back/src/types/dto/articles/OneArticleDTO.ts @@ -0,0 +1,7 @@ +import { z } from 'zod'; + +const OneArticleDTO = z.object({ + id: z.coerce.number(), +}); + +export default OneArticleDTO;