Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion src/apiClient.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,9 @@ const apiClient = axios.create({
export const getBooks = async () => {
const response = await apiClient.get('/books');
return response.data;
}
}

export const getBookById = async (bookId) => {
const response = await apiClient.get(`/books/${bookId}`);
return response.data;
}
41 changes: 39 additions & 2 deletions src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import express from 'express';
import nunjucks from 'nunjucks';
import path from 'path';
import { fileURLToPath } from 'url';
import { getBooks } from './apiClient.js';
import { getBookById, getBooks } from './apiClient.js';

const _filename = fileURLToPath(import.meta.url);
const _dirname = path.dirname(_filename);
Expand All @@ -19,6 +19,20 @@ nunjucks.configure(
}
);

function transformBookLinks(book) {
const relativeLinks = {};
if (book.links) {
for (const key in book.links) {
const absoluteUrl = book.links[key];
relativeLinks[key] = new URL(absoluteUrl).pathname;
}
}
return {
...book,
links: relativeLinks
};
};

app.set('view engine', 'njk');

const govukPath = path.join(_dirname, '../node_modules/govuk-frontend/dist')
Expand All @@ -31,10 +45,33 @@ app.get('/', (req, res) => {
app.get('/books', async (req, res) => {
const booksData = await getBooks();

const booksForView = booksData.items.map(transformBookLinks);

res.render('books.njk', {
pageTitle: 'Books',
books: booksData.items,
books: booksForView,
});
});

app.get('/books/:bookId', async (req, res) => {
const bookId = req.params.bookId;

try {
const bookData = await getBookById(bookId);
const bookForView = transformBookLinks(bookData);

res.render('book-data.njk', {
pageTitle: bookForView.title,
bookForView,
});
} catch (error) {
if (error.response && error.response.status === 404) {
res.status(404).render('404.njk', { pageTitle: 'Page not found' });
} else {
res.status(500).render('500.njk', { pageTitle: 'Sorry, there is a problem with the service' });
}
}

});

export default app;
11 changes: 11 additions & 0 deletions src/views/404.njk
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{% extends "layout.njk" %}

{% block content %}
<div class="govuk-grid-row">
<div class="govuk-grid-column-two-thirds">
<h1 class="govuk-heading-xl">Page not found</h1>
<p class="govuk-body">If you typed the web address, check it is correct.</p>
<p class="govuk-body">If you pasted the web address, check you copied the entire address.</p>
</div>
</div>
{% endblock %}
10 changes: 10 additions & 0 deletions src/views/500.njk
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{% extends "layout.njk" %}

{% block content %}
<div class="govuk-grid-row">
<div class="govuk-grid-column-two-thirds">
<h1 class="govuk-heading-xl">Sorry, there is a problem with the service</h1>
<p class="govuk-body">Try again later.</p>
</div>
</div>
{% endblock %}
18 changes: 18 additions & 0 deletions src/views/book-data.njk
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{% extends "layout.njk" %}

{% block content %}

<h1 class="govuk-heading-xl">{{ bookForView.title }}</h1>

<p class="govuk-body-l">by {{ bookForView.author }}</p>

<div class="govuk-inset-text">
<p class="govuk-body">{{ bookForView.synopsis }}</p>

<a class="govuk-link" href="{{ bookForView.links.reservations }}">Reservations</a>
</div>

{# Add a link to go back to the list #}
<a href="/books" class="govuk-back-link">Back to all books</a>

{% endblock %}
5 changes: 4 additions & 1 deletion src/views/books.njk
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,12 @@
<ul class="govuk-list govuk-list--bullet">
{% for book in books %}
<li>
<h2 class="govuk-heading-m">{{ book.title }}</h2>
<h2 class="govuk-heading-m">
<a class="govuk-link" href="{{ book.links.self }}">{{ book.title }}</a>
</h2>
<p class="govuk-body">by {{ book.author }}</p>
<p class="govuk-body">{{ book.synopsis }}</p>
<a class="govuk-link" href="{{ book.links.reservations }}">Reservations</a>
</li>
{% endfor %}
</ul>
Expand Down
1 change: 1 addition & 0 deletions tests/helpers/testSetup.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export const setupMockedApp = async () => {

jest.unstable_mockModule('../../src/apiClient.js', () => ({
getBooks: jest.fn(),
getBookById: jest.fn(),
}));

const apiClient = await import('../../src/apiClient.js');
Expand Down
125 changes: 121 additions & 4 deletions tests/integration/books.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,18 @@ const mockApiResponse = {
title: 'The Midnight Library',
author: 'Matt Haig',
synopsis: 'A novel about all the choices that go into a life well lived.',
links: { self: '/books/123-abc' },
links: {
self: 'http://localhost:5003/books/123-abc',
reservations: 'http://localhost:5003/books/123-abc/reservations' },
},
{
id: '456-def',
title: 'Project Hail Mary',
author: 'Andy Weir',
synopsis: 'A lone astronaut must save the Earth from disaster.',
links: { self: '/books/456-def' },
links: {
self: 'http://localhost:5003/books/456-def',
reservations: 'http://localhost:5003/books/456-def/reservations' },
},
],
};
Expand All @@ -41,7 +45,7 @@ describe('GET /books', () => {
apiClient.getBooks.mockResolvedValue(mockApiResponse);

// WHEN a GET request is made to the /books URL
const response = await request(app).get('/books');
const response = await request(app).get('/books/');

// THEN the correct page should be served
expect(response.statusCode).toBe(200);
Expand All @@ -53,5 +57,118 @@ describe('GET /books', () => {
expect(response.text).toMatch(/Matt Haig/);
expect(response.text).toMatch(/Project Hail Mary/);
expect(response.text).toMatch(/Andy Weir/);
// AND the book HATEOAS links should be present
expect(response.text).toMatch(/<a.*href="\/books\/123-abc".*>The Midnight Library<\/a>/);
expect(response.text).toMatch(/<a.*href="\/books\/123-abc\/reservations".*>Reservations<\/a>/);
expect(response.text).toMatch(/<a.*href="\/books\/456-def".*>Project Hail Mary<\/a>/);
expect(response.text).toMatch(/<a.*href="\/books\/456-def\/reservations".*>Reservations<\/a>/);
expect(response.text).not.toMatch(/href="http:\/\/localhost:5003/);
});
});
});

describe('GET /books/:bookId', () => {
let app;
let apiClient;

beforeAll(async () => {
const setup = await setupMockedApp();
app = setup.app;
apiClient = setup.apiClient;
});

beforeEach(() => {
apiClient.getBookById.mockClear();
});

describe('When the book exists', () => {
it('should respond with 200 OK and display the book details', async () => {
// GIVEN a mock book
const bookId = '123e4567-e89b-12d3-a456-426614174000';
const mockBook = {
id: bookId,
title: 'The Lord of the Rings',
author: 'J.R.R. Tolkien',
synopsis: 'An epic adventure in Middle-earth.',
links: {
self: `http://localhost:5003/books/${bookId}`,
reservations: `http://localhost:5003/books/${bookId}/reservations` },
};

// AND a mock apiClient that will return it
apiClient.getBookById.mockResolvedValue(mockBook);

// WHEN a request is made to the mock book's endpoint
const response = await request(app).get(`/books/${bookId}`);

// THEN The response should be successful and contain the book's data
expect(response.statusCode).toBe(200);
expect(response.text).toMatch(/<title>The Lord of the Rings - GOV.UK<\/title>/);
expect(response.text).toMatch(/<h1.*>The Lord of the Rings<\/h1>/);
expect(response.text).toMatch(/J\.R\.R\. Tolkien/);
expect(response.text).toMatch(/An epic adventure in Middle-earth\./);
// AND the reservations link should be present and correctly formatted
const expectedLinkPattern = `<a.*href="/books/${bookId}/reservations".*>Reservations</a>`;
const regex = new RegExp(expectedLinkPattern);
expect(response.text).toMatch(regex);

// AND the mock object should have been called correctly
expect(apiClient.getBookById).toHaveBeenCalledTimes(1);
expect(apiClient.getBookById).toHaveBeenCalledWith(bookId);
});
});

describe('When the book does not exist', () => {
it('should respond with 404 Not Found and display an error page', async () => {
// GIVEN a format-valid bookId for a book that does not exist
const nonExistentBookId = '123e4567-e89b-12d3-a456-426614174000';

// AND a mock API error
const apiError = {
response: {
status: 404
}
};

// AND a mock apiClient that will return this error
apiClient.getBookById.mockRejectedValue(apiError);

// WHEN a request is made to the non-existent book's endpoint
const response = await request(app).get(`/books/${nonExistentBookId}`);

// THEN the response should be 404 Not Found and contain a user-friendly message
expect(response.statusCode).toBe(404);
expect(response.text).toMatch(/<h1.*>Page not found<\/h1>/);
expect(response.text).toMatch(/If you typed the web address, check it is correct./);

// AND the mock should have been called correctly
expect(apiClient.getBookById).toHaveBeenCalledWith(nonExistentBookId);
});
});

describe('when the backend API returns a server error', () => {
it('should respond with 500 and display a generic error page', async () => {
// GIVEN: a valid bookId
const bookId = '123e4567-e89b-12d3-a456-426614174000';

// AND: a mock API error that simulates a 500 Internal Server Error
const mockApiError = {
response: {
status: 500,
data: { error: 'Something went wrong on the server' }
}
};
// AND a mock client configured to return the Error
apiClient.getBookById.mockRejectedValue(mockApiError);

// WHEN a request is made to the endpoint
const response = await request(app).get(`/books/${bookId}`);

// THEN the response should be 500 and contain our user-friendly 500 message
expect(response.statusCode).toBe(500);
expect(response.text).toMatch(/<h1.*>Sorry, there is a problem with the service<\/h1>/);
expect(response.text).toMatch(/Try again later\./);

expect(apiClient.getBookById).toHaveBeenCalledWith(bookId);
});
});
});