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
61 changes: 61 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

permissions:
contents: read

env:
JAVA_VERSION: '21'
NODE_VERSION: '22'
Expand Down Expand Up @@ -178,3 +181,61 @@ jobs:
Library-Management-System-Version-2/backend.log
retention-days: 7
if-no-files-found: ignore

# --------------------------------------------------------------------------------------------
# Publishes the built frontend to GitHub Pages.
#
# Pages serves static files and nothing else, so the API has to live somewhere else. Set the
# repository variable API_BASE_URL to a reachable backend origin and the site will talk to it;
# leave it unset and the app still loads, but every call fails and the UI says it cannot reach
# the library. That is the honest outcome for a static host with no server behind it.
# --------------------------------------------------------------------------------------------
pages:
name: Deploy to GitHub Pages
# Only from the default branch, and only once the code is known to be good.
if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request'
needs: [java, frontend]
runs-on: ubuntu-latest
permissions:
contents: read
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: npm
cache-dependency-path: frontend/package-lock.json

- uses: actions/configure-pages@v6

- name: Install
working-directory: frontend
run: npm ci

- name: Build
working-directory: frontend
env:
# The site is served from https://<owner>.github.io/<repo>/, so assets need that prefix.
VITE_BASE_PATH: /${{ github.event.repository.name }}/
VITE_API_BASE_URL: ${{ vars.API_BASE_URL }}
run: npm run build

- name: Add an SPA fallback
working-directory: frontend
# Pages has no rewrite rule, so a deep link like /books is a 404 until the same document is
# served for it. Copying index.html to 404.html is the usual way to let the router take over.
run: cp dist/index.html dist/404.html

- uses: actions/upload-pages-artifact@v5
with:
path: frontend/dist

- name: Deploy
id: deployment
uses: actions/deploy-pages@v5
Original file line number Diff line number Diff line change
Expand Up @@ -93,12 +93,21 @@ public ResponseEntity<TransactionResponse> returnBook(@PathVariable UUID bookId,
}
}

