Skip to content

Commit

Permalink
Merge pull request #48 from ASparton/feat/32-get-one-article-route
Browse files Browse the repository at this point in the history
Get one article by ID
  • Loading branch information
ASparton authored Nov 21, 2023
2 parents 565a0c9 + 5379d2e commit 0ac24cd
Show file tree
Hide file tree
Showing 9 changed files with 140 additions and 32 deletions.
46 changes: 39 additions & 7 deletions back/__tests__/controllers/articles.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand All @@ -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))
);
Expand All @@ -26,25 +29,28 @@ 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))
);
});

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))
);
});

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
)
)
)
Expand All @@ -55,25 +61,51 @@ 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([]);
});

afterAll(async () => {
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);
});
});
});
Original file line number Diff line number Diff line change
@@ -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;
6 changes: 3 additions & 3 deletions back/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Expand Down
30 changes: 15 additions & 15 deletions back/prisma/seedingOperatons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,91 +10,91 @@ const exampleUsers: User[] = [
email: '[email protected]',
username: 'Alexis Moins',
currency: 'EUR',
is_admin: true
is_admin: true,
},
{
id: '2',
email: '[email protected]',
username: 'Alexandre Sparton',
currency: 'EUR',
is_admin: false
is_admin: false,
},
{
id: '3',
email: '[email protected]',
username: 'Amaury Bourget',
currency: 'EUR',
is_admin: true
is_admin: true,
},
{
id: '4',
email: '[email protected]',
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:
'<p style="float:right; margin:0 0 10px 15px; width:240px;"><img src="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"></p><p>The Estonia-based firm noted that only one plaintiff in the class action lawsuit is actually based in Colorado, where the suit was filed.</p>',
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:
'<p style="float:right; margin:0 0 10px 15px; width:240px;"><img src="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"></p><p>Bitcoin price range trades as SOL, LINK, NEAR and THETA play catch up.</p>',
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:
'<p style="float:right; margin:0 0 10px 15px; width:240px;"><img src="https://images.cointelegraph.com/cdn-cgi/image/format=auto,onerror=redirect,quality=90,width=840/https://s3.cointelegraph.com/uploads/2023-11/7af33720-0dcf-4f6f-9983-7fd9caff3160.jpg"></p><p>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.</p>',
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:
'<p style="float:right; margin:0 0 10px 15px; width:240px;"><img src="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"></p><p>Bitcoin price range trades as SOL, LINK, NEAR and THETA play catch up.</p>',
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',
},
Expand Down
11 changes: 11 additions & 0 deletions back/src/apiErrors.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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;
13 changes: 11 additions & 2 deletions back/src/controllers/articles.ts
Original file line number Diff line number Diff line change
@@ -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();

Expand All @@ -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(';');
}
Expand Down
10 changes: 9 additions & 1 deletion back/src/database/articles.ts
Original file line number Diff line number Diff line change
@@ -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<Article[]> {
// Build keyword filter query
Expand All @@ -21,3 +21,11 @@ export async function findArticles(keywords: string[]): Promise<Article[]> {

return await database.article.findMany(query);
}

export async function findArticleById(id: number): Promise<Article | null> {
return await database.article.findUnique({
where: {
id: id,
},
});
}
11 changes: 7 additions & 4 deletions back/src/middlewares.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -96,6 +96,9 @@ export function errorHandler<T>(
.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
Expand Down
7 changes: 7 additions & 0 deletions back/src/types/dto/articles/OneArticleDTO.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { z } from 'zod';

const OneArticleDTO = z.object({
id: z.coerce.number(),
});

export default OneArticleDTO;

0 comments on commit 0ac24cd

Please sign in to comment.