From 95f15116a759daaeebb6556c78b02be0227b5b04 Mon Sep 17 00:00:00 2001 From: Ross Lockhart Date: Fri, 6 Feb 2026 14:24:58 +0000 Subject: [PATCH 1/8] feat: implement cards endpoint with image --- NOTES.md | 15 ++++++++++++++- src/server.ts | 45 +++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 57 insertions(+), 3 deletions(-) diff --git a/NOTES.md b/NOTES.md index 6a164cf..718fc5e 100644 --- a/NOTES.md +++ b/NOTES.md @@ -1 +1,14 @@ -Please add any additional notes here… \ No newline at end of file +Please add any additional notes here… + + +high level slices: +run server +load and inspect json data +expose two endpoints +return appropriate Json responses + +slices of slices: +- Save the url as consts here in the server file? +- add a function(s) to get the data (could write just one that takes a url as an arg, and then I can call that in more specific functions later?) +These could then be called in the GET. +- save the correct props/target the correct props, to a var. \ No newline at end of file diff --git a/src/server.ts b/src/server.ts index 88068f7..c0ceb11 100644 --- a/src/server.ts +++ b/src/server.ts @@ -4,8 +4,49 @@ export const app = express() app.set('json spaces', 2); -app.get('/cards', async () => { - // respond with a list of cards +//List of URL +const cardsUrl = "https://moonpig.github.io/tech-test-node-backend/cards.json" +const sizesUrl = "https://moonpig.github.io/tech-test-node-backend/sizes.json" +const templatesUrl = "https://moonpig.github.io/tech-test-node-backend/templates.json" + +//helper to get data +async function fetchJson(url: string) { + const res = await fetch(url); + if (!res.ok) throw new Error(`Failed to fetch ${url}`); + return res.json(); +} + +// function fir first endpoint +async function getAllCards() { + return fetchJson(cardsUrl); +} +async function getTemplates() { + return fetchJson(templatesUrl); +} + + +app.get('/cards', async (req, res) => { + try{ + const cards = await getAllCards(); + const templates = await getTemplates(); + + const result = cards.map((card) => { +// hit pages array on card to compare templateID + const firstPage = card.pages[0]; +// find corresponding template for id cited in page array +const firstPageTemplate = templates.find(t => t.id === firstPage.templateId); + +return { + title: card.title, + url: `/cards/${card.id}`, + imageUrl: firstPageTemplate ? firstPageTemplate.imageUrl : "No image available", + }; + }) + res.json(result); + } catch (err) { + console.error(err); + res.status(500).json({ error: "Failed to fetch cards" }); + } }) app.get('/cards/:cardId/:sizeId?', () => { From 5db6765f893cae8e3be2e7b53370bbcbf550a7b4 Mon Sep 17 00:00:00 2001 From: Ross Lockhart Date: Fri, 6 Feb 2026 14:37:25 +0000 Subject: [PATCH 2/8] test: add unit test for returning from cards endpoint --- src/__tests__/server.test.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/__tests__/server.test.ts b/src/__tests__/server.test.ts index 0ddbe51..b2567ca 100644 --- a/src/__tests__/server.test.ts +++ b/src/__tests__/server.test.ts @@ -1,6 +1,16 @@ import request from 'supertest' import { app } from '../server' + +test('returns all cards with correct structure', async () => { + const response = await request(app).get('/cards') + + expect(response.status).toBe(200) + expect(response.body).toEqual( + expect.arrayContaining([ + expect.objectContaining({ title: 'card 1 title', url: '/cards/card001' }) + ])) +}) test('returns matching card title', async () => { const response = await request(app).get('/cards/card001') From 096bd70d90854c525dde3733f6f01deb96d68a19 Mon Sep 17 00:00:00 2001 From: Ross Lockhart Date: Fri, 6 Feb 2026 15:49:03 +0000 Subject: [PATCH 3/8] feat: adding dynamic endpoint. wip, checkpoint --- src/server.ts | 45 +++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 43 insertions(+), 2 deletions(-) diff --git a/src/server.ts b/src/server.ts index c0ceb11..d17af53 100644 --- a/src/server.ts +++ b/src/server.ts @@ -23,6 +23,9 @@ async function getAllCards() { async function getTemplates() { return fetchJson(templatesUrl); } +async function getSizes() { + return fetchJson(sizesUrl); +} app.get('/cards', async (req, res) => { @@ -49,6 +52,44 @@ return { } }) -app.get('/cards/:cardId/:sizeId?', () => { - // respond with card by id +app.get('/cards/:cardId/:sizeId?', async (req, res) => { + try{ + // save what params user typed in + const { cardId, sizeId } = req.params; + const cards = await getAllCards(); + const templates = await getTemplates(); + const sizes = await getSizes(); + + // find card matching id + const card = cards.find(card => card.id === cardId); + if (!card) { + return res.status(404).json({ error: "Card not found" }); + } + // get size requested + // if (!sizeId) { + // return res.status(404).json({ error: "Card size not found" }); + const selectedSize = sizes.find(size => size.id === sizeId); + if (!selectedSize) { + return res.status(404).json({ error: "Card size not found" }); + } + // format price + // price == base price * size multiplier || if no size multiplier, price === base price + const pricePence = selectedSize ? card.basePricePence * selectedSize.priceMultiplier : card.basePricePence; + const price = (pricePence / 100).toFixed(2); + + // get template for card + const template = templates.find(t => t.id === card.pages[0].templateId); + + res.json({ + title: card.title, + price, + imageUrl: template ? template.imageUrl : "No image available", + }); + + + + } catch (err) { + //eror handling + } + }) From b44908b580526ef8bfacde3c277d1bcd9f1d8bf5 Mon Sep 17 00:00:00 2001 From: Ross Lockhart Date: Fri, 6 Feb 2026 16:00:45 +0000 Subject: [PATCH 4/8] chore: make price a string and fix typo --- src/server.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/server.ts b/src/server.ts index d17af53..da65e71 100644 --- a/src/server.ts +++ b/src/server.ts @@ -74,8 +74,8 @@ app.get('/cards/:cardId/:sizeId?', async (req, res) => { } // format price // price == base price * size multiplier || if no size multiplier, price === base price - const pricePence = selectedSize ? card.basePricePence * selectedSize.priceMultiplier : card.basePricePence; - const price = (pricePence / 100).toFixed(2); + const pricePence = selectedSize ? card.basePrice * selectedSize.priceMultiplier : card.basePrice; + const price = `£${(pricePence / 100).toFixed(2)}`; // get template for card const template = templates.find(t => t.id === card.pages[0].templateId); From 06511bf22904a45fd4016ae0ceb4c58178e46816 Mon Sep 17 00:00:00 2001 From: Ross Lockhart Date: Fri, 6 Feb 2026 16:41:54 +0000 Subject: [PATCH 5/8] chore: update logic to stop 404 when no param provided, unit tests added --- src/__tests__/server.test.ts | 27 +++++++++++++++++++++++++++ src/server.ts | 19 +++++++++---------- 2 files changed, 36 insertions(+), 10 deletions(-) diff --git a/src/__tests__/server.test.ts b/src/__tests__/server.test.ts index b2567ca..85055ba 100644 --- a/src/__tests__/server.test.ts +++ b/src/__tests__/server.test.ts @@ -19,3 +19,30 @@ test('returns matching card title', async () => { title: 'card 1 title', })) }) +test('returns 404 if card not found', async () => { + const response = await request(app).get('/cards/invalidCard') + + expect(response.status).toBe(404) + expect(response.body).toEqual(expect.objectContaining({ + "error": "Card not found" + })) +}) + +test('returns matching card size', async () => { + const response = await request(app).get('/cards/card001/sm') + + expect(response.status).toBe(200) + expect(response.body).toEqual(expect.objectContaining({ + title: 'card 1 title', + price: "£1.60", + imageUrl: '/front-cover-portrait-1.jpg' + })) +}) +test('returns 404 if card size not found', async () => { + const response = await request(app).get('/cards/card001/invalidSize') + + expect(response.status).toBe(404) + expect(response.body).toEqual(expect.objectContaining({ + "error": "Card size not found" + })) +}) \ No newline at end of file diff --git a/src/server.ts b/src/server.ts index da65e71..c1444ac 100644 --- a/src/server.ts +++ b/src/server.ts @@ -65,15 +65,16 @@ app.get('/cards/:cardId/:sizeId?', async (req, res) => { if (!card) { return res.status(404).json({ error: "Card not found" }); } - // get size requested - // if (!sizeId) { - // return res.status(404).json({ error: "Card size not found" }); - const selectedSize = sizes.find(size => size.id === sizeId); - if (!selectedSize) { + // get size requested or set to be undfined if not provided + const selectedSize = sizeId + ? sizes.find(s => s.id === sizeId) + : undefined; + + // 404 if given size param doesn't match any in data + if (sizeId && !selectedSize) { return res.status(404).json({ error: "Card size not found" }); } // format price - // price == base price * size multiplier || if no size multiplier, price === base price const pricePence = selectedSize ? card.basePrice * selectedSize.priceMultiplier : card.basePrice; const price = `£${(pricePence / 100).toFixed(2)}`; @@ -86,10 +87,8 @@ app.get('/cards/:cardId/:sizeId?', async (req, res) => { imageUrl: template ? template.imageUrl : "No image available", }); - - } catch (err) { - //eror handling + console.error(err); + res.status(500).json({ error: "Failed to fetch cards" }); } - }) From f01327439b5e87890b03abb1ba9c3ed46b8034ce Mon Sep 17 00:00:00 2001 From: Ross Lockhart Date: Fri, 6 Feb 2026 16:55:10 +0000 Subject: [PATCH 6/8] chore: remove comments and update notes --- src/server.ts | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/src/server.ts b/src/server.ts index c1444ac..6d213a0 100644 --- a/src/server.ts +++ b/src/server.ts @@ -4,19 +4,16 @@ export const app = express() app.set('json spaces', 2); -//List of URL const cardsUrl = "https://moonpig.github.io/tech-test-node-backend/cards.json" const sizesUrl = "https://moonpig.github.io/tech-test-node-backend/sizes.json" const templatesUrl = "https://moonpig.github.io/tech-test-node-backend/templates.json" -//helper to get data async function fetchJson(url: string) { const res = await fetch(url); if (!res.ok) throw new Error(`Failed to fetch ${url}`); return res.json(); } -// function fir first endpoint async function getAllCards() { return fetchJson(cardsUrl); } @@ -34,9 +31,7 @@ app.get('/cards', async (req, res) => { const templates = await getTemplates(); const result = cards.map((card) => { -// hit pages array on card to compare templateID const firstPage = card.pages[0]; -// find corresponding template for id cited in page array const firstPageTemplate = templates.find(t => t.id === firstPage.templateId); return { @@ -54,31 +49,25 @@ return { app.get('/cards/:cardId/:sizeId?', async (req, res) => { try{ - // save what params user typed in const { cardId, sizeId } = req.params; const cards = await getAllCards(); const templates = await getTemplates(); const sizes = await getSizes(); - // find card matching id const card = cards.find(card => card.id === cardId); if (!card) { return res.status(404).json({ error: "Card not found" }); } - // get size requested or set to be undfined if not provided const selectedSize = sizeId ? sizes.find(s => s.id === sizeId) : undefined; - // 404 if given size param doesn't match any in data if (sizeId && !selectedSize) { return res.status(404).json({ error: "Card size not found" }); } - // format price const pricePence = selectedSize ? card.basePrice * selectedSize.priceMultiplier : card.basePrice; const price = `£${(pricePence / 100).toFixed(2)}`; - // get template for card const template = templates.find(t => t.id === card.pages[0].templateId); res.json({ From d9e22e9e936dbadd897787771ad4662c03a77d5c Mon Sep 17 00:00:00 2001 From: Ross Lockhart Date: Fri, 6 Feb 2026 16:55:32 +0000 Subject: [PATCH 7/8] chore: remove comments and update notes --- NOTES.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/NOTES.md b/NOTES.md index 718fc5e..eecdde6 100644 --- a/NOTES.md +++ b/NOTES.md @@ -11,4 +11,11 @@ slices of slices: - Save the url as consts here in the server file? - add a function(s) to get the data (could write just one that takes a url as an arg, and then I can call that in more specific functions later?) These could then be called in the GET. -- save the correct props/target the correct props, to a var. \ No newline at end of file +- save the correct props/target the correct props, to a var. + + +Decisions: +seperate concrns with different functions, but use a resusable function for fetching- though caching would be important here in the real world. + +The mapping of the data is something I would have liked to extract into mapping functions that could be imported to make the code cleaner, and increase resusability if required. + From f9028906995decc89a2d3af5c7a9b9cf1f546278 Mon Sep 17 00:00:00 2001 From: Ross Lockhart Date: Fri, 6 Feb 2026 17:00:54 +0000 Subject: [PATCH 8/8] chore: update notes with a nth --- NOTES.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/NOTES.md b/NOTES.md index eecdde6..ea30c99 100644 --- a/NOTES.md +++ b/NOTES.md @@ -19,3 +19,5 @@ seperate concrns with different functions, but use a resusable function for fetc The mapping of the data is something I would have liked to extract into mapping functions that could be imported to make the code cleaner, and increase resusability if required. +Should have included pagination to gaurd against larger payloads. +