/** Only the member themselves or an administrator may borrow against a membership. */
@PostMapping(value = "/borrowBook/{customerId}/{bookId}",
produces = {"application/transaction-response+json;version=1", MediaType.APPLICATION_JSON_VALUE})
@Operation(summary = "Borrow a book")
public ResponseEntity<String> borrowBook(
@PathVariable UUID customerId,
@PathVariable UUID bookId) {
@PathVariable UUID bookId,
Authentication authentication) {
// The customer id comes from the path, so without this a member could borrow against
// somebody else's membership and spend their loan limit. Matches returnBook and extendLoan.
if (!isAdmin(authentication) && !isOwner(authentication, customerId)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body("You can only borrow against your own membership.");
}

try {
transactionUseCase.borrowBook(customerId, bookId);
return ResponseEntity.ok("Book borrowed successfully.");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ void testCreateNewTransaction_BadRequest() throws Exception {
.andExpect(status().isBadRequest());
}
@Test
@WithMockUser(username = "member")
void testBorrowBook() throws Exception {
UUID customerId = customer.getCustomerId();
UUID bookId = book.getBookId();
Expand All @@ -137,6 +138,7 @@ void testBorrowBook() throws Exception {
assertEquals(1, transactionCount);
}
@Test
@WithMockUser(username = "member")
void testBorrowBook_bookNotAvailable() throws Exception {
UUID customerId = customer.getCustomerId();
UUID bookId = book.getBookId();
Expand Down Expand Up @@ -292,4 +294,16 @@ public void tearDown() {
bookRepository.deleteAll();
authorRepository.deleteAll();
}

/** A member borrowing against somebody else's membership would spend their loan limit. */
@Test
@WithMockUser(username = "member", roles = "USER")
void borrowingAgainstAnotherMembershipIsRefused() throws Exception {
Customer otherMember = customerUseCase.createNewCustomer(
new CreateNewCustomer("Someone Else", "someone.else@example.com", true));

mockMvc.perform(post("/transactions/borrowBook/" + otherMember.getCustomerId() + "/"
+ book.getBookId()))
.andExpect(status().isForbidden());
}
}
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,30 @@ matrix runs with `fail-fast: false` so one red service does not hide the state o
Test reports are published to the run summary, and on failure the surefire/failsafe reports, PMD
and Checkstyle XML, and the Playwright trace are uploaded as artifacts.

## Deployment

Pushes to `main` publish the built frontend to **GitHub Pages** at
`https://<owner>.github.io/<repo>/`, after the Java and frontend jobs pass. Enable it once under
**Settings → Pages → Source → GitHub Actions**.

Pages serves static files and nothing else, so **the API has to live somewhere else**:

| Repository variable | Effect |
| ------------------- | ------ |
| `API_BASE_URL` unset | The site loads, every call fails, and the UI says it cannot reach the library |
| `API_BASE_URL` set to a backend origin | The site talks to that backend |

Set it under **Settings → Secrets and variables → Actions → Variables**. A cross-origin backend
also needs a `CorsConfigurationSource` bean on the Spring side — the dev proxy that makes requests
same-origin locally does not exist on a static host.

Two build-time details the deployment depends on:

- **`VITE_BASE_PATH`** is set to `/<repo>/`, because Pages serves from a subdirectory and absolute
asset paths would otherwise 404.
- **`404.html`** is a copy of `index.html`. Pages has no rewrite rule, so a deep link such as
`/books` is a 404 until the same document is served for it and the router can take over.

## Code style

One Checkstyle ruleset and one PMD ruleset in `config/`, shared by all three services so they are
Expand Down
18 changes: 18 additions & 0 deletions frontend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,24 @@ src/
domain.ts types mirroring the backend JSON
```

## Deploying to a static host

`npm run build` produces a `dist/` that any static host can serve, but two things have to be set at
build time:

```bash
VITE_BASE_PATH=/my-repo/ # when served from a subdirectory
VITE_API_BASE_URL=https://api.example.com # no dev proxy exists outside `npm run dev`
```

`VITE_API_BASE_URL` defaults to `/backend`, the dev proxy path. Point it at a real backend origin
and add CORS on the Spring side, since the requests are then cross-origin. Left unset on a static
host, the app loads and every call fails — which the UI reports as "can't reach the library" rather
than breaking.

For an SPA, also copy `index.html` to `404.html`: a static host has no rewrite rule, so a deep link
is a 404 until the same document is served for it.

## Error messages

Nothing in the UI shows an HTTP status code. `Request failed with status 502` is a fact about
Expand Down
10 changes: 6 additions & 4 deletions frontend/src/api/client.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import type { Page, Session } from '../types/domain'

/**
* All requests go through the Vite dev proxy (see vite.config.ts) so the browser
* treats them as same-origin. In production, point this at the real backend origin
* and add CORS on the Spring side.
* Where the API lives.
*
* <p>Locally this is the Vite dev proxy (see vite.config.ts), which keeps requests same-origin.
* A static deployment has no proxy, so set VITE_API_BASE_URL to the backend's own origin at build
* time - and add CORS on the Spring side, because the requests are then cross-origin.
*/
const BASE = '/backend'
const BASE = import.meta.env.VITE_API_BASE_URL ?? '/backend'

const TOKEN_KEY = 'library.jwt'
const SESSION_KEY = 'library.session'
Expand Down
3 changes: 3 additions & 0 deletions frontend/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ import react from '@vitejs/plugin-react'
// See src/api/client.ts, which prefixes all calls with /backend.
// noinspection JSUnusedGlobalSymbols -- Loaded by name by Vite, never imported, so an IDE sees an export nobody uses.
export default defineConfig({
// '/' locally, '/<repo>/' on GitHub Pages, where the site is served from a subdirectory and
// absolute asset paths would otherwise 404.
base: process.env.VITE_BASE_PATH ?? '/',
plugins: [react()],
server: {
port: 5173,
Expand Down
Loading