diff --git a/.github/workflows/imdb-ci.yml b/.github/workflows/imdb-ci.yml
new file mode 100644
index 0000000..89c7dfd
--- /dev/null
+++ b/.github/workflows/imdb-ci.yml
@@ -0,0 +1,107 @@
+name: imdb CI
+
+on:
+ push:
+ paths:
+ - 'imdb/**'
+ - '.github/workflows/imdb-ci.yml'
+ pull_request:
+ paths:
+ - 'imdb/**'
+ - '.github/workflows/imdb-ci.yml'
+
+jobs:
+ unit:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-java@v4
+ with:
+ distribution: temurin
+ java-version: '21'
+ cache: maven
+
+ - name: Run unit tests
+ working-directory: imdb
+ run: mvn -B test
+
+ # Testcontainers-based (real Postgres/Redis/Grafana LGTM stack) - kept out of the unit stage
+ # deliberately, since they need Docker and take real time, not because they're less important.
+ integration:
+ needs: unit
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-java@v4
+ with:
+ distribution: temurin
+ java-version: '21'
+ cache: maven
+
+ - name: Run integration tests
+ working-directory: imdb
+ run: mvn -B failsafe:integration-test failsafe:verify
+
+ # Contract/e2e: a really-running imdb-service against a lightweight, deterministic seed (plain
+ # postgres:17 + our own Flyway migrations + the same fixture-data.sql the integration tests use),
+ # not the real abanda/imdb-postgresql image - that one's ~20-30 minute dataset import is fine for
+ # local dev but not something every push should pay for.
+ e2e:
+ needs: integration
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ # Explicit -p/project name here matches docker-compose.e2e.yaml's own `name: imdb-e2e` - keeps
+ # this stage's containers/network fully isolated from anything else that might be running on
+ # the same runner under the default directory-derived project name.
+ - name: Bring up the e2e stack
+ working-directory: imdb
+ run: docker compose -f docker-compose.e2e.yaml -p imdb-e2e up -d --build
+
+ - name: Wait for imdb-service to be healthy
+ run: |
+ for i in $(seq 1 30); do
+ if curl -sf http://localhost:8080/actuator/health >/dev/null 2>&1; then
+ echo "imdb-service is up"
+ exit 0
+ fi
+ sleep 5
+ done
+ echo "imdb-service never became healthy" >&2
+ exit 1
+
+ - name: Wait for the seed job to finish loading fixture data
+ run: |
+ for i in $(seq 1 20); do
+ status=$(docker inspect --format='{{.State.Status}}' imdb-e2e-seed-1 2>/dev/null || echo "missing")
+ if [ "$status" = "exited" ]; then
+ code=$(docker inspect --format='{{.State.ExitCode}}' imdb-e2e-seed-1)
+ if [ "$code" != "0" ]; then
+ echo "seed job failed with exit code $code" >&2
+ docker logs imdb-e2e-seed-1 >&2
+ exit 1
+ fi
+ echo "seed job completed"
+ exit 0
+ fi
+ sleep 3
+ done
+ echo "seed job never finished" >&2
+ exit 1
+
+ - name: Run Postman/Newman e2e tests
+ working-directory: imdb
+ run: npx --yes newman run postman/imdb-e2e.postman_collection.json --env-var baseUrl=http://localhost:8080
+
+ - name: Show logs on failure
+ if: failure()
+ working-directory: imdb
+ run: docker compose -f docker-compose.e2e.yaml -p imdb-e2e logs
+
+ - name: Tear down
+ if: always()
+ working-directory: imdb
+ run: docker compose -f docker-compose.e2e.yaml -p imdb-e2e down -v
diff --git a/README.md b/README.md
index 7dfe9f8..e48ba09 100644
--- a/README.md
+++ b/README.md
@@ -17,6 +17,37 @@ Each subproject targets a different corner of the JVM world on purpose, so the e
- **Bare-metal Java** — framework-free exercises touching concurrency, the memory model, and JVM internals directly
- **Cross-JVM exploration** — the same problems occasionally revisited in other JVM languages (e.g. Scala) to compare idioms and trade-offs
+## Projects
+
+### [imdb](imdb/) — production-shaped REST API over the real IMDb dataset
+
+The most recent, and most complete, project in this repo. A Spring Boot 4.1 / Java 21 API over the full, untruncated [IMDb Non-Commercial Dataset](https://www.imdb.com/interfaces/) - millions of titles, tens of millions of cast/crew credits - not a sample or a toy CRUD example. Started as four read-only endpoints (fuzzy search, top-rated by genre, and a generalized "Six Degrees of Kevin Bacon" graph query), then grew into a 48-endpoint API with JWT auth, admin CRUD, and a full user-content layer (watchlists, reviews, custom lists).
+
+- **A real algorithm, not a toy one**: Six Degrees is a genuine bidirectional BFS, hand-rolled in PL/pgSQL after a first, CTE-based version passed review and its own tests, then failed under real data - a silent wrong-answer bug, plus a hub-to-hub query that took 3+ minutes and spilled to disk. The fix: **29-44ms and a correct answer** on the exact pathological pair that broke the original.
+- **Full observability, not a metrics endpoint**: structured JSON logs, Prometheus metrics, and OpenTelemetry distributed tracing, correlated in one Grafana instance - down to individual SQL statements and Redis commands as their own spans in a real trace waterfall, and every log line carrying the trace ID that produced it.
+- **Load-tested against itself**: k6 scripts exercise every endpoint in isolation, then all 48 simultaneously; the combined run found and fixed five real bugs (HikariCP pool exhaustion, three schema drifts between the test and real database, and an id sequence colliding with orphaned imported rows) rather than just producing a green checkmark.
+- **Interactive, fully-documented API** (Swagger UI) - every endpoint carries a real summary, description, and per-status-code response doc, not auto-generated `delete_3`-style operationIds.
+- **Three-tier CI pipeline**: unit → Testcontainers integration → Postman/Newman e2e against the real built Docker image, not a mocked slice.
+
+
+
+
+
+
+
+Full README, with the complete architecture, all four dashboards, and a live trace shape: [imdb/README.md](imdb/README.md).
+
+### [votee](votee/) — exact-arithmetic vote-counting library
+
+A Java 21 port of [votee-scala](votee-scala/), an existing Scala 3 library of mine, implementing nine vote-counting algorithms (Majority, Super Majority, Approval, Veto, Borda Count, Baldwin, Contingent Vote, Coombs' Method, Exhaustive Ballot) behind one shared, generic `Election` contract.
+
+- **Correctness over convenience**: every vote weight and score is tracked as an exact `Rational` (`BigInteger` numerator/denominator, reduced to lowest terms), not a `double` - tallies never drift from floating-point rounding, no matter how many rounds an election runs.
+- **Ported for behavioral parity, not just API shape**: every algorithm is checked against the same JSON fixtures the Scala reference's own test suite uses; every place this port deliberately diverges from that reference is called out and reasoned about individually, not silently different.
+- **Genuinely extensible**: bring your own `Candidate`/`Ballot`/`Winner` types by implementing the library's contracts directly - every algorithm is generic over `>`, so a custom domain type works with zero changes to the algorithm classes themselves.
+- **46 tests**, published to a private GitHub Packages Maven registry under Early SemVer.
+
+Full README, including the algorithm table, extension guide, and every documented deviation from the Scala reference: [votee/README.md](votee/README.md).
+
## How this repo is organized
This is a monorepo: every top-level directory is a self-contained project with its own build tooling, tests, and README. This root README intentionally stays high-level — open a project's folder for details on its stack, design decisions, and how to run it.
diff --git a/imdb/.gitattributes b/imdb/.gitattributes
new file mode 100644
index 0000000..3b41682
--- /dev/null
+++ b/imdb/.gitattributes
@@ -0,0 +1,2 @@
+/mvnw text eol=lf
+*.cmd text eol=crlf
diff --git a/imdb/.gitignore b/imdb/.gitignore
new file mode 100644
index 0000000..667aaef
--- /dev/null
+++ b/imdb/.gitignore
@@ -0,0 +1,33 @@
+HELP.md
+target/
+.mvn/wrapper/maven-wrapper.jar
+!**/src/main/**/target/
+!**/src/test/**/target/
+
+### STS ###
+.apt_generated
+.classpath
+.factorypath
+.project
+.settings
+.springBeans
+.sts4-cache
+
+### IntelliJ IDEA ###
+.idea
+*.iws
+*.iml
+*.ipr
+
+### NetBeans ###
+/nbproject/private/
+/nbbuild/
+/dist/
+/nbdist/
+/.nb-gradle/
+build/
+!**/src/main/**/build/
+!**/src/test/**/build/
+
+### VS Code ###
+.vscode/
diff --git a/imdb/.mvn/wrapper/maven-wrapper.properties b/imdb/.mvn/wrapper/maven-wrapper.properties
new file mode 100644
index 0000000..216df05
--- /dev/null
+++ b/imdb/.mvn/wrapper/maven-wrapper.properties
@@ -0,0 +1,3 @@
+wrapperVersion=3.3.4
+distributionType=only-script
+distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.16/apache-maven-3.9.16-bin.zip
diff --git a/imdb/Dockerfile b/imdb/Dockerfile
new file mode 100644
index 0000000..ab9e1f6
--- /dev/null
+++ b/imdb/Dockerfile
@@ -0,0 +1,19 @@
+# syntax=docker/dockerfile:1
+
+FROM eclipse-temurin:21-jdk AS build
+WORKDIR /build
+
+COPY .mvn/ .mvn/
+COPY mvnw pom.xml ./
+RUN chmod +x mvnw && ./mvnw -q dependency:go-offline
+
+COPY src/ src/
+RUN ./mvnw -q -DskipTests package && \
+ mv target/imdb-*.jar target/app.jar
+
+FROM eclipse-temurin:21-jre
+WORKDIR /app
+COPY --from=build /build/target/app.jar app.jar
+
+EXPOSE 8080
+ENTRYPOINT ["java", "-jar", "app.jar"]
diff --git a/imdb/LICENSE b/imdb/LICENSE
new file mode 100644
index 0000000..ed2aee4
--- /dev/null
+++ b/imdb/LICENSE
@@ -0,0 +1,201 @@
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright [2026] [Ludovic Temgou Abanda N.]
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
diff --git a/imdb/README.md b/imdb/README.md
new file mode 100644
index 0000000..2d1b99a
--- /dev/null
+++ b/imdb/README.md
@@ -0,0 +1,346 @@
+# imdb
+
+A production-shaped Spring Boot REST API over the full [IMDb Non-Commercial Dataset](https://www.imdb.com/interfaces/): title search with cast/crew, top-rated movies by genre, and a generalized "Six Degrees of Kevin Bacon" graph query. Part of a deliberate, professional-grade return to the Java ecosystem (see the [root README](../README.md)): real indexing decisions, a literature-grounded algorithm choice for the graph problem, full observability, and a three-tier test pipeline, not a toy CRUD example.
+
+[](https://github.com/icemc/java-backend-playground/actions/workflows/imdb-ci.yml)
+
+
+
+
+
+## Table of contents
+
+- [What this is](#what-this-is)
+- [Functional requirements](#functional-requirements)
+- [Architecture](#architecture)
+ - [Onion layering](#onion-layering)
+ - [Package layout](#package-layout)
+ - [Key design decisions](#key-design-decisions)
+ - [Six Degrees of Kevin Bacon](#six-degrees-of-kevin-bacon)
+ - [Caching strategy](#caching-strategy)
+- [API](#api)
+- [Authentication](#authentication)
+- [Observability](#observability)
+ - [Correlation in practice](#correlation-in-practice)
+- [External services](#external-services)
+- [Testing strategy](#testing-strategy)
+- [DevOps](#devops)
+- [Load testing](#load-testing)
+- [Running it locally](#running-it-locally)
+- [Project layout](#project-layout)
+- [Design documents](#design-documents)
+- [Known limitations](#known-limitations)
+- [License](#license)
+
+## What this is
+
+Given the real, untruncated IMDb dataset (millions of titles and people, tens of millions of cast/crew credits), `imdb` exposes four read-only REST endpoints, backed by:
+
+- Hand-tuned native SQL (trigram fuzzy search, GIN array containment, a Bayesian weighted rating, a custom bidirectional-BFS PL/pgSQL function) instead of an ORM (see [Why plain JDBC](#key-design-decisions)).
+- Redis cache-aside in front of every endpoint, since the dataset only changes on a one-time image reload.
+- Full observability: structured JSON logs, Prometheus metrics, and OpenTelemetry traces, correlated in one Grafana instance.
+- A three-tier test pipeline (unit → Testcontainers integration → Postman/Newman e2e) wired into CI.
+- k6 load testing, both per-endpoint and all-endpoints-at-once, with results pushed into the same observability stack.
+
+## Functional requirements
+
+| # | Requirement | Endpoint |
+|---|---|---|
+| 1 | Search a movie by title; show related cast/crew | `GET /api/v1/titles/search`, `GET /api/v1/titles/{titleId}` |
+| 2 | Top-rated movies in a given genre | `GET /api/v1/genres/{genre}/top-rated` |
+| 3 | Degree of separation between any two people ("Six Degrees", generalized beyond a fixed Kevin Bacon root) | `GET /api/v1/people/six-degrees` |
+
+Full requirements and guidelines: [`docs/REQUIREMENTS.md`](docs/REQUIREMENTS.md).
+
+## Architecture
+
+### Onion layering
+
+`presentation → infrastructure → application → domain`, plus a dependency-free `utils` layer. Import direction alone enforces the dependency rule: `application` never imports `infrastructure`, and `domain` imports nothing.
+
+| Layer | Contains |
+|---|---|
+| `presentation` | Controllers, `ApiExceptionHandler`, `RequestLoggingFilter` |
+| `infrastructure` | JDBC repository implementations, Redis caching decorators, `@Configuration` |
+| `application` | Use-case interfaces + implementations |
+| `domain` | Entities/value objects, repository **interfaces**, domain exceptions |
+| `utils` | `ImdbIds`, `HeaderSanitizer`: pure, framework-free helpers |
+
+Every top-level use case a controller depends on is an interface (`TitleSearchUseCase`, `TitleDetailUseCase`, `TopRatedUseCase`, `SixDegreesUseCase`), with a plain `*Impl` and, where caching applies, an `infrastructure.cache` decorator marked `@Primary`:
+
+```
+application.contracts.TitleSearchUseCase (interface)
+ ├─ application.TitleSearchUseCaseImpl - plain orchestration, no caching
+ └─ infrastructure.cache.CachingTitleSearchUseCase - @Primary, @Cacheable, delegates to the Impl above
+```
+
+A controller depends on the interface and never learns which implementation it got. Swapping cache technology, or Postgres for another store, touches exactly one layer.
+
+### Package layout
+
+```
+imdb/
+ src/main/java/com/ludovictemgoua/imdb/
+ domain/ entities, repository interfaces, domain exceptions
+ application/ use-case interfaces (contracts/) + plain implementations
+ infrastructure/
+ persistence/ JDBC repository implementations
+ cache/ Redis caching decorators, CacheConfig
+ presentation/ controllers, ApiExceptionHandler, RequestLoggingFilter
+ utils/ ImdbIds, HeaderSanitizer - dependency-free
+ src/main/resources/
+ application.yaml
+ db/migration/ Flyway V0-V4 (base schema, indexes, materialized view, BFS function)
+ src/test/ unit + Testcontainers integration tests, shared fixtures
+ k6/ one load-test script per endpoint
+ postman/ e2e contract test collection
+ observability/ Prometheus/Loki/Tempo/Alloy/Grafana provisioning
+ docs/ REQUIREMENTS.md, product-design.md, low-level-design.md
+```
+
+### Key design decisions
+
+| Decision | Chosen approach | Why |
+|---|---|---|
+| Data access | Plain `NamedParameterJdbcTemplate`, no JPA/Hibernate | Every query is a hand-tuned native query (trigram search, array containment, a graph BFS function); no object graph is being mutated, only read projections. |
+| Six Degrees algorithm | Bidirectional BFS as a PL/pgSQL function, meeting in the middle | A one-sided walk pays the graph's full branching factor per hop against hub actors with thousands of co-stars; meeting in the middle roughly squares down the search space, and the 7-degree cap means each side only expands ~4 hops. See [below](#six-degrees-of-kevin-bacon). |
+| Result caching | Redis cache-aside, keyed by the **unordered** person pair, storing the true shortest distance up to the absolute 7-degree cap | A distance is a fact independent of the caller's requested `maxDegree`: one cache entry serves every request for that pair regardless of their bound. |
+| Top-rated ranking | IMDb-style weighted (Bayesian) rating, not raw average | A raw average lets a movie with 3 votes at 10/10 outrank one with 500,000 votes at 8.9. |
+| Title search | PostgreSQL `pg_trgm` similarity search, GIN-indexed | Tolerates typos/partial matches while staying index-backed against a multi-million-row table. |
+| API ID format | Public API uses IMDb-style `tt`/`nm` string IDs, translated at the repository boundary (`utils.ImdbIds`) | The internal integer PK is an artifact of the seed image's import script, not a stable public contract. |
+| Build tool | Maven | Consistent with [`votee`](../votee) elsewhere in this monorepo. |
+
+Full rationale, including alternatives considered (precomputed BFS, Pruned Landmark Labeling, Neo4j) and why they were rejected: [`docs/product-design.md`](docs/product-design.md) §9.
+
+### Six Degrees of Kevin Bacon
+
+The first working version was a single bidirectional recursive CTE. It passed review and its own tests, then failed under real data two distinct ways: a fixed fan-out cap could silently drop the true connecting co-star (a *wrong-answer* bug, not just slow), and cycle prevention only checked a single path's own history, so the same hub node got independently re-expanded by every path that reached it (a real hub-to-hub query took 3+ minutes and spilled to disk).
+
+The fix moved the traversal into a PL/pgSQL function (`find_shortest_co_star_path`, `V3__shortest_co_star_path_function.sql`) with genuine iterative state: two `TEMP TABLE`s hold a real, de-duplicated visited set and parent pointer per side, expanding whichever frontier is currently smaller, with no arbitrary neighbor cap. Verified against the exact pathological pair that broke the old query (two ~8,000-co-star hub nodes, no direct edge): **29-44ms and a correct degree-2 result, versus 3+ minutes and a disk spill before.**
+
+Full derivation, including the two specific bugs found and fixed: [`docs/low-level-design.md`](docs/low-level-design.md) §5.
+
+### Caching strategy
+
+| Cache region | Key | TTL | Cached at |
+|---|---|---|---|
+| `title-search` | `query, page, size` | 24h | Use-case decorator |
+| `title-detail` | `titleId` | 24h | Use-case decorator (one entry for the fully-assembled detail, even though it fans out to five repository calls) |
+| `top-rated` | `genre, limit, minVotes` | 24h | Use-case decorator |
+| `six-degrees` | `min(personA,personB)-max(personA,personB)` | 24h | Repository decorator, cached one layer deeper than the other three, since the use case's raw inputs (names needing disambiguation, a variable `maxDegree`) make a poor cache key, while the repository's `findShortestPath(int, int)` doesn't |
+
+All TTLs are long because the dataset only changes on image reload; there is no write path invalidating entries mid-flight.
+
+## API
+
+| Method | Path | Description |
+|---|---|---|
+| GET | `/api/v1/titles/search?title=&page=&size=` | Fuzzy trigram search, paginated |
+| GET | `/api/v1/titles/{titleId}` | Full detail: metadata, rating, directors/writers, top-billed cast |
+| GET | `/api/v1/genres/{genre}/top-rated?limit=&minVotes=` | Top-rated movies by weighted rating |
+| GET | `/api/v1/people/six-degrees?personA=&personB=&maxDegree=` | Degree of separation; returns a disambiguation payload (HTTP 200) instead of an error when a `name` matches more than one person |
+
+All errors are RFC 7807 `ProblemDetail` (404 for unknown IDs, 400 for malformed IDs/out-of-range `maxDegree`/missing params, 405 for the wrong HTTP method). Full contracts, request/response shapes, and error handling: [`docs/low-level-design.md`](docs/low-level-design.md) §4/§9.
+
+Interactive API docs, generated from the live controllers: [`/swagger-ui/index.html`](http://localhost:8080/swagger-ui/index.html), reading the generated OpenAPI document at `/v3/api-docs`.
+
+
+
+*Every one of the 48 endpoints carries a real summary, description, and per-status-code response doc via `@Operation`/`@ApiResponses` - not the auto-generated `delete_3`/`create_1` operationIds a default springdoc setup produces. Bearer-auth is wired into the "Authorize" button, so every "try it out" call on a protected endpoint just works.*
+
+Beyond the original read-only endpoints above, a later CRUD expansion (`docs/crud-expansion-design.md`) added admin write access over the core entities and a user-generated-content layer, all under JWT auth ([Authentication](#authentication)):
+
+| Group | Endpoints | Notes |
+|---|---|---|
+| Admin: titles | `POST/PUT/PATCH/DELETE /api/v1/titles`, `PUT /api/v1/titles/{titleId}/crew`, `PUT/DELETE /api/v1/titles/{titleId}/rating`, full CRUD on `/api/v1/titles/{titleId}/principals` | `hasRole('ADMIN')`; optimistic locking via a `version` field, `409` on conflict |
+| Admin: people | `POST/PUT/PATCH/DELETE /api/v1/people` | `hasRole('ADMIN')`, same versioning convention |
+| Watchlist | `GET/PUT /api/v1/watchlist`, `POST/DELETE /api/v1/watchlist/items{,/{titleId}}`, `GET /api/v1/users/{userId}/watchlist` | One per user; `PRIVATE` by default |
+| Reviews | Full CRUD under `/api/v1/titles/{titleId}/reviews`, plus `GET /api/v1/users/{userId}/reviews` | One review per `(user, title)`; feeds `userRatingAverage`/`userRatingCount` on title detail |
+| Custom lists | Full CRUD under `/api/v1/lists`, plus `GET /api/v1/lists/public` | `PUBLIC`/`PRIVATE` visibility; viewing someone else's `PRIVATE` resource is `404`, writing to their `PUBLIC` one is `403` |
+
+Full endpoint tables and the exact negative-case contract (403 vs. 404 vs. 409) per resource: [`docs/crud-expansion-design.md`](docs/crud-expansion-design.md) §4/§5.
+
+## Authentication
+
+Stateless JWT, issued and verified by `infrastructure.security` (`JwtService`, `JwtAuthenticationFilter`, `SecurityConfig`):
+
+- `POST /api/v1/auth/register` / `POST /api/v1/auth/login` return an access token and a refresh token; `POST /api/v1/auth/refresh` exchanges a valid refresh token for a new pair.
+- Every admin-write endpoint is gated `@PreAuthorize("hasRole('ADMIN')")`; every user-content write endpoint requires an authenticated user, resolved from the JWT's subject claim (`infrastructure.security.CurrentUser`).
+- A fresh environment has no admin account by default. Setting `IMDB_BOOTSTRAP_ADMIN_EMAIL`/`IMDB_BOOTSTRAP_ADMIN_PASSWORD` (both `docker-compose.yaml` and `docker-compose.e2e.yaml`) makes `BootstrapAdminRunner` create exactly one `ADMIN` account on startup, if one doesn't already exist - the only way to get a first admin without direct database access.
+- `JWT_SECRET` must be set (no default) for the application to start; test/e2e environments use a fixed, clearly-non-production value (Maven Surefire/Failsafe `environmentVariables`, `docker-compose.e2e.yaml`).
+
+## Observability
+
+- **Metrics**: `micrometer-registry-prometheus`. Default HTTP (`http.server.requests`, with percentile-histogram buckets enabled) and JVM/process metrics via Boot's auto-configuration. Cache hit/miss/put counters per region are **manually** bound (`infrastructure.cache.CacheConfig`) via Spring Data Redis's own `RedisCacheWriter` statistics. Spring Boot 4.1 dropped its Boot-2/3-era automatic cache-metrics binding entirely (confirmed by decompiling the actual jar), so this design replicates it rather than relying on a feature that no longer exists.
+- **Logging**: structured JSON via Spring Boot 4.1's native structured logging (`logging.structured.format.console: logstash`, no extra dependency). A `RequestLoggingFilter` assigns/honors a per-request `X-Request-Id`, independent of trace sampling, alongside Micrometer's `traceId`/`spanId`; both land in every log line's MDC, including the filter's own request-start/request-completed lines (needed a filter-order fix past Spring's own tracing filter to actually cover those two - [`docs/low-level-design.md`](docs/low-level-design.md) §7.2). `utils.HeaderSanitizer` redacts sensitive headers (`Authorization`, `Cookie`, `Set-Cookie`, `X-Api-Key`) before anything is logged. Shipped to Loki via Grafana Alloy.
+- **Tracing**: `spring-boot-starter-opentelemetry` (Boot 4.1's unified tracing starter) exports to Tempo via OTLP, 100% sampled for this exercise. A trace covers the full request lifecycle, not just HTTP/Security: `datasource-micrometer` gives every DB connection-acquire and SQL statement its own span (real HikariCP pool name, real query text), Boot's own Lettuce integration gives every Redis command its own span, and a `HandlerInterceptor` gives each controller method its own span tagged with the cache hit/miss outcome. Details and a real trace shape: §7.2.
+- **Correlation**: Grafana's datasources are provisioned with Loki ⇄ Tempo ⇄ Prometheus derived-field wiring, so a trace opened in Grafana click-throughs to its log lines and vice versa.
+
+
+
+*All three signal types wired up as code (`observability/grafana/provisioning/datasources/`), not clicked together by hand in the UI - a fresh `docker-compose up` reproduces this exactly.*
+
+- **Dashboards**: four Grafana dashboards, provisioned from `observability/grafana/provisioning/dashboards/json/`, verified against live traffic (every panel's PromQL checked against a real scrape, not just schema-checked):
+
+ | Dashboard | Covers |
+ |---|---|
+ | HTTP Overview | Request rate by endpoint, p95 latency by endpoint, 5xx error rate, JVM heap used |
+ | Six Degrees Latency Breakdown | p50/p95/p99 latency for the six-degrees endpoint specifically, versus the other three endpoints, and its request rate; this is the one endpoint whose cost depends on graph shape, not a bounded index lookup |
+ | Cache Hit Ratio | Hit ratio by region, gets by region/result, puts by region |
+ | k6 Load Test Results | Virtual users, request rate, p95 request duration, failed request rate, fed by a k6 run's own Prometheus remote-write output |
+
+
+
+
+
+*Mid-load-test, not a static demo: request rate and p95 latency climbing together across every endpoint, JVM heap tracking along with it.*
+
+
+
+*The one endpoint that isn't a bounded index lookup gets its own dedicated latency breakdown - p99 pinned at the graph query's timeout ceiling, visibly separated from the other three endpoints on the same axes. This is what makes the accepted ~25-30% hard-pair failure rate ([Known limitations](#known-limitations)) a measured, monitored trade-off instead of an invisible one.*
+
+
+
+*Cache warming visible in real time - hit ratio climbing from 0% (cold) to 80-100% per region as repeat traffic lands, backed by the manually-rebuilt `cache.gets`/`cache.puts` counters (Boot 4.1 dropped the auto-binding this depended on - §7.1).*
+
+
+
+*Left as originally captured, failure spike included on purpose: this dashboard's job is to catch regressions, and it did - this exact run is what first surfaced the connection-pool exhaustion and schema-drift bugs fixed and documented in [Load testing](#load-testing). A clean screenshot would prove less than this one does.*
+
+### Correlation in practice
+
+The payoff of wiring all three signals together: open a trace in Tempo and see the actual request broken into spans, not just "this endpoint took 220ms."
+
+
+
+*One real trace (`GET /api/v1/titles/{titleId}`), opened directly in Grafana's Tempo explorer: Spring Security's filter-chain overhead broken out from the actual data fetch, each span independently timed, all under one trace ID. A different request (a cache miss) additionally shows a `TitleController#get` span tagged `cache.result=miss`, HikariCP connection-acquire, the real SQL text, and row count as separate spans underneath it - full shape documented in [`docs/low-level-design.md`](docs/low-level-design.md) §7.2.*
+
+
+
+*The metric backing the Cache Hit Ratio dashboard above, queried directly - a hand-rolled `FunctionCounter` (`CacheConfig.cacheStatisticsMeterBinder`), not a framework default.*
+
+
+
+*Structured JSON logs in Loki, one `RequestLoggingFilter` line per request lifecycle event - `requestId`, `traceId`, and `spanId` all queryable as first-class fields, not buried in an unstructured message string.*
+
+Full wiring details, including four real bugs found and fixed while verifying the dashboards against live data (missing percentile-histogram config, Boot 4.1's removed cache-metrics auto-binding, an invalid Prometheus flag, and wrong assumptions about k6's remote-write metric shapes): [`docs/low-level-design.md`](docs/low-level-design.md) §7/§7.1.
+
+## External services
+
+Brought up by `docker-compose.yaml` alongside the application itself:
+
+| Service | Image | Port(s) | Role |
+|---|---|---|---|
+| PostgreSQL | [`abanda/imdb-postgresql`](https://github.com/icemc/imdb-postgresql) | 5432 | The seeded IMDb dataset (full, untruncated) |
+| Redis | `redis:7-alpine` | 6379 | Cache-aside store for all four endpoints |
+| Prometheus | `prom/prometheus` | 9090 | Metrics scrape + k6 remote-write receiver |
+| Grafana | `grafana/grafana` | 3001 (host) → 3000 | Dashboards, correlated logs/metrics/traces (anonymous admin access, local-only) |
+| Loki | `grafana/loki` | 3100 | Log aggregation |
+| Grafana Alloy | `grafana/alloy` | 12345 | Ships container logs to Loki |
+| Tempo | `grafana/tempo` | 3200, 4317, 4318 | Distributed trace storage/query, OTLP receiver |
+| postgres-exporter | `quay.io/prometheuscommunity/postgres-exporter` | 9187 | Postgres metrics for Prometheus |
+| redis-exporter | `oliver006/redis_exporter` | 9121 | Redis metrics for Prometheus |
+| k6 | `grafana/k6` | N/A | Load testing, opt-in via the `load-test` compose profile |
+
+## Testing strategy
+
+Three tiers, matching three sequential CI jobs (`unit` → `integration` → `e2e`); see [DevOps](#devops):
+
+| Tier | Tooling | Scope |
+|---|---|---|
+| **Unit** (`mvn test`, Surefire) | JUnit 5 + Mockito + AssertJ | `application`-layer use cases against mocked `domain.repository` interfaces (no Spring, no Docker); `infrastructure.cache` decorators against a mocked delegate; this verifies delegation behavior but **cannot** catch real (de)serialization bugs, since nothing is actually serialized. |
+| **Integration** (`mvn failsafe:integration-test failsafe:verify`, Failsafe) | Testcontainers (Postgres + Redis) | `infrastructure.persistence` against real Postgres (Flyway-migrated, shared fixture data). Four cache integration tests wired against a **real Redis**; this tier exists specifically because a real Redis is the only place a serialization bug (like an earlier `GenericJacksonJsonRedisSerializer` type-metadata bug this project hit) actually surfaces. |
+| **E2E / contract** (Postman + Newman) | `docker-compose.e2e.yaml`, a fully-built app image against plain `postgres:17` + Redis | The only tier exercising the real `Dockerfile` image end-to-end, including the logging filter and exception handler wiring. Seeded with the *same* fixture dataset the integration tests use, one source of truth, not two to keep in sync. 19 requests, 32 assertions, covering every endpoint (including the CRUD-expansion auth/admin-write/user-content flows) and its documented edge cases. |
+
+Current counts: 90 unit tests, 69 integration tests, all passing; e2e collection verified 32/32 assertions against a live stack.
+
+Full test plan, including exactly why each tier exists and what it catches that the others can't: [`docs/low-level-design.md`](docs/low-level-design.md) §10.
+
+## DevOps
+
+- **Containerization**: multi-stage `Dockerfile` (`eclipse-temurin:21-jdk` build stage → `eclipse-temurin:21-jre` runtime stage), keeping the shipped image free of build tooling.
+- **Local orchestration**: `docker-compose.yaml` brings up the full stack (database, cache, application, entire observability stack) with one command; a separate `docker-compose.e2e.yaml` (its own `name: imdb-e2e` and network) drives the CI e2e stage against a lightweight, fast-seeding Postgres instead of the ~20-30-minute full dataset import. Keeping the project name explicit here isn't cosmetic: two compose files in the same directory with matching service names and no distinct project name will make Compose *replace* one stack's containers with the other's, a real near-miss this project hit and fixed once and for all (see the compose file's own header comment).
+- **CI**: [`imdb-ci.yml`](../.github/workflows/imdb-ci.yml), three sequential GitHub Actions jobs, each gated on the previous:
+ 1. `unit`: `mvn -B test`
+ 2. `integration`: `mvn -B failsafe:integration-test failsafe:verify` (Testcontainers)
+ 3. `e2e`: brings up `docker-compose.e2e.yaml`, polls for app health and seed completion, runs the Postman/Newman collection, dumps logs on failure, always tears down
+
+## Load testing
+
+One [k6](https://k6.io/) script per endpoint under `k6/`, run one at a time so results are attributable to a single endpoint, results pushed to Prometheus via `--out experimental-prometheus-rw` (visible in the k6 Load Test Results dashboard, [Observability](#observability)):
+
+| Script | Pattern |
+|---|---|
+| `search.js` | Ramping VUs (0 → 50 → 100 → 0), random query terms |
+| `title-detail.js` | Ramping VUs, random `tconst` from a pre-fetched pool |
+| `top-rated.js` | Ramping VUs, cycles through all genres |
+| `six-degrees.js` | Ramping VUs, **a distinct person pair per iteration**, deliberately defeating the Redis cache so the bidirectional BFS's real behavior under load is what gets measured |
+
+```
+docker compose --profile load-test run k6 run /scripts/six-degrees.js
+```
+
+`all-endpoints.js` takes the opposite approach on purpose: three scenarios (`browsing`, `userJourney`,
+`adminWrites`) covering all 48 endpoints run *simultaneously*, to see how the system behaves when
+everything is loaded at once rather than one endpoint in isolation. Running it for the first time found
+five real bugs (Hikari pool sizing, three enum/schema drifts between this project's own Testcontainers
+schema and the real imported database, and an admin id sequence colliding with orphaned imported rows) -
+details in [`docs/low-level-design.md`](docs/low-level-design.md) §8.
+
+```
+docker compose --profile load-test run k6 run /scripts/all-endpoints.js
+```
+
+Full plan and per-endpoint thresholds: [`docs/low-level-design.md`](docs/low-level-design.md) §8.
+
+## Running it locally
+
+```
+docker-compose up
+```
+
+Brings up Postgres (seeded via `abanda/imdb-postgresql`), Redis, the application, and the full observability stack. The **first run takes 20-30 minutes** while Postgres imports the full dataset; `docker-compose logs -f postgres` shows progress. Once healthy:
+
+- API: `http://localhost:8080` (e.g. `curl http://localhost:8080/actuator/health`)
+- Grafana: `http://localhost:3001` (anonymous admin access)
+- Prometheus: `http://localhost:9090`
+
+For contributing/building from source:
+
+```
+mvn test # unit tests only, no Docker
+mvn failsafe:integration-test failsafe:verify # + Testcontainers integration tests
+mvn package # build the jar
+```
+
+## Project layout
+
+See [Package layout](#package-layout) above for the Java source tree; the repository root also has:
+
+```
+imdb/
+ pom.xml, Dockerfile, docker-compose.yaml, docker-compose.e2e.yaml
+ k6/ load-test scripts + sampled person-pair data
+ postman/ e2e contract test collection
+ observability/ Prometheus/Loki/Tempo/Alloy/Grafana provisioning
+ docs/ REQUIREMENTS.md, product-design.md, low-level-design.md
+```
+
+## Design documents
+
+- [`docs/REQUIREMENTS.md`](docs/REQUIREMENTS.md): the original brief and guidelines
+- [`docs/product-design.md`](docs/product-design.md): what is being built and why, including algorithm alternatives considered
+- [`docs/low-level-design.md`](docs/low-level-design.md): schema, endpoint contracts, the bidirectional-BFS derivation, caching, observability wiring, and the full test plan
+- [`docs/crud-expansion-design.md`](docs/crud-expansion-design.md): the JWT auth layer, admin CRUD over the core entities, and the watchlist/reviews/lists user-content layer added after the initial read-only API
+
+## Known limitations
+
+- Under k6 load, six-degrees requests initially failed 83% of the time against a missing index (`title_principals.nconst` had only a partial, actor-only index: every path-enrichment call fell back to a parallel sequential scan over the 100M-row table). Adding a full index (`V4__title_principals_nconst_index.sql`) brought that down substantially, but a persistent ~25-30% failure rate remains for genuinely hard pairs (large hub actors reached mid-expansion with no intersection yet). Raising the query timeout doesn't help; it just fails slower instead of failing fast, so this is tracked as an open follow-up rather than solved by a timeout knob (`application.yaml`, `six-degrees.query-timeout-seconds`).
+- `title_akas` (localized alternate titles) and `title_episode` (TV episode hierarchy) are loaded but not surfaced by any endpoint.
+- No rate limiting on any endpoint (including auth), and no email verification or password reset flow - both are infra/product concerns deliberately scoped out of the CRUD expansion, not oversights; see [`docs/crud-expansion-design.md`](docs/crud-expansion-design.md) §11 for the full deferred list.
+
+Full list, including deferred design alternatives (Pruned Landmark Labeling, a dedicated graph database): [`docs/product-design.md`](docs/product-design.md) §11/§12.
+
+## License
+
+Apache License 2.0. See [`LICENSE`](LICENSE), matching [`votee`](../votee) elsewhere in this monorepo.
diff --git a/imdb/assets/6-degree-dashboard.png b/imdb/assets/6-degree-dashboard.png
new file mode 100644
index 0000000..8531d76
Binary files /dev/null and b/imdb/assets/6-degree-dashboard.png differ
diff --git a/imdb/assets/grafana-dashboards.png b/imdb/assets/grafana-dashboards.png
new file mode 100644
index 0000000..bb2efff
Binary files /dev/null and b/imdb/assets/grafana-dashboards.png differ
diff --git a/imdb/assets/grafana-datasources.png b/imdb/assets/grafana-datasources.png
new file mode 100644
index 0000000..485e937
Binary files /dev/null and b/imdb/assets/grafana-datasources.png differ
diff --git a/imdb/assets/http-requests-dashboard.png b/imdb/assets/http-requests-dashboard.png
new file mode 100644
index 0000000..5b4600d
Binary files /dev/null and b/imdb/assets/http-requests-dashboard.png differ
diff --git a/imdb/assets/k6-dashboard.png b/imdb/assets/k6-dashboard.png
new file mode 100644
index 0000000..fb315e6
Binary files /dev/null and b/imdb/assets/k6-dashboard.png differ
diff --git a/imdb/assets/loki-datasource-grafana-queries.png b/imdb/assets/loki-datasource-grafana-queries.png
new file mode 100644
index 0000000..b5e6640
Binary files /dev/null and b/imdb/assets/loki-datasource-grafana-queries.png differ
diff --git a/imdb/assets/prometheus-datasource-grafana-queries.png b/imdb/assets/prometheus-datasource-grafana-queries.png
new file mode 100644
index 0000000..0cbb600
Binary files /dev/null and b/imdb/assets/prometheus-datasource-grafana-queries.png differ
diff --git a/imdb/assets/redis-cache-hit-dashboard.png b/imdb/assets/redis-cache-hit-dashboard.png
new file mode 100644
index 0000000..148a8e3
Binary files /dev/null and b/imdb/assets/redis-cache-hit-dashboard.png differ
diff --git a/imdb/assets/swagger-ui.png b/imdb/assets/swagger-ui.png
new file mode 100644
index 0000000..cf06c14
Binary files /dev/null and b/imdb/assets/swagger-ui.png differ
diff --git a/imdb/assets/tempo-datasource-grafana-queries.png b/imdb/assets/tempo-datasource-grafana-queries.png
new file mode 100644
index 0000000..484e5fe
Binary files /dev/null and b/imdb/assets/tempo-datasource-grafana-queries.png differ
diff --git a/imdb/docker-compose.e2e.yaml b/imdb/docker-compose.e2e.yaml
new file mode 100644
index 0000000..1bf8b3c
--- /dev/null
+++ b/imdb/docker-compose.e2e.yaml
@@ -0,0 +1,93 @@
+# Explicit project name - without this, Compose derives the project name from the directory
+# ("imdb"), the SAME default the main docker-compose.yaml gets, and this file's service names
+# (postgres/redis) collide with that stack's, causing Compose to "recreate" - replace - the real dev
+# containers with this file's empty ones. Confirmed the hard way: running this once while the main
+# stack was up swapped out imdb-postgres (100M+ imported rows) for a fresh, empty postgres:17 - no
+# data was actually lost (it lives in the imdb_imdb_data named volume, untouched), but the running
+# container was gone until the main stack was brought back up. This name isolates the two entirely.
+name: imdb-e2e
+
+networks:
+ imdb-e2e-net:
+ name: imdb-e2e-net
+
+services:
+ # ---------------------------------------------------------------------
+ # Plain postgres:17, not abanda/imdb-postgresql - the real image's dataset
+ # import takes 20-30 minutes, which is fine for local dev but not for a
+ # CI stage that should run on every push. imdb-service's own Flyway
+ # migrations build the schema here (same V0-V4 migrations as production,
+ # V0's CREATE TABLE IF NOT EXISTS actually creates the tables this time,
+ # since this schema starts genuinely empty); the seed service below then
+ # loads the same small, known fixture dataset the integration tests use.
+ # ---------------------------------------------------------------------
+ postgres:
+ image: postgres:17
+ environment:
+ POSTGRES_USER: imdb
+ POSTGRES_PASSWORD: password
+ POSTGRES_DB: imdb
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U imdb -d imdb"]
+ interval: 5s
+ timeout: 5s
+ retries: 10
+ networks:
+ - imdb-e2e-net
+
+ redis:
+ image: redis:7-alpine
+ healthcheck:
+ test: ["CMD", "redis-cli", "ping"]
+ interval: 5s
+ timeout: 5s
+ retries: 5
+ networks:
+ - imdb-e2e-net
+
+ imdb-service:
+ build:
+ context: .
+ dockerfile: Dockerfile
+ ports:
+ - "8080:8080"
+ environment:
+ SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/imdb
+ SPRING_DATASOURCE_USERNAME: imdb
+ SPRING_DATASOURCE_PASSWORD: password
+ SPRING_DATA_REDIS_HOST: redis
+ SPRING_DATA_REDIS_PORT: "6379"
+ # No tracing/metrics export configured - the e2e stage only needs the app + its data stores,
+ # not the full observability stack (that's exercised separately, LLD §7/§8).
+ JWT_SECRET: "e2e-test-only-secret-not-for-real-deployments-32bytes-plus"
+ IMDB_BOOTSTRAP_ADMIN_EMAIL: "admin@imdb.local"
+ IMDB_BOOTSTRAP_ADMIN_PASSWORD: "e2e-test-admin-password"
+ depends_on:
+ postgres:
+ condition: service_healthy
+ redis:
+ condition: service_healthy
+ healthcheck:
+ test: ["CMD", "curl", "-sf", "http://localhost:8080/actuator/health"]
+ interval: 5s
+ timeout: 5s
+ retries: 30
+ start_period: 30s
+ networks:
+ - imdb-e2e-net
+
+ # One-shot: waits for imdb-service to be healthy (so Flyway has already built the schema), then
+ # loads the same fixture dataset the Testcontainers integration tests use - one dataset, not two to
+ # keep in sync. Exits after loading; the Postman/Newman run against the now-seeded stack.
+ seed:
+ image: postgres:17
+ depends_on:
+ imdb-service:
+ condition: service_healthy
+ environment:
+ PGPASSWORD: password
+ volumes:
+ - ./src/test/resources/fixtures/fixture-data.sql:/seed/fixture-data.sql:ro
+ entrypoint: ["psql", "-h", "postgres", "-U", "imdb", "-d", "imdb", "-f", "/seed/fixture-data.sql"]
+ networks:
+ - imdb-e2e-net
diff --git a/imdb/docker-compose.yaml b/imdb/docker-compose.yaml
new file mode 100644
index 0000000..ea99d4c
--- /dev/null
+++ b/imdb/docker-compose.yaml
@@ -0,0 +1,239 @@
+networks:
+ imdb-net:
+ name: imdb-net
+
+volumes:
+ imdb_data:
+ redis_data:
+ prometheus_data:
+ loki_data:
+ tempo_data:
+ grafana_data:
+ alloy_data:
+
+services:
+ # ---------------------------------------------------------------------
+ # Data stores
+ # ---------------------------------------------------------------------
+
+ postgres:
+ image: abanda/imdb-postgresql:latest
+ container_name: imdb-postgres
+ # Docker's default 64MB /dev/shm is too small once Postgres runs parallel workers or shared
+ # hash/sort operations under real concurrent load - discovered via a k6 run failing 100% of
+ # requests with "could not resize shared memory segment ... No space left on device".
+ shm_size: 256m
+ ports:
+ - "5432:5432"
+ volumes:
+ - imdb_data:/var/lib/postgresql/data
+ environment:
+ # MINIMAL_DATASET is honored by upstream image tooling for a faster,
+ # smaller import while iterating locally; unset/false loads the full dataset.
+ MINIMAL_DATASET: "false"
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U imdb -d imdb"]
+ interval: 10s
+ timeout: 5s
+ retries: 12
+ start_period: 40m # first-run TSV import can take 20-30 minutes
+ networks:
+ - imdb-net
+
+ redis:
+ image: redis:7-alpine
+ container_name: imdb-redis
+ ports:
+ - "6379:6379"
+ volumes:
+ - redis_data:/data
+ healthcheck:
+ test: ["CMD", "redis-cli", "ping"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+ networks:
+ - imdb-net
+
+ # ---------------------------------------------------------------------
+ # Application (built once the Spring Boot project is scaffolded)
+ # ---------------------------------------------------------------------
+
+ imdb-service:
+ build:
+ context: .
+ dockerfile: Dockerfile
+ container_name: imdb-service
+ ports:
+ - "8080:8080"
+ environment:
+ SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/imdb
+ SPRING_DATASOURCE_USERNAME: imdb
+ SPRING_DATASOURCE_PASSWORD: password
+ SPRING_DATA_REDIS_HOST: redis
+ SPRING_DATA_REDIS_PORT: "6379"
+ MANAGEMENT_OPENTELEMETRY_TRACING_EXPORT_OTLP_ENDPOINT: http://tempo:4318/v1/traces
+ MANAGEMENT_TRACING_SAMPLING_PROBABILITY: "1.0"
+ BACON_PERSON_NAME: "Kevin Bacon"
+ SIX_DEGREES_MAX_DEPTH: "7"
+ JWT_SECRET: "local-dev-only-secret-change-in-real-deployments-32bytes+"
+ IMDB_BOOTSTRAP_ADMIN_EMAIL: "admin@imdb.local"
+ IMDB_BOOTSTRAP_ADMIN_PASSWORD: "change-me-please"
+ depends_on:
+ postgres:
+ condition: service_healthy
+ redis:
+ condition: service_healthy
+ # Discovered empirically: abanda/imdb-postgresql keeps bouncing its own listener while the
+ # background import is running (config tuning at startup, and apparent connection blips under
+ # the heavy COPY load), even after the healthcheck has reported healthy once. depends_on only
+ # gates the very first startup attempt, not ongoing availability, so a restart policy - not a
+ # smarter healthcheck - is what actually makes the app recover on its own instead of staying
+ # crashed after one bad-timing connection attempt.
+ restart: on-failure:5
+ networks:
+ - imdb-net
+
+ # ---------------------------------------------------------------------
+ # Metrics
+ # ---------------------------------------------------------------------
+
+ prometheus:
+ image: prom/prometheus:latest
+ container_name: imdb-prometheus
+ command:
+ - --config.file=/etc/prometheus/prometheus.yml
+ - --storage.tsdb.path=/prometheus
+ # NOT --enable-feature=remote-write-receiver - that's not a real --enable-feature value on this
+ # Prometheus version (3.11.3 as pulled here); confirmed via a live "Unknown option for
+ # --enable-feature" warning in the container's own logs, and the write endpoint 404ing until
+ # this was corrected. Remote write receiving has its own dedicated flag instead.
+ - --web.enable-remote-write-receiver # accepts k6's experimental-prometheus-rw output
+ ports:
+ - "9090:9090"
+ volumes:
+ - ./observability/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
+ - prometheus_data:/prometheus
+ networks:
+ - imdb-net
+
+ postgres-exporter:
+ image: quay.io/prometheuscommunity/postgres-exporter:latest
+ container_name: imdb-postgres-exporter
+ environment:
+ DATA_SOURCE_NAME: "postgresql://imdb:password@postgres:5432/imdb?sslmode=disable"
+ ports:
+ - "9187:9187"
+ depends_on:
+ postgres:
+ condition: service_healthy
+ networks:
+ - imdb-net
+
+ redis-exporter:
+ image: oliver006/redis_exporter:latest
+ container_name: imdb-redis-exporter
+ environment:
+ REDIS_ADDR: "redis://redis:6379"
+ ports:
+ - "9121:9121"
+ depends_on:
+ redis:
+ condition: service_healthy
+ networks:
+ - imdb-net
+
+ # ---------------------------------------------------------------------
+ # Logs
+ # ---------------------------------------------------------------------
+
+ loki:
+ image: grafana/loki:latest
+ container_name: imdb-loki
+ ports:
+ - "3100:3100"
+ volumes:
+ - loki_data:/loki
+ networks:
+ - imdb-net
+
+ grafana-alloy:
+ image: grafana/alloy:latest
+ container_name: imdb-alloy
+ command: run --server.http.listen-addr=0.0.0.0:12345 --storage.path=/var/lib/alloy/data /etc/alloy/config.alloy
+ volumes:
+ - ./observability/alloy/config.alloy:/etc/alloy/config.alloy:ro
+ - alloy_data:/var/lib/alloy/data
+ - /var/run/docker.sock:/var/run/docker.sock:ro
+ ports:
+ - "12345:12345"
+ depends_on:
+ - loki
+ networks:
+ - imdb-net
+
+ # ---------------------------------------------------------------------
+ # Traces
+ # ---------------------------------------------------------------------
+
+ tempo:
+ image: grafana/tempo:2.10.7
+ container_name: imdb-tempo
+ command: -config.file=/etc/tempo.yaml
+ volumes:
+ - ./observability/tempo/tempo.yaml:/etc/tempo.yaml:ro
+ - tempo_data:/var/tempo
+ ports:
+ - "3200:3200" # Tempo query API
+ - "4317:4317" # OTLP gRPC receiver
+ - "4318:4318" # OTLP HTTP receiver
+ networks:
+ - imdb-net
+
+ # ---------------------------------------------------------------------
+ # Dashboards
+ # ---------------------------------------------------------------------
+
+ grafana:
+ image: grafana/grafana:latest
+ container_name: imdb-grafana
+ ports:
+ - "3001:3000"
+ environment:
+ GF_AUTH_ANONYMOUS_ENABLED: "true"
+ GF_AUTH_ANONYMOUS_ORG_ROLE: Admin
+ GF_AUTH_DISABLE_LOGIN_FORM: "true"
+ volumes:
+ - grafana_data:/var/lib/grafana
+ - ./observability/grafana/provisioning:/etc/grafana/provisioning:ro
+ depends_on:
+ - prometheus
+ - loki
+ - tempo
+ networks:
+ - imdb-net
+
+ # ---------------------------------------------------------------------
+ # Load testing (opt-in: `docker compose --profile load-test run k6 run /scripts/.js`)
+ # ---------------------------------------------------------------------
+
+ k6:
+ image: grafana/k6:latest
+ container_name: imdb-k6
+ profiles: ["load-test"]
+ environment:
+ K6_PROMETHEUS_RW_SERVER_URL: http://prometheus:9090/api/v1/write
+ # K6_PROMETHEUS_RW_SERVER_URL alone does not activate the output - confirmed empirically (a run
+ # without this completed with no errors and pushed nothing at all). K6_OUT is the env-var form
+ # of `k6 run --out experimental-prometheus-rw`, so the documented `docker compose --profile
+ # load-test run k6 run /scripts/.js` (LLD §8) works without needing that flag typed by hand.
+ K6_OUT: experimental-prometheus-rw
+ # Default trend-stat export is p99 only - the k6 dashboard's "p95 request duration" panel
+ # (LLD §7/§8) needs p95 specifically; confirmed empirically that only k6_..._p99 series existed
+ # under the default. Each Trend metric (http_req_duration, http_req_waiting, etc.) gets both
+ # suffixes once this is set.
+ K6_PROMETHEUS_RW_TREND_STATS: "p(95),p(99)"
+ volumes:
+ - ./k6:/scripts:ro
+ networks:
+ - imdb-net
diff --git a/imdb/docs/REQUIREMENTS.md b/imdb/docs/REQUIREMENTS.md
new file mode 100644
index 0000000..f181ea6
--- /dev/null
+++ b/imdb/docs/REQUIREMENTS.md
@@ -0,0 +1,77 @@
+# IMDb Copycat - Requirements
+
+Part of a personal return to Java after a couple of years spent primarily in other stacks (see the
+[root README](../../README.md)) - a deliberate exercise in building a production-grade REST API with
+Spring Boot against a real, large, publicly available dataset rather than a toy example. The dataset is
+IMDb's own [Non-Commercial Dataset](https://www.imdb.com/interfaces/), which offers rich, realistic
+movie/TV/people data at real scale (millions of rows) - exactly the kind of data volume where "does the
+indexing strategy actually hold up" and "does the algorithm choice actually scale" stop being academic
+questions.
+
+See [`product-design.md`](product-design.md) for the full design rationale and
+[`low-level-design.md`](low-level-design.md) for schema, endpoint contracts, and implementation detail.
+
+## Guidelines
+
+- Treat this as a real production service, not a toy: project structure, code quality, readability,
+ documentation, tests, CI.
+- Java / Spring Boot for the API layer; the rest of the stack is chosen to fit the domain - see the design
+ docs for what and why.
+- Don't truncate the dataset. It's feasible to hold all of it on a single machine; working against the
+ real data volume is the point of the exercise.
+
+## The Requirements
+
+### Requirement #1 (easy): Title search
+
+Search by a movie's primary title or original title. The result should include related information,
+including cast and crew.
+
+### Requirement #2 (easy): Top-rated movies by genre
+
+Given a genre, return the top-rated movies in that genre.
+
+### Requirement #3 (difficult): Six Degrees of Kevin Bacon, generalized
+
+[Six degrees of Kevin
+Bacon](https://en.wikipedia.org/wiki/Six_Degrees_of_Kevin_Bacon): given two people (e.g. actors),
+determine their degree of separation. Generalized to any two people rather than fixed to Kevin Bacon
+specifically (see `product-design.md` §9 for why), bounded to a maximum of 7 degrees.
+
+## Data Source & Setup
+
+This implementation uses a personal Docker image, [`abanda/imdb-postgresql`](https://github.com/icemc/imdb-postgresql),
+which loads the **full** IMDb Non-Commercial Dataset (including TV episodes) into PostgreSQL 17 across
+seven tables (`name_basics`, `title_basics`, `title_ratings`, `title_crew`, `title_episode`,
+`title_principals`, `title_akas`). Connection details:
+
+```
+JDBC URL = jdbc:postgresql://localhost:5432/imdb
+Username = imdb
+Password = password
+```
+
+IDs (`tconst`/`nconst`) are stored as plain integers in this schema (the `tt`/`nm` prefix and leading
+zeros are stripped on import); the API translates between IMDb-style string IDs and these internal
+integers at the boundary - see the low-level design for details.
+
+### Running it
+
+1. Run `docker-compose up` from the `imdb/` directory. This brings up PostgreSQL (seeded via
+ `abanda/imdb-postgresql`), Redis, and the full observability stack (Prometheus, Loki, Tempo, Grafana,
+ Grafana Alloy, plus Postgres/Redis exporters) - see `docker-compose.yaml` and the `observability/`
+ directory. The first run takes 20-30 minutes while Postgres imports the dataset; `docker-compose logs -f postgres`
+ shows import progress.
+2. The Spring Boot application (`imdb-service`, Java 21 / Maven, scaffolded via
+ [start.spring.io](https://start.spring.io/)) starts as part of the same `docker-compose up` once the
+ project exists in this directory - see the low-level design for module layout and dependencies.
+3. Grafana is available at `http://localhost:3000` (anonymous admin access, local-only) with Prometheus,
+ Loki, and Tempo pre-provisioned as datasources with trace/log/metric correlation wired up.
+4. Load tests live under `imdb/k6/`, one script per endpoint. They are not part of `docker-compose up` by
+ default (`k6` is behind the `load-test` compose profile) and are run one at a time, e.g.:
+ `docker-compose --profile load-test run k6 run /scripts/six-degrees.js`.
+
+### Scope
+
+Four read-only REST endpoints cover the three requirements above. No authentication, no write endpoints -
+see the product design document's Non-Goals section for the full list of deliberate exclusions.
diff --git a/imdb/docs/crud-expansion-design.md b/imdb/docs/crud-expansion-design.md
new file mode 100644
index 0000000..d1626d4
--- /dev/null
+++ b/imdb/docs/crud-expansion-design.md
@@ -0,0 +1,307 @@
+# IMDb CRUD Expansion - Design Document
+
+| | |
+|---|---|
+| Author | Ludovic Temgoua Abanda |
+| Status | Approved (brainstormed interactively; see decisions below) |
+| Date | 2026-07-13 |
+| Related docs | `imdb/docs/product-design.md`, `imdb/docs/low-level-design.md`, `imdb/docs/REQUIREMENTS.md` |
+| Supersedes | PDD §4's original non-goals: "Authentication/authorization" and "Write endpoints of any kind" |
+
+## 1. Purpose and Scope
+
+The original `imdb` API (PDD/LLD) is a deliberately read-only layer over an externally-seeded, immutable
+IMDb dataset - no auth, no writes, 24h cache TTLs justified entirely by "nothing ever changes." This
+document extends that into a full CRUD RESTful API, adding two new layers on top of the existing
+read-only core:
+
+1. **A user-generated content layer** - accounts, watchlists, reviews, and custom lists - entirely new
+ tables the app owns outright.
+2. **An admin curation layer** - CRUD over the core IMDb entities themselves (titles, people, ratings,
+ cast/crew), gated behind an `ADMIN` role, writing into the same tables the original design treated as
+ permanently immutable.
+
+Everything in the original PDD/LLD (search, title detail, top-rated, six-degrees, the observability stack,
+the three-tier test pipeline) stays as-is and stays public/unauthenticated. This is a strict addition, not
+a breaking change.
+
+## 2. Decisions Made (interactive brainstorming)
+
+| Decision | Chosen | Rejected alternatives |
+|---|---|---|
+| CRUD scope | Both: user-generated layer AND admin CRUD over core entities | User-layer only; core-entity CRUD only |
+| Auth mechanism | Self-issued JWT via Spring Security (hand-rolled filter, not a full OAuth2 resource server) | External OAuth2/OIDC IdP (Keycloak); static API keys |
+| Delete semantics | Soft delete everywhere (`deleted_at`) | Hard delete everywhere; hybrid (soft for user content, hard for core) |
+| List/watchlist visibility | Both PUBLIC and PRIVATE, owner's choice per list | Private-only |
+
+## 3. Authentication & Authorization
+
+- New dependency: `spring-boot-starter-security`. JWTs are both issued and validated by this app, so
+ authentication is a small hand-rolled `JwtAuthenticationFilter` (reads `Authorization: Bearer `,
+ validates signature/expiry, populates `SecurityContext`) rather than the full
+ `spring-boot-starter-oauth2-resource-server` machinery built for validating externally-issued tokens.
+- `infrastructure.security.JwtService`: issues/parses HMAC-SHA256-signed tokens, secret from an env var
+ (`JWT_SECRET`, no default in any committed config). Claims: `sub` (userId), `roles` (`["USER"]` or
+ `["USER","ADMIN"]`), `exp`.
+- Passwords hashed with BCrypt (`PasswordEncoder` bean, Spring Security's default).
+- Two token types: a short-lived access token (15 min) and a longer-lived refresh token (7 days), issued
+ as a pair on login/refresh.
+- A bootstrap admin account makes the `ADMIN` role reachable at all on a fresh stack (there is otherwise no
+ way to grant it). Flyway (`V5`, see §7) creates the `users` table but does **not** insert this row - a
+ Flyway migration runs before the Spring context (and its `PasswordEncoder` bean) exists, so it can't
+ BCrypt-hash a password cleanly. Instead, an `ApplicationRunner` bean
+ (`infrastructure.security.BootstrapAdminRunner`) runs once after the context is fully up: if no user with
+ the configured bootstrap email (`IMDB_BOOTSTRAP_ADMIN_EMAIL`) exists, it creates one with role `ADMIN`,
+ password BCrypt-hashed from `IMDB_BOOTSTRAP_ADMIN_PASSWORD` via the same `PasswordEncoder` bean every
+ other registration uses - idempotent on every restart, no separate migration-time password handling.
+- **Error-shape consistency**: Spring Security's default 401/403 responses aren't RFC 7807 `ProblemDetail`
+ - they're a bare, framework-shaped response inconsistent with the rest of this API's error handling
+ (LLD §9's `ApiExceptionHandler` work). Fixed via custom `AuthenticationEntryPoint` (401) and
+ `AccessDeniedHandler` (403) beans that produce `ProblemDetail`, registered in `SecurityConfig`, so a
+ caller never sees two different error shapes depending on whether Spring MVC or Spring Security rejected
+ the request.
+- Authorization is method-level (`@PreAuthorize("hasRole('ADMIN')")` on admin write methods,
+ `@PreAuthorize("isAuthenticated()")` on user-owned-resource methods), not a blanket URL-pattern rule -
+ this keeps the read endpoints (search, title detail, top-rated, six-degrees, and the new public
+ review/list-browsing endpoints) genuinely public with zero filter overhead, while write endpoints are
+ individually and explicitly protected.
+
+## 4. New Resources: Users, Watchlists, Reviews, Custom Lists
+
+### 4.1 Users (`users` table)
+
+| Endpoint | Auth | Description |
+|---|---|---|
+| `POST /api/v1/auth/register` | Public | Create account (`email`, `password`, `displayName`) -> role `USER`. 409 if email taken. |
+| `POST /api/v1/auth/login` | Public | `{ email, password }` -> `{ accessToken, refreshToken }` |
+| `POST /api/v1/auth/refresh` | Public (valid refresh token) | `{ refreshToken }` -> new access token |
+| `GET /api/v1/users/me` | User | Own full profile |
+| `PUT /api/v1/users/me` | User | Update own profile (`displayName`, `bio`) |
+| `DELETE /api/v1/users/me` | User | Soft-delete own account |
+| `GET /api/v1/users/{userId}` | Public | Limited public profile (`displayName` only) |
+| `GET /api/v1/users` | Admin | Paginated list of all users |
+| `PUT /api/v1/users/{userId}/role` | Admin | Grant/revoke `ADMIN` |
+| `DELETE /api/v1/users/{userId}` | Admin | Moderation: soft-delete any account |
+
+### 4.2 Watchlist (`watchlists` + `watchlist_items`, one watchlist per user)
+
+| Endpoint | Auth | Description |
+|---|---|---|
+| `GET /api/v1/watchlist` | User | Own watchlist + items (auto-created on first access) |
+| `POST /api/v1/watchlist/items` | User | Add `{ titleId }` |
+| `DELETE /api/v1/watchlist/items/{titleId}` | User | Remove a title |
+| `PUT /api/v1/watchlist/visibility` | User | `{ visibility: PUBLIC\|PRIVATE }` |
+| `GET /api/v1/users/{userId}/watchlist` | Public if `PUBLIC` | View another user's watchlist |
+
+### 4.3 Reviews (`reviews` table, one per `(user, title)`, always public)
+
+| Endpoint | Auth | Description |
+|---|---|---|
+| `POST /api/v1/titles/{titleId}/reviews` | User | Create own review+rating. 409 if one already exists for this pair - use PUT instead. |
+| `GET /api/v1/titles/{titleId}/reviews` | Public | Paginated reviews for a title |
+| `GET /api/v1/titles/{titleId}/reviews/me` | User | Own review for that title |
+| `PUT /api/v1/titles/{titleId}/reviews/me` | User | Update own review |
+| `DELETE /api/v1/titles/{titleId}/reviews/me` | User | Soft-delete own review |
+| `GET /api/v1/users/{userId}/reviews` | Public | All reviews a user has written |
+
+Title detail gains two new, additive fields derived from this table: `userRatingAverage` and
+`userRatingCount`, shown alongside the existing IMDb `rating` field. Both are `null`/`0` if no reviews
+exist yet.
+
+### 4.4 Custom lists (`lists` + `list_items`)
+
+| Endpoint | Auth | Description |
+|---|---|---|
+| `POST /api/v1/lists` | User | `{ name, visibility }` |
+| `GET /api/v1/lists/me` | User | All own lists (any visibility) |
+| `GET /api/v1/lists/public` | Public | Paginated discovery of public lists across all users |
+| `GET /api/v1/lists/{listId}` | Public if `PUBLIC`, else owner-only | View a list + its items |
+| `PUT /api/v1/lists/{listId}` | Owner | Rename / change visibility |
+| `DELETE /api/v1/lists/{listId}` | Owner | Soft-delete |
+| `POST /api/v1/lists/{listId}/items` | Owner | Add `{ titleId }` |
+| `DELETE /api/v1/lists/{listId}/items/{titleId}` | Owner | Remove a title |
+
+Accessing a `PRIVATE` list/watchlist you don't own returns `404`, not `403` - existence of another user's
+private list is not itself information this API discloses.
+
+## 5. Admin CRUD over the Core IMDb Entities
+
+**ID-collision wrinkle**: `tconst`/`nconst` are plain integers, seeded by the external
+`abanda/imdb-postgresql` image and already densely allocated. Admin-created titles/people need IDs that
+can never collide with an existing seeded row. Fix: two Postgres `SEQUENCE`s
+(`title_id_seq`/`person_id_seq`), initialized once in `V6` to `MAX(tconst)+1`/`MAX(nconst)+1`, used *only*
+for app-created rows. Seeded rows keep their original IDs untouched. New rows translate through the same
+`utils.ImdbIds` formatter already at the API boundary - a caller never sees anything different from a
+normal `tt`/`nm` ID.
+
+### 5.1 Titles
+
+| Endpoint | Description |
+|---|---|
+| `POST /api/v1/titles` | Create (`primaryTitle`, `originalTitle`, `titleType`, `startYear`, `endYear`, `runtimeMinutes`, `genres`) |
+| `PUT /api/v1/titles/{titleId}` | Full update (requires current `version`, §6.1) |
+| `PATCH /api/v1/titles/{titleId}` | Partial update (merge-patch: only send fields to change) |
+| `DELETE /api/v1/titles/{titleId}` | Soft-delete |
+| `PUT /api/v1/titles/{titleId}/crew` | Upsert `{ directors: [personId...], writers: [personId...] }` - a singleton per title, mirroring `title_crew` |
+
+### 5.2 People
+
+| Endpoint | Description |
+|---|---|
+| `POST /api/v1/people` | Create (`primaryName`, `birthYear`, `deathYear`, `primaryProfession`) |
+| `PUT /api/v1/people/{personId}` | Full update |
+| `PATCH /api/v1/people/{personId}` | Partial update |
+| `DELETE /api/v1/people/{personId}` | Soft-delete |
+
+### 5.3 Ratings (singleton per title)
+
+| Endpoint | Description |
+|---|---|
+| `PUT /api/v1/titles/{titleId}/rating` | Set/replace `{ averageRating, numVotes }` |
+| `DELETE /api/v1/titles/{titleId}/rating` | Remove the rating (title detail omits it) |
+
+### 5.4 Cast/crew credits (`title_principals`)
+
+| Endpoint | Auth | Description |
+|---|---|---|
+| `GET /api/v1/titles/{titleId}/principals` | Public | Full, uncapped credit list (title detail's own list stays capped at 20) |
+| `POST /api/v1/titles/{titleId}/principals` | Admin | Add `{ personId, category, job, characters, ordering }` |
+| `PUT /api/v1/titles/{titleId}/principals/{principalId}` | Admin | Update a credit |
+| `DELETE /api/v1/titles/{titleId}/principals/{principalId}` | Admin | Soft-delete a credit |
+
+Genres stay a plain validated string array on the title itself (matching the current schema), not a
+separate managed `Genre` resource - a full taxonomy-management feature isn't justified by anything in
+scope here.
+
+Admin write endpoints are added to the *existing* `TitleController`/`PersonController`/`GenreController`
+classes (`@PreAuthorize`-gated), not parallel `Admin*Controller` classes - the resource is the same, only
+the allowed verb set differs by role.
+
+## 6. Cross-Cutting Concerns
+
+### 6.1 Optimistic locking
+
+Every entity with a `PUT`/`PATCH` update path gets a `version` integer column, incremented on each
+successful write. The current `version` must be included in the update request body; a mismatch is a
+`409 Conflict` (new `domain.exception.ConflictException`), not a silent overwrite. This applies to:
+`title_basics`, `name_basics`, `title_ratings`, `title_principals`, `title_crew`, `reviews`, `lists`,
+`users` (profile updates). Join/item-only tables (`watchlist_items`, `list_items`) don't get a version -
+they're pure add/remove, not update-in-place.
+
+### 6.2 Cache invalidation on writes
+
+The original caching design's 24h TTLs and "no write path, so `FLUSHDB` on redeploy is fine" reasoning
+(LLD §6) no longer holds once writes exist. Fixed per cache region, using Spring's existing `@Cacheable`
+decorator classes (`infrastructure.cache`) extended with `@CacheEvict` methods - no new custom eviction
+infrastructure needed, since this is exactly what the annotation-driven cache abstraction already does:
+
+| Cache region | Eviction on write |
+|---|---|
+| `title-detail` | Precise: `@CacheEvict(cacheNames = "title-detail", key = "#titleId")` on any title/rating/crew/principals/review write affecting that title |
+| `title-search` | No precise key to evict (keyed by arbitrary `query:page:size` combos). TTL reduced from 24h to 15 minutes instead - the honest trade-off, since evicting "every search result that might now be stale" isn't feasible without scanning |
+| `top-rated` | Coarse: `@CacheEvict(cacheNames = "top-rated", allEntries = true)` on any title/rating write - correct and cheap, since admin writes are expected to be infrequent |
+| `six-degrees` | Coarse: `@CacheEvict(cacheNames = "six-degrees", allEntries = true)` on any people/principals write - a precise per-affected-pair eviction would need a reverse lookup this design doesn't build; full-region eviction favors correctness over cache efficiency for a rarely-written path |
+
+### 6.3 Error handling additions
+
+- `domain.exception.ConflictException` -> `409`: duplicate review, stale optimistic-lock version, duplicate
+ email at registration.
+- `domain.exception.ForbiddenException` -> `403`: non-owner attempting to modify someone else's
+ watchlist/list/review.
+- Both mapped in `ApiExceptionHandler` alongside the existing `NotFoundException`/`IllegalArgumentException`
+ handlers - same pattern, no change to the `extends ResponseEntityExceptionHandler` approach that fixed
+ the earlier 404/400/405 regression.
+- Bean Validation (`jakarta.validation`, already a dependency) on every new request DTO.
+
+### 6.4 Soft-delete convention
+
+A `deleted_at TIMESTAMPTZ` column on every writable table (added in `V7` for the core tables, native on
+every new table from `V5` onward). Every `SELECT` in `infrastructure.persistence` gains a
+`WHERE deleted_at IS NULL` clause. A soft-deleted title's existing cast credits, reviews, and
+watchlist/list items are left in place but the title itself stops appearing in search, detail, top-rated,
+and six-degrees traversal - a watchlist/list item referencing a since-deleted title is filtered out of its
+parent's item list rather than erroring.
+
+### 6.5 Pagination and PATCH conventions
+
+New collection endpoints (reviews, public lists, admin user list) reuse the existing hand-rolled
+`PagedResult` (LLD §4.1) - no new pagination mechanism. `PATCH` uses merge-patch semantics (only
+included fields change; omitted fields are left alone), not full JSON Patch (RFC 6902) - consistent with
+this project's plain-DTO style elsewhere.
+
+## 7. Data Model / Schema Additions
+
+New Flyway migrations, `V5` onward (current latest is `V4__title_principals_nconst_index.sql`):
+
+| Migration | Adds |
+|---|---|
+| `V5__users_table.sql` | `users` (id, email, password_hash, display_name, bio, role, version, created_at, deleted_at) - schema only, no seed row (bootstrap admin handled by `BootstrapAdminRunner`, see §3) |
+| `V6__admin_id_sequences.sql` | `title_id_seq` seeded to `MAX(tconst)+1`, `person_id_seq` seeded to `MAX(nconst)+1` |
+| `V7__core_entity_version_and_soft_delete.sql` | `version`/`deleted_at` columns on `title_basics`, `name_basics`, `title_ratings`, `title_principals`, `title_crew` |
+| `V8__watchlists.sql` | `watchlists` (id, user_id, visibility, version, deleted_at), `watchlist_items` (watchlist_id, title_id, added_at) |
+| `V9__reviews.sql` | `reviews` (id, user_id, title_id, rating, body, version, deleted_at, created_at, updated_at); unique constraint on `(user_id, title_id)` where `deleted_at IS NULL` |
+| `V10__lists.sql` | `lists` (id, user_id, name, visibility, version, deleted_at), `list_items` (list_id, title_id, added_at, ordering) |
+
+## 8. Architecture Impact
+
+New additions within the existing onion layering (LLD §2.1), no change to the dependency rule itself:
+
+```
+domain/
+ model/ User, Role, Watchlist, WatchlistItem, Review, CustomList, ListItem, Visibility
+ repository/ UserRepository, WatchlistRepository, ReviewRepository, ListRepository (interfaces)
+ exception/ ConflictException, ForbiddenException
+application/
+ contracts/ AuthUseCase, UserUseCase, WatchlistUseCase, ReviewUseCase, ListUseCase,
+ TitleAdminUseCase, PersonAdminUseCase
+ *Impl plain orchestration for each, mirroring the existing use-case/decorator split
+infrastructure/
+ persistence/ JdbcUserRepository, JdbcWatchlistRepository, JdbcReviewRepository, JdbcListRepository
+ security/ JwtService, JwtAuthenticationFilter, SecurityConfig,
+ ProblemDetailAuthenticationEntryPoint, ProblemDetailAccessDeniedHandler
+ cache/ @CacheEvict additions to the existing Caching* decorators (§6.2) - no new decorator
+ classes, since eviction is added to the classes that already own each cache region
+presentation/
+ AuthController, UserController (new), WatchlistController, ReviewController, ListController
+ TitleController, PersonController, GenreController - gain @PreAuthorize-gated write methods (existing
+ classes, not new Admin* ones)
+```
+
+`TitleAdminUseCase` covers title CRUD + crew + rating + principals (all title-scoped admin operations,
+grouped by domain cohesion rather than one interface per table); `PersonAdminUseCase` covers person CRUD.
+
+## 9. Testing Plan Additions
+
+- **Unit**: one test class per new use case (mocked repository interfaces), matching the existing pattern
+ exactly. `JwtService` gets dedicated unit tests (issue/parse/expiry/tampered-signature rejection).
+- **Integration** (Testcontainers): new JDBC repository integration tests for `users`/`watchlists`/
+ `reviews`/`lists`, following the existing `*IntegrationTest.java` convention. New integration tests
+ verifying `@CacheEvict` actually clears the affected Redis entry after a write - the same
+ Redis-Testcontainers rationale as the existing four cache integration tests (LLD §10.2): a mocked cache
+ can't prove an eviction call actually reached Redis.
+- **E2E** (Postman/Newman): a full auth flow (register -> login -> use access token -> refresh), a full
+ CRUD lifecycle per new resource (watchlist, review, list), and negative cases specifically worth
+ contract-testing at this tier: a non-admin hitting an admin write endpoint (403), a stale-version update
+ (409), and access to another user's private list (404).
+
+## 10. Observability Additions
+
+Per the existing level policy (LLD §7): INFO for login success/failure and every admin write (business-
+significant, same tier as the existing six-degrees timing/ambiguity logs); DEBUG for cache-eviction calls
+(mirrors the existing cache-miss DEBUG convention); WARN for repeated failed-login attempts from the same
+account, as a lightweight brute-force signal without building a full account-lockout feature. No new
+dashboard is planned in this pass - the existing HTTP Overview dashboard's per-`uri` breakdown already
+covers the new endpoints automatically.
+
+## 11. Out of Scope / Deferred
+
+- **Rate limiting** - a genuinely separate, infra-level concern (normally a gateway/proxy responsibility,
+ not application code); adding a Redis-backed token-bucket limiter now would meaningfully balloon this
+ pass's scope without being core to "CRUD expansion." Documented here as a deliberate boundary, the same
+ way the original PDD documented Pruned Landmark Labeling/Neo4j as evaluated-but-not-built.
+- **Email verification / password reset flows** - registration creates a usable account immediately; a
+ real "verify your email" or "forgot password" flow is a distinct feature or its own pass.
+- **A separate `Genre` managed resource** - genres stay a plain validated string array (§5.4).
+- **Bulk import/export endpoints** - every write endpoint here is single-resource; batch operations aren't
+ part of this pass.
+- **Full JSON Patch (RFC 6902)** - `PATCH` uses simple merge-patch semantics instead (§6.5).
diff --git a/imdb/docs/crud-expansion-plan.md b/imdb/docs/crud-expansion-plan.md
new file mode 100644
index 0000000..59680dd
--- /dev/null
+++ b/imdb/docs/crud-expansion-plan.md
@@ -0,0 +1,6655 @@
+# IMDb CRUD Expansion Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Extend the read-only `imdb` API into a full CRUD RESTful API: JWT-based auth, a user-generated content layer (watchlists/reviews/custom lists), and admin CRUD over the core IMDb entities (titles/people/ratings/cast-crew), plus the optimistic locking, cache invalidation, and soft-delete conventions that make those writes safe.
+
+**Architecture:** Strict addition on top of the existing onion layers (`presentation -> infrastructure -> application -> domain`, plus `utils`). New resources get the same interface/impl/decorator shape already used by the four read endpoints. Admin writes land in the existing `TitleController`/`PersonController`/`GenreController` classes, gated by `@PreAuthorize`, not new `Admin*` classes.
+
+**Tech Stack:** Spring Boot 4.1, Java 21, Maven, `spring-boot-starter-security` (new), plain JDBC (`NamedParameterJdbcTemplate`), PostgreSQL, Redis (Spring Cache), Flyway, JUnit 5 + Mockito + AssertJ, Testcontainers, Postman/Newman.
+
+## Global Constraints
+
+- Package root: `com.ludovictemgoua.imdb`. Follow the onion layering exactly: `domain` imports nothing; `application` imports only `domain`/`utils`; `infrastructure`/`presentation` may import inward layers only.
+- Every writable table gets `version INTEGER NOT NULL DEFAULT 0` and `deleted_at TIMESTAMPTZ` (soft delete, per `docs/crud-expansion-design.md` §6.1/§6.4). Every read query filters `WHERE deleted_at IS NULL`.
+- IDs at the API boundary are always IMDb-style `tt`/`nm` strings via `utils.ImdbIds`, never raw integers - matches the existing convention exactly.
+- New Flyway migrations start at `V5` (current latest is `V4__title_principals_nconst_index.sql`).
+- Domain models are Java records. Repositories are interfaces in `domain.repository`, implementations in `infrastructure.persistence` using `NamedParameterJdbcTemplate` + `MapSqlParameterSource`, row-mapped via static private methods - copy `JdbcTitleRepository`'s style exactly.
+- Unit tests: JUnit 5 + `@ExtendWith(MockitoExtension.class)` + AssertJ, no Spring context, mocked repository interfaces - copy `TitleSearchUseCaseImplTest`'s style exactly.
+- Integration tests: `@Import(TestcontainersConfiguration.class) @SpringBootTest @Transactional @Sql("/fixtures/fixture-data.sql")`, named `*IntegrationTest.java` (Surefire/Failsafe split depends on this suffix - see `pom.xml`).
+- Controller tests: `@WebMvcTest(XController.class)` + `@MockitoBean` on the use-case interfaces - copy `TitleControllerTest`'s style exactly.
+- New domain exceptions (`ConflictException`, `ForbiddenException`) get handlers added to the existing `ApiExceptionHandler` (which `extends ResponseEntityExceptionHandler` - do not replace this with a bare `@RestControllerAdvice`, see that class's own header comment for why).
+- Run `JAVA_HOME="/c/Program Files/Java/jdk-21"` before any `./mvnw` command on this machine (sdkman's default `mvnw` resolves Java 8 otherwise).
+
+---
+
+## Phase 1: Security & Auth Foundation
+
+### Task 1.1: `users` table, `User`/`Role` domain models, `UserRepository`
+
+**Files:**
+- Create: `src/main/resources/db/migration/V5__users_table.sql`
+- Create: `src/main/java/com/ludovictemgoua/imdb/domain/model/Role.java`
+- Create: `src/main/java/com/ludovictemgoua/imdb/domain/model/User.java`
+- Create: `src/main/java/com/ludovictemgoua/imdb/domain/repository/UserRepository.java`
+- Create: `src/main/java/com/ludovictemgoua/imdb/infrastructure/persistence/JdbcUserRepository.java`
+- Test: `src/test/java/com/ludovictemgoua/imdb/infrastructure/persistence/JdbcUserRepositoryIntegrationTest.java`
+
+**Interfaces:**
+- Produces: `User(int id, String email, String passwordHash, String displayName, String bio, Role role, int version)`, `Role.USER`/`Role.ADMIN`, `UserRepository` with `insert`, `findById`, `findByEmail`, `existsByEmail`, `updateProfile`, `updateRole`, `softDelete`. `WriteResult` enum (`SUCCESS`, `NOT_FOUND`, `VERSION_CONFLICT`) used by every later versioned-update repository method in this plan.
+
+- [ ] **Step 1: Write the failing integration test**
+
+```java
+package com.ludovictemgoua.imdb.infrastructure.persistence;
+
+import com.ludovictemgoua.imdb.TestcontainersConfiguration;
+import com.ludovictemgoua.imdb.domain.model.Role;
+import com.ludovictemgoua.imdb.domain.repository.WriteResult;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.context.annotation.Import;
+import org.springframework.transaction.annotation.Transactional;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+@Import(TestcontainersConfiguration.class)
+@SpringBootTest
+@Transactional
+class JdbcUserRepositoryIntegrationTest {
+
+ @Autowired
+ JdbcUserRepository repository;
+
+ @Test
+ void insertThenFindByEmailReturnsTheSameUser() {
+ var inserted = repository.insert("ada@example.com", "hash1", "Ada", Role.USER);
+
+ var found = repository.findByEmail("ada@example.com").orElseThrow();
+
+ assertThat(found.id()).isEqualTo(inserted.id());
+ assertThat(found.displayName()).isEqualTo("Ada");
+ assertThat(found.role()).isEqualTo(Role.USER);
+ assertThat(found.version()).isEqualTo(0);
+ }
+
+ @Test
+ void existsByEmailIsFalseForAnUnknownAddress() {
+ assertThat(repository.existsByEmail("nobody@example.com")).isFalse();
+ }
+
+ @Test
+ void updateProfileBumpsVersionAndPersistsChanges() {
+ var user = repository.insert("grace@example.com", "hash2", "Grace", Role.USER);
+
+ var result = repository.updateProfile(user.id(), "Grace H.", "Compiler pioneer", user.version());
+
+ assertThat(result).isEqualTo(WriteResult.SUCCESS);
+ var updated = repository.findById(user.id()).orElseThrow();
+ assertThat(updated.displayName()).isEqualTo("Grace H.");
+ assertThat(updated.bio()).isEqualTo("Compiler pioneer");
+ assertThat(updated.version()).isEqualTo(1);
+ }
+
+ @Test
+ void updateProfileReturnsVersionConflictOnStaleVersion() {
+ var user = repository.insert("alan@example.com", "hash3", "Alan", Role.USER);
+
+ var result = repository.updateProfile(user.id(), "Alan T.", null, user.version() + 1);
+
+ assertThat(result).isEqualTo(WriteResult.VERSION_CONFLICT);
+ }
+
+ @Test
+ void softDeleteExcludesTheUserFromFindById() {
+ var user = repository.insert("delete-me@example.com", "hash4", "Temp", Role.USER);
+
+ repository.softDelete(user.id());
+
+ assertThat(repository.findById(user.id())).isEmpty();
+ }
+}
+```
+
+- [ ] **Step 2: Run the test to verify it fails**
+
+Run: `JAVA_HOME="/c/Program Files/Java/jdk-21" ./mvnw -q failsafe:integration-test failsafe:verify -Dit.test=JdbcUserRepositoryIntegrationTest`
+Expected: FAIL - compilation error, `JdbcUserRepository`/`Role`/`WriteResult` don't exist yet.
+
+- [ ] **Step 3: Create the migration**
+
+```sql
+CREATE TABLE users (
+ id SERIAL PRIMARY KEY,
+ email TEXT NOT NULL,
+ password_hash TEXT NOT NULL,
+ display_name TEXT NOT NULL,
+ bio TEXT,
+ role TEXT NOT NULL DEFAULT 'USER',
+ version INTEGER NOT NULL DEFAULT 0,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ deleted_at TIMESTAMPTZ
+);
+
+CREATE UNIQUE INDEX idx_users_email ON users (email) WHERE deleted_at IS NULL;
+```
+
+- [ ] **Step 4: Create `Role`, `User`, `WriteResult`**
+
+```java
+package com.ludovictemgoua.imdb.domain.model;
+
+public enum Role { USER, ADMIN }
+```
+
+```java
+package com.ludovictemgoua.imdb.domain.model;
+
+public record User(int id, String email, String passwordHash, String displayName, String bio,
+ Role role, int version) {
+}
+```
+
+```java
+package com.ludovictemgoua.imdb.domain.repository;
+
+// Shared by every repository method backing a PUT/PATCH update or a DELETE on a versioned entity
+// (users, titles, people, reviews, lists, ...) - lets the use-case layer distinguish "no such row"
+// from "row exists but your version is stale" without the repository itself deciding which HTTP
+// status or domain exception that becomes (that stays an application-layer decision, matching how
+// NotFoundException is already thrown by use cases today, not repositories).
+public enum WriteResult { SUCCESS, NOT_FOUND, VERSION_CONFLICT }
+```
+
+- [ ] **Step 5: Create `UserRepository` and `JdbcUserRepository`**
+
+```java
+package com.ludovictemgoua.imdb.domain.repository;
+
+import com.ludovictemgoua.imdb.domain.model.PagedResult;
+import com.ludovictemgoua.imdb.domain.model.Role;
+import com.ludovictemgoua.imdb.domain.model.User;
+
+import java.util.Optional;
+
+public interface UserRepository {
+
+ User insert(String email, String passwordHash, String displayName, Role role);
+
+ Optional findById(int id);
+
+ Optional findByEmail(String email);
+
+ boolean existsByEmail(String email);
+
+ WriteResult updateProfile(int id, String displayName, String bio, int expectedVersion);
+
+ void updateRole(int id, Role role);
+
+ void softDelete(int id);
+
+ PagedResult findAll(int page, int size);
+}
+```
+
+```java
+package com.ludovictemgoua.imdb.infrastructure.persistence;
+
+import com.ludovictemgoua.imdb.domain.model.PagedResult;
+import com.ludovictemgoua.imdb.domain.model.Role;
+import com.ludovictemgoua.imdb.domain.model.User;
+import com.ludovictemgoua.imdb.domain.repository.UserRepository;
+import com.ludovictemgoua.imdb.domain.repository.WriteResult;
+import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
+import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
+import org.springframework.jdbc.support.GeneratedKeyHolder;
+import org.springframework.jdbc.support.KeyHolder;
+import org.springframework.stereotype.Repository;
+
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+@Repository
+public class JdbcUserRepository implements UserRepository {
+
+ private final NamedParameterJdbcTemplate jdbc;
+
+ public JdbcUserRepository(NamedParameterJdbcTemplate jdbc) {
+ this.jdbc = jdbc;
+ }
+
+ @Override
+ public User insert(String email, String passwordHash, String displayName, Role role) {
+ String sql = """
+ INSERT INTO users (email, password_hash, display_name, role)
+ VALUES (:email, :passwordHash, :displayName, :role)
+ """;
+ var params = new MapSqlParameterSource()
+ .addValue("email", email).addValue("passwordHash", passwordHash)
+ .addValue("displayName", displayName).addValue("role", role.name());
+ KeyHolder keyHolder = new GeneratedKeyHolder();
+ jdbc.update(sql, params, keyHolder, new String[]{"id"});
+ int id = keyHolder.getKey().intValue();
+ return new User(id, email, passwordHash, displayName, null, role, 0);
+ }
+
+ @Override
+ public Optional findById(int id) {
+ String sql = "SELECT * FROM users WHERE id = :id AND deleted_at IS NULL";
+ return jdbc.query(sql, Map.of("id", id), JdbcUserRepository::mapUser).stream().findFirst();
+ }
+
+ @Override
+ public Optional findByEmail(String email) {
+ String sql = "SELECT * FROM users WHERE email = :email AND deleted_at IS NULL";
+ return jdbc.query(sql, Map.of("email", email), JdbcUserRepository::mapUser).stream().findFirst();
+ }
+
+ @Override
+ public boolean existsByEmail(String email) {
+ Integer count = jdbc.queryForObject(
+ "SELECT count(*) FROM users WHERE email = :email AND deleted_at IS NULL",
+ Map.of("email", email), Integer.class);
+ return count != null && count > 0;
+ }
+
+ @Override
+ public WriteResult updateProfile(int id, String displayName, String bio, int expectedVersion) {
+ if (findById(id).isEmpty()) {
+ return WriteResult.NOT_FOUND;
+ }
+ String sql = """
+ UPDATE users SET display_name = :displayName, bio = :bio, version = version + 1
+ WHERE id = :id AND version = :expectedVersion AND deleted_at IS NULL
+ """;
+ var params = new MapSqlParameterSource()
+ .addValue("displayName", displayName).addValue("bio", bio)
+ .addValue("id", id).addValue("expectedVersion", expectedVersion);
+ int updated = jdbc.update(sql, params);
+ return updated == 0 ? WriteResult.VERSION_CONFLICT : WriteResult.SUCCESS;
+ }
+
+ @Override
+ public void updateRole(int id, Role role) {
+ jdbc.update("UPDATE users SET role = :role, version = version + 1 WHERE id = :id",
+ new MapSqlParameterSource().addValue("role", role.name()).addValue("id", id));
+ }
+
+ @Override
+ public void softDelete(int id) {
+ jdbc.update("UPDATE users SET deleted_at = now() WHERE id = :id", Map.of("id", id));
+ }
+
+ @Override
+ public PagedResult findAll(int page, int size) {
+ String dataSql = "SELECT * FROM users WHERE deleted_at IS NULL ORDER BY id LIMIT :limit OFFSET :offset";
+ String countSql = "SELECT count(*) FROM users WHERE deleted_at IS NULL";
+ var params = new MapSqlParameterSource().addValue("limit", size).addValue("offset", (long) page * size);
+ List content = jdbc.query(dataSql, params, JdbcUserRepository::mapUser);
+ Long total = jdbc.queryForObject(countSql, params, Long.class);
+ return new PagedResult<>(content, total == null ? 0 : total, page, size);
+ }
+
+ private static User mapUser(ResultSet rs, int rowNum) throws SQLException {
+ return new User(rs.getInt("id"), rs.getString("email"), rs.getString("password_hash"),
+ rs.getString("display_name"), rs.getString("bio"),
+ Role.valueOf(rs.getString("role")), rs.getInt("version"));
+ }
+}
+```
+
+- [ ] **Step 6: Run the test to verify it passes**
+
+Run: `JAVA_HOME="/c/Program Files/Java/jdk-21" ./mvnw -q failsafe:integration-test failsafe:verify -Dit.test=JdbcUserRepositoryIntegrationTest`
+Expected: PASS, 5 tests green.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add src/main/resources/db/migration/V5__users_table.sql src/main/java/com/ludovictemgoua/imdb/domain/model/Role.java src/main/java/com/ludovictemgoua/imdb/domain/model/User.java src/main/java/com/ludovictemgoua/imdb/domain/repository/UserRepository.java src/main/java/com/ludovictemgoua/imdb/domain/repository/WriteResult.java src/main/java/com/ludovictemgoua/imdb/infrastructure/persistence/JdbcUserRepository.java src/test/java/com/ludovictemgoua/imdb/infrastructure/persistence/JdbcUserRepositoryIntegrationTest.java
+git commit -m "Add users table, User/Role domain models, and JdbcUserRepository"
+```
+
+### Task 1.2: Spring Security dependency, `PasswordEncoder`, `JwtService`
+
+**Files:**
+- Modify: `pom.xml`
+- Create: `src/main/java/com/ludovictemgoua/imdb/infrastructure/security/SecurityConfig.java`
+- Create: `src/main/java/com/ludovictemgoua/imdb/infrastructure/security/JwtService.java`
+- Test: `src/test/java/com/ludovictemgoua/imdb/infrastructure/security/JwtServiceTest.java`
+
+**Interfaces:**
+- Consumes: `Role` (Task 1.1)
+- Produces: `JwtService.issueAccessToken(int userId, Set roles)`, `issueRefreshToken(int userId)`,
+ `JwtService.Parsed(int userId, Set roles)` record, `parse(String token)` returning
+ `Optional` (empty on expired/tampered/malformed tokens - never throws).
+
+- [ ] **Step 1: Write the failing unit test**
+
+```java
+package com.ludovictemgoua.imdb.infrastructure.security;
+
+import com.ludovictemgoua.imdb.domain.model.Role;
+import org.junit.jupiter.api.Test;
+
+import java.time.Duration;
+import java.util.Set;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class JwtServiceTest {
+
+ private final JwtService jwtService = new JwtService(
+ "test-secret-at-least-32-bytes-long-for-hs256", Duration.ofMinutes(15), Duration.ofDays(7));
+
+ @Test
+ void issuedAccessTokenParsesBackToTheSameUserAndRoles() {
+ String token = jwtService.issueAccessToken(42, Set.of(Role.USER, Role.ADMIN));
+
+ var parsed = jwtService.parse(token).orElseThrow();
+
+ assertThat(parsed.userId()).isEqualTo(42);
+ assertThat(parsed.roles()).containsExactlyInAnyOrder(Role.USER, Role.ADMIN);
+ }
+
+ @Test
+ void parseReturnsEmptyForATamperedToken() {
+ String token = jwtService.issueAccessToken(1, Set.of(Role.USER));
+ String tampered = token.substring(0, token.length() - 1) + (token.endsWith("A") ? "B" : "A");
+
+ assertThat(jwtService.parse(tampered)).isEmpty();
+ }
+
+ @Test
+ void parseReturnsEmptyForAnAlreadyExpiredToken() {
+ var shortLived = new JwtService(
+ "test-secret-at-least-32-bytes-long-for-hs256", Duration.ofMillis(1), Duration.ofDays(7));
+ String token = shortLived.issueAccessToken(1, Set.of(Role.USER));
+
+ await(50);
+
+ assertThat(shortLived.parse(token)).isEmpty();
+ }
+
+ private static void await(long millis) {
+ try {
+ Thread.sleep(millis);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ }
+}
+```
+
+- [ ] **Step 2: Run the test to verify it fails**
+
+Run: `JAVA_HOME="/c/Program Files/Java/jdk-21" ./mvnw -q test -Dtest=JwtServiceTest`
+Expected: FAIL - `JwtService` doesn't exist yet.
+
+- [ ] **Step 3: Add the Spring Security and JJWT dependencies to `pom.xml`**
+
+Add inside ``, alongside the existing entries:
+
+```xml
+
+ org.springframework.boot
+ spring-boot-starter-security
+
+
+ org.springframework.boot
+ spring-boot-starter-security-test
+ test
+
+
+ io.jsonwebtoken
+ jjwt-api
+ 0.12.6
+
+
+ io.jsonwebtoken
+ jjwt-impl
+ 0.12.6
+ runtime
+
+
+ io.jsonwebtoken
+ jjwt-jackson
+ 0.12.6
+ runtime
+
+```
+
+- [ ] **Step 4: Create `JwtService`**
+
+```java
+package com.ludovictemgoua.imdb.infrastructure.security;
+
+import com.ludovictemgoua.imdb.domain.model.Role;
+import io.jsonwebtoken.Claims;
+import io.jsonwebtoken.JwtException;
+import io.jsonwebtoken.Jwts;
+import io.jsonwebtoken.security.Keys;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+
+import javax.crypto.SecretKey;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.Date;
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+@Component
+public class JwtService {
+
+ private final SecretKey key;
+ private final Duration accessTokenTtl;
+ private final Duration refreshTokenTtl;
+
+ public JwtService(
+ @Value("${imdb.jwt.secret}") String secret,
+ @Value("${imdb.jwt.access-token-ttl:PT15M}") Duration accessTokenTtl,
+ @Value("${imdb.jwt.refresh-token-ttl:P7D}") Duration refreshTokenTtl) {
+ this.key = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
+ this.accessTokenTtl = accessTokenTtl;
+ this.refreshTokenTtl = refreshTokenTtl;
+ }
+
+ public String issueAccessToken(int userId, Set roles) {
+ Instant now = Instant.now();
+ return Jwts.builder()
+ .subject(String.valueOf(userId))
+ .claim("roles", roles.stream().map(Role::name).collect(Collectors.toList()))
+ .issuedAt(Date.from(now))
+ .expiration(Date.from(now.plus(accessTokenTtl)))
+ .signWith(key)
+ .compact();
+ }
+
+ public String issueRefreshToken(int userId) {
+ Instant now = Instant.now();
+ return Jwts.builder()
+ .subject(String.valueOf(userId))
+ .claim("type", "refresh")
+ .issuedAt(Date.from(now))
+ .expiration(Date.from(now.plus(refreshTokenTtl)))
+ .signWith(key)
+ .compact();
+ }
+
+ public Optional parse(String token) {
+ try {
+ Claims claims = Jwts.parser().verifyWith(key).build()
+ .parseSignedClaims(token).getPayload();
+ int userId = Integer.parseInt(claims.getSubject());
+ @SuppressWarnings("unchecked")
+ List roleNames = claims.get("roles", List.class);
+ Set roles = roleNames == null ? Set.of()
+ : roleNames.stream().map(Role::valueOf).collect(Collectors.toSet());
+ return Optional.of(new Parsed(userId, roles));
+ } catch (JwtException | IllegalArgumentException e) {
+ return Optional.empty();
+ }
+ }
+
+ public record Parsed(int userId, Set roles) {
+ }
+}
+```
+
+- [ ] **Step 5: Add `imdb.jwt.secret` to `application.yaml` and the `PasswordEncoder` bean**
+
+Append to `src/main/resources/application.yaml`:
+
+```yaml
+imdb:
+ jwt:
+ # No default - must be set via JWT_SECRET env var (>= 32 bytes for HS256). Deliberately absent
+ # here rather than defaulted to something committed, since this signs every access/refresh token.
+ secret: ${JWT_SECRET}
+```
+
+```java
+package com.ludovictemgoua.imdb.infrastructure.security;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
+import org.springframework.security.crypto.password.PasswordEncoder;
+
+@Configuration
+public class SecurityConfig {
+
+ @Bean
+ public PasswordEncoder passwordEncoder() {
+ return new BCryptPasswordEncoder();
+ }
+}
+```
+
+- [ ] **Step 6: Run the test to verify it passes**
+
+Run: `JAVA_HOME="/c/Program Files/Java/jdk-21" ./mvnw -q test -Dtest=JwtServiceTest`
+Expected: PASS, 3 tests green.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add pom.xml src/main/resources/application.yaml src/main/java/com/ludovictemgoua/imdb/infrastructure/security/SecurityConfig.java src/main/java/com/ludovictemgoua/imdb/infrastructure/security/JwtService.java src/test/java/com/ludovictemgoua/imdb/infrastructure/security/JwtServiceTest.java
+git commit -m "Add Spring Security dependency, PasswordEncoder, and JwtService"
+```
+
+### Task 1.3: `JwtAuthenticationFilter`, security filter chain, `ProblemDetail` error handlers
+
+**Files:**
+- Create: `src/main/java/com/ludovictemgoua/imdb/infrastructure/security/JwtAuthenticationFilter.java`
+- Create: `src/main/java/com/ludovictemgoua/imdb/infrastructure/security/ProblemDetailAuthenticationEntryPoint.java`
+- Create: `src/main/java/com/ludovictemgoua/imdb/infrastructure/security/ProblemDetailAccessDeniedHandler.java`
+- Modify: `src/main/java/com/ludovictemgoua/imdb/infrastructure/security/SecurityConfig.java`
+- Test: `src/test/java/com/ludovictemgoua/imdb/infrastructure/security/JwtAuthenticationFilterTest.java`
+
+**Interfaces:**
+- Consumes: `JwtService.parse(String)` (Task 1.2)
+- Produces: a populated `SecurityContextHolder` (authorities `ROLE_USER`/`ROLE_ADMIN`) for any request
+ carrying a valid `Authorization: Bearer` token; the filter chain permits `/api/v1/auth/**`, GETs on the
+ existing read endpoints plus the new public browse endpoints, and `/actuator/**` with no token at all;
+ everything else requires authentication. Role-specific gating is `@PreAuthorize` on individual methods
+ (later tasks); ownership gating (e.g. "is this your list") is a use-case-level check, not done here.
+
+- [ ] **Step 1: Write the failing unit test**
+
+```java
+package com.ludovictemgoua.imdb.infrastructure.security;
+
+import com.ludovictemgoua.imdb.domain.model.Role;
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.security.core.context.SecurityContextHolder;
+
+import java.util.Optional;
+import java.util.Set;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.verify;
+
+@ExtendWith(MockitoExtension.class)
+class JwtAuthenticationFilterTest {
+
+ @Mock
+ JwtService jwtService;
+ @Mock
+ HttpServletRequest request;
+ @Mock
+ HttpServletResponse response;
+ @Mock
+ FilterChain chain;
+
+ @AfterEach
+ void clearContext() {
+ SecurityContextHolder.clearContext();
+ }
+
+ @Test
+ void populatesSecurityContextForAValidBearerToken() throws Exception {
+ given(request.getHeader("Authorization")).willReturn("Bearer good-token");
+ given(jwtService.parse("good-token"))
+ .willReturn(Optional.of(new JwtService.Parsed(7, Set.of(Role.USER))));
+
+ new JwtAuthenticationFilter(jwtService).doFilterInternal(request, response, chain);
+
+ var auth = SecurityContextHolder.getContext().getAuthentication();
+ assertThat(auth.getName()).isEqualTo("7");
+ assertThat(auth.getAuthorities()).extracting(Object::toString).containsExactly("ROLE_USER");
+ verify(chain).doFilter(request, response);
+ }
+
+ @Test
+ void leavesSecurityContextEmptyWithNoAuthorizationHeader() throws Exception {
+ given(request.getHeader("Authorization")).willReturn(null);
+
+ new JwtAuthenticationFilter(jwtService).doFilterInternal(request, response, chain);
+
+ assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull();
+ verify(chain).doFilter(request, response);
+ }
+
+ @Test
+ void leavesSecurityContextEmptyForAnInvalidToken() throws Exception {
+ given(request.getHeader("Authorization")).willReturn("Bearer bad-token");
+ given(jwtService.parse("bad-token")).willReturn(Optional.empty());
+
+ new JwtAuthenticationFilter(jwtService).doFilterInternal(request, response, chain);
+
+ assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull();
+ verify(chain).doFilter(request, response);
+ }
+}
+```
+
+- [ ] **Step 2: Run the test to verify it fails**
+
+Run: `JAVA_HOME="/c/Program Files/Java/jdk-21" ./mvnw -q test -Dtest=JwtAuthenticationFilterTest`
+Expected: FAIL - `JwtAuthenticationFilter` doesn't exist yet.
+
+- [ ] **Step 3: Create `JwtAuthenticationFilter`**
+
+```java
+package com.ludovictemgoua.imdb.infrastructure.security;
+
+import com.ludovictemgoua.imdb.domain.model.Role;
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import org.springframework.lang.NonNull;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.GrantedAuthority;
+import org.springframework.security.core.authority.SimpleGrantedAuthority;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.stereotype.Component;
+import org.springframework.web.filter.OncePerRequestFilter;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.Set;
+
+@Component
+public class JwtAuthenticationFilter extends OncePerRequestFilter {
+
+ private final JwtService jwtService;
+
+ public JwtAuthenticationFilter(JwtService jwtService) {
+ this.jwtService = jwtService;
+ }
+
+ @Override
+ protected void doFilterInternal(@NonNull HttpServletRequest request, @NonNull HttpServletResponse response,
+ @NonNull FilterChain filterChain) throws ServletException, IOException {
+ String header = request.getHeader("Authorization");
+ if (header != null && header.startsWith("Bearer ")) {
+ String token = header.substring("Bearer ".length());
+ jwtService.parse(token).ifPresent(parsed -> authenticate(parsed.userId(), parsed.roles()));
+ }
+ filterChain.doFilter(request, response);
+ }
+
+ private void authenticate(int userId, Set roles) {
+ List authorities = roles.stream()
+ .map(role -> (GrantedAuthority) new SimpleGrantedAuthority("ROLE_" + role.name()))
+ .toList();
+ var auth = new UsernamePasswordAuthenticationToken(String.valueOf(userId), null, authorities);
+ SecurityContextHolder.getContext().setAuthentication(auth);
+ }
+}
+```
+
+- [ ] **Step 4: Create the `ProblemDetail` entry point and access-denied handler**
+
+```java
+package com.ludovictemgoua.imdb.infrastructure.security;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.http.ProblemDetail;
+import org.springframework.security.core.AuthenticationException;
+import org.springframework.security.web.AuthenticationEntryPoint;
+import org.springframework.stereotype.Component;
+
+import java.io.IOException;
+
+// Spring Security's default 401 response isn't a ProblemDetail - it's a bare, framework-shaped
+// response, inconsistent with every other error this API returns (ApiExceptionHandler). This keeps
+// the shape consistent regardless of whether Spring MVC or Spring Security rejected the request.
+@Component
+public class ProblemDetailAuthenticationEntryPoint implements AuthenticationEntryPoint {
+
+ private final ObjectMapper objectMapper;
+
+ public ProblemDetailAuthenticationEntryPoint(ObjectMapper objectMapper) {
+ this.objectMapper = objectMapper;
+ }
+
+ @Override
+ public void commence(HttpServletRequest request, HttpServletResponse response,
+ AuthenticationException authException) throws IOException {
+ ProblemDetail body = ProblemDetail.forStatusAndDetail(
+ HttpStatus.UNAUTHORIZED, "A valid Authorization: Bearer token is required");
+ response.setStatus(HttpStatus.UNAUTHORIZED.value());
+ response.setContentType(MediaType.APPLICATION_PROBLEM_JSON_VALUE);
+ objectMapper.writeValue(response.getWriter(), body);
+ }
+}
+```
+
+```java
+package com.ludovictemgoua.imdb.infrastructure.security;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.http.ProblemDetail;
+import org.springframework.security.access.AccessDeniedException;
+import org.springframework.security.web.access.AccessDeniedHandler;
+import org.springframework.stereotype.Component;
+
+import java.io.IOException;
+
+@Component
+public class ProblemDetailAccessDeniedHandler implements AccessDeniedHandler {
+
+ private final ObjectMapper objectMapper;
+
+ public ProblemDetailAccessDeniedHandler(ObjectMapper objectMapper) {
+ this.objectMapper = objectMapper;
+ }
+
+ @Override
+ public void handle(HttpServletRequest request, HttpServletResponse response,
+ AccessDeniedException accessDeniedException) throws IOException {
+ ProblemDetail body = ProblemDetail.forStatusAndDetail(
+ HttpStatus.FORBIDDEN, "You do not have permission to perform this action");
+ response.setStatus(HttpStatus.FORBIDDEN.value());
+ response.setContentType(MediaType.APPLICATION_PROBLEM_JSON_VALUE);
+ objectMapper.writeValue(response.getWriter(), body);
+ }
+}
+```
+
+- [ ] **Step 5: Wire the filter chain into `SecurityConfig`**
+
+```java
+package com.ludovictemgoua.imdb.infrastructure.security;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.http.HttpMethod;
+import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
+import org.springframework.security.config.annotation.web.builders.HttpSecurity;
+import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
+import org.springframework.security.config.http.SessionCreationPolicy;
+import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.security.web.SecurityFilterChain;
+import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
+
+@Configuration
+@EnableMethodSecurity
+public class SecurityConfig {
+
+ @Bean
+ public PasswordEncoder passwordEncoder() {
+ return new BCryptPasswordEncoder();
+ }
+
+ @Bean
+ public SecurityFilterChain securityFilterChain(
+ HttpSecurity http, JwtAuthenticationFilter jwtAuthenticationFilter,
+ ProblemDetailAuthenticationEntryPoint authenticationEntryPoint,
+ ProblemDetailAccessDeniedHandler accessDeniedHandler) throws Exception {
+ return http
+ .csrf(AbstractHttpConfigurer::disable)
+ .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
+ .authorizeHttpRequests(auth -> auth
+ .requestMatchers("/actuator/**", "/api/v1/auth/**").permitAll()
+ .requestMatchers(HttpMethod.GET,
+ "/api/v1/titles/**", "/api/v1/genres/**", "/api/v1/people/six-degrees",
+ "/api/v1/lists/public", "/api/v1/lists/*", "/api/v1/users/*",
+ "/api/v1/users/*/watchlist", "/api/v1/users/*/reviews").permitAll()
+ .anyRequest().authenticated())
+ .exceptionHandling(handling -> handling
+ .authenticationEntryPoint(authenticationEntryPoint)
+ .accessDeniedHandler(accessDeniedHandler))
+ .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
+ .build();
+ }
+}
+```
+
+- [ ] **Step 6: Run the test to verify it passes**
+
+Run: `JAVA_HOME="/c/Program Files/Java/jdk-21" ./mvnw -q test -Dtest=JwtAuthenticationFilterTest`
+Expected: PASS, 3 tests green.
+
+- [ ] **Step 7: Run the full unit suite to confirm nothing else broke**
+
+Run: `JAVA_HOME="/c/Program Files/Java/jdk-21" ./mvnw -q test`
+Expected: PASS - adding Spring Security can make existing `@WebMvcTest`s 401/403 if a test doesn't
+authenticate; if any of the four existing controller tests fail here, add
+`.with(SecurityMockMvcRequestPostProcessors.anonymous())` or confirm the failing path is already in the
+`permitAll()` list above before proceeding - do not weaken the filter chain to make a test pass.
+
+- [ ] **Step 8: Commit**
+
+```bash
+git add src/main/java/com/ludovictemgoua/imdb/infrastructure/security/
+git add src/test/java/com/ludovictemgoua/imdb/infrastructure/security/JwtAuthenticationFilterTest.java
+git commit -m "Add JwtAuthenticationFilter, security filter chain, and ProblemDetail error handlers"
+```
+
+### Task 1.4: `ConflictException`/`ForbiddenException`, `AuthUseCase`, `AuthController`
+
+**Files:**
+- Create: `src/main/java/com/ludovictemgoua/imdb/domain/exception/ConflictException.java`
+- Create: `src/main/java/com/ludovictemgoua/imdb/domain/exception/ForbiddenException.java`
+- Modify: `src/main/java/com/ludovictemgoua/imdb/presentation/ApiExceptionHandler.java`
+- Create: `src/main/java/com/ludovictemgoua/imdb/application/contracts/AuthUseCase.java`
+- Create: `src/main/java/com/ludovictemgoua/imdb/application/AuthUseCaseImpl.java`
+- Create: `src/main/java/com/ludovictemgoua/imdb/application/RegisterRequest.java`, `LoginRequest.java`, `TokenPair.java` (records)
+- Create: `src/main/java/com/ludovictemgoua/imdb/presentation/AuthController.java`
+- Test: `src/test/java/com/ludovictemgoua/imdb/application/AuthUseCaseImplTest.java`
+- Test: `src/test/java/com/ludovictemgoua/imdb/presentation/AuthControllerTest.java`
+
+**Interfaces:**
+- Consumes: `UserRepository` (Task 1.1), `JwtService` (Task 1.2), `PasswordEncoder` (Task 1.2)
+- Produces: `AuthUseCase.register(RegisterRequest)`, `login(LoginRequest)`, `refresh(String refreshToken)`,
+ all returning `TokenPair(String accessToken, String refreshToken)`. `ConflictException`/
+ `ForbiddenException` (both `RuntimeException`), used by every later task needing 409/403.
+
+- [ ] **Step 1: Write the failing unit test**
+
+```java
+package com.ludovictemgoua.imdb.application;
+
+import com.ludovictemgoua.imdb.application.rest.LoginRequest;
+import com.ludovictemgoua.imdb.application.rest.RegisterRequest;
+import com.ludovictemgoua.imdb.domain.exception.ConflictException;
+import com.ludovictemgoua.imdb.domain.model.Role;
+import com.ludovictemgoua.imdb.domain.model.User;
+import com.ludovictemgoua.imdb.domain.repository.UserRepository;
+import com.ludovictemgoua.imdb.infrastructure.security.JwtService;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
+
+import java.util.Optional;
+import java.util.Set;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.verify;
+
+@ExtendWith(MockitoExtension.class)
+class AuthUseCaseImplTest {
+
+ @Mock
+ UserRepository userRepository;
+ @Mock
+ JwtService jwtService;
+
+ private final BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
+
+ @Test
+ void registerCreatesAUserWithRoleUser() {
+ given(userRepository.existsByEmail("ada@example.com")).willReturn(false);
+ given(userRepository.insert(any(), any(), any(), any()))
+ .willReturn(new User(1, "ada@example.com", "hash", "Ada", null, Role.USER, 0));
+ given(jwtService.issueAccessToken(1, Set.of(Role.USER))).willReturn("access");
+ given(jwtService.issueRefreshToken(1)).willReturn("refresh");
+
+ var tokens = new AuthUseCaseImpl(userRepository, jwtService, passwordEncoder)
+ .register(new RegisterRequest("ada@example.com", "password123", "Ada"));
+
+ assertThat(tokens.accessToken()).isEqualTo("access");
+ assertThat(tokens.refreshToken()).isEqualTo("refresh");
+ verify(userRepository).insert("ada@example.com", any(), "Ada", Role.USER);
+ }
+
+ @Test
+ void registerThrowsConflictWhenEmailAlreadyExists() {
+ given(userRepository.existsByEmail("ada@example.com")).willReturn(true);
+ var useCase = new AuthUseCaseImpl(userRepository, jwtService, passwordEncoder);
+
+ assertThatThrownBy(() -> useCase.register(new RegisterRequest("ada@example.com", "pw", "Ada")))
+ .isInstanceOf(ConflictException.class);
+ }
+
+ @Test
+ void loginIssuesTokensForACorrectPassword() {
+ String hash = passwordEncoder.encode("password123");
+ given(userRepository.findByEmail("ada@example.com"))
+ .willReturn(Optional.of(new User(1, "ada@example.com", hash, "Ada", null, Role.USER, 0)));
+ given(jwtService.issueAccessToken(1, Set.of(Role.USER))).willReturn("access");
+ given(jwtService.issueRefreshToken(1)).willReturn("refresh");
+
+ var tokens = new AuthUseCaseImpl(userRepository, jwtService, passwordEncoder)
+ .login(new LoginRequest("ada@example.com", "password123"));
+
+ assertThat(tokens.accessToken()).isEqualTo("access");
+ }
+
+ @Test
+ void loginThrowsForbiddenForAWrongPassword() {
+ String hash = passwordEncoder.encode("password123");
+ given(userRepository.findByEmail("ada@example.com"))
+ .willReturn(Optional.of(new User(1, "ada@example.com", hash, "Ada", null, Role.USER, 0)));
+ var useCase = new AuthUseCaseImpl(userRepository, jwtService, passwordEncoder);
+
+ assertThatThrownBy(() -> useCase.login(new LoginRequest("ada@example.com", "wrong")))
+ .isInstanceOf(com.ludovictemgoua.imdb.domain.exception.ForbiddenException.class);
+ }
+}
+```
+
+- [ ] **Step 2: Run the test to verify it fails**
+
+Run: `JAVA_HOME="/c/Program Files/Java/jdk-21" ./mvnw -q test -Dtest=AuthUseCaseImplTest`
+Expected: FAIL - `AuthUseCaseImpl`/`RegisterRequest`/`LoginRequest`/`ConflictException`/`ForbiddenException` don't exist yet.
+
+- [ ] **Step 3: Create the two new domain exceptions**
+
+```java
+package com.ludovictemgoua.imdb.domain.exception;
+
+public class ConflictException extends RuntimeException {
+ public ConflictException(String message) {
+ super(message);
+ }
+}
+```
+
+```java
+package com.ludovictemgoua.imdb.domain.exception;
+
+public class ForbiddenException extends RuntimeException {
+ public ForbiddenException(String message) {
+ super(message);
+ }
+}
+```
+
+- [ ] **Step 4: Add handlers to `ApiExceptionHandler`**
+
+Add these two methods to the existing class, alongside `handleNotFound`/`handleBadId` (before the
+catch-all `handleUnexpected`):
+
+```java
+ @ExceptionHandler(com.ludovictemgoua.imdb.domain.exception.ConflictException.class)
+ public ProblemDetail handleConflict(com.ludovictemgoua.imdb.domain.exception.ConflictException ex) {
+ log.debug("conflict: {}", ex.getMessage());
+ return ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, ex.getMessage());
+ }
+
+ @ExceptionHandler(com.ludovictemgoua.imdb.domain.exception.ForbiddenException.class)
+ public ProblemDetail handleForbidden(com.ludovictemgoua.imdb.domain.exception.ForbiddenException ex) {
+ log.debug("forbidden: {}", ex.getMessage());
+ return ProblemDetail.forStatusAndDetail(HttpStatus.FORBIDDEN, ex.getMessage());
+ }
+```
+
+(Use the fully-qualified names as shown, or add proper `import` statements at the top of the file next to
+the existing `NotFoundException` import - either is fine, match whichever style is already there.)
+
+- [ ] **Step 5: Create the request/response records and `AuthUseCase`/`AuthUseCaseImpl`**
+
+```java
+package com.ludovictemgoua.imdb.application;
+
+public record RegisterRequest(String email, String password, String displayName) {
+}
+```
+
+```java
+package com.ludovictemgoua.imdb.application;
+
+public record LoginRequest(String email, String password) {
+}
+```
+
+```java
+package com.ludovictemgoua.imdb.application;
+
+public record TokenPair(String accessToken, String refreshToken) {
+}
+```
+
+```java
+package com.ludovictemgoua.imdb.application.contracts;
+
+import com.ludovictemgoua.imdb.application.rest.TokenPair;
+
+public interface AuthUseCase {
+
+ TokenPair register(com.ludovictemgoua.imdb.application.rest.RegisterRequest request);
+
+ com.ludovictemgoua.imdb.application.rest.TokenPair login(com.ludovictemgoua.imdb.application.rest.LoginRequest request);
+
+ com.ludovictemgoua.imdb.application.rest.TokenPair refresh(String refreshToken);
+}
+```
+
+```java
+package com.ludovictemgoua.imdb.application;
+
+import com.ludovictemgoua.imdb.application.contracts.AuthUseCase;
+import com.ludovictemgoua.imdb.application.rest.LoginRequest;
+import com.ludovictemgoua.imdb.application.rest.RegisterRequest;
+import com.ludovictemgoua.imdb.application.rest.TokenPair;
+import com.ludovictemgoua.imdb.domain.exception.ConflictException;
+import com.ludovictemgoua.imdb.domain.exception.ForbiddenException;
+import com.ludovictemgoua.imdb.domain.model.Role;
+import com.ludovictemgoua.imdb.domain.model.User;
+import com.ludovictemgoua.imdb.domain.repository.UserRepository;
+import com.ludovictemgoua.imdb.infrastructure.security.JwtService;
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.stereotype.Service;
+
+import java.util.Set;
+
+@Service
+public class AuthUseCaseImpl implements AuthUseCase {
+
+ private final UserRepository userRepository;
+ private final JwtService jwtService;
+ private final PasswordEncoder passwordEncoder;
+
+ public AuthUseCaseImpl(UserRepository userRepository, JwtService jwtService, PasswordEncoder passwordEncoder) {
+ this.userRepository = userRepository;
+ this.jwtService = jwtService;
+ this.passwordEncoder = passwordEncoder;
+ }
+
+ @Override
+ public TokenPair register(RegisterRequest request) {
+ if (userRepository.existsByEmail(request.email())) {
+ throw new ConflictException("An account with this email already exists");
+ }
+ String hash = passwordEncoder.encode(request.password());
+ User user = userRepository.insert(request.email(), hash, request.displayName(), Role.USER);
+ return issueTokens(user);
+ }
+
+ @Override
+ public TokenPair login(LoginRequest request) {
+ User user = userRepository.findByEmail(request.email())
+ .orElseThrow(() -> new ForbiddenException("Invalid email or password"));
+ if (!passwordEncoder.matches(request.password(), user.passwordHash())) {
+ throw new ForbiddenException("Invalid email or password");
+ }
+ return issueTokens(user);
+ }
+
+ @Override
+ public TokenPair refresh(String refreshToken) {
+ var parsed = jwtService.parse(refreshToken)
+ .orElseThrow(() -> new ForbiddenException("Invalid or expired refresh token"));
+ User user = userRepository.findById(parsed.userId())
+ .orElseThrow(() -> new ForbiddenException("Invalid or expired refresh token"));
+ return issueTokens(user);
+ }
+
+ private TokenPair issueTokens(User user) {
+ String access = jwtService.issueAccessToken(user.id(), Set.of(user.role()));
+ String refresh = jwtService.issueRefreshToken(user.id());
+ return new TokenPair(access, refresh);
+ }
+}
+```
+
+- [ ] **Step 6: Run the test to verify it passes**
+
+Run: `JAVA_HOME="/c/Program Files/Java/jdk-21" ./mvnw -q test -Dtest=AuthUseCaseImplTest`
+Expected: PASS, 4 tests green.
+
+- [ ] **Step 7: Write the failing controller test**
+
+```java
+package com.ludovictemgoua.imdb.presentation;
+
+import com.ludovictemgoua.imdb.application.rest.RegisterRequest;
+import com.ludovictemgoua.imdb.application.contracts.AuthUseCase;
+import com.ludovictemgoua.imdb.domain.exception.ConflictException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
+import org.springframework.security.test.context.support.WithAnonymousUser;
+import org.springframework.test.context.bean.override.mockito.MockitoBean;
+import org.springframework.test.web.servlet.MockMvc;
+
+import static org.mockito.BDDMockito.given;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+@WebMvcTest(AuthController.class)
+@WithAnonymousUser
+class AuthControllerTest {
+
+ @Autowired
+ MockMvc mockMvc;
+ @Autowired
+ ObjectMapper objectMapper;
+ @MockitoBean
+ AuthUseCase authUseCase;
+
+ @Test
+ void registerReturns201WithTokens() throws Exception {
+ given(authUseCase.register(new RegisterRequest("ada@example.com", "password123", "Ada")))
+ .willReturn(new com.ludovictemgoua.imdb.application.rest.TokenPair("access", "refresh"));
+
+ mockMvc.perform(post("/api/v1/auth/register")
+ .contentType("application/json")
+ .content(objectMapper.writeValueAsString(
+ new RegisterRequest("ada@example.com", "password123", "Ada"))))
+ .andExpect(status().isCreated())
+ .andExpect(jsonPath("$.accessToken").value("access"));
+ }
+
+ @Test
+ void registerReturns409ForADuplicateEmail() throws Exception {
+ given(authUseCase.register(new RegisterRequest("ada@example.com", "password123", "Ada")))
+ .willThrow(new ConflictException("An account with this email already exists"));
+
+ mockMvc.perform(post("/api/v1/auth/register")
+ .contentType("application/json")
+ .content(objectMapper.writeValueAsString(
+ new RegisterRequest("ada@example.com", "password123", "Ada"))))
+ .andExpect(status().isConflict());
+ }
+
+ @Test
+ void loginReturnsTokensForValidCredentials() throws Exception {
+ given(authUseCase.login(new com.ludovictemgoua.imdb.application.rest.LoginRequest("ada@example.com", "password123")))
+ .willReturn(new com.ludovictemgoua.imdb.application.rest.TokenPair("access", "refresh"));
+
+ mockMvc.perform(post("/api/v1/auth/login")
+ .contentType("application/json")
+ .content(objectMapper.writeValueAsString(
+ new com.ludovictemgoua.imdb.application.rest.LoginRequest("ada@example.com", "password123"))))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.refreshToken").value("refresh"));
+ }
+}
+```
+
+- [ ] **Step 8: Run the test to verify it fails**
+
+Run: `JAVA_HOME="/c/Program Files/Java/jdk-21" ./mvnw -q test -Dtest=AuthControllerTest`
+Expected: FAIL - `AuthController` doesn't exist yet.
+
+- [ ] **Step 9: Create `AuthController`**
+
+```java
+package com.ludovictemgoua.imdb.presentation;
+
+import com.ludovictemgoua.imdb.application.rest.LoginRequest;
+import com.ludovictemgoua.imdb.application.rest.RegisterRequest;
+import com.ludovictemgoua.imdb.application.rest.TokenPair;
+import com.ludovictemgoua.imdb.application.contracts.AuthUseCase;
+import jakarta.validation.Valid;
+import jakarta.validation.constraints.NotBlank;
+import org.springframework.http.HttpStatus;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.ResponseStatus;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+@RequestMapping("/api/v1/auth")
+public class AuthController {
+
+ private final AuthUseCase authUseCase;
+
+ public AuthController(AuthUseCase authUseCase) {
+ this.authUseCase = authUseCase;
+ }
+
+ @PostMapping("/register")
+ @ResponseStatus(HttpStatus.CREATED)
+ public TokenPair register(@Valid @RequestBody RegisterRequest request) {
+ return authUseCase.register(request);
+ }
+
+ @PostMapping("/login")
+ public TokenPair login(@Valid @RequestBody LoginRequest request) {
+ return authUseCase.login(request);
+ }
+
+ @PostMapping("/refresh")
+ public com.ludovictemgoua.imdb.application.rest.TokenPair refresh(@RequestBody RefreshRequest request) {
+ return authUseCase.refresh(request.refreshToken());
+ }
+
+ public record RefreshRequest(@NotBlank String refreshToken) {
+ }
+}
+```
+
+Add Bean Validation annotations to `RegisterRequest`/`LoginRequest` (both are plain records used as
+`@RequestBody` types, so the annotations go directly on the record components):
+
+```java
+package com.ludovictemgoua.imdb.application;
+
+import jakarta.validation.constraints.Email;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Size;
+
+public record RegisterRequest(
+ @NotBlank @Email String email,
+ @NotBlank @Size(min = 8) String password,
+ @NotBlank String displayName) {
+}
+```
+
+```java
+package com.ludovictemgoua.imdb.application;
+
+import jakarta.validation.constraints.Email;
+import jakarta.validation.constraints.NotBlank;
+
+public record LoginRequest(@NotBlank @Email String email, @NotBlank String password) {
+}
+```
+
+- [ ] **Step 10: Run the test to verify it passes**
+
+Run: `JAVA_HOME="/c/Program Files/Java/jdk-21" ./mvnw -q test -Dtest=AuthControllerTest`
+Expected: PASS, 3 tests green.
+
+- [ ] **Step 11: Commit**
+
+```bash
+git add src/main/java/com/ludovictemgoua/imdb/domain/exception/ConflictException.java src/main/java/com/ludovictemgoua/imdb/domain/exception/ForbiddenException.java src/main/java/com/ludovictemgoua/imdb/presentation/ApiExceptionHandler.java src/main/java/com/ludovictemgoua/imdb/application/ src/main/java/com/ludovictemgoua/imdb/presentation/AuthController.java src/test/java/com/ludovictemgoua/imdb/application/AuthUseCaseImplTest.java src/test/java/com/ludovictemgoua/imdb/presentation/AuthControllerTest.java
+git commit -m "Add ConflictException/ForbiddenException and the auth register/login/refresh flow"
+```
+
+### Task 1.5: `BootstrapAdminRunner`
+
+**Files:**
+- Create: `src/main/java/com/ludovictemgoua/imdb/infrastructure/security/BootstrapAdminRunner.java`
+- Test: `src/test/java/com/ludovictemgoua/imdb/infrastructure/security/BootstrapAdminRunnerIntegrationTest.java`
+
+**Interfaces:**
+- Consumes: `UserRepository` (Task 1.1), `PasswordEncoder` (Task 1.2)
+- Produces: on application startup, exactly one `ADMIN` user exists with the configured bootstrap email
+ (idempotent - a restart never creates a second one).
+
+- [ ] **Step 1: Write the failing integration test**
+
+```java
+package com.ludovictemgoua.imdb.infrastructure.security;
+
+import com.ludovictemgoua.imdb.TestcontainersConfiguration;
+import com.ludovictemgoua.imdb.domain.model.Role;
+import com.ludovictemgoua.imdb.domain.repository.UserRepository;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.context.annotation.Import;
+import org.springframework.test.context.TestPropertySource;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+@Import(TestcontainersConfiguration.class)
+@SpringBootTest
+@TestPropertySource(properties = {
+ "imdb.bootstrap-admin.email=admin@imdb.local",
+ "imdb.bootstrap-admin.password=change-me-please"
+})
+class BootstrapAdminRunnerIntegrationTest {
+
+ @Autowired
+ UserRepository userRepository;
+
+ @Test
+ void bootstrapAdminExistsWithAdminRoleAfterStartup() {
+ var admin = userRepository.findByEmail("admin@imdb.local").orElseThrow();
+
+ assertThat(admin.role()).isEqualTo(Role.ADMIN);
+ }
+}
+```
+
+- [ ] **Step 2: Run the test to verify it fails**
+
+Run: `JAVA_HOME="/c/Program Files/Java/jdk-21" ./mvnw -q failsafe:integration-test failsafe:verify -Dit.test=BootstrapAdminRunnerIntegrationTest`
+Expected: FAIL - no bootstrap admin is created yet.
+
+- [ ] **Step 3: Add the bootstrap properties to `application.yaml`**
+
+```yaml
+imdb:
+ bootstrap-admin:
+ # No defaults for either - a fresh stack with neither set simply never gets an admin account,
+ # which is the safe failure mode (an operator has to deliberately opt in), rather than shipping
+ # a guessable default admin password.
+ email: ${IMDB_BOOTSTRAP_ADMIN_EMAIL:}
+ password: ${IMDB_BOOTSTRAP_ADMIN_PASSWORD:}
+```
+
+- [ ] **Step 4: Create `BootstrapAdminRunner`**
+
+```java
+package com.ludovictemgoua.imdb.infrastructure.security;
+
+import com.ludovictemgoua.imdb.domain.model.Role;
+import com.ludovictemgoua.imdb.domain.repository.UserRepository;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.boot.ApplicationArguments;
+import org.springframework.boot.ApplicationRunner;
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.stereotype.Component;
+import org.springframework.util.StringUtils;
+
+@Component
+public class BootstrapAdminRunner implements ApplicationRunner {
+
+ private static final Logger log = LoggerFactory.getLogger(BootstrapAdminRunner.class);
+
+ private final UserRepository userRepository;
+ private final PasswordEncoder passwordEncoder;
+ private final String bootstrapEmail;
+ private final String bootstrapPassword;
+
+ public BootstrapAdminRunner(
+ UserRepository userRepository, PasswordEncoder passwordEncoder,
+ @Value("${imdb.bootstrap-admin.email}") String bootstrapEmail,
+ @Value("${imdb.bootstrap-admin.password}") String bootstrapPassword) {
+ this.userRepository = userRepository;
+ this.passwordEncoder = passwordEncoder;
+ this.bootstrapEmail = bootstrapEmail;
+ this.bootstrapPassword = bootstrapPassword;
+ }
+
+ @Override
+ public void run(ApplicationArguments args) {
+ if (!StringUtils.hasText(bootstrapEmail) || !StringUtils.hasText(bootstrapPassword)) {
+ log.debug("no bootstrap admin configured (imdb.bootstrap-admin.email/password unset)");
+ return;
+ }
+ if (userRepository.existsByEmail(bootstrapEmail)) {
+ log.debug("bootstrap admin already exists: email={}", bootstrapEmail);
+ return;
+ }
+ userRepository.insert(bootstrapEmail, passwordEncoder.encode(bootstrapPassword), "Admin", Role.ADMIN);
+ log.info("bootstrap admin created: email={}", bootstrapEmail);
+ }
+}
+```
+
+- [ ] **Step 5: Run the test to verify it passes**
+
+Run: `JAVA_HOME="/c/Program Files/Java/jdk-21" ./mvnw -q failsafe:integration-test failsafe:verify -Dit.test=BootstrapAdminRunnerIntegrationTest`
+Expected: PASS.
+
+- [ ] **Step 6: Add the bootstrap env vars to `docker-compose.yaml`'s `imdb-service` environment block**
+
+```yaml
+ IMDB_BOOTSTRAP_ADMIN_EMAIL: "admin@imdb.local"
+ IMDB_BOOTSTRAP_ADMIN_PASSWORD: "change-me-please"
+ JWT_SECRET: "local-dev-only-secret-change-in-real-deployments-32bytes+"
+```
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add src/main/java/com/ludovictemgoua/imdb/infrastructure/security/BootstrapAdminRunner.java src/test/java/com/ludovictemgoua/imdb/infrastructure/security/BootstrapAdminRunnerIntegrationTest.java src/main/resources/application.yaml docker-compose.yaml
+git commit -m "Add BootstrapAdminRunner so the ADMIN role is reachable on a fresh stack"
+```
+
+### Task 1.6: `CurrentUser` helper, `UserUseCase`, `UserController`
+
+**Files:**
+- Create: `src/main/java/com/ludovictemgoua/imdb/infrastructure/security/CurrentUser.java`
+- Create: `src/main/java/com/ludovictemgoua/imdb/domain/model/UserProfile.java`, `PublicUserProfile.java`
+- Create: `src/main/java/com/ludovictemgoua/imdb/application/UpdateProfileRequest.java`, `RoleRequest.java` (records)
+- Create: `src/main/java/com/ludovictemgoua/imdb/application/contracts/UserUseCase.java`
+- Create: `src/main/java/com/ludovictemgoua/imdb/application/UserUseCaseImpl.java`
+- Create: `src/main/java/com/ludovictemgoua/imdb/presentation/UserController.java`
+- Modify: `src/main/java/com/ludovictemgoua/imdb/infrastructure/security/SecurityConfig.java`
+- Test: `src/test/java/com/ludovictemgoua/imdb/infrastructure/security/CurrentUserTest.java`
+- Test: `src/test/java/com/ludovictemgoua/imdb/application/UserUseCaseImplTest.java`
+- Test: `src/test/java/com/ludovictemgoua/imdb/presentation/UserControllerTest.java`
+
+**Interfaces:**
+- Consumes: `UserRepository` (Task 1.1 - `findById`, `updateProfile`, `updateRole`, `softDelete`, `findAll`
+ all already exist from that task)
+- Produces: `CurrentUser.idOf(Authentication) -> Optional`, `requireId(Authentication) -> int` -
+ reused by every controller in Phases 6-8 needing "who is calling, if anyone" (this plan originally
+ placed this helper in Phase 6; it's built here instead since `UserController` needs it first
+ chronologically - Phase 6 Task 6.1 below has been corrected to consume it rather than create it).
+ `UserProfile(int id, String email, String displayName, String bio, Role role, int version)` (excludes
+ `passwordHash` - the API must never echo it back). `PublicUserProfile(int id, String displayName)`.
+ `GET/PUT/DELETE /api/v1/users/me`, `GET /api/v1/users/{userId}`, `GET /api/v1/users` (admin),
+ `PUT /api/v1/users/{userId}/role` (admin), `DELETE /api/v1/users/{userId}` (admin) - the remaining seven
+ endpoints from `docs/crud-expansion-design.md` §4.1 not already covered by `AuthController` (Task 1.4).
+
+- [ ] **Step 1: Write the failing `CurrentUser` unit test**
+
+```java
+package com.ludovictemgoua.imdb.infrastructure.security;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.security.authentication.TestingAuthenticationToken;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+class CurrentUserTest {
+
+ @Test
+ void idOfReturnsEmptyForNullAuthentication() {
+ assertThat(CurrentUser.idOf(null)).isEmpty();
+ }
+
+ @Test
+ void idOfReturnsTheParsedUserIdForARealToken() {
+ var auth = new TestingAuthenticationToken("42", null);
+
+ assertThat(CurrentUser.idOf(auth)).contains(42);
+ }
+
+ @Test
+ void idOfReturnsEmptyForAnonymousAuthentication() {
+ var auth = new TestingAuthenticationToken("anonymousUser", null);
+
+ assertThat(CurrentUser.idOf(auth)).isEmpty();
+ }
+
+ @Test
+ void requireIdThrowsWhenNoUserIsAuthenticated() {
+ assertThatThrownBy(() -> CurrentUser.requireId(null)).isInstanceOf(IllegalStateException.class);
+ }
+}
+```
+
+- [ ] **Step 2: Run the test to verify it fails**
+
+Run: `JAVA_HOME="/c/Program Files/Java/jdk-21" ./mvnw -q test -Dtest=CurrentUserTest`
+Expected: FAIL - `CurrentUser` doesn't exist yet.
+
+- [ ] **Step 3: Create `CurrentUser`**
+
+```java
+package com.ludovictemgoua.imdb.infrastructure.security;
+
+import org.springframework.security.core.Authentication;
+
+import java.util.Optional;
+
+public final class CurrentUser {
+
+ private CurrentUser() {
+ }
+
+ public static Optional idOf(Authentication authentication) {
+ if (authentication == null) {
+ return Optional.empty();
+ }
+ try {
+ return Optional.of(Integer.parseInt(authentication.getName()));
+ } catch (NumberFormatException e) {
+ return Optional.empty();
+ }
+ }
+
+ public static int requireId(Authentication authentication) {
+ return idOf(authentication)
+ .orElseThrow(() -> new IllegalStateException("No authenticated user in this request"));
+ }
+}
+```
+
+- [ ] **Step 4: Run the test to verify it passes**
+
+Run: `JAVA_HOME="/c/Program Files/Java/jdk-21" ./mvnw -q test -Dtest=CurrentUserTest`
+Expected: PASS, 4 tests green.
+
+- [ ] **Step 5: Write the failing `UserUseCaseImpl` unit test**
+
+```java
+package com.ludovictemgoua.imdb.application;
+
+import com.ludovictemgoua.imdb.application.rest.UpdateProfileRequest;
+import com.ludovictemgoua.imdb.domain.exception.ConflictException;
+import com.ludovictemgoua.imdb.domain.exception.NotFoundException;
+import com.ludovictemgoua.imdb.domain.model.Role;
+import com.ludovictemgoua.imdb.domain.model.User;
+import com.ludovictemgoua.imdb.domain.repository.UserRepository;
+import com.ludovictemgoua.imdb.domain.repository.WriteResult;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.util.Optional;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.BDDMockito.given;
+
+@ExtendWith(MockitoExtension.class)
+class UserUseCaseImplTest {
+
+ @Mock
+ UserRepository userRepository;
+
+ @Test
+ void getOwnProfileExcludesThePasswordHash() {
+ given(userRepository.findById(7))
+ .willReturn(Optional.of(new User(7, "a@example.com", "secret-hash", "Ada", "bio", Role.USER, 0)));
+
+ var profile = new UserUseCaseImpl(userRepository).getOwnProfile(7);
+
+ assertThat(profile.email()).isEqualTo("a@example.com");
+ assertThat(profile.displayName()).isEqualTo("Ada");
+ }
+
+ @Test
+ void getOwnProfileThrowsNotFoundForAnUnknownId() {
+ given(userRepository.findById(999)).willReturn(Optional.empty());
+
+ assertThatThrownBy(() -> new UserUseCaseImpl(userRepository).getOwnProfile(999))
+ .isInstanceOf(NotFoundException.class);
+ }
+
+ @Test
+ void updateOwnProfileThrowsConflictOnVersionMismatch() {
+ given(userRepository.updateProfile(7, "New Name", "New Bio", 0)).willReturn(WriteResult.VERSION_CONFLICT);
+ var useCase = new UserUseCaseImpl(userRepository);
+
+ assertThatThrownBy(() -> useCase.updateOwnProfile(7, new UpdateProfileRequest("New Name", "New Bio", 0)))
+ .isInstanceOf(ConflictException.class);
+ }
+
+ @Test
+ void updateRoleThrowsNotFoundForAnUnknownUser() {
+ given(userRepository.findById(999)).willReturn(Optional.empty());
+ var useCase = new UserUseCaseImpl(userRepository);
+
+ assertThatThrownBy(() -> useCase.updateRole(999, Role.ADMIN)).isInstanceOf(NotFoundException.class);
+ }
+}
+```
+
+- [ ] **Step 6: Run the test to verify it fails**
+
+Run: `JAVA_HOME="/c/Program Files/Java/jdk-21" ./mvnw -q test -Dtest=UserUseCaseImplTest`
+Expected: FAIL - `UserUseCaseImpl`/`UpdateProfileRequest` don't exist yet.
+
+- [ ] **Step 7: Create the domain/request records and `UserUseCase`/`Impl`**
+
+```java
+package com.ludovictemgoua.imdb.domain.model;
+
+public record UserProfile(int id, String email, String displayName, String bio, Role role, int version) {
+}
+```
+
+```java
+package com.ludovictemgoua.imdb.domain.model;
+
+public record PublicUserProfile(int id, String displayName) {
+}
+```
+
+```java
+package com.ludovictemgoua.imdb.application;
+
+import jakarta.validation.constraints.NotBlank;
+
+public record UpdateProfileRequest(@NotBlank String displayName, String bio, int version) {
+}
+```
+
+```java
+package com.ludovictemgoua.imdb.application;
+
+import com.ludovictemgoua.imdb.domain.model.Role;
+import jakarta.validation.constraints.NotNull;
+
+public record RoleRequest(@NotNull Role role) {
+}
+```
+
+```java
+package com.ludovictemgoua.imdb.application.contracts;
+
+import com.ludovictemgoua.imdb.application.rest.UpdateProfileRequest;
+import com.ludovictemgoua.imdb.domain.model.PagedResult;
+import com.ludovictemgoua.imdb.domain.model.PublicUserProfile;
+import com.ludovictemgoua.imdb.domain.model.Role;
+import com.ludovictemgoua.imdb.domain.model.UserProfile;
+
+public interface UserUseCase {
+
+ UserProfile getOwnProfile(int userId);
+
+ UserProfile updateOwnProfile(int userId, UpdateProfileRequest request);
+
+ void deleteOwnAccount(int userId);
+
+ PublicUserProfile getPublicProfile(int userId);
+
+ PagedResult listAll(int page, int size);
+
+ void updateRole(int userId, Role role);
+
+ void deleteAccount(int userId);
+}
+```
+
+```java
+package com.ludovictemgoua.imdb.application;
+
+import com.ludovictemgoua.imdb.application.contracts.UserUseCase;
+import com.ludovictemgoua.imdb.application.rest.UpdateProfileRequest;
+import com.ludovictemgoua.imdb.domain.exception.ConflictException;
+import com.ludovictemgoua.imdb.domain.exception.NotFoundException;
+import com.ludovictemgoua.imdb.domain.model.PagedResult;
+import com.ludovictemgoua.imdb.domain.model.PublicUserProfile;
+import com.ludovictemgoua.imdb.domain.model.Role;
+import com.ludovictemgoua.imdb.domain.model.User;
+import com.ludovictemgoua.imdb.domain.model.UserProfile;
+import com.ludovictemgoua.imdb.domain.repository.UserRepository;
+import com.ludovictemgoua.imdb.domain.repository.WriteResult;
+import org.springframework.stereotype.Service;
+
+@Service
+public class UserUseCaseImpl implements UserUseCase {
+
+ private final UserRepository userRepository;
+
+ public UserUseCaseImpl(UserRepository userRepository) {
+ this.userRepository = userRepository;
+ }
+
+ @Override
+ public UserProfile getOwnProfile(int userId) {
+ return toProfile(findOrThrow(userId));
+ }
+
+ @Override
+ public UserProfile updateOwnProfile(int userId, UpdateProfileRequest request) {
+ WriteResult result = userRepository.updateProfile(userId, request.displayName(), request.bio(), request.version());
+ switch (result) {
+ case NOT_FOUND -> throw new NotFoundException("No user with id " + userId);
+ case VERSION_CONFLICT ->
+ throw new ConflictException("Your profile was modified concurrently - refresh and retry");
+ case SUCCESS -> {
+ }
+ }
+ return getOwnProfile(userId);
+ }
+
+ @Override
+ public void deleteOwnAccount(int userId) {
+ userRepository.softDelete(userId);
+ }
+
+ @Override
+ public PublicUserProfile getPublicProfile(int userId) {
+ User user = findOrThrow(userId);
+ return new PublicUserProfile(user.id(), user.displayName());
+ }
+
+ @Override
+ public PagedResult listAll(int page, int size) {
+ PagedResult users = userRepository.findAll(page, size);
+ return new PagedResult<>(users.content().stream().map(UserUseCaseImpl::toProfile).toList(),
+ users.totalElements(), users.page(), users.size());
+ }
+
+ @Override
+ public void updateRole(int userId, Role role) {
+ findOrThrow(userId);
+ userRepository.updateRole(userId, role);
+ }
+
+ @Override
+ public void deleteAccount(int userId) {
+ findOrThrow(userId);
+ userRepository.softDelete(userId);
+ }
+
+ private User findOrThrow(int userId) {
+ return userRepository.findById(userId).orElseThrow(() -> new NotFoundException("No user with id " + userId));
+ }
+
+ private static UserProfile toProfile(User user) {
+ return new UserProfile(user.id(), user.email(), user.displayName(), user.bio(), user.role(), user.version());
+ }
+}
+```
+
+- [ ] **Step 8: Run the test to verify it passes**
+
+Run: `JAVA_HOME="/c/Program Files/Java/jdk-21" ./mvnw -q test -Dtest=UserUseCaseImplTest`
+Expected: PASS, 4 tests green.
+
+- [ ] **Step 9: Write the failing `UserController` test**
+
+```java
+package com.ludovictemgoua.imdb.presentation;
+
+import com.ludovictemgoua.imdb.application.contracts.UserUseCase;
+import com.ludovictemgoua.imdb.domain.model.PublicUserProfile;
+import com.ludovictemgoua.imdb.domain.model.Role;
+import com.ludovictemgoua.imdb.domain.model.UserProfile;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
+import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors;
+import org.springframework.test.context.bean.override.mockito.MockitoBean;
+import org.springframework.test.web.servlet.MockMvc;
+
+import static org.mockito.BDDMockito.given;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+@WebMvcTest(UserController.class)
+class UserControllerTest {
+
+ @Autowired
+ MockMvc mockMvc;
+ @MockitoBean
+ UserUseCase userUseCase;
+
+ @Test
+ void getOwnRequiresAuthentication() throws Exception {
+ mockMvc.perform(get("/api/v1/users/me")).andExpect(status().isUnauthorized());
+ }
+
+ @Test
+ void getOwnReturnsTheProfileForAnAuthenticatedUser() throws Exception {
+ given(userUseCase.getOwnProfile(7))
+ .willReturn(new UserProfile(7, "a@example.com", "Ada", "bio", Role.USER, 0));
+
+ mockMvc.perform(get("/api/v1/users/me").with(SecurityMockMvcRequestPostProcessors.user("7").roles("USER")))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.displayName").value("Ada"));
+ }
+
+ @Test
+ void getPublicProfileIsAccessibleAnonymously() throws Exception {
+ given(userUseCase.getPublicProfile(7)).willReturn(new PublicUserProfile(7, "Ada"));
+
+ mockMvc.perform(get("/api/v1/users/7")).andExpect(status().isOk());
+ }
+
+ @Test
+ void deleteAccountRequiresAdminRole() throws Exception {
+ mockMvc.perform(delete("/api/v1/users/7")
+ .with(SecurityMockMvcRequestPostProcessors.user("1").roles("USER")))
+ .andExpect(status().isForbidden());
+ }
+}
+```
+
+- [ ] **Step 10: Run the test to verify it fails**
+
+Run: `JAVA_HOME="/c/Program Files/Java/jdk-21" ./mvnw -q test -Dtest=UserControllerTest`
+Expected: FAIL - `UserController` doesn't exist yet.
+
+- [ ] **Step 11: Create `UserController`**
+
+```java
+package com.ludovictemgoua.imdb.presentation;
+
+import com.ludovictemgoua.imdb.application.rest.RoleRequest;
+import com.ludovictemgoua.imdb.application.rest.UpdateProfileRequest;
+import com.ludovictemgoua.imdb.application.contracts.UserUseCase;
+import com.ludovictemgoua.imdb.domain.model.PagedResult;
+import com.ludovictemgoua.imdb.domain.model.PublicUserProfile;
+import com.ludovictemgoua.imdb.domain.model.UserProfile;
+import com.ludovictemgoua.imdb.infrastructure.security.CurrentUser;
+import jakarta.validation.Valid;
+import jakarta.validation.constraints.Max;
+import jakarta.validation.constraints.Min;
+import org.springframework.http.HttpStatus;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.security.core.Authentication;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.ResponseStatus;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+@Validated
+public class UserController {
+
+ private final UserUseCase userUseCase;
+
+ public UserController(UserUseCase userUseCase) {
+ this.userUseCase = userUseCase;
+ }
+
+ @GetMapping("/api/v1/users/me")
+ public UserProfile getOwn(Authentication authentication) {
+ return userUseCase.getOwnProfile(CurrentUser.requireId(authentication));
+ }
+
+ @PutMapping("/api/v1/users/me")
+ public UserProfile updateOwn(Authentication authentication, @Valid @RequestBody UpdateProfileRequest request) {
+ return userUseCase.updateOwnProfile(CurrentUser.requireId(authentication), request);
+ }
+
+ @DeleteMapping("/api/v1/users/me")
+ @ResponseStatus(HttpStatus.NO_CONTENT)
+ public void deleteOwn(Authentication authentication) {
+ userUseCase.deleteOwnAccount(CurrentUser.requireId(authentication));
+ }
+
+ @GetMapping("/api/v1/users/{userId}")
+ public PublicUserProfile getPublicProfile(@PathVariable int userId) {
+ return userUseCase.getPublicProfile(userId);
+ }
+
+ @GetMapping("/api/v1/users")
+ @PreAuthorize("hasRole('ADMIN')")
+ public PagedResult listAll(
+ @RequestParam(defaultValue = "0") @Min(0) int page,
+ @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size) {
+ return userUseCase.listAll(page, size);
+ }
+
+ @PutMapping("/api/v1/users/{userId}/role")
+ @PreAuthorize("hasRole('ADMIN')")
+ public void updateRole(@PathVariable int userId, @Valid @RequestBody RoleRequest request) {
+ userUseCase.updateRole(userId, request.role());
+ }
+
+ @DeleteMapping("/api/v1/users/{userId}")
+ @ResponseStatus(HttpStatus.NO_CONTENT)
+ @PreAuthorize("hasRole('ADMIN')")
+ public void deleteAccount(@PathVariable int userId) {
+ userUseCase.deleteAccount(userId);
+ }
+}
+```
+
+`GET /api/v1/users/{userId}` is already in the security filter chain's `permitAll()` GET list
+(`"/api/v1/users/*"`, Task 1.3) - confirm `GET /api/v1/users/me` does **not** incorrectly match that same
+wildcard the way `/api/v1/lists/me` did (Task 8.2, Step 7); if it does, apply the identical fix: declare
+`.requestMatchers(HttpMethod.GET, "/api/v1/users/me").authenticated()` before the broader permit rule.
+
+- [ ] **Step 12: Run the test to verify it passes**
+
+Run: `JAVA_HOME="/c/Program Files/Java/jdk-21" ./mvnw -q test -Dtest=UserControllerTest`
+Expected: PASS, 4 tests green.
+
+- [ ] **Step 13: Run the full unit and integration suites**
+
+Run: `JAVA_HOME="/c/Program Files/Java/jdk-21" ./mvnw -q test && JAVA_HOME="/c/Program Files/Java/jdk-21" ./mvnw -q failsafe:integration-test failsafe:verify`
+Expected: PASS.
+
+- [ ] **Step 14: Commit**
+
+```bash
+git add src/main/java/com/ludovictemgoua/imdb/infrastructure/security/CurrentUser.java src/main/java/com/ludovictemgoua/imdb/domain/model/UserProfile.java src/main/java/com/ludovictemgoua/imdb/domain/model/PublicUserProfile.java src/main/java/com/ludovictemgoua/imdb/application/UpdateProfileRequest.java src/main/java/com/ludovictemgoua/imdb/application/RoleRequest.java src/main/java/com/ludovictemgoua/imdb/application/contracts/UserUseCase.java src/main/java/com/ludovictemgoua/imdb/application/UserUseCaseImpl.java src/main/java/com/ludovictemgoua/imdb/presentation/UserController.java src/main/java/com/ludovictemgoua/imdb/infrastructure/security/SecurityConfig.java src/test/java/com/ludovictemgoua/imdb/infrastructure/security/CurrentUserTest.java src/test/java/com/ludovictemgoua/imdb/application/UserUseCaseImplTest.java src/test/java/com/ludovictemgoua/imdb/presentation/UserControllerTest.java
+git commit -m "Add UserUseCase/UserController: profile management and admin user management"
+```
+
+**Phase 1 checkpoint**: run the full suite (`mvn test` then `mvn failsafe:integration-test failsafe:verify`)
+before moving to Phase 2 - registration, login, refresh, profile management, admin user management, and
+the bootstrap admin are all real and tested at this point, independent of everything that follows.
+
+---
+
+## Phase 2: Core Entity Versioning & Soft Delete
+
+### Task 2.1: Admin-created ID sequences (`V6`)
+
+**Files:**
+- Create: `src/main/resources/db/migration/V6__admin_id_sequences.sql`
+- Test: `src/test/java/com/ludovictemgoua/imdb/infrastructure/persistence/AdminIdSequencesIntegrationTest.java`
+
+**Interfaces:**
+- Produces: `title_id_seq`/`person_id_seq` Postgres sequences, usable via `nextval('title_id_seq')` /
+ `nextval('person_id_seq')` from any later JDBC insert (Phase 3/4).
+
+- [ ] **Step 1: Write the failing integration test**
+
+```java
+package com.ludovictemgoua.imdb.infrastructure.persistence;
+
+import com.ludovictemgoua.imdb.TestcontainersConfiguration;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.context.annotation.Import;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.test.context.jdbc.Sql;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+@Import(TestcontainersConfiguration.class)
+@SpringBootTest
+@Sql("/fixtures/fixture-data.sql")
+class AdminIdSequencesIntegrationTest {
+
+ @Autowired
+ JdbcTemplate jdbc;
+
+ @Test
+ void titleIdSequenceStartsAboveTheHighestSeededTconst() {
+ Integer maxSeeded = jdbc.queryForObject("SELECT max(tconst) FROM title_basics", Integer.class);
+ Integer nextVal = jdbc.queryForObject("SELECT nextval('title_id_seq')", Integer.class);
+
+ assertThat(nextVal).isGreaterThan(maxSeeded);
+ }
+
+ @Test
+ void personIdSequenceStartsAboveTheHighestSeededNconst() {
+ Integer maxSeeded = jdbc.queryForObject("SELECT max(nconst) FROM name_basics", Integer.class);
+ Integer nextVal = jdbc.queryForObject("SELECT nextval('person_id_seq')", Integer.class);
+
+ assertThat(nextVal).isGreaterThan(maxSeeded);
+ }
+}
+```
+
+- [ ] **Step 2: Run the test to verify it fails**
+
+Run: `JAVA_HOME="/c/Program Files/Java/jdk-21" ./mvnw -q failsafe:integration-test failsafe:verify -Dit.test=AdminIdSequencesIntegrationTest`
+Expected: FAIL - `title_id_seq`/`person_id_seq` don't exist.
+
+- [ ] **Step 3: Create the migration**
+
+```sql
+-- Admin-created titles/people (Phase 3/4) need ids that can never collide with the seeded, already-
+-- densely-allocated tconst/nconst range from abanda/imdb-postgresql. Starting each sequence one past
+-- the current max is done in a DO block, not a plain CREATE SEQUENCE START WITH literal, since the
+-- actual max differs across environments (the full dataset here vs. the small fixture Testcontainers
+-- and the e2e stack seed) - this migration must work correctly against all three.
+DO $$
+DECLARE
+ next_title_id BIGINT;
+ next_person_id BIGINT;
+BEGIN
+ SELECT COALESCE(max(tconst), 0) + 1 INTO next_title_id FROM title_basics;
+ SELECT COALESCE(max(nconst), 0) + 1 INTO next_person_id FROM name_basics;
+
+ EXECUTE format('CREATE SEQUENCE title_id_seq START WITH %s', next_title_id);
+ EXECUTE format('CREATE SEQUENCE person_id_seq START WITH %s', next_person_id);
+END $$;
+```
+
+- [ ] **Step 4: Run the test to verify it passes**
+
+Run: `JAVA_HOME="/c/Program Files/Java/jdk-21" ./mvnw -q failsafe:integration-test failsafe:verify -Dit.test=AdminIdSequencesIntegrationTest`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/main/resources/db/migration/V6__admin_id_sequences.sql src/test/java/com/ludovictemgoua/imdb/infrastructure/persistence/AdminIdSequencesIntegrationTest.java
+git commit -m "Add title_id_seq/person_id_seq for admin-created rows (V6)"
+```
+
+### Task 2.2: `version`/`deleted_at` on core tables, soft-delete filtering in existing read queries
+
+**Files:**
+- Create: `src/main/resources/db/migration/V7__core_entity_version_and_soft_delete.sql`
+- Modify: `src/main/java/com/ludovictemgoua/imdb/infrastructure/persistence/JdbcTitleRepository.java`
+- Modify: `src/main/java/com/ludovictemgoua/imdb/infrastructure/persistence/JdbcPersonRepository.java`
+- Modify: `src/test/java/com/ludovictemgoua/imdb/infrastructure/persistence/JdbcTitleRepositoryIntegrationTest.java`
+- Modify: `src/test/java/com/ludovictemgoua/imdb/infrastructure/persistence/JdbcPersonRepositoryIntegrationTest.java`
+
+**Interfaces:**
+- Produces: `version`/`deleted_at` columns on `title_basics`, `name_basics`, `title_ratings`,
+ `title_principals`, `title_crew`. A soft-deleted title/person is excluded from search, detail,
+ top-rated, and person-name-lookup-by-search - but NOT from `findAnyCommonTitle` enrichment being able
+ to reference it if it was already the connecting title before deletion, and NOT from
+ `findNameById`/`findNamesByIds` (existing cast/crew references still render a name rather than
+ breaking). Admin repository insert/update methods added in Phase 3/4 read/write these two columns
+ directly; nothing in this task writes to them yet (no writer exists before Phase 3).
+
+**Known limitation, stated here rather than solved**: `co_star_edges` (the six-degrees materialized view)
+is built once from `title_principals`/`title_basics` and refreshed manually (LLD §3.3, PDD §11's open
+question on refresh cadence) - a soft-deleted person/title can still appear in a six-degrees path until
+the next refresh. This plan does not change that refresh cadence; it's an existing, already-documented
+trade-off, not a new one introduced here.
+
+- [ ] **Step 1: Write the failing test additions**
+
+Add to `JdbcTitleRepositoryIntegrationTest`:
+
+```java
+ @Test
+ void findCoreExcludesASoftDeletedTitle() {
+ jdbc.update("UPDATE title_basics SET deleted_at = now() WHERE tconst = 100", Map.of());
+
+ assertThat(repository.findCore(100)).isEmpty();
+ }
+
+ @Test
+ void searchExcludesASoftDeletedTitle() {
+ jdbc.update("UPDATE title_basics SET deleted_at = now() WHERE tconst = 100", Map.of());
+
+ assertThat(repository.search("Few Good Men", 0, 20).content()).extracting("id").doesNotContain("tt0000100");
+ }
+```
+
+(This requires autowiring a plain `JdbcTemplate jdbc` field in the test class alongside the existing
+`JdbcTitleRepository repository` field, and `import org.springframework.jdbc.core.JdbcTemplate;` /
+`import java.util.Map;` at the top.)
+
+Add to `JdbcPersonRepositoryIntegrationTest` (create this file if it doesn't already exist, following the
+exact structure of `JdbcTitleRepositoryIntegrationTest` - `@Import(TestcontainersConfiguration.class)
+@SpringBootTest @Transactional @Sql("/fixtures/fixture-data.sql")`, autowiring `JdbcPersonRepository`):
+
+```java
+ @Test
+ void findByNameExcludesASoftDeletedPerson() {
+ jdbc.update("UPDATE name_basics SET deleted_at = now() WHERE nconst = 1", Map.of());
+
+ assertThat(repository.findByName("Kevin Bacon")).isEmpty();
+ }
+```
+
+- [ ] **Step 2: Run the tests to verify they fail**
+
+Run: `JAVA_HOME="/c/Program Files/Java/jdk-21" ./mvnw -q failsafe:integration-test failsafe:verify -Dit.test=JdbcTitleRepositoryIntegrationTest,JdbcPersonRepositoryIntegrationTest`
+Expected: FAIL - `deleted_at` column doesn't exist yet, so the `UPDATE` in each test itself errors.
+
+- [ ] **Step 3: Create the migration**
+
+```sql
+ALTER TABLE title_basics ADD COLUMN version INTEGER NOT NULL DEFAULT 0, ADD COLUMN deleted_at TIMESTAMPTZ;
+ALTER TABLE name_basics ADD COLUMN version INTEGER NOT NULL DEFAULT 0, ADD COLUMN deleted_at TIMESTAMPTZ;
+ALTER TABLE title_ratings ADD COLUMN version INTEGER NOT NULL DEFAULT 0, ADD COLUMN deleted_at TIMESTAMPTZ;
+ALTER TABLE title_principals ADD COLUMN version INTEGER NOT NULL DEFAULT 0, ADD COLUMN deleted_at TIMESTAMPTZ;
+ALTER TABLE title_crew ADD COLUMN version INTEGER NOT NULL DEFAULT 0, ADD COLUMN deleted_at TIMESTAMPTZ;
+```
+
+- [ ] **Step 4: Add `AND deleted_at IS NULL` to the discovery queries in `JdbcTitleRepository`**
+
+In `search(...)`, both `dataSql` and `countSql` gain the filter:
+
+```java
+ String dataSql = """
+ SELECT tconst, primary_title, original_title, title_type, start_year, end_year
+ FROM title_basics
+ WHERE (primary_title % :query OR original_title % :query) AND deleted_at IS NULL
+ ORDER BY similarity(primary_title, :query) DESC
+ LIMIT :limit OFFSET :offset
+ """;
+ String countSql = """
+ SELECT count(*) FROM title_basics
+ WHERE (primary_title % :query OR original_title % :query) AND deleted_at IS NULL
+ """;
+```
+
+In `findCore(...)`:
+
+```java
+ String sql = """
+ SELECT tb.tconst, tb.primary_title, tb.original_title, tb.title_type,
+ tb.start_year, tb.end_year, tb.runtime_minutes, tb.genres,
+ tr.average_rating, tr.num_votes
+ FROM title_basics tb
+ LEFT JOIN title_ratings tr ON tr.tconst = tb.tconst
+ WHERE tb.tconst = :tconst AND tb.deleted_at IS NULL
+ """;
+```
+
+In `findTopRated(...)`, add `AND tb.deleted_at IS NULL` to the `pool` CTE's `WHERE` clause:
+
+```java
+ String sql = """
+ WITH pool AS (
+ SELECT tb.tconst, tb.primary_title, tb.start_year, tr.average_rating, tr.num_votes
+ FROM title_basics tb
+ JOIN title_ratings tr ON tr.tconst = tb.tconst
+ WHERE tb.title_type = 'movie'
+ AND genres_as_text(tb.genres) @> ARRAY[:genre]::text[]
+ AND tr.num_votes >= :minVotes
+ AND tb.deleted_at IS NULL
+ ),
+ stats AS (SELECT AVG(average_rating) AS mean_rating FROM pool)
+ SELECT p.tconst, p.primary_title, p.start_year, p.average_rating, p.num_votes,
+ (p.num_votes::numeric / (p.num_votes + :minVotes)) * p.average_rating
+ + (:minVotes::numeric / (p.num_votes + :minVotes)) * s.mean_rating AS weighted_rating
+ FROM pool p CROSS JOIN stats s
+ ORDER BY weighted_rating DESC
+ LIMIT :limit
+ """;
+```
+
+`findDirectors`/`findWriters`/`findTopCast`/`countCast`/`findAnyCommonTitle` are deliberately left
+unchanged - they render or enrich *existing* references (a title/person that might have since been
+soft-deleted but was real cast/crew at the time), matching the design decision that soft-deleting a title
+doesn't retroactively break historical references to it (`docs/crud-expansion-design.md` §6.4).
+
+- [ ] **Step 5: Add `AND deleted_at IS NULL` to `findByName` in `JdbcPersonRepository`**
+
+```java
+ String sql = """
+ SELECT nconst, primary_name, birth_year, known_for_titles
+ FROM name_basics
+ WHERE primary_name % :name AND deleted_at IS NULL
+ ORDER BY similarity(primary_name, :name) DESC
+ LIMIT 10
+ """;
+```
+
+`findNameById`/`findNamesByIds` are left unchanged for the same historical-reference reason as above.
+
+- [ ] **Step 6: Run the tests to verify they pass**
+
+Run: `JAVA_HOME="/c/Program Files/Java/jdk-21" ./mvnw -q failsafe:integration-test failsafe:verify -Dit.test=JdbcTitleRepositoryIntegrationTest,JdbcPersonRepositoryIntegrationTest`
+Expected: PASS.
+
+- [ ] **Step 7: Run the full suite to confirm nothing regressed**
+
+Run: `JAVA_HOME="/c/Program Files/Java/jdk-21" ./mvnw -q test && JAVA_HOME="/c/Program Files/Java/jdk-21" ./mvnw -q failsafe:integration-test failsafe:verify`
+Expected: PASS - all existing tests remain green; the new `version`/`deleted_at` columns are additive
+(`NOT NULL DEFAULT 0` / nullable) so no existing insert statement anywhere in the codebase needs to
+change.
+
+- [ ] **Step 8: Commit**
+
+```bash
+git add src/main/resources/db/migration/V7__core_entity_version_and_soft_delete.sql src/main/java/com/ludovictemgoua/imdb/infrastructure/persistence/JdbcTitleRepository.java src/main/java/com/ludovictemgoua/imdb/infrastructure/persistence/JdbcPersonRepository.java src/test/java/com/ludovictemgoua/imdb/infrastructure/persistence/JdbcTitleRepositoryIntegrationTest.java src/test/java/com/ludovictemgoua/imdb/infrastructure/persistence/JdbcPersonRepositoryIntegrationTest.java
+git commit -m "Add version/deleted_at to core tables; filter soft-deleted rows from discovery queries"
+```
+
+**Phase 2 checkpoint**: core tables can now support versioned updates and soft deletes, but nothing writes
+to them yet - Phase 3 adds the first writer.
+
+---
+
+## Phase 3: Admin CRUD - Titles
+
+### Task 3.1: `TitleRepository` write methods (insert/update/delete/crew/rating)
+
+**Files:**
+- Modify: `src/main/java/com/ludovictemgoua/imdb/domain/repository/TitleRepository.java`
+- Modify: `src/main/java/com/ludovictemgoua/imdb/infrastructure/persistence/JdbcTitleRepository.java`
+- Modify: `src/test/java/com/ludovictemgoua/imdb/infrastructure/persistence/JdbcTitleRepositoryIntegrationTest.java`
+
+**Interfaces:**
+- Produces: `TitleRepository.insertTitle(...) -> TitleCore`, `updateTitle(int tconst, ..., int expectedVersion) -> WriteResult`, `softDeleteTitle(int tconst) -> WriteResult`, `upsertCrew(int tconst, List directorIds, List writerIds) -> WriteResult`, `upsertRating(int tconst, double averageRating, int numVotes) -> WriteResult`, `deleteRating(int tconst) -> WriteResult`. `TitleCore.version()` - add a `version` component to the existing `TitleCore` record (Task's Step 1 shows the exact new record shape; every caller of the constructor - `mapCore` in this file, and the record's one other reader in `TitleDetailUseCaseImpl` - is updated in this same task).
+
+- [ ] **Step 1: Write the failing integration tests**
+
+Add to `JdbcTitleRepositoryIntegrationTest`:
+
+```java
+ @Test
+ void insertTitleCreatesARowWithVersionZero() {
+ var created = repository.insertTitle("New Movie", "New Movie", "movie", 2024, null, 120, List.of("Drama"));
+
+ assertThat(created.version()).isEqualTo(0);
+ assertThat(repository.findCore(ImdbIds.parseTitleId(created.id())).orElseThrow().primaryTitle())
+ .isEqualTo("New Movie");
+ }
+
+ @Test
+ void insertedTitleIdIsAboveTheSeededRange() {
+ var created = repository.insertTitle("Another Movie", "Another Movie", "movie", 2024, null, 90, List.of());
+
+ assertThat(ImdbIds.parseTitleId(created.id())).isGreaterThan(200);
+ }
+
+ @Test
+ void updateTitleBumpsVersionAndPersists() {
+ var created = repository.insertTitle("Old Name", "Old Name", "movie", 2020, null, 100, List.of("Drama"));
+ int tconst = ImdbIds.parseTitleId(created.id());
+
+ var result = repository.updateTitle(
+ tconst, "New Name", "New Name", "movie", 2021, null, 110, List.of("Comedy"), created.version());
+
+ assertThat(result).isEqualTo(com.ludovictemgoua.imdb.domain.repository.WriteResult.SUCCESS);
+ var updated = repository.findCore(tconst).orElseThrow();
+ assertThat(updated.primaryTitle()).isEqualTo("New Name");
+ assertThat(updated.version()).isEqualTo(1);
+ }
+
+ @Test
+ void updateTitleReturnsVersionConflictOnStaleVersion() {
+ var created = repository.insertTitle("Stale Test", "Stale Test", "movie", 2020, null, 100, List.of());
+ int tconst = ImdbIds.parseTitleId(created.id());
+
+ var result = repository.updateTitle(
+ tconst, "New Name", "New Name", "movie", 2021, null, 110, List.of(), created.version() + 1);
+
+ assertThat(result).isEqualTo(com.ludovictemgoua.imdb.domain.repository.WriteResult.VERSION_CONFLICT);
+ }
+
+ @Test
+ void softDeleteTitleExcludesItFromFindCore() {
+ var created = repository.insertTitle("Delete Me", "Delete Me", "movie", 2020, null, 100, List.of());
+ int tconst = ImdbIds.parseTitleId(created.id());
+
+ repository.softDeleteTitle(tconst);
+
+ assertThat(repository.findCore(tconst)).isEmpty();
+ }
+
+ @Test
+ void upsertRatingThenDeleteRatingRoundTrips() {
+ var created = repository.insertTitle("Rating Test", "Rating Test", "movie", 2020, null, 100, List.of());
+ int tconst = ImdbIds.parseTitleId(created.id());
+
+ repository.upsertRating(tconst, 7.5, 1000);
+ assertThat(repository.findCore(tconst).orElseThrow().averageRating()).isEqualTo(7.5);
+
+ repository.deleteRating(tconst);
+ assertThat(repository.findCore(tconst).orElseThrow().averageRating()).isNull();
+ }
+```
+
+Add `import com.ludovictemgoua.imdb.utils.ImdbIds;` and `import java.util.List;` to the test's imports if not
+already present (`List` already is, via the existing `findTopRated` test).
+
+- [ ] **Step 2: Run the tests to verify they fail**
+
+Run: `JAVA_HOME="/c/Program Files/Java/jdk-21" ./mvnw -q failsafe:integration-test failsafe:verify -Dit.test=JdbcTitleRepositoryIntegrationTest`
+Expected: FAIL - the new repository methods don't exist yet.
+
+- [ ] **Step 3: Add `version` to `TitleCore` and update its two existing usages**
+
+`TitleCore` currently ends in `Integer runtimeMinutes, List genres, Double averageRating, Integer numVotes)` (check the actual file for its exact full signature) - add `, int version` as the final component. Update `JdbcTitleRepository.mapCore` to read `rs.getInt("version")` as the last constructor argument, and update `TitleDetailUseCaseImpl.getDetail` - it destructures `core` into a `TitleDetail`; `TitleDetail` itself does **not** need a `version` field (it's a read response, not a write target), so `TitleDetailUseCaseImpl` needs no change beyond compiling against the new `TitleCore` shape (it doesn't reference `core.version()`).
+
+- [ ] **Step 4: Add the new methods to `TitleRepository`**
+
+```java
+ TitleCore insertTitle(String primaryTitle, String originalTitle, String titleType,
+ Integer startYear, Integer endYear, Integer runtimeMinutes, List genres);
+
+ WriteResult updateTitle(int tconst, String primaryTitle, String originalTitle, String titleType,
+ Integer startYear, Integer endYear, Integer runtimeMinutes,
+ List genres, int expectedVersion);
+
+ WriteResult softDeleteTitle(int tconst);
+
+ WriteResult upsertCrew(int tconst, List directorIds, List writerIds);
+
+ WriteResult upsertRating(int tconst, double averageRating, int numVotes);
+
+ WriteResult deleteRating(int tconst);
+```
+
+(Add `import com.ludovictemgoua.imdb.domain.repository.WriteResult;` is unnecessary since `WriteResult` is
+already in this same package; add `import java.util.List;` if not already present.)
+
+- [ ] **Step 5: Implement the new methods in `JdbcTitleRepository`**
+
+```java
+ @Override
+ public TitleCore insertTitle(String primaryTitle, String originalTitle, String titleType,
+ Integer startYear, Integer endYear, Integer runtimeMinutes, List genres) {
+ String sql = """
+ INSERT INTO title_basics (tconst, primary_title, original_title, title_type,
+ start_year, end_year, runtime_minutes, genres)
+ VALUES (nextval('title_id_seq'), :primaryTitle, :originalTitle, :titleType,
+ :startYear, :endYear, :runtimeMinutes, :genres)
+ RETURNING tconst
+ """;
+ var params = new MapSqlParameterSource()
+ .addValue("primaryTitle", primaryTitle).addValue("originalTitle", originalTitle)
+ .addValue("titleType", titleType).addValue("startYear", startYear)
+ .addValue("endYear", endYear).addValue("runtimeMinutes", runtimeMinutes)
+ .addValue("genres", genres.toArray(new String[0]), java.sql.Types.ARRAY, "text");
+ int tconst = jdbc.queryForObject(sql, params, Integer.class);
+ return findCore(tconst).orElseThrow();
+ }
+
+ @Override
+ public WriteResult updateTitle(int tconst, String primaryTitle, String originalTitle, String titleType,
+ Integer startYear, Integer endYear, Integer runtimeMinutes,
+ List genres, int expectedVersion) {
+ if (findCore(tconst).isEmpty()) {
+ return WriteResult.NOT_FOUND;
+ }
+ String sql = """
+ UPDATE title_basics
+ SET primary_title = :primaryTitle, original_title = :originalTitle, title_type = :titleType,
+ start_year = :startYear, end_year = :endYear, runtime_minutes = :runtimeMinutes,
+ genres = :genres, version = version + 1
+ WHERE tconst = :tconst AND version = :expectedVersion AND deleted_at IS NULL
+ """;
+ var params = new MapSqlParameterSource()
+ .addValue("primaryTitle", primaryTitle).addValue("originalTitle", originalTitle)
+ .addValue("titleType", titleType).addValue("startYear", startYear)
+ .addValue("endYear", endYear).addValue("runtimeMinutes", runtimeMinutes)
+ .addValue("genres", genres.toArray(new String[0]), java.sql.Types.ARRAY, "text")
+ .addValue("tconst", tconst).addValue("expectedVersion", expectedVersion);
+ return jdbc.update(sql, params) == 0 ? WriteResult.VERSION_CONFLICT : WriteResult.SUCCESS;
+ }
+
+ @Override
+ public WriteResult softDeleteTitle(int tconst) {
+ if (findCore(tconst).isEmpty()) {
+ return WriteResult.NOT_FOUND;
+ }
+ jdbc.update("UPDATE title_basics SET deleted_at = now() WHERE tconst = :tconst", Map.of("tconst", tconst));
+ return WriteResult.SUCCESS;
+ }
+
+ @Override
+ public WriteResult upsertCrew(int tconst, List directorIds, List writerIds) {
+ if (findCore(tconst).isEmpty()) {
+ return WriteResult.NOT_FOUND;
+ }
+ String sql = """
+ INSERT INTO title_crew (tconst, directors, writers)
+ VALUES (:tconst, :directors, :writers)
+ ON CONFLICT (tconst) DO UPDATE SET directors = :directors, writers = :writers, version = title_crew.version + 1
+ """;
+ var params = new MapSqlParameterSource()
+ .addValue("tconst", tconst)
+ .addValue("directors", directorIds.toArray(new Integer[0]), java.sql.Types.ARRAY, "integer")
+ .addValue("writers", writerIds.toArray(new Integer[0]), java.sql.Types.ARRAY, "integer");
+ jdbc.update(sql, params);
+ return WriteResult.SUCCESS;
+ }
+
+ @Override
+ public WriteResult upsertRating(int tconst, double averageRating, int numVotes) {
+ if (findCore(tconst).isEmpty()) {
+ return WriteResult.NOT_FOUND;
+ }
+ String sql = """
+ INSERT INTO title_ratings (tconst, average_rating, num_votes)
+ VALUES (:tconst, :averageRating, :numVotes)
+ ON CONFLICT (tconst) DO UPDATE SET average_rating = :averageRating, num_votes = :numVotes,
+ version = title_ratings.version + 1
+ """;
+ var params = new MapSqlParameterSource()
+ .addValue("tconst", tconst).addValue("averageRating", averageRating).addValue("numVotes", numVotes);
+ jdbc.update(sql, params);
+ return WriteResult.SUCCESS;
+ }
+
+ @Override
+ public WriteResult deleteRating(int tconst) {
+ int updated = jdbc.update("UPDATE title_ratings SET deleted_at = now() WHERE tconst = :tconst AND deleted_at IS NULL",
+ Map.of("tconst", tconst));
+ return updated == 0 ? WriteResult.NOT_FOUND : WriteResult.SUCCESS;
+ }
+```
+
+`upsertRating`/`deleteRating` require `findCore`'s `LEFT JOIN title_ratings` to also filter
+`(tr.deleted_at IS NULL OR tr.deleted_at IS NULL)` - add `AND (tr.tconst IS NULL OR tr.deleted_at IS NULL)`
+to `findCore`'s `WHERE` clause so a deleted rating correctly shows as no rating rather than a stale one:
+
+```java
+ WHERE tb.tconst = :tconst AND tb.deleted_at IS NULL
+ AND (tr.tconst IS NULL OR tr.deleted_at IS NULL)
+```
+
+`title_ratings`/`title_crew` need a unique constraint on `tconst` for the `ON CONFLICT` clauses above to
+work - add this to `V7__core_entity_version_and_soft_delete.sql` from Task 2.2 (both tables already have
+`tconst` as their primary key per the `V0` base schema, so `ON CONFLICT (tconst)` already targets a real
+unique constraint with no migration change needed - confirm this against `V0__base_schema.sql` before
+writing Step 5's SQL; if either table's PK is composite instead, change `ON CONFLICT (tconst)` to that
+table's actual primary key column list).
+
+- [ ] **Step 6: Run the tests to verify they pass**
+
+Run: `JAVA_HOME="/c/Program Files/Java/jdk-21" ./mvnw -q failsafe:integration-test failsafe:verify -Dit.test=JdbcTitleRepositoryIntegrationTest`
+Expected: PASS, all 6 new tests plus the existing ones green.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add src/main/java/com/ludovictemgoua/imdb/domain/repository/TitleRepository.java src/main/java/com/ludovictemgoua/imdb/infrastructure/persistence/JdbcTitleRepository.java src/main/java/com/ludovictemgoua/imdb/domain/model/TitleCore.java src/main/java/com/ludovictemgoua/imdb/application/TitleDetailUseCaseImpl.java src/test/java/com/ludovictemgoua/imdb/infrastructure/persistence/JdbcTitleRepositoryIntegrationTest.java
+git commit -m "Add TitleRepository write methods: insert/update/soft-delete/crew/rating"
+```
+
+### Task 3.2: `TitleAdminUseCase`, cache-evicting decorator, `TitleController` admin endpoints
+
+**Files:**
+- Create: `src/main/java/com/ludovictemgoua/imdb/application/contracts/TitleAdminUseCase.java`
+- Create: `src/main/java/com/ludovictemgoua/imdb/application/TitleAdminUseCaseImpl.java`
+- Create: `src/main/java/com/ludovictemgoua/imdb/application/CreateTitleRequest.java`, `UpdateTitleRequest.java`, `PatchTitleRequest.java`, `CrewRequest.java`, `RatingRequest.java` (records)
+- Create: `src/main/java/com/ludovictemgoua/imdb/infrastructure/cache/CachingTitleAdminUseCase.java`
+- Modify: `src/main/java/com/ludovictemgoua/imdb/presentation/TitleController.java`
+- Test: `src/test/java/com/ludovictemgoua/imdb/application/TitleAdminUseCaseImplTest.java`
+- Test: `src/test/java/com/ludovictemgoua/imdb/presentation/TitleControllerTest.java` (extend)
+
+**Interfaces:**
+- Consumes: `TitleRepository` write methods (Task 3.1), `WriteResult` (Task 1.1)
+- Produces: `TitleAdminUseCase` with `create`, `update`, `patch`, `delete`, `upsertCrew`, `upsertRating`,
+ `deleteRating` - every admin write endpoint in `docs/crud-expansion-design.md` §5.1/§5.3 routes through
+ this one interface (title-scoped admin operations grouped by domain cohesion, per §8).
+
+- [ ] **Step 1: Write the failing unit test**
+
+```java
+package com.ludovictemgoua.imdb.application;
+
+import com.ludovictemgoua.imdb.application.rest.CreateTitleRequest;
+import com.ludovictemgoua.imdb.application.rest.UpdateTitleRequest;
+import com.ludovictemgoua.imdb.domain.exception.ConflictException;
+import com.ludovictemgoua.imdb.domain.exception.NotFoundException;
+import com.ludovictemgoua.imdb.domain.model.TitleCore;
+import com.ludovictemgoua.imdb.domain.repository.TitleRepository;
+import com.ludovictemgoua.imdb.domain.repository.WriteResult;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.BDDMockito.given;
+
+@ExtendWith(MockitoExtension.class)
+class TitleAdminUseCaseImplTest {
+
+ @Mock
+ TitleRepository titleRepository;
+
+ @Test
+ void createDelegatesToInsertTitle() {
+ var created = new TitleCore("tt0000300", "New", "New", "movie", 2024, null, 100, List.of(), null, null, 0);
+ given(titleRepository.insertTitle("New", "New", "movie", 2024, null, 100, List.of()))
+ .willReturn(created);
+
+ var result = new TitleAdminUseCaseImpl(titleRepository)
+ .create(new CreateTitleRequest("New", "New", "movie", 2024, null, 100, List.of()));
+
+ assertThat(result.id()).isEqualTo("tt0000300");
+ }
+
+ @Test
+ void updateThrowsConflictOnVersionMismatch() {
+ given(titleRepository.updateTitle(300, "New", "New", "movie", 2024, null, 100, List.of(), 0))
+ .willReturn(WriteResult.VERSION_CONFLICT);
+ var useCase = new TitleAdminUseCaseImpl(titleRepository);
+
+ assertThatThrownBy(() -> useCase.update("tt0000300",
+ new UpdateTitleRequest("New", "New", "movie", 2024, null, 100, List.of(), 0)))
+ .isInstanceOf(ConflictException.class);
+ }
+
+ @Test
+ void updateThrowsNotFoundWhenTheTitleDoesNotExist() {
+ given(titleRepository.updateTitle(300, "New", "New", "movie", 2024, null, 100, List.of(), 0))
+ .willReturn(WriteResult.NOT_FOUND);
+ var useCase = new TitleAdminUseCaseImpl(titleRepository);
+
+ assertThatThrownBy(() -> useCase.update("tt0000300",
+ new UpdateTitleRequest("New", "New", "movie", 2024, null, 100, List.of(), 0)))
+ .isInstanceOf(NotFoundException.class);
+ }
+
+ @Test
+ void deleteThrowsNotFoundWhenTheTitleDoesNotExist() {
+ given(titleRepository.softDeleteTitle(300)).willReturn(WriteResult.NOT_FOUND);
+ var useCase = new TitleAdminUseCaseImpl(titleRepository);
+
+ assertThatThrownBy(() -> useCase.delete("tt0000300")).isInstanceOf(NotFoundException.class);
+ }
+}
+```
+
+- [ ] **Step 2: Run the test to verify it fails**
+
+Run: `JAVA_HOME="/c/Program Files/Java/jdk-21" ./mvnw -q test -Dtest=TitleAdminUseCaseImplTest`
+Expected: FAIL - `TitleAdminUseCaseImpl`/the request records don't exist yet.
+
+- [ ] **Step 3: Create the request records**
+
+```java
+package com.ludovictemgoua.imdb.application;
+
+import jakarta.validation.constraints.NotBlank;
+
+import java.util.List;
+
+public record CreateTitleRequest(@NotBlank String primaryTitle, @NotBlank String originalTitle,
+ @NotBlank String titleType, Integer startYear, Integer endYear,
+ Integer runtimeMinutes, List genres) {
+}
+```
+
+```java
+package com.ludovictemgoua.imdb.application;
+
+import jakarta.validation.constraints.NotBlank;
+
+import java.util.List;
+
+public record UpdateTitleRequest(@NotBlank String primaryTitle, @NotBlank String originalTitle,
+ @NotBlank String titleType, Integer startYear, Integer endYear,
+ Integer runtimeMinutes, List genres, int version) {
+}
+```
+
+```java
+package com.ludovictemgoua.imdb.application;
+
+import java.util.List;
+
+// Every field nullable/absent - merge-patch semantics (docs/crud-expansion-design.md §6.5): only
+// fields present in the JSON body are applied, everything else is left as-is on the existing row.
+public record PatchTitleRequest(String primaryTitle, String originalTitle, String titleType,
+ Integer startYear, Integer endYear, Integer runtimeMinutes,
+ List genres, int version) {
+}
+```
+
+```java
+package com.ludovictemgoua.imdb.application;
+
+import java.util.List;
+
+public record CrewRequest(List directors, List writers) {
+}
+```
+
+```java
+package com.ludovictemgoua.imdb.application;
+
+import jakarta.validation.constraints.DecimalMax;
+import jakarta.validation.constraints.DecimalMin;
+import jakarta.validation.constraints.Min;
+
+public record RatingRequest(@DecimalMin("0.0") @DecimalMax("10.0") double averageRating,
+ @Min(0) int numVotes) {
+}
+```
+
+- [ ] **Step 4: Create `TitleAdminUseCase`/`TitleAdminUseCaseImpl`**
+
+```java
+package com.ludovictemgoua.imdb.application.contracts;
+
+import com.ludovictemgoua.imdb.application.rest.CreateTitleRequest;
+import com.ludovictemgoua.imdb.application.rest.CrewRequest;
+import com.ludovictemgoua.imdb.application.rest.UpdateTitleRequest;
+import com.ludovictemgoua.imdb.domain.model.TitleCore;
+
+public interface TitleAdminUseCase {
+
+ TitleCore create(CreateTitleRequest request);
+
+ TitleCore update(String titleId, UpdateTitleRequest request);
+
+ TitleCore patch(String titleId, com.ludovictemgoua.imdb.application.rest.PatchTitleRequest request);
+
+ void delete(String titleId);
+
+ void upsertCrew(String titleId, CrewRequest request);
+
+ void upsertRating(String titleId, com.ludovictemgoua.imdb.application.rest.RatingRequest request);
+
+ void deleteRating(String titleId);
+}
+```
+
+```java
+package com.ludovictemgoua.imdb.application;
+
+import com.ludovictemgoua.imdb.application.contracts.TitleAdminUseCase;
+import com.ludovictemgoua.imdb.application.rest.*;
+import com.ludovictemgoua.imdb.domain.exception.ConflictException;
+import com.ludovictemgoua.imdb.domain.exception.NotFoundException;
+import com.ludovictemgoua.imdb.domain.model.TitleCore;
+import com.ludovictemgoua.imdb.domain.repository.TitleRepository;
+import com.ludovictemgoua.imdb.domain.repository.WriteResult;
+import com.ludovictemgoua.imdb.utils.ImdbIds;
+import org.springframework.stereotype.Service;
+
+import java.util.List;
+import java.util.stream.Collectors;
+
+@Service
+public class TitleAdminUseCaseImpl implements TitleAdminUseCase {
+
+ private final TitleRepository titleRepository;
+
+ public TitleAdminUseCaseImpl(TitleRepository titleRepository) {
+ this.titleRepository = titleRepository;
+ }
+
+ @Override
+ public TitleCore create(CreateTitleRequest request) {
+ return titleRepository.insertTitle(request.primaryTitle(), request.originalTitle(), request.titleType(),
+ request.startYear(), request.endYear(), request.runtimeMinutes(), request.genres());
+ }
+
+ @Override
+ public TitleCore update(String titleId, UpdateTitleRequest request) {
+ int tconst = ImdbIds.parseTitleId(titleId);
+ handle(titleRepository.updateTitle(tconst, request.primaryTitle(), request.originalTitle(),
+ request.titleType(), request.startYear(), request.endYear(), request.runtimeMinutes(),
+ request.genres(), request.version()), titleId);
+ return titleRepository.findCore(tconst).orElseThrow();
+ }
+
+ @Override
+ public TitleCore patch(String titleId, PatchTitleRequest request) {
+ int tconst = ImdbIds.parseTitleId(titleId);
+ TitleCore current = titleRepository.findCore(tconst)
+ .orElseThrow(() -> new NotFoundException("No title with id " + titleId));
+ handle(titleRepository.updateTitle(tconst,
+ request.primaryTitle() != null ? request.primaryTitle() : current.primaryTitle(),
+ request.originalTitle() != null ? request.originalTitle() : current.originalTitle(),
+ request.titleType() != null ? request.titleType() : current.titleType(),
+ request.startYear() != null ? request.startYear() : current.startYear(),
+ request.endYear() != null ? request.endYear() : current.endYear(),
+ request.runtimeMinutes() != null ? request.runtimeMinutes() : current.runtimeMinutes(),
+ request.genres() != null ? request.genres() : current.genres(),
+ request.version()), titleId);
+ return titleRepository.findCore(tconst).orElseThrow();
+ }
+
+ @Override
+ public void delete(String titleId) {
+ handle(titleRepository.softDeleteTitle(ImdbIds.parseTitleId(titleId)), titleId);
+ }
+
+ @Override
+ public void upsertCrew(String titleId, CrewRequest request) {
+ int tconst = ImdbIds.parseTitleId(titleId);
+ List directorIds = toPersonIds(request.directors());
+ List writerIds = toPersonIds(request.writers());
+ handle(titleRepository.upsertCrew(tconst, directorIds, writerIds), titleId);
+ }
+
+ @Override
+ public void upsertRating(String titleId, RatingRequest request) {
+ handle(titleRepository.upsertRating(ImdbIds.parseTitleId(titleId),
+ request.averageRating(), request.numVotes()), titleId);
+ }
+
+ @Override
+ public void deleteRating(String titleId) {
+ handle(titleRepository.deleteRating(ImdbIds.parseTitleId(titleId)), titleId);
+ }
+
+ private static List toPersonIds(List personIds) {
+ return personIds == null ? List.of()
+ : personIds.stream().map(ImdbIds::parsePersonId).collect(Collectors.toList());
+ }
+
+ private static void handle(WriteResult result, String titleId) {
+ switch (result) {
+ case NOT_FOUND -> throw new NotFoundException("No title with id " + titleId);
+ case VERSION_CONFLICT -> throw new ConflictException(
+ "Title " + titleId + " was modified by someone else - refresh and retry");
+ case SUCCESS -> {
+ }
+ }
+ }
+}
+```
+
+- [ ] **Step 5: Run the test to verify it passes**
+
+Run: `JAVA_HOME="/c/Program Files/Java/jdk-21" ./mvnw -q test -Dtest=TitleAdminUseCaseImplTest`
+Expected: PASS, 4 tests green.
+
+- [ ] **Step 6: Create the cache-evicting decorator**
+
+```java
+package com.ludovictemgoua.imdb.infrastructure.cache;
+
+import com.ludovictemgoua.imdb.application.rest.CreateTitleRequest;
+import com.ludovictemgoua.imdb.application.rest.PatchTitleRequest;
+import com.ludovictemgoua.imdb.application.rest.RatingRequest;
+import com.ludovictemgoua.imdb.application.TitleAdminUseCaseImpl;
+import com.ludovictemgoua.imdb.application.contracts.TitleAdminUseCase;
+import com.ludovictemgoua.imdb.domain.model.TitleCore;
+import org.springframework.cache.annotation.CacheEvict;
+import org.springframework.cache.annotation.Caching;
+import org.springframework.context.annotation.Primary;
+import org.springframework.stereotype.Service;
+
+// Precise title-detail eviction on any write affecting that title; a rating write also clears the
+// entire top-rated region (allEntries) since there's no cheap way to know which genre/limit/minVotes
+// combinations it affects - the same coarse-but-correct trade-off documented in
+// docs/crud-expansion-design.md §6.2. Admin writes are expected to be infrequent, so full-region
+// eviction here is cheap in practice.
+@Service
+@Primary
+public class CachingTitleAdminUseCase implements TitleAdminUseCase {
+
+ private final TitleAdminUseCaseImpl delegate;
+
+ public CachingTitleAdminUseCase(TitleAdminUseCaseImpl delegate) {
+ this.delegate = delegate;
+ }
+
+ @Override
+ public TitleCore create(CreateTitleRequest request) {
+ return delegate.create(request);
+ }
+
+ @Override
+ @CacheEvict(cacheNames = "title-detail", key = "#titleId")
+ public TitleCore update(String titleId, com.ludovictemgoua.imdb.application.rest.UpdateTitleRequest request) {
+ return delegate.update(titleId, request);
+ }
+
+ @Override
+ @CacheEvict(cacheNames = "title-detail", key = "#titleId")
+ public TitleCore patch(String titleId, PatchTitleRequest request) {
+ return delegate.patch(titleId, request);
+ }
+
+ @Override
+ @CacheEvict(cacheNames = "title-detail", key = "#titleId")
+ public void delete(String titleId) {
+ delegate.delete(titleId);
+ }
+
+ @Override
+ @CacheEvict(cacheNames = "title-detail", key = "#titleId")
+ public void upsertCrew(String titleId, com.ludovictemgoua.imdb.application.rest.CrewRequest request) {
+ delegate.upsertCrew(titleId, request);
+ }
+
+ @Override
+ @Caching(evict = {
+ @CacheEvict(cacheNames = "title-detail", key = "#titleId"),
+ @CacheEvict(cacheNames = "top-rated", allEntries = true)
+ })
+ public void upsertRating(String titleId, RatingRequest request) {
+ delegate.upsertRating(titleId, request);
+ }
+
+ @Override
+ @Caching(evict = {
+ @CacheEvict(cacheNames = "title-detail", key = "#titleId"),
+ @CacheEvict(cacheNames = "top-rated", allEntries = true)
+ })
+ public void deleteRating(String titleId) {
+ delegate.deleteRating(titleId);
+ }
+}
+```
+
+- [ ] **Step 7: Write the failing controller test additions**
+
+Add to `TitleControllerTest` (needs a new `@MockitoBean TitleAdminUseCase titleAdminUseCase;` field and
+`@Import(...)` of nothing extra - `@WebMvcTest` auto-mocks any constructor dependency not already present):
+
+```java
+ @org.springframework.test.context.bean.override.mockito.MockitoBean
+ com.ludovictemgoua.imdb.application.contracts.TitleAdminUseCase titleAdminUseCase;
+
+ @Test
+ void createTitleRequiresAdminRole() throws Exception {
+ mockMvc.perform(post("/api/v1/titles")
+ .with(org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user("1").roles("USER"))
+ .contentType("application/json")
+ .content("""
+ {"primaryTitle":"New","originalTitle":"New","titleType":"movie","genres":[]}
+ """))
+ .andExpect(status().isForbidden());
+ }
+
+ @Test
+ void createTitleSucceedsForAdmin() throws Exception {
+ var created = new com.ludovictemgoua.imdb.domain.model.TitleCore(
+ "tt0000300", "New", "New", "movie", 2024, null, 100, List.of(), null, null, 0);
+ given(titleAdminUseCase.create(org.mockito.ArgumentMatchers.any())).willReturn(created);
+
+ mockMvc.perform(post("/api/v1/titles")
+ .with(org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user("1").roles("ADMIN"))
+ .contentType("application/json")
+ .content("""
+ {"primaryTitle":"New","originalTitle":"New","titleType":"movie","genres":[]}
+ """))
+ .andExpect(status().isCreated())
+ .andExpect(jsonPath("$.id").value("tt0000300"));
+ }
+```
+
+Add `import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;` and
+`import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;` to the test's
+imports (`post`/`get` may already partially be there - add whichever verbs are missing).
+
+- [ ] **Step 8: Run the test to verify it fails**
+
+Run: `JAVA_HOME="/c/Program Files/Java/jdk-21" ./mvnw -q test -Dtest=TitleControllerTest`
+Expected: FAIL - `TitleController` has no `POST /api/v1/titles` mapping yet.
+
+- [ ] **Step 9: Add the admin endpoints to `TitleController`**
+
+```java
+ import com.ludovictemgoua.imdb.application.rest.*;
+
+private final TitleAdminUseCase titleAdminUseCase;
+
+public TitleController(TitleSearchUseCase searchUseCase, TitleDetailUseCase detailUseCase,
+ TitleAdminUseCase titleAdminUseCase) {
+ this.searchUseCase = searchUseCase;
+ this.detailUseCase = detailUseCase;
+ this.titleAdminUseCase = titleAdminUseCase;
+}
+
+@PostMapping
+@org.springframework.http.HttpStatus.CREATED // placeholder marker removed below - see @ResponseStatus line
+@org.springframework.web.bind.annotation.ResponseStatus(org.springframework.http.HttpStatus.CREATED)
+@org.springframework.security.access.prepost.PreAuthorize("hasRole('ADMIN')")
+public TitleCore create(@jakarta.validation.Valid @org.springframework.web.bind.annotation.RequestBody
+ com.ludovictemgoua.imdb.application.rest.CreateTitleRequest request) {
+ return titleAdminUseCase.create(request);
+}
+
+@org.springframework.web.bind.annotation.PutMapping("/{titleId}")
+@org.springframework.security.access.prepost.PreAuthorize("hasRole('ADMIN')")
+public TitleCore update(@PathVariable String titleId,
+ @jakarta.validation.Valid @org.springframework.web.bind.annotation.RequestBody
+ com.ludovictemgoua.imdb.application.rest.UpdateTitleRequest request) {
+ return titleAdminUseCase.update(titleId, request);
+}
+
+@org.springframework.web.bind.annotation.PatchMapping("/{titleId}")
+@org.springframework.security.access.prepost.PreAuthorize("hasRole('ADMIN')")
+public TitleCore patch(@PathVariable String titleId,
+ @org.springframework.web.bind.annotation.RequestBody
+ com.ludovictemgoua.imdb.application.rest.PatchTitleRequest request) {
+ return titleAdminUseCase.patch(titleId, request);
+}
+
+@org.springframework.web.bind.annotation.DeleteMapping("/{titleId}")
+@org.springframework.web.bind.annotation.ResponseStatus(org.springframework.http.HttpStatus.NO_CONTENT)
+@org.springframework.security.access.prepost.PreAuthorize("hasRole('ADMIN')")
+public void delete(@PathVariable String titleId) {
+ titleAdminUseCase.delete(titleId);
+}
+
+@org.springframework.web.bind.annotation.PutMapping("/{titleId}/crew")
+@org.springframework.security.access.prepost.PreAuthorize("hasRole('ADMIN')")
+public void upsertCrew(@PathVariable String titleId,
+ @org.springframework.web.bind.annotation.RequestBody
+ com.ludovictemgoua.imdb.application.rest.CrewRequest request) {
+ titleAdminUseCase.upsertCrew(titleId, request);
+}
+
+@org.springframework.web.bind.annotation.PutMapping("/{titleId}/rating")
+@org.springframework.security.access.prepost.PreAuthorize("hasRole('ADMIN')")
+public void upsertRating(@PathVariable String titleId,
+ @jakarta.validation.Valid @org.springframework.web.bind.annotation.RequestBody
+ com.ludovictemgoua.imdb.application.rest.RatingRequest request) {
+ titleAdminUseCase.upsertRating(titleId, request);
+}
+
+@org.springframework.web.bind.annotation.DeleteMapping("/{titleId}/rating")
+@org.springframework.web.bind.annotation.ResponseStatus(org.springframework.http.HttpStatus.NO_CONTENT)
+@org.springframework.security.access.prepost.PreAuthorize("hasRole('ADMIN')")
+public void deleteRating(@PathVariable String titleId) {
+ titleAdminUseCase.deleteRating(titleId);
+}
+```
+
+Replace the fully-qualified names above with proper `import` statements at the top of the file (matching
+the file's existing style - it already imports `PagedResult`/`TitleDetail`/`TitleSummary` etc. by name, so
+do the same for every class used above: `TitleCore`, `TitleAdminUseCase`, `CreateTitleRequest`,
+`UpdateTitleRequest`, `PatchTitleRequest`, `CrewRequest`, `RatingRequest`, `PostMapping`, `PutMapping`,
+`PatchMapping`, `DeleteMapping`, `ResponseStatus`, `RequestBody`, `HttpStatus`, `Valid`, `PreAuthorize`).
+Delete the stray `@org.springframework.http.HttpStatus.CREATED` marker line above - it isn't valid
+annotation syntax and was left in only to flag "this is where `@ResponseStatus(CREATED)` goes" during
+planning; the real code has just the one `@ResponseStatus` line beneath it.
+
+- [ ] **Step 10: Run the test to verify it passes**
+
+Run: `JAVA_HOME="/c/Program Files/Java/jdk-21" ./mvnw -q test -Dtest=TitleControllerTest`
+Expected: PASS, all tests (existing + new) green.
+
+- [ ] **Step 11: Run the full unit suite, then the full integration suite**
+
+Run: `JAVA_HOME="/c/Program Files/Java/jdk-21" ./mvnw -q test && JAVA_HOME="/c/Program Files/Java/jdk-21" ./mvnw -q failsafe:integration-test failsafe:verify`
+Expected: PASS.
+
+- [ ] **Step 12: Commit**
+
+```bash
+git add src/main/java/com/ludovictemgoua/imdb/application/ src/main/java/com/ludovictemgoua/imdb/infrastructure/cache/CachingTitleAdminUseCase.java src/main/java/com/ludovictemgoua/imdb/presentation/TitleController.java src/test/java/com/ludovictemgoua/imdb/application/TitleAdminUseCaseImplTest.java src/test/java/com/ludovictemgoua/imdb/presentation/TitleControllerTest.java
+git commit -m "Add admin CRUD for titles (create/update/patch/delete/crew/rating) with cache eviction"
+```
+
+### Task 3.3: Reduce the `title-search` cache TTL
+
+**Files:**
+- Modify: `src/main/java/com/ludovictemgoua/imdb/infrastructure/cache/CacheConfig.java`
+
+**Interfaces:** none new - this only changes a `RedisCacheConfiguration` applied to the existing
+`title-search` cache region.
+
+`title-search` is keyed by `query:page:size` (LLD §6) - an admin title write has no cheap way to know
+which of those arbitrary combinations it affects, so precise eviction (like `title-detail`) isn't
+possible and full-region eviction on every title write would defeat the cache almost entirely (title
+writes and title searches share the same cache region's traffic). `docs/crud-expansion-design.md` §6.2's
+resolution: accept a bounded staleness window instead, by shortening this one region's TTL from the
+default 24h to 15 minutes.
+
+- [ ] **Step 1: Write the failing test**
+
+```java
+package com.ludovictemgoua.imdb.infrastructure.cache;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.data.redis.cache.RedisCacheManager;
+
+import java.time.Duration;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+@SpringBootTest
+class CacheConfigTest {
+
+ @Autowired
+ RedisCacheManager cacheManager;
+
+ @Test
+ void titleSearchCacheHasAFifteenMinuteTtlNotTheDefaultTwentyFourHours() {
+ var ttl = cacheManager.getCache("title-search").getNativeCache().toString();
+ // RedisCache doesn't expose its configured TTL directly via a public getter on getNativeCache();
+ // assert against the cache's own configuration object instead:
+ var config = ((org.springframework.data.redis.cache.RedisCache) cacheManager.getCache("title-search"))
+ .getCacheConfiguration();
+
+ assertThat(config.getTtl()).isEqualTo(Duration.ofMinutes(15));
+ }
+
+ @Test
+ void titleDetailCacheKeepsTheDefaultTwentyFourHourTtl() {
+ var config = ((org.springframework.data.redis.cache.RedisCache) cacheManager.getCache("title-detail"))
+ .getCacheConfiguration();
+
+ assertThat(config.getTtl()).isEqualTo(Duration.ofHours(24));
+ }
+}
+```
+
+This test needs a real Spring context with the actual `RedisCacheManager` bean (not Testcontainers Redis
+specifically - `@SpringBootTest` alone will fail to start without a Redis connection, so add
+`@Import(com.ludovictemgoua.imdb.TestcontainersConfiguration.class)` to the class, matching every other
+`@SpringBootTest` in this codebase).
+
+- [ ] **Step 2: Run the test to verify it fails**
+
+Run: `JAVA_HOME="/c/Program Files/Java/jdk-21" ./mvnw -q failsafe:integration-test failsafe:verify -Dit.test=CacheConfigTest`
+Expected: FAIL - `title-search` still has the default 24h TTL. (Note: this test class needs to be named
+`*IntegrationTest.java` or added to the Failsafe `` pattern in `pom.xml` to actually run under
+`failsafe:verify` - rename it to `CacheConfigIntegrationTest` to match the existing convention rather than
+adding a one-off Failsafe include pattern for a single class.)
+
+- [ ] **Step 3: Add the per-cache TTL override**
+
+```java
+ RedisCacheConfiguration searchCacheConfig = defaults.entryTtl(Duration.ofMinutes(15));
+
+ return RedisCacheManager.builder(redisCacheWriter)
+ .cacheDefaults(defaults)
+ .withCacheConfiguration("title-search", searchCacheConfig)
+ .initialCacheNames(CACHE_NAMES)
+ .build();
+```
+
+(Replace the existing `return RedisCacheManager.builder(redisCacheWriter)...build();` block in
+`cacheManager(...)` with the above - `searchCacheConfig` is `defaults` with just the TTL overridden,
+inheriting the same serializer configuration.)
+
+- [ ] **Step 4: Run the test to verify it passes**
+
+Run: `JAVA_HOME="/c/Program Files/Java/jdk-21" ./mvnw -q failsafe:integration-test failsafe:verify -Dit.test=CacheConfigIntegrationTest`
+Expected: PASS, both tests green.
+
+- [ ] **Step 5: Run the full unit and integration suites**
+
+Run: `JAVA_HOME="/c/Program Files/Java/jdk-21" ./mvnw -q test && JAVA_HOME="/c/Program Files/Java/jdk-21" ./mvnw -q failsafe:integration-test failsafe:verify`
+Expected: PASS.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add src/main/java/com/ludovictemgoua/imdb/infrastructure/cache/CacheConfig.java src/test/java/com/ludovictemgoua/imdb/infrastructure/cache/CacheConfigIntegrationTest.java
+git commit -m "Shorten the title-search cache TTL to 15 minutes now that title writes exist"
+```
+
+**Phase 3 checkpoint**: admin title CRUD is fully live, gated by `@PreAuthorize("hasRole('ADMIN')")`, with
+correct cache eviction (precise for `title-detail`/`top-rated`, a shortened TTL for the un-evictable
+`title-search`). This is the template Phase 4 (People) follows.
+
+---
+
+## Phase 4: Admin CRUD - People
+
+### Task 4.1: `PersonCore`, `PersonRepository` write methods
+
+**Files:**
+- Create: `src/main/java/com/ludovictemgoua/imdb/domain/model/PersonCore.java`
+- Modify: `src/main/java/com/ludovictemgoua/imdb/domain/repository/PersonRepository.java`
+- Modify: `src/main/java/com/ludovictemgoua/imdb/infrastructure/persistence/JdbcPersonRepository.java`
+- Test: `src/test/java/com/ludovictemgoua/imdb/infrastructure/persistence/JdbcPersonRepositoryIntegrationTest.java` (create if Task 2.2 didn't already, extend either way)
+
+**Interfaces:**
+- Produces: `PersonCore(String id, String primaryName, Integer birthYear, Integer deathYear, List primaryProfession, int version)`. `PersonRepository.findCore(int nconst) -> Optional`, `insertPerson(...) -> PersonCore`, `updatePerson(int nconst, ..., int expectedVersion) -> WriteResult`, `softDeletePerson(int nconst) -> WriteResult`. This `findCore` is internal-only (no controller exposes a plain "get person" read endpoint - matches `docs/crud-expansion-design.md` §5.2 exactly, which lists only the four write endpoints).
+
+- [ ] **Step 1: Write the failing integration tests**
+
+```java
+ @Test
+ void insertPersonThenFindCoreRoundTrips() {
+ var created = repository.insertPerson("Ada Lovelace", 1815, 1852, List.of("mathematician"));
+
+ var found = repository.findCore(ImdbIds.parsePersonId(created.id())).orElseThrow();
+
+ assertThat(found.primaryName()).isEqualTo("Ada Lovelace");
+ assertThat(found.version()).isEqualTo(0);
+ }
+
+ @Test
+ void insertedPersonIdIsAboveTheSeededRange() {
+ var created = repository.insertPerson("New Person", null, null, List.of());
+
+ assertThat(ImdbIds.parsePersonId(created.id())).isGreaterThan(10);
+ }
+
+ @Test
+ void updatePersonBumpsVersionAndPersists() {
+ var created = repository.insertPerson("Old Name", null, null, List.of());
+ int nconst = ImdbIds.parsePersonId(created.id());
+
+ var result = repository.updatePerson(nconst, "New Name", 1990, null, List.of("actor"), created.version());
+
+ assertThat(result).isEqualTo(com.ludovictemgoua.imdb.domain.repository.WriteResult.SUCCESS);
+ assertThat(repository.findCore(nconst).orElseThrow().primaryName()).isEqualTo("New Name");
+ }
+
+ @Test
+ void updatePersonReturnsVersionConflictOnStaleVersion() {
+ var created = repository.insertPerson("Stale", null, null, List.of());
+ int nconst = ImdbIds.parsePersonId(created.id());
+
+ var result = repository.updatePerson(nconst, "New Name", null, null, List.of(), created.version() + 1);
+
+ assertThat(result).isEqualTo(com.ludovictemgoua.imdb.domain.repository.WriteResult.VERSION_CONFLICT);
+ }
+
+ @Test
+ void softDeletePersonExcludesThemFromFindCore() {
+ var created = repository.insertPerson("Delete Me", null, null, List.of());
+ int nconst = ImdbIds.parsePersonId(created.id());
+
+ repository.softDeletePerson(nconst);
+
+ assertThat(repository.findCore(nconst)).isEmpty();
+ }
+```
+
+If `JdbcPersonRepositoryIntegrationTest` doesn't exist yet, create it now with the full class structure
+(copy `JdbcTitleRepositoryIntegrationTest`'s header exactly:
+`@Import(TestcontainersConfiguration.class) @SpringBootTest @Transactional @Sql("/fixtures/fixture-data.sql")`,
+autowiring `JdbcPersonRepository repository`), and include Task 2.2's `findByNameExcludesASoftDeletedPerson`
+test in it too if that's still pending.
+
+- [ ] **Step 2: Run the tests to verify they fail**
+
+Run: `JAVA_HOME="/c/Program Files/Java/jdk-21" ./mvnw -q failsafe:integration-test failsafe:verify -Dit.test=JdbcPersonRepositoryIntegrationTest`
+Expected: FAIL - `PersonCore`/the new repository methods don't exist yet.
+
+- [ ] **Step 3: Create `PersonCore`**
+
+```java
+package com.ludovictemgoua.imdb.domain.model;
+
+import java.util.List;
+
+public record PersonCore(String id, String primaryName, Integer birthYear, Integer deathYear,
+ List primaryProfession, int version) {
+}
+```
+
+- [ ] **Step 4: Add the new methods to `PersonRepository`**
+
+```java
+ PersonCore insertPerson(String primaryName, Integer birthYear, Integer deathYear, List primaryProfession);
+
+ Optional findCore(int nconst);
+
+ com.ludovictemgoua.imdb.domain.repository.WriteResult updatePerson(
+ int nconst, String primaryName, Integer birthYear, Integer deathYear,
+ List primaryProfession, int expectedVersion);
+
+ com.ludovictemgoua.imdb.domain.repository.WriteResult softDeletePerson(int nconst);
+```
+
+Add `import com.ludovictemgoua.imdb.domain.model.PersonCore;`, `import java.util.List;`, and
+`import java.util.Optional;` to the top of `PersonRepository.java` (replace the fully-qualified
+`WriteResult` references above with a plain `import com.ludovictemgoua.imdb.domain.repository.WriteResult;`
+- it's the same package as this file, so a bare `WriteResult` reference works with no import at all;
+either is fine, prefer the bare reference since it's simpler).
+
+- [ ] **Step 5: Implement the new methods in `JdbcPersonRepository`**
+
+```java
+ @Override
+ public PersonCore insertPerson(String primaryName, Integer birthYear, Integer deathYear,
+ List primaryProfession) {
+ String sql = """
+ INSERT INTO name_basics (nconst, primary_name, birth_year, death_year, primary_profession)
+ VALUES (nextval('person_id_seq'), :primaryName, :birthYear, :deathYear, :primaryProfession)
+ RETURNING nconst
+ """;
+ var params = new MapSqlParameterSource()
+ .addValue("primaryName", primaryName).addValue("birthYear", birthYear)
+ .addValue("deathYear", deathYear)
+ .addValue("primaryProfession", primaryProfession.toArray(new String[0]), java.sql.Types.ARRAY, "text");
+ int nconst = jdbc.queryForObject(sql, params, Integer.class);
+ return findCore(nconst).orElseThrow();
+ }
+
+ @Override
+ public Optional findCore(int nconst) {
+ String sql = """
+ SELECT nconst, primary_name, birth_year, death_year, primary_profession, version
+ FROM name_basics WHERE nconst = :nconst AND deleted_at IS NULL
+ """;
+ return jdbc.query(sql, Map.of("nconst", nconst), JdbcPersonRepository::mapCore).stream().findFirst();
+ }
+
+ @Override
+ public WriteResult updatePerson(int nconst, String primaryName, Integer birthYear, Integer deathYear,
+ List primaryProfession, int expectedVersion) {
+ if (findCore(nconst).isEmpty()) {
+ return WriteResult.NOT_FOUND;
+ }
+ String sql = """
+ UPDATE name_basics
+ SET primary_name = :primaryName, birth_year = :birthYear, death_year = :deathYear,
+ primary_profession = :primaryProfession, version = version + 1
+ WHERE nconst = :nconst AND version = :expectedVersion AND deleted_at IS NULL
+ """;
+ var params = new MapSqlParameterSource()
+ .addValue("primaryName", primaryName).addValue("birthYear", birthYear)
+ .addValue("deathYear", deathYear)
+ .addValue("primaryProfession", primaryProfession.toArray(new String[0]), java.sql.Types.ARRAY, "text")
+ .addValue("nconst", nconst).addValue("expectedVersion", expectedVersion);
+ return jdbc.update(sql, params) == 0 ? WriteResult.VERSION_CONFLICT : WriteResult.SUCCESS;
+ }
+
+ @Override
+ public WriteResult softDeletePerson(int nconst) {
+ if (findCore(nconst).isEmpty()) {
+ return WriteResult.NOT_FOUND;
+ }
+ jdbc.update("UPDATE name_basics SET deleted_at = now() WHERE nconst = :nconst", Map.of("nconst", nconst));
+ return WriteResult.SUCCESS;
+ }
+
+ private static PersonCore mapCore(ResultSet rs, int rowNum) throws SQLException {
+ return new PersonCore(ImdbIds.formatPersonId(rs.getInt("nconst")), rs.getString("primary_name"),
+ (Integer) rs.getObject("birth_year"), (Integer) rs.getObject("death_year"),
+ toStringList(rs.getArray("primary_profession")), rs.getInt("version"));
+ }
+
+ private static List toStringList(java.sql.Array sqlArray) throws SQLException {
+ if (sqlArray == null) return List.of();
+ return List.of((String[]) sqlArray.getArray());
+ }
+```
+
+Add `import com.ludovictemgoua.imdb.domain.model.PersonCore;` and
+`import com.ludovictemgoua.imdb.domain.repository.WriteResult;` to `JdbcPersonRepository.java`'s imports.
+
+- [ ] **Step 6: Run the tests to verify they pass**
+
+Run: `JAVA_HOME="/c/Program Files/Java/jdk-21" ./mvnw -q failsafe:integration-test failsafe:verify -Dit.test=JdbcPersonRepositoryIntegrationTest`
+Expected: PASS.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add src/main/java/com/ludovictemgoua/imdb/domain/model/PersonCore.java src/main/java/com/ludovictemgoua/imdb/domain/repository/PersonRepository.java src/main/java/com/ludovictemgoua/imdb/infrastructure/persistence/JdbcPersonRepository.java src/test/java/com/ludovictemgoua/imdb/infrastructure/persistence/JdbcPersonRepositoryIntegrationTest.java
+git commit -m "Add PersonRepository write methods: insert/update/soft-delete"
+```
+
+### Task 4.2: `PersonAdminUseCase`, `PersonController` admin endpoints
+
+**Files:**
+- Create: `src/main/java/com/ludovictemgoua/imdb/application/contracts/PersonAdminUseCase.java`
+- Create: `src/main/java/com/ludovictemgoua/imdb/application/PersonAdminUseCaseImpl.java`
+- Create: `src/main/java/com/ludovictemgoua/imdb/application/CreatePersonRequest.java`, `UpdatePersonRequest.java`, `PatchPersonRequest.java` (records)
+- Modify: `src/main/java/com/ludovictemgoua/imdb/presentation/PersonController.java`
+- Test: `src/test/java/com/ludovictemgoua/imdb/application/PersonAdminUseCaseImplTest.java`
+- Test: `src/test/java/com/ludovictemgoua/imdb/presentation/PersonControllerTest.java` (extend)
+
+**Interfaces:**
+- Consumes: `PersonRepository` write methods (Task 4.1)
+- Produces: `PersonAdminUseCase.create/update/patch/delete`, all four gated `@PreAuthorize("hasRole('ADMIN')")`
+ on `PersonController`. No cache eviction needed here directly (nothing caches a person by id today - the
+ `six-degrees` cache is evicted from `CachingCoStarGraphRepository`, wired in Phase 5 alongside principals,
+ since that's the cache region a person write can actually invalidate).
+
+- [ ] **Step 1: Write the failing unit test**
+
+```java
+package com.ludovictemgoua.imdb.application;
+
+import com.ludovictemgoua.imdb.application.rest.CreatePersonRequest;
+import com.ludovictemgoua.imdb.application.rest.UpdatePersonRequest;
+import com.ludovictemgoua.imdb.domain.exception.ConflictException;
+import com.ludovictemgoua.imdb.domain.exception.NotFoundException;
+import com.ludovictemgoua.imdb.domain.model.PersonCore;
+import com.ludovictemgoua.imdb.domain.repository.PersonRepository;
+import com.ludovictemgoua.imdb.domain.repository.WriteResult;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.BDDMockito.given;
+
+@ExtendWith(MockitoExtension.class)
+class PersonAdminUseCaseImplTest {
+
+ @Mock
+ PersonRepository personRepository;
+
+ @Test
+ void createDelegatesToInsertPerson() {
+ var created = new PersonCore("nm0000011", "New Person", null, null, List.of(), 0);
+ given(personRepository.insertPerson("New Person", null, null, List.of())).willReturn(created);
+
+ var result = new PersonAdminUseCaseImpl(personRepository)
+ .create(new CreatePersonRequest("New Person", null, null, List.of()));
+
+ assertThat(result.id()).isEqualTo("nm0000011");
+ }
+
+ @Test
+ void updateThrowsConflictOnVersionMismatch() {
+ given(personRepository.updatePerson(11, "New", null, null, List.of(), 0))
+ .willReturn(WriteResult.VERSION_CONFLICT);
+ var useCase = new PersonAdminUseCaseImpl(personRepository);
+
+ assertThatThrownBy(() -> useCase.update("nm0000011",
+ new UpdatePersonRequest("New", null, null, List.of(), 0)))
+ .isInstanceOf(ConflictException.class);
+ }
+
+ @Test
+ void deleteThrowsNotFoundWhenThePersonDoesNotExist() {
+ given(personRepository.softDeletePerson(11)).willReturn(WriteResult.NOT_FOUND);
+ var useCase = new PersonAdminUseCaseImpl(personRepository);
+
+ assertThatThrownBy(() -> useCase.delete("nm0000011")).isInstanceOf(NotFoundException.class);
+ }
+}
+```
+
+- [ ] **Step 2: Run the test to verify it fails**
+
+Run: `JAVA_HOME="/c/Program Files/Java/jdk-21" ./mvnw -q test -Dtest=PersonAdminUseCaseImplTest`
+Expected: FAIL.
+
+- [ ] **Step 3: Create the request records**
+
+```java
+package com.ludovictemgoua.imdb.application;
+
+import jakarta.validation.constraints.NotBlank;
+
+import java.util.List;
+
+public record CreatePersonRequest(@NotBlank String primaryName, Integer birthYear, Integer deathYear,
+ List primaryProfession) {
+}
+```
+
+```java
+package com.ludovictemgoua.imdb.application;
+
+import jakarta.validation.constraints.NotBlank;
+
+import java.util.List;
+
+public record UpdatePersonRequest(@NotBlank String primaryName, Integer birthYear, Integer deathYear,
+ List primaryProfession, int version) {
+}
+```
+
+```java
+package com.ludovictemgoua.imdb.application;
+
+import java.util.List;
+
+public record PatchPersonRequest(String primaryName, Integer birthYear, Integer deathYear,
+ List primaryProfession, int version) {
+}
+```
+
+- [ ] **Step 4: Create `PersonAdminUseCase`/`PersonAdminUseCaseImpl`**
+
+```java
+package com.ludovictemgoua.imdb.application.contracts;
+
+com.ludovictemgoua.imdb.application.rest.PatchPersonRequest;
+import com.ludovictemgoua.imdb.domain.model.PersonCore;
+
+public interface PersonAdminUseCase {
+
+ PersonCore create(com.ludovictemgoua.imdb.application.rest.CreatePersonRequest request);
+
+ PersonCore update(String personId, com.ludovictemgoua.imdb.application.rest.UpdatePersonRequest request);
+
+ PersonCore patch(String personId, PatchPersonRequest request);
+
+ void delete(String personId);
+}
+```
+
+```java
+package com.ludovictemgoua.imdb.application;
+
+import com.ludovictemgoua.imdb.application.contracts.PersonAdminUseCase;
+import com.ludovictemgoua.imdb.application.rest.CreatePersonRequest;
+import com.ludovictemgoua.imdb.application.rest.PatchPersonRequest;
+import com.ludovictemgoua.imdb.application.rest.UpdatePersonRequest;
+import com.ludovictemgoua.imdb.domain.exception.ConflictException;
+import com.ludovictemgoua.imdb.domain.exception.NotFoundException;
+import com.ludovictemgoua.imdb.domain.model.PersonCore;
+import com.ludovictemgoua.imdb.domain.repository.PersonRepository;
+import com.ludovictemgoua.imdb.domain.repository.WriteResult;
+import com.ludovictemgoua.imdb.utils.ImdbIds;
+import org.springframework.stereotype.Service;
+
+@Service
+public class PersonAdminUseCaseImpl implements PersonAdminUseCase {
+
+ private final PersonRepository personRepository;
+
+ public PersonAdminUseCaseImpl(PersonRepository personRepository) {
+ this.personRepository = personRepository;
+ }
+
+ @Override
+ public PersonCore create(CreatePersonRequest request) {
+ return personRepository.insertPerson(
+ request.primaryName(), request.birthYear(), request.deathYear(), request.primaryProfession());
+ }
+
+ @Override
+ public PersonCore update(String personId, UpdatePersonRequest request) {
+ int nconst = ImdbIds.parsePersonId(personId);
+ handle(personRepository.updatePerson(nconst, request.primaryName(), request.birthYear(),
+ request.deathYear(), request.primaryProfession(), request.version()), personId);
+ return personRepository.findCore(nconst).orElseThrow();
+ }
+
+ @Override
+ public PersonCore patch(String personId, PatchPersonRequest request) {
+ int nconst = ImdbIds.parsePersonId(personId);
+ PersonCore current = personRepository.findCore(nconst)
+ .orElseThrow(() -> new NotFoundException("No person with id " + personId));
+ handle(personRepository.updatePerson(nconst,
+ request.primaryName() != null ? request.primaryName() : current.primaryName(),
+ request.birthYear() != null ? request.birthYear() : current.birthYear(),
+ request.deathYear() != null ? request.deathYear() : current.deathYear(),
+ request.primaryProfession() != null ? request.primaryProfession() : current.primaryProfession(),
+ request.version()), personId);
+ return personRepository.findCore(nconst).orElseThrow();
+ }
+
+ @Override
+ public void delete(String personId) {
+ handle(personRepository.softDeletePerson(ImdbIds.parsePersonId(personId)), personId);
+ }
+
+ private static void handle(WriteResult result, String personId) {
+ switch (result) {
+ case NOT_FOUND -> throw new NotFoundException("No person with id " + personId);
+ case VERSION_CONFLICT -> throw new ConflictException(
+ "Person " + personId + " was modified by someone else - refresh and retry");
+ case SUCCESS -> {
+ }
+ }
+ }
+}
+```
+
+- [ ] **Step 5: Run the test to verify it passes**
+
+Run: `JAVA_HOME="/c/Program Files/Java/jdk-21" ./mvnw -q test -Dtest=PersonAdminUseCaseImplTest`
+Expected: PASS, 3 tests green.
+
+- [ ] **Step 6: Write the failing controller test additions**
+
+Add to `PersonControllerTest` (create the file with the full `@WebMvcTest(PersonController.class)`
+structure if it doesn't exist yet, mirroring `TitleControllerTest`):
+
+```java
+ @org.springframework.test.context.bean.override.mockito.MockitoBean
+ com.ludovictemgoua.imdb.application.contracts.PersonAdminUseCase personAdminUseCase;
+
+ @Test
+ void createPersonRequiresAdminRole() throws Exception {
+ mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/v1/people")
+ .with(org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user("1").roles("USER"))
+ .contentType("application/json")
+ .content("""
+ {"primaryName":"New Person","primaryProfession":[]}
+ """))
+ .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.status().isForbidden());
+ }
+```
+
+- [ ] **Step 7: Run the test to verify it fails**
+
+Run: `JAVA_HOME="/c/Program Files/Java/jdk-21" ./mvnw -q test -Dtest=PersonControllerTest`
+Expected: FAIL - `PersonController` has no `POST /api/v1/people` mapping yet.
+
+- [ ] **Step 8: Add the admin endpoints to `PersonController`**
+
+Add these imports: `com.ludovictemgoua.imdb.application.contracts.PersonAdminUseCase`,
+`com.ludovictemgoua.imdb.application.rest.CreatePersonRequest`, `UpdatePersonRequest`, `PatchPersonRequest`,
+`com.ludovictemgoua.imdb.domain.model.PersonCore`, `jakarta.validation.Valid`,
+`org.springframework.http.HttpStatus`, `org.springframework.security.access.prepost.PreAuthorize`,
+`org.springframework.web.bind.annotation.{PostMapping,PutMapping,PatchMapping,DeleteMapping,RequestBody,ResponseStatus}`:
+
+```java
+ private final PersonAdminUseCase personAdminUseCase;
+
+ public PersonController(SixDegreesUseCase sixDegreesUseCase, PersonAdminUseCase personAdminUseCase) {
+ this.sixDegreesUseCase = sixDegreesUseCase;
+ this.personAdminUseCase = personAdminUseCase;
+ }
+
+ @PostMapping
+ @ResponseStatus(HttpStatus.CREATED)
+ @PreAuthorize("hasRole('ADMIN')")
+ public PersonCore create(@Valid @RequestBody CreatePersonRequest request) {
+ return personAdminUseCase.create(request);
+ }
+
+ @PutMapping("/{personId}")
+ @PreAuthorize("hasRole('ADMIN')")
+ public PersonCore update(@PathVariable String personId, @Valid @RequestBody UpdatePersonRequest request) {
+ return personAdminUseCase.update(personId, request);
+ }
+
+ @PatchMapping("/{personId}")
+ @PreAuthorize("hasRole('ADMIN')")
+ public PersonCore patch(@PathVariable String personId, @RequestBody PatchPersonRequest request) {
+ return personAdminUseCase.patch(personId, request);
+ }
+
+ @DeleteMapping("/{personId}")
+ @ResponseStatus(HttpStatus.NO_CONTENT)
+ @PreAuthorize("hasRole('ADMIN')")
+ public void delete(@PathVariable String personId) {
+ personAdminUseCase.delete(personId);
+ }
+```
+
+(Add `import org.springframework.web.bind.annotation.PathVariable;` too if not already present in this
+file - it likely isn't, since the existing `sixDegrees` method takes only `@RequestParam`s.)
+
+- [ ] **Step 9: Run the test to verify it passes**
+
+Run: `JAVA_HOME="/c/Program Files/Java/jdk-21" ./mvnw -q test -Dtest=PersonControllerTest`
+Expected: PASS.
+
+- [ ] **Step 10: Run the full suites**
+
+Run: `JAVA_HOME="/c/Program Files/Java/jdk-21" ./mvnw -q test && JAVA_HOME="/c/Program Files/Java/jdk-21" ./mvnw -q failsafe:integration-test failsafe:verify`
+Expected: PASS.
+
+- [ ] **Step 11: Commit**
+
+```bash
+git add src/main/java/com/ludovictemgoua/imdb/application/ src/main/java/com/ludovictemgoua/imdb/presentation/PersonController.java src/test/java/com/ludovictemgoua/imdb/application/PersonAdminUseCaseImplTest.java src/test/java/com/ludovictemgoua/imdb/presentation/PersonControllerTest.java
+git commit -m "Add admin CRUD for people (create/update/patch/delete)"
+```
+
+---
+
+## Phase 5: Admin CRUD - Cast/Crew Credits (Principals)
+
+`title_principals`' primary key is the composite `(tconst, ordering)` (`V0__base_schema.sql`) - no
+surrogate id column is added in this phase. The `{principalId}` path segment in
+`docs/crud-expansion-design.md` §5.4 maps directly to the `ordering` value, scoped under the existing
+`{titleId}` path segment; no schema change needed beyond what `V7` already added.
+
+### Task 5.1: `PrincipalCredit`, `TitleRepository` principal methods
+
+**Files:**
+- Create: `src/main/java/com/ludovictemgoua/imdb/domain/model/PrincipalCredit.java`
+- Modify: `src/main/java/com/ludovictemgoua/imdb/domain/repository/TitleRepository.java`
+- Modify: `src/main/java/com/ludovictemgoua/imdb/infrastructure/persistence/JdbcTitleRepository.java`
+- Modify: `src/test/java/com/ludovictemgoua/imdb/infrastructure/persistence/JdbcTitleRepositoryIntegrationTest.java`
+
+**Interfaces:**
+- Produces: `PrincipalCredit(String personId, String personName, String category, String job, List characters, int ordering, int version)`. `TitleRepository.findAllPrincipals(int tconst) -> List` (uncapped, unlike `findTopCast`), `insertPrincipal(int tconst, int personId, String category, String job, List characters, int ordering) -> WriteResult`, `updatePrincipal(int tconst, int ordering, String category, String job, List characters, int expectedVersion) -> WriteResult`, `softDeletePrincipal(int tconst, int ordering) -> WriteResult`.
+
+- [ ] **Step 1: Write the failing integration tests**
+
+```java
+ @Test
+ void insertPrincipalThenFindAllPrincipalsIncludesIt() {
+ var result = repository.insertPrincipal(100, 1, "actor", null, List.of("New Role"), 99);
+
+ assertThat(result).isEqualTo(com.ludovictemgoua.imdb.domain.repository.WriteResult.SUCCESS);
+ assertThat(repository.findAllPrincipals(100)).extracting("ordering").contains(99);
+ }
+
+ @Test
+ void updatePrincipalBumpsVersionAndPersists() {
+ repository.insertPrincipal(100, 1, "actor", null, List.of("Original"), 98);
+
+ var result = repository.updatePrincipal(100, 98, "actor", null, List.of("Updated"), 0);
+
+ assertThat(result).isEqualTo(com.ludovictemgoua.imdb.domain.repository.WriteResult.SUCCESS);
+ var updated = repository.findAllPrincipals(100).stream()
+ .filter(p -> p.ordering() == 98).findFirst().orElseThrow();
+ assertThat(updated.characters()).containsExactly("Updated");
+ assertThat(updated.version()).isEqualTo(1);
+ }
+
+ @Test
+ void softDeletePrincipalExcludesItFromFindAllPrincipals() {
+ repository.insertPrincipal(100, 1, "actor", null, List.of("Temp"), 97);
+
+ repository.softDeletePrincipal(100, 97);
+
+ assertThat(repository.findAllPrincipals(100)).extracting("ordering").doesNotContain(97);
+ }
+```
+
+- [ ] **Step 2: Run the tests to verify they fail**
+
+Run: `JAVA_HOME="/c/Program Files/Java/jdk-21" ./mvnw -q failsafe:integration-test failsafe:verify -Dit.test=JdbcTitleRepositoryIntegrationTest`
+Expected: FAIL.
+
+- [ ] **Step 3: Create `PrincipalCredit`**
+
+```java
+package com.ludovictemgoua.imdb.domain.model;
+
+import java.util.List;
+
+public record PrincipalCredit(String personId, String personName, String category, String job,
+ List characters, int ordering, int version) {
+}
+```
+
+- [ ] **Step 4: Add the new methods to `TitleRepository`**
+
+```java
+ List findAllPrincipals(int tconst);
+
+ WriteResult insertPrincipal(int tconst, int personId, String category, String job,
+ List characters, int ordering);
+
+ WriteResult updatePrincipal(int tconst, int ordering, String category, String job,
+ List characters, int expectedVersion);
+
+ WriteResult softDeletePrincipal(int tconst, int ordering);
+```
+
+Add `import com.ludovictemgoua.imdb.domain.model.PrincipalCredit;` to this file's imports.
+
+- [ ] **Step 5: Implement the new methods in `JdbcTitleRepository`**
+
+```java
+ @Override
+ public List findAllPrincipals(int tconst) {
+ String sql = """
+ SELECT tp.nconst, nb.primary_name, tp.category, tp.job, tp.characters, tp.ordering, tp.version
+ FROM title_principals tp
+ JOIN name_basics nb ON nb.nconst = tp.nconst
+ WHERE tp.tconst = :tconst AND tp.deleted_at IS NULL
+ ORDER BY tp.ordering
+ """;
+ return jdbc.query(sql, Map.of("tconst", tconst), JdbcTitleRepository::mapPrincipal);
+ }
+
+ @Override
+ public WriteResult insertPrincipal(int tconst, int personId, String category, String job,
+ List characters, int ordering) {
+ if (findCore(tconst).isEmpty()) {
+ return WriteResult.NOT_FOUND;
+ }
+ String sql = """
+ INSERT INTO title_principals (tconst, ordering, nconst, category, job, characters)
+ VALUES (:tconst, :ordering, :nconst, :category, :job, :characters)
+ """;
+ var params = new MapSqlParameterSource()
+ .addValue("tconst", tconst).addValue("ordering", ordering).addValue("nconst", personId)
+ .addValue("category", category).addValue("job", job)
+ .addValue("characters", characters.toArray(new String[0]), java.sql.Types.ARRAY, "text");
+ jdbc.update(sql, params);
+ return WriteResult.SUCCESS;
+ }
+
+ @Override
+ public WriteResult updatePrincipal(int tconst, int ordering, String category, String job,
+ List characters, int expectedVersion) {
+ String sql = """
+ UPDATE title_principals
+ SET category = :category, job = :job, characters = :characters, version = version + 1
+ WHERE tconst = :tconst AND ordering = :ordering AND version = :expectedVersion AND deleted_at IS NULL
+ """;
+ var params = new MapSqlParameterSource()
+ .addValue("category", category).addValue("job", job)
+ .addValue("characters", characters.toArray(new String[0]), java.sql.Types.ARRAY, "text")
+ .addValue("tconst", tconst).addValue("ordering", ordering).addValue("expectedVersion", expectedVersion);
+ return jdbc.update(sql, params) == 0 ? WriteResult.VERSION_CONFLICT : WriteResult.SUCCESS;
+ }
+
+ @Override
+ public WriteResult softDeletePrincipal(int tconst, int ordering) {
+ var params = new MapSqlParameterSource().addValue("tconst", tconst).addValue("ordering", ordering);
+ int updated = jdbc.update(
+ "UPDATE title_principals SET deleted_at = now() WHERE tconst = :tconst AND ordering = :ordering AND deleted_at IS NULL",
+ params);
+ return updated == 0 ? WriteResult.NOT_FOUND : WriteResult.SUCCESS;
+ }
+
+ private static PrincipalCredit mapPrincipal(ResultSet rs, int rowNum) throws SQLException {
+ return new PrincipalCredit(ImdbIds.formatPersonId(rs.getInt("nconst")), rs.getString("primary_name"),
+ rs.getString("category"), rs.getString("job"), toStringList(rs.getArray("characters")),
+ rs.getInt("ordering"), rs.getInt("version"));
+ }
+```
+
+`updatePrincipal`'s missing existence pre-check (unlike the other write methods in this file) is
+deliberate - `(tconst, ordering)` has no separate lookup method worth adding just for this, so a
+version-mismatch-shaped `0` and a not-found-shaped `0` are indistinguishable here; both correctly surface
+as `VERSION_CONFLICT` to the caller, which - for a composite-keyed row a caller must already know the
+`ordering` of - is an acceptable simplification over adding a dedicated existence check.
+
+- [ ] **Step 6: Run the tests to verify they pass**
+
+Run: `JAVA_HOME="/c/Program Files/Java/jdk-21" ./mvnw -q failsafe:integration-test failsafe:verify -Dit.test=JdbcTitleRepositoryIntegrationTest`
+Expected: PASS.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add src/main/java/com/ludovictemgoua/imdb/domain/model/PrincipalCredit.java src/main/java/com/ludovictemgoua/imdb/domain/repository/TitleRepository.java src/main/java/com/ludovictemgoua/imdb/infrastructure/persistence/JdbcTitleRepository.java src/test/java/com/ludovictemgoua/imdb/infrastructure/persistence/JdbcTitleRepositoryIntegrationTest.java
+git commit -m "Add TitleRepository principal-credit write methods"
+```
+
+### Task 5.2: `TitleAdminUseCase` principal methods, `TitleController` endpoints, six-degrees cache eviction
+
+**Files:**
+- Modify: `src/main/java/com/ludovictemgoua/imdb/application/contracts/TitleAdminUseCase.java`
+- Modify: `src/main/java/com/ludovictemgoua/imdb/application/TitleAdminUseCaseImpl.java`
+- Create: `src/main/java/com/ludovictemgoua/imdb/application/PrincipalRequest.java` (record)
+- Modify: `src/main/java/com/ludovictemgoua/imdb/infrastructure/cache/CachingTitleAdminUseCase.java`
+- Create: `src/main/java/com/ludovictemgoua/imdb/infrastructure/cache/CachingPersonAdminUseCase.java`
+- Modify: `src/main/java/com/ludovictemgoua/imdb/presentation/TitleController.java`
+- Test: `src/test/java/com/ludovictemgoua/imdb/application/TitleAdminUseCaseImplTest.java` (extend)
+- Test: `src/test/java/com/ludovictemgoua/imdb/presentation/TitleControllerTest.java` (extend)
+
+**Interfaces:**
+- Consumes: `TitleRepository` principal methods (Task 5.1), `PersonAdminUseCaseImpl` (Task 4.2)
+- Produces: `TitleAdminUseCase.getAllPrincipals/addPrincipal/updatePrincipal/deletePrincipal` - `getAllPrincipals`
+ is the one method on this interface **not** gated `@PreAuthorize` at the controller (it's the public
+ uncapped-cast-list endpoint, §5.4). `CachingPersonAdminUseCase` - the `@Primary` decorator missing since
+ Phase 4 - now evicts the `six-degrees` region (`allEntries`) on person update/delete, per
+ `docs/crud-expansion-design.md` §6.2's "any people/principals write" rule.
+
+- [ ] **Step 1: Write the failing unit test additions**
+
+Add to `TitleAdminUseCaseImplTest`:
+
+```java
+ @Test
+ void addPrincipalDelegatesToInsertPrincipal() {
+ given(titleRepository.insertPrincipal(300, 1, "actor", null, List.of("Role"), 5))
+ .willReturn(WriteResult.SUCCESS);
+ var useCase = new TitleAdminUseCaseImpl(titleRepository);
+
+ useCase.addPrincipal("tt0000300", new PrincipalRequest("nm0000001", "actor", null, List.of("Role"), 5));
+ }
+
+ @Test
+ void deletePrincipalThrowsNotFoundWhenMissing() {
+ given(titleRepository.softDeletePrincipal(300, 5)).willReturn(WriteResult.NOT_FOUND);
+ var useCase = new TitleAdminUseCaseImpl(titleRepository);
+
+ assertThatThrownBy(() -> useCase.deletePrincipal("tt0000300", 5)).isInstanceOf(NotFoundException.class);
+ }
+```
+
+- [ ] **Step 2: Run the tests to verify they fail**
+
+Run: `JAVA_HOME="/c/Program Files/Java/jdk-21" ./mvnw -q test -Dtest=TitleAdminUseCaseImplTest`
+Expected: FAIL - the new interface methods don't exist yet.
+
+- [ ] **Step 3: Create `PrincipalRequest` and add the methods to `TitleAdminUseCase`/`Impl`**
+
+```java
+package com.ludovictemgoua.imdb.application;
+
+import jakarta.validation.constraints.NotBlank;
+
+import java.util.List;
+
+public record PrincipalRequest(@NotBlank String personId, @NotBlank String category, String job,
+ List characters, int ordering) {
+}
+```
+
+Add to `TitleAdminUseCase`:
+
+```java
+ java.util.List getAllPrincipals(String titleId);
+
+ void addPrincipal(String titleId, PrincipalRequest request);
+
+ void updatePrincipal(String titleId, int ordering, PrincipalRequest request, int expectedVersion);
+
+ void deletePrincipal(String titleId, int ordering);
+```
+
+Add to `TitleAdminUseCaseImpl`:
+
+```java
+ @Override
+ public java.util.List getAllPrincipals(String titleId) {
+ return titleRepository.findAllPrincipals(ImdbIds.parseTitleId(titleId));
+ }
+
+ @Override
+ public void addPrincipal(String titleId, PrincipalRequest request) {
+ int tconst = ImdbIds.parseTitleId(titleId);
+ handle(titleRepository.insertPrincipal(tconst, ImdbIds.parsePersonId(request.personId()),
+ request.category(), request.job(), request.characters(), request.ordering()), titleId);
+ }
+
+ @Override
+ public void updatePrincipal(String titleId, int ordering, PrincipalRequest request, int expectedVersion) {
+ int tconst = ImdbIds.parseTitleId(titleId);
+ handle(titleRepository.updatePrincipal(tconst, ordering, request.category(), request.job(),
+ request.characters(), expectedVersion), titleId);
+ }
+
+ @Override
+ public void deletePrincipal(String titleId, int ordering) {
+ handle(titleRepository.softDeletePrincipal(ImdbIds.parseTitleId(titleId), ordering), titleId);
+ }
+```
+
+- [ ] **Step 4: Run the tests to verify they pass**
+
+Run: `JAVA_HOME="/c/Program Files/Java/jdk-21" ./mvnw -q test -Dtest=TitleAdminUseCaseImplTest`
+Expected: PASS.
+
+- [ ] **Step 5: Add eviction methods to `CachingTitleAdminUseCase` and create `CachingPersonAdminUseCase`**
+
+Add to `CachingTitleAdminUseCase` (import `PrincipalRequest`, `PrincipalCredit`, `List`):
+
+```java
+ @Override
+ public List getAllPrincipals(String titleId) {
+ return delegate.getAllPrincipals(titleId);
+ }
+
+ @Override
+ @Caching(evict = {
+ @CacheEvict(cacheNames = "title-detail", key = "#titleId"),
+ @CacheEvict(cacheNames = "six-degrees", allEntries = true)
+ })
+ public void addPrincipal(String titleId, PrincipalRequest request) {
+ delegate.addPrincipal(titleId, request);
+ }
+
+ @Override
+ @Caching(evict = {
+ @CacheEvict(cacheNames = "title-detail", key = "#titleId"),
+ @CacheEvict(cacheNames = "six-degrees", allEntries = true)
+ })
+ public void updatePrincipal(String titleId, int ordering, PrincipalRequest request, int expectedVersion) {
+ delegate.updatePrincipal(titleId, ordering, request, expectedVersion);
+ }
+
+ @Override
+ @Caching(evict = {
+ @CacheEvict(cacheNames = "title-detail", key = "#titleId"),
+ @CacheEvict(cacheNames = "six-degrees", allEntries = true)
+ })
+ public void deletePrincipal(String titleId, int ordering) {
+ delegate.deletePrincipal(titleId, ordering);
+ }
+```
+
+Create `CachingPersonAdminUseCase`:
+
+```java
+package com.ludovictemgoua.imdb.infrastructure.cache;
+
+import com.ludovictemgoua.imdb.application.rest.CreatePersonRequest;
+import com.ludovictemgoua.imdb.application.rest.PatchPersonRequest;
+import com.ludovictemgoua.imdb.application.PersonAdminUseCaseImpl;
+import com.ludovictemgoua.imdb.application.rest.UpdatePersonRequest;
+import com.ludovictemgoua.imdb.application.contracts.PersonAdminUseCase;
+import com.ludovictemgoua.imdb.domain.model.PersonCore;
+import org.springframework.cache.annotation.CacheEvict;
+import org.springframework.context.annotation.Primary;
+import org.springframework.stereotype.Service;
+
+// A renamed/removed person can change six-degrees path enrichment or its underlying graph edges -
+// coarse full-region eviction on any update/delete, same trade-off as CachingTitleAdminUseCase's
+// principal writes. create() needs no eviction: a brand-new person can't already be in any cached
+// six-degrees result.
+@Service
+@Primary
+public class CachingPersonAdminUseCase implements PersonAdminUseCase {
+
+ private final PersonAdminUseCaseImpl delegate;
+
+ public CachingPersonAdminUseCase(PersonAdminUseCaseImpl delegate) {
+ this.delegate = delegate;
+ }
+
+ @Override
+ public PersonCore create(CreatePersonRequest request) {
+ return delegate.create(request);
+ }
+
+ @Override
+ @CacheEvict(cacheNames = "six-degrees", allEntries = true)
+ public PersonCore update(String personId, UpdatePersonRequest request) {
+ return delegate.update(personId, request);
+ }
+
+ @Override
+ @CacheEvict(cacheNames = "six-degrees", allEntries = true)
+ public PersonCore patch(String personId, PatchPersonRequest request) {
+ return delegate.patch(personId, request);
+ }
+
+ @Override
+ @CacheEvict(cacheNames = "six-degrees", allEntries = true)
+ public void delete(String personId) {
+ delegate.delete(personId);
+ }
+}
+```
+
+- [ ] **Step 6: Add the principal endpoints to `TitleController`**
+
+```java
+ @GetMapping("/{titleId}/principals")
+ public List getAllPrincipals(@PathVariable String titleId) {
+ return titleAdminUseCase.getAllPrincipals(titleId);
+ }
+
+ @PostMapping("/{titleId}/principals")
+ @ResponseStatus(HttpStatus.CREATED)
+ @PreAuthorize("hasRole('ADMIN')")
+ public void addPrincipal(@PathVariable String titleId, @Valid @RequestBody PrincipalRequest request) {
+ titleAdminUseCase.addPrincipal(titleId, request);
+ }
+
+ @PutMapping("/{titleId}/principals/{ordering}")
+ @PreAuthorize("hasRole('ADMIN')")
+ public void updatePrincipal(@PathVariable String titleId, @PathVariable int ordering,
+ @Valid @RequestBody PrincipalRequest request,
+ @RequestParam int expectedVersion) {
+ titleAdminUseCase.updatePrincipal(titleId, ordering, request, expectedVersion);
+ }
+
+ @DeleteMapping("/{titleId}/principals/{ordering}")
+ @ResponseStatus(HttpStatus.NO_CONTENT)
+ @PreAuthorize("hasRole('ADMIN')")
+ public void deletePrincipal(@PathVariable String titleId, @PathVariable int ordering) {
+ titleAdminUseCase.deletePrincipal(titleId, ordering);
+ }
+```
+
+Add `import com.ludovictemgoua.imdb.domain.model.PrincipalCredit;` and
+`import com.ludovictemgoua.imdb.application.rest.PrincipalRequest;` to `TitleController.java`'s imports (all the
+Spring annotation imports needed here were already added in Task 3.2's cleanup).
+
+- [ ] **Step 7: Run the full unit and integration suites**
+
+Run: `JAVA_HOME="/c/Program Files/Java/jdk-21" ./mvnw -q test && JAVA_HOME="/c/Program Files/Java/jdk-21" ./mvnw -q failsafe:integration-test failsafe:verify`
+Expected: PASS.
+
+- [ ] **Step 8: Commit**
+
+```bash
+git add src/main/java/com/ludovictemgoua/imdb/application/ src/main/java/com/ludovictemgoua/imdb/infrastructure/cache/ src/main/java/com/ludovictemgoua/imdb/presentation/TitleController.java src/test/java/com/ludovictemgoua/imdb/application/TitleAdminUseCaseImplTest.java src/test/java/com/ludovictemgoua/imdb/presentation/TitleControllerTest.java
+git commit -m "Add admin CRUD for principals and six-degrees cache eviction for people/principal writes"
+```
+
+### Task 5.3: Prove `@CacheEvict` actually reaches Redis (integration test)
+
+**Files:**
+- Create: `src/test/java/com/ludovictemgoua/imdb/infrastructure/cache/CacheEvictionIntegrationTest.java`
+
+**Interfaces:** none new - this task only adds test coverage over `CachingTitleAdminUseCase` (Tasks 3.2/5.2)
+and `CachingPersonAdminUseCase` (Task 5.2).
+
+A mocked `CacheManager` in a unit test can prove a `@CacheEvict`-annotated method was *called*, but not
+that it actually reached Redis - the same reasoning that motivated the four Redis-Testcontainers cache
+integration tests already in this codebase (LLD §10.2) applies identically to eviction. This test primes
+each of the three affected cache regions, performs the write that should evict them, then asserts the
+specific previously-cached key is gone.
+
+- [ ] **Step 1: Write the failing integration test**
+
+```java
+package com.ludovictemgoua.imdb.infrastructure.cache;
+
+import com.ludovictemgoua.imdb.TestcontainersConfiguration;
+import com.ludovictemgoua.imdb.application.rest.RatingRequest;
+import com.ludovictemgoua.imdb.application.contracts.PersonAdminUseCase;
+import com.ludovictemgoua.imdb.application.contracts.TitleAdminUseCase;
+import com.ludovictemgoua.imdb.application.contracts.TitleDetailUseCase;
+import com.ludovictemgoua.imdb.application.contracts.TopRatedUseCase;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.cache.CacheManager;
+import org.springframework.context.annotation.Import;
+import org.springframework.test.context.jdbc.Sql;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+@Import(TestcontainersConfiguration.class)
+@SpringBootTest
+@Transactional
+@Sql("/fixtures/fixture-data.sql")
+class CacheEvictionIntegrationTest {
+
+ @Autowired
+ TitleDetailUseCase titleDetailUseCase;
+ @Autowired
+ TopRatedUseCase topRatedUseCase;
+ @Autowired
+ TitleAdminUseCase titleAdminUseCase;
+ @Autowired
+ PersonAdminUseCase personAdminUseCase;
+ @Autowired
+ CacheManager cacheManager;
+
+ @Test
+ void updatingATitleEvictsItsTitleDetailCacheEntry() {
+ titleDetailUseCase.getDetail("tt0000100");
+ assertThat(cacheManager.getCache("title-detail").get("tt0000100")).isNotNull();
+
+ var current = titleDetailUseCase.getDetail("tt0000100");
+ titleAdminUseCase.update("tt0000100", new com.ludovictemgoua.imdb.application.rest.UpdateTitleRequest(
+ current.primaryTitle(), current.originalTitle(), current.titleType(),
+ current.startYear(), current.endYear(), current.runtimeMinutes(), current.genres(), 0));
+
+ assertThat(cacheManager.getCache("title-detail").get("tt0000100")).isNull();
+ }
+
+ @Test
+ void writingARatingEvictsTheEntireTopRatedRegion() {
+ topRatedUseCase.findTopRated("Action", 10, 100);
+ assertThat(cacheManager.getCache("top-rated").get("Action:10:100")).isNotNull();
+
+ titleAdminUseCase.upsertRating("tt0000200", new RatingRequest(9.0, 200000));
+
+ assertThat(cacheManager.getCache("top-rated").get("Action:10:100")).isNull();
+ }
+
+ @Test
+ void updatingAPersonEvictsTheEntireSixDegreesRegion() {
+ // Priming six-degrees requires a real SixDegreesUseCase call (personA=1, personB=2 per the
+ // fixture graph, LLD §3) - autowire com.ludovictemgoua.imdb.application.contracts.SixDegreesUseCase
+ // and call sixDegreesUseCase.compute("nm0000001", "nm0000002", 7) here, then assert
+ // cacheManager.getCache("six-degrees").get("1-2") is not null (matching the "min-max" key
+ // convention, LLD §6) before calling personAdminUseCase.patch(...) below and re-asserting null.
+ personAdminUseCase.patch("nm0000001", new com.ludovictemgoua.imdb.application.rest.PatchPersonRequest("Kevin Bacon Jr.", null, null, List.of(), 0));
+
+ assertThat(cacheManager.getCache("six-degrees").get("1-2")).isNull();
+ }
+}
+```
+
+- [ ] **Step 2: Run the test to verify it fails**
+
+Run: `JAVA_HOME="/c/Program Files/Java/jdk-21" ./mvnw -q failsafe:integration-test failsafe:verify -Dit.test=CacheEvictionIntegrationTest`
+Expected: initially FAIL only if the eviction wiring from Tasks 3.2/5.2 has any bug - if Tasks 3.2/5.2 were
+implemented and verified correctly already, this test should pass immediately; treat a failure here as a
+signal to revisit those tasks' `@CacheEvict`/`@Caching` annotations, not as expected red-then-green TDD
+churn (this task is a **verification** of already-built behavior, not new production code).
+
+- [ ] **Step 3: Fill in the `six-degrees` priming call from the comment above**, then re-run
+
+Run: `JAVA_HOME="/c/Program Files/Java/jdk-21" ./mvnw -q failsafe:integration-test failsafe:verify -Dit.test=CacheEvictionIntegrationTest`
+Expected: PASS, all three tests green.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add src/test/java/com/ludovictemgoua/imdb/infrastructure/cache/CacheEvictionIntegrationTest.java
+git commit -m "Add integration test proving cache eviction reaches real Redis for title/rating/person writes"
+```
+
+**Phase 5 checkpoint**: all admin CRUD from `docs/crud-expansion-design.md` §5 is complete and cache-safe,
+now verified against real Redis rather than assumed correct from the annotations alone.
+Phases 3-5 are independent of Phases 6-8 below (both depend only on Phase 1/2) - if executing with
+subagents, these two groups can run in parallel.
+
+---
+
+## Phase 6: Watchlist
+
+### Task 6.1: `Visibility`, `WatchlistView`/`WatchlistItemView`, `WatchlistRepository`
+
+**Files:**
+- Create: `src/main/resources/db/migration/V8__watchlists.sql`
+- Create: `src/main/java/com/ludovictemgoua/imdb/domain/model/Visibility.java`
+- Create: `src/main/java/com/ludovictemgoua/imdb/domain/model/WatchlistView.java`, `WatchlistItemView.java`
+- Create: `src/main/java/com/ludovictemgoua/imdb/domain/repository/WatchlistRepository.java`
+- Create: `src/main/java/com/ludovictemgoua/imdb/infrastructure/persistence/JdbcWatchlistRepository.java`
+- Test: `src/test/java/com/ludovictemgoua/imdb/infrastructure/persistence/JdbcWatchlistRepositoryIntegrationTest.java`
+
+**Interfaces:**
+- Consumes: `CurrentUser` (already built in Task 1.6 - `WatchlistController`, Task 6.2, uses it)
+- Produces: `Visibility.PUBLIC`/`PRIVATE` (reused by Reviews/Lists, Phases 7-8). `WatchlistView(int id, int userId, Visibility visibility, int version, List items)`, `WatchlistItemView(String titleId, String primaryTitle, Instant addedAt)`. `WatchlistRepository.findOrCreateByUserId(int userId) -> WatchlistView`, `findByUserId(int userId) -> Optional`, `addItem`/`removeItem(int watchlistId, int titleId) -> WriteResult`, `updateVisibility(int watchlistId, Visibility, int expectedVersion) -> WriteResult`.
+
+- [ ] **Step 1: Write the failing test**
+
+```java
+package com.ludovictemgoua.imdb.infrastructure.persistence;
+
+import com.ludovictemgoua.imdb.TestcontainersConfiguration;
+import com.ludovictemgoua.imdb.domain.model.Role;
+import com.ludovictemgoua.imdb.domain.model.Visibility;
+import com.ludovictemgoua.imdb.domain.repository.UserRepository;
+import com.ludovictemgoua.imdb.domain.repository.WriteResult;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.context.annotation.Import;
+import org.springframework.test.context.jdbc.Sql;
+import org.springframework.transaction.annotation.Transactional;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+@Import(TestcontainersConfiguration.class)
+@SpringBootTest
+@Transactional
+@Sql("/fixtures/fixture-data.sql")
+class JdbcWatchlistRepositoryIntegrationTest {
+
+ @Autowired
+ JdbcWatchlistRepository repository;
+ @Autowired
+ UserRepository userRepository;
+
+ @Test
+ void findOrCreateByUserIdCreatesAnEmptyPrivateWatchlistOnFirstAccess() {
+ int userId = userRepository.insert("watchlist-user@example.com", "hash", "User", Role.USER).id();
+
+ var watchlist = repository.findOrCreateByUserId(userId);
+
+ assertThat(watchlist.userId()).isEqualTo(userId);
+ assertThat(watchlist.visibility()).isEqualTo(Visibility.PRIVATE);
+ assertThat(watchlist.items()).isEmpty();
+ }
+
+ @Test
+ void findOrCreateByUserIdIsIdempotent() {
+ int userId = userRepository.insert("watchlist-user2@example.com", "hash", "User", Role.USER).id();
+
+ var first = repository.findOrCreateByUserId(userId);
+ var second = repository.findOrCreateByUserId(userId);
+
+ assertThat(first.id()).isEqualTo(second.id());
+ }
+
+ @Test
+ void addItemThenFindOrCreateIncludesIt() {
+ int userId = userRepository.insert("watchlist-user3@example.com", "hash", "User", Role.USER).id();
+ var watchlist = repository.findOrCreateByUserId(userId);
+
+ repository.addItem(watchlist.id(), 100);
+
+ assertThat(repository.findOrCreateByUserId(userId).items()).extracting("titleId").contains("tt0000100");
+ }
+
+ @Test
+ void removeItemExcludesItFromTheWatchlist() {
+ int userId = userRepository.insert("watchlist-user4@example.com", "hash", "User", Role.USER).id();
+ var watchlist = repository.findOrCreateByUserId(userId);
+ repository.addItem(watchlist.id(), 100);
+
+ repository.removeItem(watchlist.id(), 100);
+
+ assertThat(repository.findOrCreateByUserId(userId).items()).isEmpty();
+ }
+
+ @Test
+ void updateVisibilityChangesItAndBumpsVersion() {
+ int userId = userRepository.insert("watchlist-user5@example.com", "hash", "User", Role.USER).id();
+ var watchlist = repository.findOrCreateByUserId(userId);
+
+ var result = repository.updateVisibility(watchlist.id(), Visibility.PUBLIC, watchlist.version());
+
+ assertThat(result).isEqualTo(WriteResult.SUCCESS);
+ assertThat(repository.findByUserId(userId).orElseThrow().visibility()).isEqualTo(Visibility.PUBLIC);
+ }
+}
+```
+
+- [ ] **Step 2: Run the test to verify it fails**
+
+Run: `JAVA_HOME="/c/Program Files/Java/jdk-21" ./mvnw -q failsafe:integration-test failsafe:verify -Dit.test=JdbcWatchlistRepositoryIntegrationTest`
+Expected: FAIL - none of these classes exist yet.
+
+- [ ] **Step 3: Create the migration**
+
+```sql
+CREATE TABLE watchlists (
+ id SERIAL PRIMARY KEY,
+ user_id INTEGER NOT NULL REFERENCES users (id),
+ visibility TEXT NOT NULL DEFAULT 'PRIVATE',
+ version INTEGER NOT NULL DEFAULT 0,
+ deleted_at TIMESTAMPTZ
+);
+
+CREATE UNIQUE INDEX idx_watchlists_user_id ON watchlists (user_id) WHERE deleted_at IS NULL;
+
+CREATE TABLE watchlist_items (
+ watchlist_id INTEGER NOT NULL REFERENCES watchlists (id),
+ title_id INTEGER NOT NULL REFERENCES title_basics (tconst),
+ added_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ PRIMARY KEY (watchlist_id, title_id)
+);
+```
+
+- [ ] **Step 4: Create `Visibility`, `WatchlistView`, `WatchlistItemView`**
+
+```java
+package com.ludovictemgoua.imdb.domain.model;
+
+public enum Visibility { PUBLIC, PRIVATE }
+```
+
+```java
+package com.ludovictemgoua.imdb.domain.model;
+
+import java.time.Instant;
+
+public record WatchlistItemView(String titleId, String primaryTitle, Instant addedAt) {
+}
+```
+
+```java
+package com.ludovictemgoua.imdb.domain.model;
+
+import java.util.List;
+
+public record WatchlistView(int id, int userId, Visibility visibility, int version, List items) {
+}
+```
+
+- [ ] **Step 5: Create `WatchlistRepository` and `JdbcWatchlistRepository`**
+
+```java
+package com.ludovictemgoua.imdb.domain.repository;
+
+import com.ludovictemgoua.imdb.domain.model.Visibility;
+import com.ludovictemgoua.imdb.domain.model.WatchlistView;
+
+import java.util.Optional;
+
+public interface WatchlistRepository {
+
+ WatchlistView findOrCreateByUserId(int userId);
+
+ Optional findByUserId(int userId);
+
+ WriteResult addItem(int watchlistId, int titleId);
+
+ WriteResult removeItem(int watchlistId, int titleId);
+
+ WriteResult updateVisibility(int watchlistId, Visibility visibility, int expectedVersion);
+}
+```
+
+```java
+package com.ludovictemgoua.imdb.infrastructure.persistence;
+
+import com.ludovictemgoua.imdb.domain.model.Visibility;
+import com.ludovictemgoua.imdb.domain.model.WatchlistItemView;
+import com.ludovictemgoua.imdb.domain.model.WatchlistView;
+import com.ludovictemgoua.imdb.domain.repository.WatchlistRepository;
+import com.ludovictemgoua.imdb.domain.repository.WriteResult;
+import com.ludovictemgoua.imdb.utils.ImdbIds;
+import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
+import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
+import org.springframework.jdbc.support.GeneratedKeyHolder;
+import org.springframework.jdbc.support.KeyHolder;
+import org.springframework.stereotype.Repository;
+
+import java.time.Instant;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+@Repository
+public class JdbcWatchlistRepository implements WatchlistRepository {
+
+ private final NamedParameterJdbcTemplate jdbc;
+
+ public JdbcWatchlistRepository(NamedParameterJdbcTemplate jdbc) {
+ this.jdbc = jdbc;
+ }
+
+ @Override
+ public WatchlistView findOrCreateByUserId(int userId) {
+ return findByUserId(userId).orElseGet(() -> create(userId));
+ }
+
+ @Override
+ public Optional findByUserId(int userId) {
+ String sql = "SELECT id, user_id, visibility, version FROM watchlists WHERE user_id = :userId AND deleted_at IS NULL";
+ return jdbc.query(sql, Map.of("userId", userId), (rs, rowNum) -> new int[]{rs.getInt("id")})
+ .stream().findFirst()
+ .map(row -> hydrate(row[0], userId));
+ }
+
+ @Override
+ public WriteResult addItem(int watchlistId, int titleId) {
+ String sql = """
+ INSERT INTO watchlist_items (watchlist_id, title_id) VALUES (:watchlistId, :titleId)
+ ON CONFLICT DO NOTHING
+ """;
+ jdbc.update(sql, Map.of("watchlistId", watchlistId, "titleId", titleId));
+ return WriteResult.SUCCESS;
+ }
+
+ @Override
+ public WriteResult removeItem(int watchlistId, int titleId) {
+ jdbc.update("DELETE FROM watchlist_items WHERE watchlist_id = :watchlistId AND title_id = :titleId",
+ Map.of("watchlistId", watchlistId, "titleId", titleId));
+ return WriteResult.SUCCESS;
+ }
+
+ @Override
+ public WriteResult updateVisibility(int watchlistId, Visibility visibility, int expectedVersion) {
+ String sql = """
+ UPDATE watchlists SET visibility = :visibility, version = version + 1
+ WHERE id = :id AND version = :expectedVersion AND deleted_at IS NULL
+ """;
+ var params = new MapSqlParameterSource()
+ .addValue("visibility", visibility.name()).addValue("id", watchlistId)
+ .addValue("expectedVersion", expectedVersion);
+ return jdbc.update(sql, params) == 0 ? WriteResult.VERSION_CONFLICT : WriteResult.SUCCESS;
+ }
+
+ private WatchlistView create(int userId) {
+ String sql = "INSERT INTO watchlists (user_id) VALUES (:userId)";
+ KeyHolder keyHolder = new GeneratedKeyHolder();
+ jdbc.update(sql, new MapSqlParameterSource("userId", userId), keyHolder, new String[]{"id"});
+ return new WatchlistView(keyHolder.getKey().intValue(), userId, Visibility.PRIVATE, 0, List.of());
+ }
+
+ private WatchlistView hydrate(int watchlistId, int userId) {
+ String metaSql = "SELECT visibility, version FROM watchlists WHERE id = :id";
+ var meta = jdbc.queryForMap(metaSql, Map.of("id", watchlistId));
+ String itemsSql = """
+ SELECT tb.tconst, tb.primary_title, wi.added_at
+ FROM watchlist_items wi JOIN title_basics tb ON tb.tconst = wi.title_id
+ WHERE wi.watchlist_id = :watchlistId AND tb.deleted_at IS NULL
+ ORDER BY wi.added_at
+ """;
+ List items = jdbc.query(itemsSql, Map.of("watchlistId", watchlistId),
+ (rs, rowNum) -> new WatchlistItemView(ImdbIds.formatTitleId(rs.getInt("tconst")),
+ rs.getString("primary_title"), rs.getTimestamp("added_at").toInstant()));
+ return new WatchlistView(watchlistId, userId, Visibility.valueOf((String) meta.get("visibility")),
+ ((Number) meta.get("version")).intValue(), items);
+ }
+}
+```
+
+- [ ] **Step 6: Run the test to verify it passes**
+
+Run: `JAVA_HOME="/c/Program Files/Java/jdk-21" ./mvnw -q failsafe:integration-test failsafe:verify -Dit.test=JdbcWatchlistRepositoryIntegrationTest`
+Expected: PASS.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add src/main/resources/db/migration/V8__watchlists.sql src/main/java/com/ludovictemgoua/imdb/domain/model/Visibility.java src/main/java/com/ludovictemgoua/imdb/domain/model/WatchlistView.java src/main/java/com/ludovictemgoua/imdb/domain/model/WatchlistItemView.java src/main/java/com/ludovictemgoua/imdb/domain/repository/WatchlistRepository.java src/main/java/com/ludovictemgoua/imdb/infrastructure/persistence/JdbcWatchlistRepository.java src/test/java/com/ludovictemgoua/imdb/infrastructure/persistence/JdbcWatchlistRepositoryIntegrationTest.java
+git commit -m "Add watchlists/watchlist_items tables and WatchlistRepository"
+```
+
+### Task 6.2: `WatchlistUseCase`, `WatchlistController`
+
+**Files:**
+- Create: `src/main/java/com/ludovictemgoua/imdb/application/contracts/WatchlistUseCase.java`
+- Create: `src/main/java/com/ludovictemgoua/imdb/application/WatchlistUseCaseImpl.java`
+- Create: `src/main/java/com/ludovictemgoua/imdb/application/VisibilityRequest.java`, `AddWatchlistItemRequest.java` (records)
+- Create: `src/main/java/com/ludovictemgoua/imdb/presentation/WatchlistController.java`
+- Test: `src/test/java/com/ludovictemgoua/imdb/application/WatchlistUseCaseImplTest.java`
+- Test: `src/test/java/com/ludovictemgoua/imdb/presentation/WatchlistControllerTest.java`
+
+**Interfaces:**
+- Consumes: `WatchlistRepository` (Task 6.1), `CurrentUser` (Task 1.6)
+- Produces: `GET/PUT /api/v1/watchlist`, `POST/DELETE /api/v1/watchlist/items{,/{titleId}}`,
+ `GET /api/v1/users/{userId}/watchlist` - the full watchlist endpoint set from
+ `docs/crud-expansion-design.md` §4.2.
+
+- [ ] **Step 1: Write the failing unit test**
+
+```java
+package com.ludovictemgoua.imdb.application;
+
+import com.ludovictemgoua.imdb.domain.exception.ForbiddenException;
+import com.ludovictemgoua.imdb.domain.model.Visibility;
+import com.ludovictemgoua.imdb.domain.model.WatchlistView;
+import com.ludovictemgoua.imdb.domain.repository.WatchlistRepository;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.util.List;
+import java.util.Optional;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.BDDMockito.given;
+
+@ExtendWith(MockitoExtension.class)
+class WatchlistUseCaseImplTest {
+
+ @Mock
+ WatchlistRepository watchlistRepository;
+
+ @Test
+ void getOwnDelegatesToFindOrCreate() {
+ var view = new WatchlistView(1, 7, Visibility.PRIVATE, 0, List.of());
+ given(watchlistRepository.findOrCreateByUserId(7)).willReturn(view);
+
+ assertThat(new WatchlistUseCaseImpl(watchlistRepository).getOwn(7)).isSameAs(view);
+ }
+
+ @Test
+ void getForUserReturnsThePublicWatchlistToAnyone() {
+ var view = new WatchlistView(1, 7, Visibility.PUBLIC, 0, List.of());
+ given(watchlistRepository.findByUserId(7)).willReturn(Optional.of(view));
+
+ var result = new WatchlistUseCaseImpl(watchlistRepository).getForUser(Optional.empty(), 7);
+
+ assertThat(result).isSameAs(view);
+ }
+
+ @Test
+ void getForUserThrowsNotFoundForAPrivateWatchlistViewedByAStranger() {
+ var view = new WatchlistView(1, 7, Visibility.PRIVATE, 0, List.of());
+ given(watchlistRepository.findByUserId(7)).willReturn(Optional.of(view));
+ var useCase = new WatchlistUseCaseImpl(watchlistRepository);
+
+ assertThatThrownBy(() -> useCase.getForUser(Optional.of(99), 7))
+ .isInstanceOf(com.ludovictemgoua.imdb.domain.exception.NotFoundException.class);
+ }
+
+ @Test
+ void getForUserAllowsTheOwnerToViewTheirOwnPrivateWatchlist() {
+ var view = new WatchlistView(1, 7, Visibility.PRIVATE, 0, List.of());
+ given(watchlistRepository.findByUserId(7)).willReturn(Optional.of(view));
+
+ var result = new WatchlistUseCaseImpl(watchlistRepository).getForUser(Optional.of(7), 7);
+
+ assertThat(result).isSameAs(view);
+ }
+}
+```
+
+- [ ] **Step 2: Run the test to verify it fails**
+
+Run: `JAVA_HOME="/c/Program Files/Java/jdk-21" ./mvnw -q test -Dtest=WatchlistUseCaseImplTest`
+Expected: FAIL - `WatchlistUseCaseImpl` doesn't exist yet.
+
+- [ ] **Step 3: Create the request records and `WatchlistUseCase`/`Impl`**
+
+```java
+package com.ludovictemgoua.imdb.application;
+
+import com.ludovictemgoua.imdb.domain.model.Visibility;
+import jakarta.validation.constraints.NotNull;
+
+public record VisibilityRequest(@NotNull Visibility visibility) {
+}
+```
+
+```java
+package com.ludovictemgoua.imdb.application;
+
+import jakarta.validation.constraints.NotBlank;
+
+public record AddWatchlistItemRequest(@NotBlank String titleId) {
+}
+```
+
+```java
+package com.ludovictemgoua.imdb.application.contracts;
+
+import com.ludovictemgoua.imdb.domain.model.Visibility;
+import com.ludovictemgoua.imdb.domain.model.WatchlistView;
+
+import java.util.Optional;
+
+public interface WatchlistUseCase {
+
+ WatchlistView getOwn(int userId);
+
+ WatchlistView getForUser(Optional viewerUserId, int targetUserId);
+
+ void addItem(int userId, String titleId);
+
+ void removeItem(int userId, String titleId);
+
+ void updateVisibility(int userId, Visibility visibility);
+}
+```
+
+```java
+package com.ludovictemgoua.imdb.application;
+
+import com.ludovictemgoua.imdb.application.contracts.WatchlistUseCase;
+import com.ludovictemgoua.imdb.domain.exception.ConflictException;
+import com.ludovictemgoua.imdb.domain.exception.NotFoundException;
+import com.ludovictemgoua.imdb.domain.model.Visibility;
+import com.ludovictemgoua.imdb.domain.model.WatchlistView;
+import com.ludovictemgoua.imdb.domain.repository.WatchlistRepository;
+import com.ludovictemgoua.imdb.utils.ImdbIds;
+import org.springframework.stereotype.Service;
+
+import java.util.Optional;
+
+@Service
+public class WatchlistUseCaseImpl implements WatchlistUseCase {
+
+ private final WatchlistRepository watchlistRepository;
+
+ public WatchlistUseCaseImpl(WatchlistRepository watchlistRepository) {
+ this.watchlistRepository = watchlistRepository;
+ }
+
+ @Override
+ public WatchlistView getOwn(int userId) {
+ return watchlistRepository.findOrCreateByUserId(userId);
+ }
+
+ @Override
+ public WatchlistView getForUser(Optional viewerUserId, int targetUserId) {
+ WatchlistView view = watchlistRepository.findByUserId(targetUserId)
+ .orElseThrow(() -> new NotFoundException("No watchlist for that user"));
+ boolean isOwner = viewerUserId.isPresent() && viewerUserId.get() == targetUserId;
+ if (view.visibility() == Visibility.PRIVATE && !isOwner) {
+ throw new NotFoundException("No watchlist for that user");
+ }
+ return view;
+ }
+
+ @Override
+ public void addItem(int userId, String titleId) {
+ var watchlist = watchlistRepository.findOrCreateByUserId(userId);
+ watchlistRepository.addItem(watchlist.id(), ImdbIds.parseTitleId(titleId));
+ }
+
+ @Override
+ public void removeItem(int userId, String titleId) {
+ var watchlist = watchlistRepository.findOrCreateByUserId(userId);
+ watchlistRepository.removeItem(watchlist.id(), ImdbIds.parseTitleId(titleId));
+ }
+
+ @Override
+ public void updateVisibility(int userId, Visibility visibility) {
+ var watchlist = watchlistRepository.findOrCreateByUserId(userId);
+ var result = watchlistRepository.updateVisibility(watchlist.id(), visibility, watchlist.version());
+ if (result == com.ludovictemgoua.imdb.domain.repository.WriteResult.VERSION_CONFLICT) {
+ throw new ConflictException("Watchlist was modified concurrently - retry");
+ }
+ }
+}
+```
+
+- [ ] **Step 4: Run the test to verify it passes**
+
+Run: `JAVA_HOME="/c/Program Files/Java/jdk-21" ./mvnw -q test -Dtest=WatchlistUseCaseImplTest`
+Expected: PASS, 4 tests green.
+
+- [ ] **Step 5: Write the failing controller test**
+
+```java
+package com.ludovictemgoua.imdb.presentation;
+
+import com.ludovictemgoua.imdb.application.contracts.WatchlistUseCase;
+import com.ludovictemgoua.imdb.domain.model.Visibility;
+import com.ludovictemgoua.imdb.domain.model.WatchlistView;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
+import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors;
+import org.springframework.test.context.bean.override.mockito.MockitoBean;
+import org.springframework.test.web.servlet.MockMvc;
+
+import java.util.List;
+import java.util.Optional;
+
+import static org.mockito.BDDMockito.given;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+@WebMvcTest(WatchlistController.class)
+class WatchlistControllerTest {
+
+ @Autowired
+ MockMvc mockMvc;
+ @MockitoBean
+ WatchlistUseCase watchlistUseCase;
+
+ @Test
+ void getOwnWatchlistRequiresAuthentication() throws Exception {
+ mockMvc.perform(get("/api/v1/watchlist"))
+ .andExpect(status().isUnauthorized());
+ }
+
+ @Test
+ void getOwnWatchlistReturnsItForAnAuthenticatedUser() throws Exception {
+ given(watchlistUseCase.getOwn(7)).willReturn(new WatchlistView(1, 7, Visibility.PRIVATE, 0, List.of()));
+
+ mockMvc.perform(get("/api/v1/watchlist").with(SecurityMockMvcRequestPostProcessors.user("7").roles("USER")))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.visibility").value("PRIVATE"));
+ }
+
+ @Test
+ void getUserWatchlistIsAccessibleAnonymouslyWhenPublic() throws Exception {
+ given(watchlistUseCase.getForUser(Optional.empty(), 7))
+ .willReturn(new WatchlistView(1, 7, Visibility.PUBLIC, 0, List.of()));
+
+ mockMvc.perform(get("/api/v1/users/7/watchlist"))
+ .andExpect(status().isOk());
+ }
+}
+```
+
+- [ ] **Step 6: Run the test to verify it fails**
+
+Run: `JAVA_HOME="/c/Program Files/Java/jdk-21" ./mvnw -q test -Dtest=WatchlistControllerTest`
+Expected: FAIL - `WatchlistController` doesn't exist yet.
+
+- [ ] **Step 7: Create `WatchlistController`**
+
+```java
+package com.ludovictemgoua.imdb.presentation;
+
+import com.ludovictemgoua.imdb.application.rest.AddWatchlistItemRequest;
+import com.ludovictemgoua.imdb.application.contracts.WatchlistUseCase;
+import com.ludovictemgoua.imdb.domain.model.WatchlistView;
+import com.ludovictemgoua.imdb.infrastructure.security.CurrentUser;
+import jakarta.validation.Valid;
+import org.springframework.http.HttpStatus;
+import org.springframework.security.core.Authentication;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.ResponseStatus;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+public class WatchlistController {
+
+ private final WatchlistUseCase watchlistUseCase;
+
+ public WatchlistController(WatchlistUseCase watchlistUseCase) {
+ this.watchlistUseCase = watchlistUseCase;
+ }
+
+ @GetMapping("/api/v1/watchlist")
+ public WatchlistView getOwn(Authentication authentication) {
+ return watchlistUseCase.getOwn(CurrentUser.requireId(authentication));
+ }
+
+ @PostMapping("/api/v1/watchlist/items")
+ @ResponseStatus(HttpStatus.CREATED)
+ public void addItem(Authentication authentication, @Valid @RequestBody AddWatchlistItemRequest request) {
+ watchlistUseCase.addItem(CurrentUser.requireId(authentication), request.titleId());
+ }
+
+ @DeleteMapping("/api/v1/watchlist/items/{titleId}")
+ @ResponseStatus(HttpStatus.NO_CONTENT)
+ public void removeItem(Authentication authentication, @PathVariable String titleId) {
+ watchlistUseCase.removeItem(CurrentUser.requireId(authentication), titleId);
+ }
+
+ @PutMapping("/api/v1/watchlist/visibility")
+ public void updateVisibility(Authentication authentication, @Valid @RequestBody com.ludovictemgoua.imdb.application.rest.VisibilityRequest request) {
+ watchlistUseCase.updateVisibility(CurrentUser.requireId(authentication), request.visibility());
+ }
+
+ @GetMapping("/api/v1/users/{userId}/watchlist")
+ public WatchlistView getForUser(Authentication authentication, @PathVariable int userId) {
+ return watchlistUseCase.getForUser(CurrentUser.idOf(authentication), userId);
+ }
+}
+```
+
+`getOwn`/`addItem`/`removeItem`/`updateVisibility` are **not** in the security filter chain's `permitAll()`
+list (Task 1.3), so Spring Security already rejects an anonymous request with `401` before this
+controller ever runs, verified by `getOwnWatchlistRequiresAuthentication` above -
+`CurrentUser.requireId`'s `IllegalStateException` for a missing user is an internal-consistency
+safety net, not the primary 401 mechanism.
+
+- [ ] **Step 8: Run the test to verify it passes**
+
+Run: `JAVA_HOME="/c/Program Files/Java/jdk-21" ./mvnw -q test -Dtest=WatchlistControllerTest`
+Expected: PASS, 3 tests green.
+
+- [ ] **Step 9: Run the full unit and integration suites**
+
+Run: `JAVA_HOME="/c/Program Files/Java/jdk-21" ./mvnw -q test && JAVA_HOME="/c/Program Files/Java/jdk-21" ./mvnw -q failsafe:integration-test failsafe:verify`
+Expected: PASS.
+
+- [ ] **Step 10: Commit**
+
+```bash
+git add src/main/java/com/ludovictemgoua/imdb/application/contracts/WatchlistUseCase.java src/main/java/com/ludovictemgoua/imdb/application/WatchlistUseCaseImpl.java src/main/java/com/ludovictemgoua/imdb/application/VisibilityRequest.java src/main/java/com/ludovictemgoua/imdb/application/AddWatchlistItemRequest.java src/main/java/com/ludovictemgoua/imdb/presentation/WatchlistController.java src/test/java/com/ludovictemgoua/imdb/application/WatchlistUseCaseImplTest.java src/test/java/com/ludovictemgoua/imdb/presentation/WatchlistControllerTest.java
+git commit -m "Add WatchlistUseCase and WatchlistController"
+```
+
+**Phase 6 checkpoint**: watchlists are fully live. This is the template Phase 8 (Custom Lists) follows
+closely (public/private visibility, ownership checks, 404-not-403 for private resources).
+
+---
+
+## Phase 7: Reviews
+
+### Task 7.1: `Review`/`RatingAggregate`, `ReviewRepository`
+
+**Files:**
+- Create: `src/main/resources/db/migration/V9__reviews.sql`
+- Create: `src/main/java/com/ludovictemgoua/imdb/domain/model/Review.java`, `RatingAggregate.java`
+- Create: `src/main/java/com/ludovictemgoua/imdb/domain/repository/ReviewRepository.java`
+- Create: `src/main/java/com/ludovictemgoua/imdb/infrastructure/persistence/JdbcReviewRepository.java`
+- Test: `src/test/java/com/ludovictemgoua/imdb/infrastructure/persistence/JdbcReviewRepositoryIntegrationTest.java`
+
+**Interfaces:**
+- Produces: `Review(int id, int userId, int titleId, int rating, String body, int version, Instant createdAt, Instant updatedAt)`, `RatingAggregate(double average, int count)`. `ReviewRepository.insert`, `findByUserAndTitle`, `update`, `softDelete`, `findByTitle`/`findByUser` (paged), `aggregateForTitle(int titleId) -> RatingAggregate` (average `0.0`/count `0` when no reviews exist).
+
+- [ ] **Step 1: Write the failing integration test**
+
+```java
+package com.ludovictemgoua.imdb.infrastructure.persistence;
+
+import com.ludovictemgoua.imdb.TestcontainersConfiguration;
+import com.ludovictemgoua.imdb.domain.model.Role;
+import com.ludovictemgoua.imdb.domain.repository.UserRepository;
+import com.ludovictemgoua.imdb.domain.repository.WriteResult;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.context.annotation.Import;
+import org.springframework.test.context.jdbc.Sql;
+import org.springframework.transaction.annotation.Transactional;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.within;
+
+@Import(TestcontainersConfiguration.class)
+@SpringBootTest
+@Transactional
+@Sql("/fixtures/fixture-data.sql")
+class JdbcReviewRepositoryIntegrationTest {
+
+ @Autowired
+ JdbcReviewRepository repository;
+ @Autowired
+ UserRepository userRepository;
+
+ @Test
+ void insertThenFindByUserAndTitleRoundTrips() {
+ int userId = userRepository.insert("reviewer1@example.com", "hash", "Reviewer", Role.USER).id();
+
+ var review = repository.insert(userId, 100, 9, "Great film");
+
+ var found = repository.findByUserAndTitle(userId, 100).orElseThrow();
+ assertThat(found.id()).isEqualTo(review.id());
+ assertThat(found.rating()).isEqualTo(9);
+ assertThat(found.version()).isEqualTo(0);
+ }
+
+ @Test
+ void updateBumpsVersionAndPersists() {
+ int userId = userRepository.insert("reviewer2@example.com", "hash", "Reviewer", Role.USER).id();
+ var review = repository.insert(userId, 100, 5, "Meh");
+
+ var result = repository.update(review.id(), 8, "Actually great", review.version());
+
+ assertThat(result).isEqualTo(WriteResult.SUCCESS);
+ var updated = repository.findByUserAndTitle(userId, 100).orElseThrow();
+ assertThat(updated.rating()).isEqualTo(8);
+ assertThat(updated.version()).isEqualTo(1);
+ }
+
+ @Test
+ void softDeleteExcludesItFromFindByUserAndTitle() {
+ int userId = userRepository.insert("reviewer3@example.com", "hash", "Reviewer", Role.USER).id();
+ var review = repository.insert(userId, 100, 5, "Meh");
+
+ repository.softDelete(review.id(), review.version());
+
+ assertThat(repository.findByUserAndTitle(userId, 100)).isEmpty();
+ }
+
+ @Test
+ void aggregateForTitleAveragesAcrossReviewers() {
+ int user1 = userRepository.insert("reviewer4@example.com", "hash", "R4", Role.USER).id();
+ int user2 = userRepository.insert("reviewer5@example.com", "hash", "R5", Role.USER).id();
+ repository.insert(user1, 200, 10, null);
+ repository.insert(user2, 200, 6, null);
+
+ var aggregate = repository.aggregateForTitle(200);
+
+ assertThat(aggregate.count()).isEqualTo(2);
+ assertThat(aggregate.average()).isCloseTo(8.0, within(0.01));
+ }
+
+ @Test
+ void aggregateForTitleIsZeroWhenNoReviewsExist() {
+ var aggregate = repository.aggregateForTitle(999999);
+
+ assertThat(aggregate.count()).isEqualTo(0);
+ assertThat(aggregate.average()).isEqualTo(0.0);
+ }
+}
+```
+
+- [ ] **Step 2: Run the test to verify it fails**
+
+Run: `JAVA_HOME="/c/Program Files/Java/jdk-21" ./mvnw -q failsafe:integration-test failsafe:verify -Dit.test=JdbcReviewRepositoryIntegrationTest`
+Expected: FAIL.
+
+- [ ] **Step 3: Create the migration**
+
+```sql
+CREATE TABLE reviews (
+ id SERIAL PRIMARY KEY,
+ user_id INTEGER NOT NULL REFERENCES users (id),
+ title_id INTEGER NOT NULL REFERENCES title_basics (tconst),
+ rating INTEGER NOT NULL CHECK (rating BETWEEN 1 AND 10),
+ body TEXT,
+ version INTEGER NOT NULL DEFAULT 0,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ deleted_at TIMESTAMPTZ
+);
+
+CREATE UNIQUE INDEX idx_reviews_user_title ON reviews (user_id, title_id) WHERE deleted_at IS NULL;
+```
+
+- [ ] **Step 4: Create `Review`/`RatingAggregate`**
+
+```java
+package com.ludovictemgoua.imdb.domain.model;
+
+import java.time.Instant;
+
+public record Review(int id, int userId, int titleId, int rating, String body, int version,
+ Instant createdAt, Instant updatedAt) {
+}
+```
+
+```java
+package com.ludovictemgoua.imdb.domain.model;
+
+public record RatingAggregate(double average, int count) {
+}
+```
+
+- [ ] **Step 5: Create `ReviewRepository` and `JdbcReviewRepository`**
+
+```java
+package com.ludovictemgoua.imdb.domain.repository;
+
+import com.ludovictemgoua.imdb.domain.model.PagedResult;
+import com.ludovictemgoua.imdb.domain.model.RatingAggregate;
+import com.ludovictemgoua.imdb.domain.model.Review;
+
+import java.util.Optional;
+
+public interface ReviewRepository {
+
+ Review insert(int userId, int titleId, int rating, String body);
+
+ Optional findByUserAndTitle(int userId, int titleId);
+
+ WriteResult update(int reviewId, int rating, String body, int expectedVersion);
+
+ WriteResult softDelete(int reviewId, int expectedVersion);
+
+ PagedResult findByTitle(int titleId, int page, int size);
+
+ PagedResult findByUser(int userId, int page, int size);
+
+ RatingAggregate aggregateForTitle(int titleId);
+}
+```
+
+```java
+package com.ludovictemgoua.imdb.infrastructure.persistence;
+
+import com.ludovictemgoua.imdb.domain.model.PagedResult;
+import com.ludovictemgoua.imdb.domain.model.RatingAggregate;
+import com.ludovictemgoua.imdb.domain.model.Review;
+import com.ludovictemgoua.imdb.domain.repository.ReviewRepository;
+import com.ludovictemgoua.imdb.domain.repository.WriteResult;
+import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
+import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
+import org.springframework.jdbc.support.GeneratedKeyHolder;
+import org.springframework.jdbc.support.KeyHolder;
+import org.springframework.stereotype.Repository;
+
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+@Repository
+public class JdbcReviewRepository implements ReviewRepository {
+
+ private final NamedParameterJdbcTemplate jdbc;
+
+ public JdbcReviewRepository(NamedParameterJdbcTemplate jdbc) {
+ this.jdbc = jdbc;
+ }
+
+ @Override
+ public Review insert(int userId, int titleId, int rating, String body) {
+ String sql = """
+ INSERT INTO reviews (user_id, title_id, rating, body) VALUES (:userId, :titleId, :rating, :body)
+ """;
+ var params = new MapSqlParameterSource()
+ .addValue("userId", userId).addValue("titleId", titleId)
+ .addValue("rating", rating).addValue("body", body);
+ KeyHolder keyHolder = new GeneratedKeyHolder();
+ jdbc.update(sql, params, keyHolder, new String[]{"id"});
+ return findByUserAndTitle(userId, titleId).orElseThrow();
+ }
+
+ @Override
+ public Optional findByUserAndTitle(int userId, int titleId) {
+ String sql = """
+ SELECT * FROM reviews WHERE user_id = :userId AND title_id = :titleId AND deleted_at IS NULL
+ """;
+ return jdbc.query(sql, Map.of("userId", userId, "titleId", titleId), JdbcReviewRepository::mapReview)
+ .stream().findFirst();
+ }
+
+ @Override
+ public WriteResult update(int reviewId, int rating, String body, int expectedVersion) {
+ String sql = """
+ UPDATE reviews SET rating = :rating, body = :body, version = version + 1, updated_at = now()
+ WHERE id = :id AND version = :expectedVersion AND deleted_at IS NULL
+ """;
+ var params = new MapSqlParameterSource()
+ .addValue("rating", rating).addValue("body", body)
+ .addValue("id", reviewId).addValue("expectedVersion", expectedVersion);
+ return jdbc.update(sql, params) == 0 ? WriteResult.VERSION_CONFLICT : WriteResult.SUCCESS;
+ }
+
+ @Override
+ public WriteResult softDelete(int reviewId, int expectedVersion) {
+ String sql = """
+ UPDATE reviews SET deleted_at = now() WHERE id = :id AND version = :expectedVersion AND deleted_at IS NULL
+ """;
+ var params = Map.of("id", reviewId, "expectedVersion", expectedVersion);
+ return jdbc.update(sql, params) == 0 ? WriteResult.VERSION_CONFLICT : WriteResult.SUCCESS;
+ }
+
+ @Override
+ public PagedResult findByTitle(int titleId, int page, int size) {
+ String dataSql = """
+ SELECT * FROM reviews WHERE title_id = :titleId AND deleted_at IS NULL
+ ORDER BY created_at DESC LIMIT :limit OFFSET :offset
+ """;
+ String countSql = "SELECT count(*) FROM reviews WHERE title_id = :titleId AND deleted_at IS NULL";
+ var params = new MapSqlParameterSource()
+ .addValue("titleId", titleId).addValue("limit", size).addValue("offset", (long) page * size);
+ List content = jdbc.query(dataSql, params, JdbcReviewRepository::mapReview);
+ Long total = jdbc.queryForObject(countSql, params, Long.class);
+ return new PagedResult<>(content, total == null ? 0 : total, page, size);
+ }
+
+ @Override
+ public PagedResult findByUser(int userId, int page, int size) {
+ String dataSql = """
+ SELECT * FROM reviews WHERE user_id = :userId AND deleted_at IS NULL
+ ORDER BY created_at DESC LIMIT :limit OFFSET :offset
+ """;
+ String countSql = "SELECT count(*) FROM reviews WHERE user_id = :userId AND deleted_at IS NULL";
+ var params = new MapSqlParameterSource()
+ .addValue("userId", userId).addValue("limit", size).addValue("offset", (long) page * size);
+ List content = jdbc.query(dataSql, params, JdbcReviewRepository::mapReview);
+ Long total = jdbc.queryForObject(countSql, params, Long.class);
+ return new PagedResult<>(content, total == null ? 0 : total, page, size);
+ }
+
+ @Override
+ public RatingAggregate aggregateForTitle(int titleId) {
+ String sql = """
+ SELECT COALESCE(AVG(rating), 0) AS avg_rating, COUNT(*) AS review_count
+ FROM reviews WHERE title_id = :titleId AND deleted_at IS NULL
+ """;
+ return jdbc.queryForObject(sql, Map.of("titleId", titleId), (rs, rowNum) ->
+ new RatingAggregate(rs.getDouble("avg_rating"), rs.getInt("review_count")));
+ }
+
+ private static Review mapReview(ResultSet rs, int rowNum) throws SQLException {
+ return new Review(rs.getInt("id"), rs.getInt("user_id"), rs.getInt("title_id"), rs.getInt("rating"),
+ rs.getString("body"), rs.getInt("version"),
+ rs.getTimestamp("created_at").toInstant(), rs.getTimestamp("updated_at").toInstant());
+ }
+}
+```
+
+- [ ] **Step 6: Run the test to verify it passes**
+
+Run: `JAVA_HOME="/c/Program Files/Java/jdk-21" ./mvnw -q failsafe:integration-test failsafe:verify -Dit.test=JdbcReviewRepositoryIntegrationTest`
+Expected: PASS, 5 tests green.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add src/main/resources/db/migration/V9__reviews.sql src/main/java/com/ludovictemgoua/imdb/domain/model/Review.java src/main/java/com/ludovictemgoua/imdb/domain/model/RatingAggregate.java src/main/java/com/ludovictemgoua/imdb/domain/repository/ReviewRepository.java src/main/java/com/ludovictemgoua/imdb/infrastructure/persistence/JdbcReviewRepository.java src/test/java/com/ludovictemgoua/imdb/infrastructure/persistence/JdbcReviewRepositoryIntegrationTest.java
+git commit -m "Add reviews table and ReviewRepository"
+```
+
+### Task 7.2: `ReviewUseCase`, `ReviewController`, `TitleDetail.userRating*` wiring, cache eviction
+
+**Files:**
+- Create: `src/main/java/com/ludovictemgoua/imdb/application/contracts/ReviewUseCase.java`
+- Create: `src/main/java/com/ludovictemgoua/imdb/application/ReviewUseCaseImpl.java`
+- Create: `src/main/java/com/ludovictemgoua/imdb/application/ReviewRequest.java` (record)
+- Create: `src/main/java/com/ludovictemgoua/imdb/infrastructure/cache/CachingReviewUseCase.java`
+- Create: `src/main/java/com/ludovictemgoua/imdb/presentation/ReviewController.java`
+- Modify: `src/main/java/com/ludovictemgoua/imdb/domain/model/TitleDetail.java`
+- Modify: `src/main/java/com/ludovictemgoua/imdb/application/TitleDetailUseCaseImpl.java`
+- Test: `src/test/java/com/ludovictemgoua/imdb/application/ReviewUseCaseImplTest.java`
+- Test: `src/test/java/com/ludovictemgoua/imdb/application/TitleDetailUseCaseImplTest.java` (extend)
+- Test: `src/test/java/com/ludovictemgoua/imdb/presentation/ReviewControllerTest.java`
+
+**Interfaces:**
+- Consumes: `ReviewRepository` (Task 7.1), `CurrentUser` (Task 1.6)
+- Produces: the full reviews endpoint set (`docs/crud-expansion-design.md` §4.3), and
+ `TitleDetail.userRatingAverage()`/`userRatingCount()` populated from `ReviewRepository.aggregateForTitle`.
+
+- [ ] **Step 1: Write the failing unit tests**
+
+```java
+package com.ludovictemgoua.imdb.application;
+
+import com.ludovictemgoua.imdb.application.rest.ReviewRequest;
+import com.ludovictemgoua.imdb.domain.exception.ConflictException;
+import com.ludovictemgoua.imdb.domain.model.Review;
+import com.ludovictemgoua.imdb.domain.repository.ReviewRepository;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.time.Instant;
+import java.util.Optional;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.BDDMockito.given;
+
+@ExtendWith(MockitoExtension.class)
+class ReviewUseCaseImplTest {
+
+ @Mock
+ ReviewRepository reviewRepository;
+
+ @Test
+ void createThrowsConflictWhenAReviewAlreadyExists() {
+ given(reviewRepository.findByUserAndTitle(7, 100)).willReturn(
+ Optional.of(new Review(1, 7, 100, 8, "Existing", 0, Instant.now(), Instant.now())));
+ var useCase = new ReviewUseCaseImpl(reviewRepository);
+
+ assertThatThrownBy(() -> useCase.create(7, "tt0000100", new ReviewRequest(9, "New", 0)))
+ .isInstanceOf(ConflictException.class);
+ }
+
+ @Test
+ void createInsertsWhenNoneExistsYet() {
+ given(reviewRepository.findByUserAndTitle(7, 100)).willReturn(Optional.empty());
+ var created = new Review(1, 7, 100, 9, "New", 0, Instant.now(), Instant.now());
+ given(reviewRepository.insert(7, 100, 9, "New")).willReturn(created);
+
+ var result = new ReviewUseCaseImpl(reviewRepository).create(7, "tt0000100", new ReviewRequest(9, "New", 0));
+
+ assertThat(result.rating()).isEqualTo(9);
+ }
+}
+```
+
+- [ ] **Step 2: Run the test to verify it fails**
+
+Run: `JAVA_HOME="/c/Program Files/Java/jdk-21" ./mvnw -q test -Dtest=ReviewUseCaseImplTest`
+Expected: FAIL.
+
+- [ ] **Step 3: Create `ReviewRequest`, `ReviewUseCase`/`Impl`**
+
+```java
+package com.ludovictemgoua.imdb.application;
+
+import jakarta.validation.constraints.Max;
+import jakarta.validation.constraints.Min;
+
+public record ReviewRequest(@Min(1) @Max(10) int rating, String body, int version) {
+}
+```
+
+```java
+package com.ludovictemgoua.imdb.application.contracts;
+
+import com.ludovictemgoua.imdb.application.rest.ReviewRequest;
+import com.ludovictemgoua.imdb.domain.model.PagedResult;
+import com.ludovictemgoua.imdb.domain.model.Review;
+
+public interface ReviewUseCase {
+
+ Review create(int userId, String titleId, ReviewRequest request);
+
+ Review getMine(int userId, String titleId);
+
+ Review update(int userId, String titleId, com.ludovictemgoua.imdb.application.rest.ReviewRequest request);
+
+ void delete(int userId, String titleId, int expectedVersion);
+
+ PagedResult listForTitle(String titleId, int page, int size);
+
+ PagedResult listForUser(int userId, int page, int size);
+}
+```
+
+```java
+package com.ludovictemgoua.imdb.application;
+
+import com.ludovictemgoua.imdb.application.contracts.ReviewUseCase;
+import com.ludovictemgoua.imdb.application.rest.ReviewRequest;
+import com.ludovictemgoua.imdb.domain.exception.ConflictException;
+import com.ludovictemgoua.imdb.domain.exception.NotFoundException;
+import com.ludovictemgoua.imdb.domain.model.PagedResult;
+import com.ludovictemgoua.imdb.domain.model.Review;
+import com.ludovictemgoua.imdb.domain.repository.ReviewRepository;
+import com.ludovictemgoua.imdb.domain.repository.WriteResult;
+import com.ludovictemgoua.imdb.utils.ImdbIds;
+import org.springframework.stereotype.Service;
+
+@Service
+public class ReviewUseCaseImpl implements ReviewUseCase {
+
+ private final ReviewRepository reviewRepository;
+
+ public ReviewUseCaseImpl(ReviewRepository reviewRepository) {
+ this.reviewRepository = reviewRepository;
+ }
+
+ @Override
+ public Review create(int userId, String titleId, ReviewRequest request) {
+ int tconst = ImdbIds.parseTitleId(titleId);
+ if (reviewRepository.findByUserAndTitle(userId, tconst).isPresent()) {
+ throw new ConflictException("You already reviewed this title - use PUT to update it");
+ }
+ return reviewRepository.insert(userId, tconst, request.rating(), request.body());
+ }
+
+ @Override
+ public Review getMine(int userId, String titleId) {
+ return reviewRepository.findByUserAndTitle(userId, ImdbIds.parseTitleId(titleId))
+ .orElseThrow(() -> new NotFoundException("You haven't reviewed this title"));
+ }
+
+ @Override
+ public Review update(int userId, String titleId, ReviewRequest request) {
+ Review existing = getMine(userId, titleId);
+ WriteResult result = reviewRepository.update(existing.id(), request.rating(), request.body(), request.version());
+ if (result == WriteResult.VERSION_CONFLICT) {
+ throw new ConflictException("Your review was modified concurrently - refresh and retry");
+ }
+ return getMine(userId, titleId);
+ }
+
+ @Override
+ public void delete(int userId, String titleId, int expectedVersion) {
+ Review existing = getMine(userId, titleId);
+ WriteResult result = reviewRepository.softDelete(existing.id(), expectedVersion);
+ if (result == WriteResult.VERSION_CONFLICT) {
+ throw new ConflictException("Your review was modified concurrently - refresh and retry");
+ }
+ }
+
+ @Override
+ public PagedResult listForTitle(String titleId, int page, int size) {
+ return reviewRepository.findByTitle(ImdbIds.parseTitleId(titleId), page, size);
+ }
+
+ @Override
+ public PagedResult listForUser(int userId, int page, int size) {
+ return reviewRepository.findByUser(userId, page, size);
+ }
+}
+```
+
+- [ ] **Step 4: Run the test to verify it passes**
+
+Run: `JAVA_HOME="/c/Program Files/Java/jdk-21" ./mvnw -q test -Dtest=ReviewUseCaseImplTest`
+Expected: PASS.
+
+- [ ] **Step 5: Add `userRatingAverage`/`userRatingCount` to `TitleDetail` and wire them in `TitleDetailUseCaseImpl`**
+
+`TitleDetail` gains two components at the end:
+
+```java
+package com.ludovictemgoua.imdb.domain.model;
+
+import java.util.List;
+
+public record TitleDetail(String id, String primaryTitle, String originalTitle, String titleType,
+ Integer startYear, Integer endYear, Integer runtimeMinutes,
+ List genres, RatingView rating,
+ List directors, List writers,
+ List cast, int castTotalCount,
+ double userRatingAverage, int userRatingCount) {
+}
+```
+
+`TitleDetailUseCaseImpl` gains a `ReviewRepository` dependency and populates the two new fields:
+
+```java
+package com.ludovictemgoua.imdb.application;
+
+import com.ludovictemgoua.imdb.application.contracts.TitleDetailUseCase;
+import com.ludovictemgoua.imdb.domain.exception.NotFoundException;
+import com.ludovictemgoua.imdb.domain.model.RatingView;
+import com.ludovictemgoua.imdb.domain.model.TitleDetail;
+import com.ludovictemgoua.imdb.domain.repository.ReviewRepository;
+import com.ludovictemgoua.imdb.domain.repository.TitleRepository;
+import com.ludovictemgoua.imdb.utils.ImdbIds;
+import org.springframework.stereotype.Service;
+
+@Service
+public class TitleDetailUseCaseImpl implements TitleDetailUseCase {
+
+ private static final int CAST_LIMIT = 20;
+
+ private final TitleRepository titleRepository;
+ private final ReviewRepository reviewRepository;
+
+ public TitleDetailUseCaseImpl(TitleRepository titleRepository, ReviewRepository reviewRepository) {
+ this.titleRepository = titleRepository;
+ this.reviewRepository = reviewRepository;
+ }
+
+ @Override
+ public TitleDetail getDetail(String titleId) {
+ int tconst = ImdbIds.parseTitleId(titleId);
+ var core = titleRepository.findCore(tconst)
+ .orElseThrow(() -> new NotFoundException("No title with id " + titleId));
+ var directors = titleRepository.findDirectors(tconst);
+ var writers = titleRepository.findWriters(tconst);
+ var cast = titleRepository.findTopCast(tconst, CAST_LIMIT);
+ int castTotal = titleRepository.countCast(tconst);
+ var userRating = reviewRepository.aggregateForTitle(tconst);
+ return new TitleDetail(
+ core.id(), core.primaryTitle(), core.originalTitle(), core.titleType(),
+ core.startYear(), core.endYear(), core.runtimeMinutes(), core.genres(),
+ new RatingView(core.averageRating() == null ? 0 : core.averageRating(),
+ core.numVotes() == null ? 0 : core.numVotes()),
+ directors, writers, cast, castTotal,
+ userRating.average(), userRating.count());
+ }
+}
+```
+
+Update the existing `TitleDetailUseCaseImplTest` (if one exists; if not, this is the first test for this
+class - either way, the existing four-argument `TitleDetail` assertions elsewhere in the test suite need
+a `ReviewRepository` mock added and a stubbed `aggregateForTitle` call). Add to that test class:
+
+```java
+ @Mock
+ ReviewRepository reviewRepository;
+
+ // In every existing test method that constructs `new TitleDetailUseCaseImpl(titleRepository)`,
+ // change the constructor call to `new TitleDetailUseCaseImpl(titleRepository, reviewRepository)`
+ // and stub `given(reviewRepository.aggregateForTitle(100)).willReturn(new RatingAggregate(0, 0));`
+ // (or the appropriate tconst or a lenient stub) before each call, matching whatever fixture tconst
+ // that test already uses.
+```
+
+- [ ] **Step 6: Run the full unit suite to catch every other place `TitleDetail`'s constructor is called**
+
+Run: `JAVA_HOME="/c/Program Files/Java/jdk-21" ./mvnw -q test`
+Expected: FAIL initially with compile errors everywhere `new TitleDetail(...)` or
+`new TitleDetailUseCaseImpl(...)` is called with the old arity - fix each call site the compiler reports
+(likely `TitleControllerTest` and any fixture-building test helper) by adding the two new arguments
+(`0.0, 0` for a plain stub, or real aggregate values where the test cares). Re-run until green.
+
+- [ ] **Step 7: Create the cache-evicting decorator and `ReviewController`**
+
+```java
+package com.ludovictemgoua.imdb.infrastructure.cache;
+
+import com.ludovictemgoua.imdb.application.rest.ReviewRequest;
+import com.ludovictemgoua.imdb.application.ReviewUseCaseImpl;
+import com.ludovictemgoua.imdb.application.contracts.ReviewUseCase;
+import com.ludovictemgoua.imdb.domain.model.PagedResult;
+import com.ludovictemgoua.imdb.domain.model.Review;
+import org.springframework.cache.annotation.CacheEvict;
+import org.springframework.context.annotation.Primary;
+import org.springframework.stereotype.Service;
+
+// title-detail embeds userRatingAverage/userRatingCount (Step 5 above) - any review write must evict
+// the affected title's cache entry the same way an admin rating write does (CachingTitleAdminUseCase).
+@Service
+@Primary
+public class CachingReviewUseCase implements ReviewUseCase {
+
+ private final ReviewUseCaseImpl delegate;
+
+ public CachingReviewUseCase(ReviewUseCaseImpl delegate) {
+ this.delegate = delegate;
+ }
+
+ @Override
+ @CacheEvict(cacheNames = "title-detail", key = "#titleId")
+ public Review create(int userId, String titleId, ReviewRequest request) {
+ return delegate.create(userId, titleId, request);
+ }
+
+ @Override
+ public Review getMine(int userId, String titleId) {
+ return delegate.getMine(userId, titleId);
+ }
+
+ @Override
+ @CacheEvict(cacheNames = "title-detail", key = "#titleId")
+ public Review update(int userId, String titleId, ReviewRequest request) {
+ return delegate.update(userId, titleId, request);
+ }
+
+ @Override
+ @CacheEvict(cacheNames = "title-detail", key = "#titleId")
+ public void delete(int userId, String titleId, int expectedVersion) {
+ delegate.delete(userId, titleId, expectedVersion);
+ }
+
+ @Override
+ public PagedResult listForTitle(String titleId, int page, int size) {
+ return delegate.listForTitle(titleId, page, size);
+ }
+
+ @Override
+ public PagedResult listForUser(int userId, int page, int size) {
+ return delegate.listForUser(userId, page, size);
+ }
+}
+```
+
+```java
+package com.ludovictemgoua.imdb.presentation;
+
+import com.ludovictemgoua.imdb.application.rest.ReviewRequest;
+import com.ludovictemgoua.imdb.application.contracts.ReviewUseCase;
+import com.ludovictemgoua.imdb.domain.model.PagedResult;
+import com.ludovictemgoua.imdb.domain.model.Review;
+import com.ludovictemgoua.imdb.infrastructure.security.CurrentUser;
+import jakarta.validation.Valid;
+import jakarta.validation.constraints.Max;
+import jakarta.validation.constraints.Min;
+import org.springframework.http.HttpStatus;
+import org.springframework.security.core.Authentication;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.ResponseStatus;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+@Validated
+public class ReviewController {
+
+ private final ReviewUseCase reviewUseCase;
+
+ public ReviewController(ReviewUseCase reviewUseCase) {
+ this.reviewUseCase = reviewUseCase;
+ }
+
+ @PostMapping("/api/v1/titles/{titleId}/reviews")
+ @ResponseStatus(HttpStatus.CREATED)
+ public Review create(Authentication authentication, @PathVariable String titleId,
+ @Valid @RequestBody ReviewRequest request) {
+ return reviewUseCase.create(CurrentUser.requireId(authentication), titleId, request);
+ }
+
+ @GetMapping("/api/v1/titles/{titleId}/reviews")
+ public PagedResult listForTitle(
+ @PathVariable String titleId,
+ @RequestParam(defaultValue = "0") @Min(0) int page,
+ @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size) {
+ return reviewUseCase.listForTitle(titleId, page, size);
+ }
+
+ @GetMapping("/api/v1/titles/{titleId}/reviews/me")
+ public Review getMine(Authentication authentication, @PathVariable String titleId) {
+ return reviewUseCase.getMine(CurrentUser.requireId(authentication), titleId);
+ }
+
+ @PutMapping("/api/v1/titles/{titleId}/reviews/me")
+ public Review update(Authentication authentication, @PathVariable String titleId,
+ @Valid @RequestBody ReviewRequest request) {
+ return reviewUseCase.update(CurrentUser.requireId(authentication), titleId, request);
+ }
+
+ @DeleteMapping("/api/v1/titles/{titleId}/reviews/me")
+ @ResponseStatus(HttpStatus.NO_CONTENT)
+ public void delete(Authentication authentication, @PathVariable String titleId, @RequestParam int expectedVersion) {
+ reviewUseCase.delete(CurrentUser.requireId(authentication), titleId, expectedVersion);
+ }
+
+ @GetMapping("/api/v1/users/{userId}/reviews")
+ public PagedResult listForUser(
+ @PathVariable int userId,
+ @RequestParam(defaultValue = "0") @Min(0) int page,
+ @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size) {
+ return reviewUseCase.listForUser(userId, page, size);
+ }
+}
+```
+
+Add `GET /api/v1/titles/*/reviews` (note: **not** `/reviews/me`, which needs auth) and
+`GET /api/v1/users/*/reviews` to the security filter chain's `permitAll()` GET list (Task 1.3) if either
+was missed there - `/api/v1/users/*/reviews` is already listed; add
+`"/api/v1/titles/*/reviews"` alongside it now (`/api/v1/titles/**` already covers this as a prefix match,
+so no change is actually needed - confirm this by testing `listForTitleIsPubliclyAccessible` below before
+assuming so).
+
+- [ ] **Step 8: Write and run the controller test**
+
+```java
+package com.ludovictemgoua.imdb.presentation;
+
+import com.ludovictemgoua.imdb.application.contracts.ReviewUseCase;
+import com.ludovictemgoua.imdb.domain.model.PagedResult;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
+import org.springframework.test.context.bean.override.mockito.MockitoBean;
+import org.springframework.test.web.servlet.MockMvc;
+
+import java.util.List;
+
+import static org.mockito.BDDMockito.given;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+@WebMvcTest(ReviewController.class)
+class ReviewControllerTest {
+
+ @Autowired
+ MockMvc mockMvc;
+ @MockitoBean
+ ReviewUseCase reviewUseCase;
+
+ @Test
+ void listForTitleIsPubliclyAccessible() throws Exception {
+ given(reviewUseCase.listForTitle("tt0000100", 0, 20)).willReturn(new PagedResult<>(List.of(), 0, 0, 20));
+
+ mockMvc.perform(get("/api/v1/titles/tt0000100/reviews"))
+ .andExpect(status().isOk());
+ }
+
+ @Test
+ void getMineRequiresAuthentication() throws Exception {
+ mockMvc.perform(get("/api/v1/titles/tt0000100/reviews/me"))
+ .andExpect(status().isUnauthorized());
+ }
+}
+```
+
+Run: `JAVA_HOME="/c/Program Files/Java/jdk-21" ./mvnw -q test -Dtest=ReviewControllerTest`
+Expected: PASS. If `listForTitleIsPubliclyAccessible` instead returns `401`, add
+`"/api/v1/titles/**"` is already present as a GET-permitted prefix (Task 1.3) - the likely real cause is
+`/reviews/me` also incorrectly matching that same prefix and being wrongly public; if so, order Spring
+Security's matchers so the more specific `/reviews/me` pattern is declared **before** the broader
+`/api/v1/titles/**` pattern (Spring Security evaluates `authorizeHttpRequests` matchers in declaration
+order, first match wins), moving `"/api/v1/titles/*/reviews/me"` is not itself a GET-permitted pattern
+today so this should not occur - if it does, it means the broad `/api/v1/titles/**` permit is matching
+`/me` unintentionally, and the fix is adding an explicit
+`.requestMatchers(HttpMethod.GET, "/api/v1/titles/*/reviews/me").authenticated()` **above** the broader
+permit rule in `SecurityConfig`.
+
+- [ ] **Step 9: Run the full unit and integration suites**
+
+Run: `JAVA_HOME="/c/Program Files/Java/jdk-21" ./mvnw -q test && JAVA_HOME="/c/Program Files/Java/jdk-21" ./mvnw -q failsafe:integration-test failsafe:verify`
+Expected: PASS.
+
+- [ ] **Step 10: Commit**
+
+```bash
+git add src/main/java/com/ludovictemgoua/imdb/application/contracts/ReviewUseCase.java src/main/java/com/ludovictemgoua/imdb/application/ReviewUseCaseImpl.java src/main/java/com/ludovictemgoua/imdb/application/ReviewRequest.java src/main/java/com/ludovictemgoua/imdb/infrastructure/cache/CachingReviewUseCase.java src/main/java/com/ludovictemgoua/imdb/presentation/ReviewController.java src/main/java/com/ludovictemgoua/imdb/domain/model/TitleDetail.java src/main/java/com/ludovictemgoua/imdb/application/TitleDetailUseCaseImpl.java src/test/
+git commit -m "Add ReviewUseCase/ReviewController and wire user ratings into title detail"
+```
+
+**Phase 7 checkpoint**: reviews are fully live, and title detail now shows both the original IMDb rating
+and the aggregate user rating side by side.
+
+---
+
+## Phase 8: Custom Lists
+
+### Task 8.1: `CustomList`/`CustomListView`/`ListItemView`, `CustomListRepository`
+
+**Files:**
+- Create: `src/main/resources/db/migration/V10__lists.sql`
+- Create: `src/main/java/com/ludovictemgoua/imdb/domain/model/CustomList.java`, `CustomListView.java`, `ListItemView.java`
+- Create: `src/main/java/com/ludovictemgoua/imdb/domain/repository/CustomListRepository.java`
+- Create: `src/main/java/com/ludovictemgoua/imdb/infrastructure/persistence/JdbcCustomListRepository.java`
+- Test: `src/test/java/com/ludovictemgoua/imdb/infrastructure/persistence/JdbcCustomListRepositoryIntegrationTest.java`
+
+**Interfaces:**
+- Produces: `CustomList(int id, int userId, String name, Visibility visibility, int version)`,
+ `ListItemView(String titleId, String primaryTitle, Instant addedAt)`,
+ `CustomListView(int id, int userId, String name, Visibility visibility, int version, List items)`.
+ `CustomListRepository.insert(int userId, String name, Visibility) -> CustomList`,
+ `findById(int listId) -> Optional`,
+ `update(int listId, String name, Visibility, int expectedVersion) -> WriteResult`,
+ `softDelete(int listId, int expectedVersion) -> WriteResult`,
+ `findByUser(int userId, int page, int size) -> PagedResult`,
+ `findPublic(int page, int size) -> PagedResult`,
+ `addItem(int listId, int titleId) -> WriteResult`, `removeItem(int listId, int titleId) -> WriteResult`.
+
+- [ ] **Step 1: Write the failing integration test**
+
+```java
+package com.ludovictemgoua.imdb.infrastructure.persistence;
+
+import com.ludovictemgoua.imdb.TestcontainersConfiguration;
+import com.ludovictemgoua.imdb.domain.model.Role;
+import com.ludovictemgoua.imdb.domain.model.Visibility;
+import com.ludovictemgoua.imdb.domain.repository.UserRepository;
+import com.ludovictemgoua.imdb.domain.repository.WriteResult;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.context.annotation.Import;
+import org.springframework.test.context.jdbc.Sql;
+import org.springframework.transaction.annotation.Transactional;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+@Import(TestcontainersConfiguration.class)
+@SpringBootTest
+@Transactional
+@Sql("/fixtures/fixture-data.sql")
+class JdbcCustomListRepositoryIntegrationTest {
+
+ @Autowired
+ JdbcCustomListRepository repository;
+ @Autowired
+ UserRepository userRepository;
+
+ @Test
+ void insertThenFindByIdRoundTrips() {
+ int userId = userRepository.insert("lister1@example.com", "hash", "Lister", Role.USER).id();
+
+ var created = repository.insert(userId, "Best of 2024", Visibility.PRIVATE);
+
+ var found = repository.findById(created.id()).orElseThrow();
+ assertThat(found.name()).isEqualTo("Best of 2024");
+ assertThat(found.items()).isEmpty();
+ }
+
+ @Test
+ void addItemThenFindByIdIncludesIt() {
+ int userId = userRepository.insert("lister2@example.com", "hash", "Lister", Role.USER).id();
+ var created = repository.insert(userId, "Watch Later", Visibility.PUBLIC);
+
+ repository.addItem(created.id(), 100);
+
+ assertThat(repository.findById(created.id()).orElseThrow().items())
+ .extracting("titleId").contains("tt0000100");
+ }
+
+ @Test
+ void removeItemExcludesItFromTheList() {
+ int userId = userRepository.insert("lister3@example.com", "hash", "Lister", Role.USER).id();
+ var created = repository.insert(userId, "Watch Later", Visibility.PUBLIC);
+ repository.addItem(created.id(), 100);
+
+ repository.removeItem(created.id(), 100);
+
+ assertThat(repository.findById(created.id()).orElseThrow().items()).isEmpty();
+ }
+
+ @Test
+ void updateRenamesAndBumpsVersion() {
+ int userId = userRepository.insert("lister4@example.com", "hash", "Lister", Role.USER).id();
+ var created = repository.insert(userId, "Old Name", Visibility.PRIVATE);
+
+ var result = repository.update(created.id(), "New Name", Visibility.PUBLIC, created.version());
+
+ assertThat(result).isEqualTo(WriteResult.SUCCESS);
+ var updated = repository.findById(created.id()).orElseThrow();
+ assertThat(updated.name()).isEqualTo("New Name");
+ assertThat(updated.visibility()).isEqualTo(Visibility.PUBLIC);
+ }
+
+ @Test
+ void softDeleteExcludesItFromFindById() {
+ int userId = userRepository.insert("lister5@example.com", "hash", "Lister", Role.USER).id();
+ var created = repository.insert(userId, "Delete Me", Visibility.PRIVATE);
+
+ repository.softDelete(created.id(), created.version());
+
+ assertThat(repository.findById(created.id())).isEmpty();
+ }
+
+ @Test
+ void findPublicOnlyReturnsPublicLists() {
+ int userId = userRepository.insert("lister6@example.com", "hash", "Lister", Role.USER).id();
+ repository.insert(userId, "Public List", Visibility.PUBLIC);
+ repository.insert(userId, "Private List", Visibility.PRIVATE);
+
+ var publicLists = repository.findPublic(0, 20);
+
+ assertThat(publicLists.content()).extracting("name").contains("Public List").doesNotContain("Private List");
+ }
+}
+```
+
+- [ ] **Step 2: Run the test to verify it fails**
+
+Run: `JAVA_HOME="/c/Program Files/Java/jdk-21" ./mvnw -q failsafe:integration-test failsafe:verify -Dit.test=JdbcCustomListRepositoryIntegrationTest`
+Expected: FAIL.
+
+- [ ] **Step 3: Create the migration**
+
+```sql
+CREATE TABLE lists (
+ id SERIAL PRIMARY KEY,
+ user_id INTEGER NOT NULL REFERENCES users (id),
+ name TEXT NOT NULL,
+ visibility TEXT NOT NULL DEFAULT 'PRIVATE',
+ version INTEGER NOT NULL DEFAULT 0,
+ deleted_at TIMESTAMPTZ
+);
+
+CREATE TABLE list_items (
+ list_id INTEGER NOT NULL REFERENCES lists (id),
+ title_id INTEGER NOT NULL REFERENCES title_basics (tconst),
+ added_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ ordering SERIAL,
+ PRIMARY KEY (list_id, title_id)
+);
+```
+
+- [ ] **Step 4: Create the domain models**
+
+```java
+package com.ludovictemgoua.imdb.domain.model;
+
+public record CustomList(int id, int userId, String name, Visibility visibility, int version) {
+}
+```
+
+```java
+package com.ludovictemgoua.imdb.domain.model;
+
+import java.time.Instant;
+
+public record ListItemView(String titleId, String primaryTitle, Instant addedAt) {
+}
+```
+
+```java
+package com.ludovictemgoua.imdb.domain.model;
+
+import java.util.List;
+
+public record CustomListView(int id, int userId, String name, Visibility visibility, int version,
+ List items) {
+}
+```
+
+- [ ] **Step 5: Create `CustomListRepository` and `JdbcCustomListRepository`**
+
+```java
+package com.ludovictemgoua.imdb.domain.repository;
+
+import com.ludovictemgoua.imdb.domain.model.CustomList;
+import com.ludovictemgoua.imdb.domain.model.CustomListView;
+import com.ludovictemgoua.imdb.domain.model.PagedResult;
+import com.ludovictemgoua.imdb.domain.model.Visibility;
+
+import java.util.Optional;
+
+public interface CustomListRepository {
+
+ CustomList insert(int userId, String name, Visibility visibility);
+
+ Optional findById(int listId);
+
+ WriteResult update(int listId, String name, Visibility visibility, int expectedVersion);
+
+ WriteResult softDelete(int listId, int expectedVersion);
+
+ PagedResult findByUser(int userId, int page, int size);
+
+ PagedResult findPublic(int page, int size);
+
+ WriteResult addItem(int listId, int titleId);
+
+ WriteResult removeItem(int listId, int titleId);
+}
+```
+
+```java
+package com.ludovictemgoua.imdb.infrastructure.persistence;
+
+import com.ludovictemgoua.imdb.domain.model.CustomList;
+import com.ludovictemgoua.imdb.domain.model.CustomListView;
+import com.ludovictemgoua.imdb.domain.model.ListItemView;
+import com.ludovictemgoua.imdb.domain.model.PagedResult;
+import com.ludovictemgoua.imdb.domain.model.Visibility;
+import com.ludovictemgoua.imdb.domain.repository.CustomListRepository;
+import com.ludovictemgoua.imdb.domain.repository.WriteResult;
+import com.ludovictemgoua.imdb.utils.ImdbIds;
+import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
+import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
+import org.springframework.jdbc.support.GeneratedKeyHolder;
+import org.springframework.jdbc.support.KeyHolder;
+import org.springframework.stereotype.Repository;
+
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+@Repository
+public class JdbcCustomListRepository implements CustomListRepository {
+
+ private final NamedParameterJdbcTemplate jdbc;
+
+ public JdbcCustomListRepository(NamedParameterJdbcTemplate jdbc) {
+ this.jdbc = jdbc;
+ }
+
+ @Override
+ public CustomList insert(int userId, String name, Visibility visibility) {
+ String sql = "INSERT INTO lists (user_id, name, visibility) VALUES (:userId, :name, :visibility)";
+ var params = new MapSqlParameterSource()
+ .addValue("userId", userId).addValue("name", name).addValue("visibility", visibility.name());
+ KeyHolder keyHolder = new GeneratedKeyHolder();
+ jdbc.update(sql, params, keyHolder, new String[]{"id"});
+ return new CustomList(keyHolder.getKey().intValue(), userId, name, visibility, 0);
+ }
+
+ @Override
+ public Optional findById(int listId) {
+ String sql = "SELECT * FROM lists WHERE id = :id AND deleted_at IS NULL";
+ return jdbc.query(sql, Map.of("id", listId), (rs, rowNum) -> rs.getInt("user_id"))
+ .stream().findFirst()
+ .flatMap(ownerId -> hydrate(listId));
+ }
+
+ @Override
+ public WriteResult update(int listId, String name, Visibility visibility, int expectedVersion) {
+ String sql = """
+ UPDATE lists SET name = :name, visibility = :visibility, version = version + 1
+ WHERE id = :id AND version = :expectedVersion AND deleted_at IS NULL
+ """;
+ var params = new MapSqlParameterSource()
+ .addValue("name", name).addValue("visibility", visibility.name())
+ .addValue("id", listId).addValue("expectedVersion", expectedVersion);
+ return jdbc.update(sql, params) == 0 ? WriteResult.VERSION_CONFLICT : WriteResult.SUCCESS;
+ }
+
+ @Override
+ public WriteResult softDelete(int listId, int expectedVersion) {
+ String sql = "UPDATE lists SET deleted_at = now() WHERE id = :id AND version = :expectedVersion AND deleted_at IS NULL";
+ var params = Map.of("id", listId, "expectedVersion", expectedVersion);
+ return jdbc.update(sql, params) == 0 ? WriteResult.VERSION_CONFLICT : WriteResult.SUCCESS;
+ }
+
+ @Override
+ public PagedResult findByUser(int userId, int page, int size) {
+ String dataSql = """
+ SELECT * FROM lists WHERE user_id = :userId AND deleted_at IS NULL
+ ORDER BY id LIMIT :limit OFFSET :offset
+ """;
+ String countSql = "SELECT count(*) FROM lists WHERE user_id = :userId AND deleted_at IS NULL";
+ var params = new MapSqlParameterSource()
+ .addValue("userId", userId).addValue("limit", size).addValue("offset", (long) page * size);
+ List content = jdbc.query(dataSql, params, JdbcCustomListRepository::mapList);
+ Long total = jdbc.queryForObject(countSql, params, Long.class);
+ return new PagedResult<>(content, total == null ? 0 : total, page, size);
+ }
+
+ @Override
+ public PagedResult findPublic(int page, int size) {
+ String dataSql = """
+ SELECT * FROM lists WHERE visibility = 'PUBLIC' AND deleted_at IS NULL
+ ORDER BY id LIMIT :limit OFFSET :offset
+ """;
+ String countSql = "SELECT count(*) FROM lists WHERE visibility = 'PUBLIC' AND deleted_at IS NULL";
+ var params = new MapSqlParameterSource().addValue("limit", size).addValue("offset", (long) page * size);
+ List content = jdbc.query(dataSql, params, JdbcCustomListRepository::mapList);
+ Long total = jdbc.queryForObject(countSql, params, Long.class);
+ return new PagedResult<>(content, total == null ? 0 : total, page, size);
+ }
+
+ @Override
+ public WriteResult addItem(int listId, int titleId) {
+ String sql = "INSERT INTO list_items (list_id, title_id) VALUES (:listId, :titleId) ON CONFLICT DO NOTHING";
+ jdbc.update(sql, Map.of("listId", listId, "titleId", titleId));
+ return WriteResult.SUCCESS;
+ }
+
+ @Override
+ public WriteResult removeItem(int listId, int titleId) {
+ jdbc.update("DELETE FROM list_items WHERE list_id = :listId AND title_id = :titleId",
+ Map.of("listId", listId, "titleId", titleId));
+ return WriteResult.SUCCESS;
+ }
+
+ private Optional hydrate(int listId) {
+ String metaSql = "SELECT * FROM lists WHERE id = :id AND deleted_at IS NULL";
+ List meta = jdbc.query(metaSql, Map.of("id", listId), JdbcCustomListRepository::mapList);
+ if (meta.isEmpty()) {
+ return Optional.empty();
+ }
+ String itemsSql = """
+ SELECT tb.tconst, tb.primary_title, li.added_at
+ FROM list_items li JOIN title_basics tb ON tb.tconst = li.title_id
+ WHERE li.list_id = :listId AND tb.deleted_at IS NULL
+ ORDER BY li.ordering
+ """;
+ List items = jdbc.query(itemsSql, Map.of("listId", listId),
+ (rs, rowNum) -> new ListItemView(ImdbIds.formatTitleId(rs.getInt("tconst")),
+ rs.getString("primary_title"), rs.getTimestamp("added_at").toInstant()));
+ CustomList list = meta.get(0);
+ return Optional.of(new CustomListView(list.id(), list.userId(), list.name(), list.visibility(),
+ list.version(), items));
+ }
+
+ private static CustomList mapList(ResultSet rs, int rowNum) throws SQLException {
+ return new CustomList(rs.getInt("id"), rs.getInt("user_id"), rs.getString("name"),
+ Visibility.valueOf(rs.getString("visibility")), rs.getInt("version"));
+ }
+}
+```
+
+- [ ] **Step 6: Run the test to verify it passes**
+
+Run: `JAVA_HOME="/c/Program Files/Java/jdk-21" ./mvnw -q failsafe:integration-test failsafe:verify -Dit.test=JdbcCustomListRepositoryIntegrationTest`
+Expected: PASS, 6 tests green.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add src/main/resources/db/migration/V10__lists.sql src/main/java/com/ludovictemgoua/imdb/domain/model/CustomList.java src/main/java/com/ludovictemgoua/imdb/domain/model/CustomListView.java src/main/java/com/ludovictemgoua/imdb/domain/model/ListItemView.java src/main/java/com/ludovictemgoua/imdb/domain/repository/CustomListRepository.java src/main/java/com/ludovictemgoua/imdb/infrastructure/persistence/JdbcCustomListRepository.java src/test/java/com/ludovictemgoua/imdb/infrastructure/persistence/JdbcCustomListRepositoryIntegrationTest.java
+git commit -m "Add lists/list_items tables and CustomListRepository"
+```
+
+### Task 8.2: `ListUseCase` (ownership/visibility rules), `ListController`
+
+**Files:**
+- Create: `src/main/java/com/ludovictemgoua/imdb/application/contracts/ListUseCase.java`
+- Create: `src/main/java/com/ludovictemgoua/imdb/application/ListUseCaseImpl.java`
+- Create: `src/main/java/com/ludovictemgoua/imdb/application/CreateListRequest.java`, `UpdateListRequest.java`, `AddListItemRequest.java` (records)
+- Create: `src/main/java/com/ludovictemgoua/imdb/presentation/ListController.java`
+- Test: `src/test/java/com/ludovictemgoua/imdb/application/ListUseCaseImplTest.java`
+- Test: `src/test/java/com/ludovictemgoua/imdb/presentation/ListControllerTest.java`
+
+**Interfaces:**
+- Consumes: `CustomListRepository` (Task 8.1), `CurrentUser` (Task 1.6)
+- Produces: the full custom-lists endpoint set (`docs/crud-expansion-design.md` §4.4), with the exact
+ privacy rule stated there: viewing a `PRIVATE` list you don't own is `404`; *writing* to a list you
+ don't own is `403` if it's `PUBLIC` (its existence is already visible) and `404` if it's `PRIVATE`
+ (existence stays hidden either way).
+
+- [ ] **Step 1: Write the failing unit test**
+
+```java
+package com.ludovictemgoua.imdb.application;
+
+import com.ludovictemgoua.imdb.domain.exception.ForbiddenException;
+import com.ludovictemgoua.imdb.domain.exception.NotFoundException;
+import com.ludovictemgoua.imdb.domain.model.CustomListView;
+import com.ludovictemgoua.imdb.domain.model.Visibility;
+import com.ludovictemgoua.imdb.domain.repository.CustomListRepository;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.util.List;
+import java.util.Optional;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.BDDMockito.given;
+
+@ExtendWith(MockitoExtension.class)
+class ListUseCaseImplTest {
+
+ @Mock
+ CustomListRepository customListRepository;
+
+ @Test
+ void getByIdReturnsAPublicListToAnyone() {
+ var view = new CustomListView(1, 7, "Public", Visibility.PUBLIC, 0, List.of());
+ given(customListRepository.findById(1)).willReturn(Optional.of(view));
+
+ assertThat(new ListUseCaseImpl(customListRepository).getById(1, Optional.empty())).isSameAs(view);
+ }
+
+ @Test
+ void getByIdThrowsNotFoundForAPrivateListViewedByAStranger() {
+ var view = new CustomListView(1, 7, "Private", Visibility.PRIVATE, 0, List.of());
+ given(customListRepository.findById(1)).willReturn(Optional.of(view));
+ var useCase = new ListUseCaseImpl(customListRepository);
+
+ assertThatThrownBy(() -> useCase.getById(1, Optional.of(99))).isInstanceOf(NotFoundException.class);
+ }
+
+ @Test
+ void addItemThrowsForbiddenWhenAStrangerWritesToAPublicList() {
+ var view = new CustomListView(1, 7, "Public", Visibility.PUBLIC, 0, List.of());
+ given(customListRepository.findById(1)).willReturn(Optional.of(view));
+ var useCase = new ListUseCaseImpl(customListRepository);
+
+ assertThatThrownBy(() -> useCase.addItem(1, 99, "tt0000100")).isInstanceOf(ForbiddenException.class);
+ }
+
+ @Test
+ void addItemThrowsNotFoundWhenAStrangerWritesToAPrivateList() {
+ var view = new CustomListView(1, 7, "Private", Visibility.PRIVATE, 0, List.of());
+ given(customListRepository.findById(1)).willReturn(Optional.of(view));
+ var useCase = new ListUseCaseImpl(customListRepository);
+
+ assertThatThrownBy(() -> useCase.addItem(1, 99, "tt0000100")).isInstanceOf(NotFoundException.class);
+ }
+
+ @Test
+ void addItemSucceedsForTheOwner() {
+ var view = new CustomListView(1, 7, "Private", Visibility.PRIVATE, 0, List.of());
+ given(customListRepository.findById(1)).willReturn(Optional.of(view));
+
+ new ListUseCaseImpl(customListRepository).addItem(1, 7, "tt0000100");
+ }
+}
+```
+
+- [ ] **Step 2: Run the test to verify it fails**
+
+Run: `JAVA_HOME="/c/Program Files/Java/jdk-21" ./mvnw -q test -Dtest=ListUseCaseImplTest`
+Expected: FAIL.
+
+- [ ] **Step 3: Create the request records and `ListUseCase`/`Impl`**
+
+```java
+package com.ludovictemgoua.imdb.application;
+
+import com.ludovictemgoua.imdb.domain.model.Visibility;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+
+public record CreateListRequest(@NotBlank String name, @NotNull Visibility visibility) {
+}
+```
+
+```java
+package com.ludovictemgoua.imdb.application;
+
+import com.ludovictemgoua.imdb.domain.model.Visibility;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+
+public record UpdateListRequest(@NotBlank String name, @NotNull Visibility visibility, int version) {
+}
+```
+
+```java
+package com.ludovictemgoua.imdb.application;
+
+import jakarta.validation.constraints.NotBlank;
+
+public record AddListItemRequest(@NotBlank String titleId) {
+}
+```
+
+```java
+package com.ludovictemgoua.imdb.application.contracts;
+
+import com.ludovictemgoua.imdb.domain.model.CustomList;
+import com.ludovictemgoua.imdb.domain.model.CustomListView;
+import com.ludovictemgoua.imdb.domain.model.PagedResult;
+
+import java.util.Optional;
+
+public interface ListUseCase {
+
+ CustomList create(int userId, com.ludovictemgoua.imdb.application.rest.CreateListRequest request);
+
+ CustomListView getById(int listId, Optional viewerUserId);
+
+ PagedResult getMine(int userId, int page, int size);
+
+ PagedResult getPublic(int page, int size);
+
+ void update(int listId, int userId, com.ludovictemgoua.imdb.application.rest.UpdateListRequest request);
+
+ void delete(int listId, int userId, int expectedVersion);
+
+ void addItem(int listId, int userId, String titleId);
+
+ void removeItem(int listId, int userId, String titleId);
+}
+```
+
+```java
+package com.ludovictemgoua.imdb.application;
+
+import com.ludovictemgoua.imdb.application.contracts.ListUseCase;
+import com.ludovictemgoua.imdb.application.rest.CreateListRequest;
+import com.ludovictemgoua.imdb.application.rest.UpdateListRequest;
+import com.ludovictemgoua.imdb.domain.exception.ConflictException;
+import com.ludovictemgoua.imdb.domain.exception.ForbiddenException;
+import com.ludovictemgoua.imdb.domain.exception.NotFoundException;
+import com.ludovictemgoua.imdb.domain.model.CustomList;
+import com.ludovictemgoua.imdb.domain.model.CustomListView;
+import com.ludovictemgoua.imdb.domain.model.PagedResult;
+import com.ludovictemgoua.imdb.domain.model.Visibility;
+import com.ludovictemgoua.imdb.domain.repository.CustomListRepository;
+import com.ludovictemgoua.imdb.domain.repository.WriteResult;
+import com.ludovictemgoua.imdb.utils.ImdbIds;
+import org.springframework.stereotype.Service;
+
+import java.util.Optional;
+
+@Service
+public class ListUseCaseImpl implements ListUseCase {
+
+ private final CustomListRepository customListRepository;
+
+ public ListUseCaseImpl(CustomListRepository customListRepository) {
+ this.customListRepository = customListRepository;
+ }
+
+ @Override
+ public CustomList create(int userId, CreateListRequest request) {
+ return customListRepository.insert(userId, request.name(), request.visibility());
+ }
+
+ @Override
+ public CustomListView getById(int listId, Optional viewerUserId) {
+ CustomListView list = findOrThrow(listId);
+ boolean isOwner = viewerUserId.isPresent() && viewerUserId.get() == list.userId();
+ if (list.visibility() == Visibility.PRIVATE && !isOwner) {
+ throw new NotFoundException("No list with id " + listId);
+ }
+ return list;
+ }
+
+ @Override
+ public PagedResult getMine(int userId, int page, int size) {
+ return customListRepository.findByUser(userId, page, size);
+ }
+
+ @Override
+ public PagedResult getPublic(int page, int size) {
+ return customListRepository.findPublic(page, size);
+ }
+
+ @Override
+ public void update(int listId, int userId, UpdateListRequest request) {
+ CustomListView list = requireOwner(listId, userId);
+ WriteResult result = customListRepository.update(listId, request.name(), request.visibility(), request.version());
+ if (result == WriteResult.VERSION_CONFLICT) {
+ throw new ConflictException("List " + list.id() + " was modified concurrently - refresh and retry");
+ }
+ }
+
+ @Override
+ public void delete(int listId, int userId, int expectedVersion) {
+ requireOwner(listId, userId);
+ WriteResult result = customListRepository.softDelete(listId, expectedVersion);
+ if (result == WriteResult.VERSION_CONFLICT) {
+ throw new ConflictException("List " + listId + " was modified concurrently - refresh and retry");
+ }
+ }
+
+ @Override
+ public void addItem(int listId, int userId, String titleId) {
+ requireOwner(listId, userId);
+ customListRepository.addItem(listId, ImdbIds.parseTitleId(titleId));
+ }
+
+ @Override
+ public void removeItem(int listId, int userId, String titleId) {
+ requireOwner(listId, userId);
+ customListRepository.removeItem(listId, ImdbIds.parseTitleId(titleId));
+ }
+
+ private CustomListView findOrThrow(int listId) {
+ return customListRepository.findById(listId)
+ .orElseThrow(() -> new NotFoundException("No list with id " + listId));
+ }
+
+ // A non-owner writing to a PRIVATE list gets 404 (existence hidden, same as a read); a non-owner
+ // writing to a PUBLIC list gets 403 (existence is already visible, the action is what's denied).
+ // docs/crud-expansion-design.md §4.4.
+ private CustomListView requireOwner(int listId, int userId) {
+ CustomListView list = findOrThrow(listId);
+ if (list.userId() == userId) {
+ return list;
+ }
+ if (list.visibility() == Visibility.PRIVATE) {
+ throw new NotFoundException("No list with id " + listId);
+ }
+ throw new ForbiddenException("You do not own list " + listId);
+ }
+}
+```
+
+- [ ] **Step 4: Run the test to verify it passes**
+
+Run: `JAVA_HOME="/c/Program Files/Java/jdk-21" ./mvnw -q test -Dtest=ListUseCaseImplTest`
+Expected: PASS, 5 tests green.
+
+- [ ] **Step 5: Write the failing controller test**
+
+```java
+package com.ludovictemgoua.imdb.presentation;
+
+import com.ludovictemgoua.imdb.application.contracts.ListUseCase;
+import com.ludovictemgoua.imdb.domain.model.CustomListView;
+import com.ludovictemgoua.imdb.domain.model.Visibility;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
+import org.springframework.test.context.bean.override.mockito.MockitoBean;
+import org.springframework.test.web.servlet.MockMvc;
+
+import java.util.List;
+import java.util.Optional;
+
+import static org.mockito.BDDMockito.given;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+@WebMvcTest(ListController.class)
+class ListControllerTest {
+
+ @Autowired
+ MockMvc mockMvc;
+ @MockitoBean
+ ListUseCase listUseCase;
+
+ @Test
+ void getPublicListsIsAccessibleAnonymously() throws Exception {
+ given(listUseCase.getPublic(0, 20)).willReturn(new com.ludovictemgoua.imdb.domain.model.PagedResult<>(List.of(), 0, 0, 20));
+
+ mockMvc.perform(get("/api/v1/lists/public"))
+ .andExpect(status().isOk());
+ }
+
+ @Test
+ void getByIdIsAccessibleAnonymouslyForAPublicList() throws Exception {
+ given(listUseCase.getById(1, Optional.empty()))
+ .willReturn(new CustomListView(1, 7, "Public", Visibility.PUBLIC, 0, List.of()));
+
+ mockMvc.perform(get("/api/v1/lists/1"))
+ .andExpect(status().isOk());
+ }
+
+ @Test
+ void createRequiresAuthentication() throws Exception {
+ mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/v1/lists")
+ .contentType("application/json")
+ .content("""
+ {"name":"My List","visibility":"PRIVATE"}
+ """))
+ .andExpect(status().isUnauthorized());
+ }
+}
+```
+
+- [ ] **Step 6: Run the test to verify it fails**
+
+Run: `JAVA_HOME="/c/Program Files/Java/jdk-21" ./mvnw -q test -Dtest=ListControllerTest`
+Expected: FAIL - `ListController` doesn't exist yet.
+
+- [ ] **Step 7: Create `ListController`**
+
+```java
+package com.ludovictemgoua.imdb.presentation;
+
+import com.ludovictemgoua.imdb.application.rest.AddListItemRequest;
+import com.ludovictemgoua.imdb.application.rest.CreateListRequest;
+import com.ludovictemgoua.imdb.application.contracts.ListUseCase;
+import com.ludovictemgoua.imdb.domain.model.CustomList;
+import com.ludovictemgoua.imdb.domain.model.CustomListView;
+import com.ludovictemgoua.imdb.domain.model.PagedResult;
+import com.ludovictemgoua.imdb.infrastructure.security.CurrentUser;
+import jakarta.validation.Valid;
+import jakarta.validation.constraints.Max;
+import jakarta.validation.constraints.Min;
+import org.springframework.http.HttpStatus;
+import org.springframework.security.core.Authentication;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.ResponseStatus;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+@RequestMapping("/api/v1/lists")
+@Validated
+public class ListController {
+
+ private final ListUseCase listUseCase;
+
+ public ListController(ListUseCase listUseCase) {
+ this.listUseCase = listUseCase;
+ }
+
+ @PostMapping
+ @ResponseStatus(HttpStatus.CREATED)
+ public CustomList create(Authentication authentication, @Valid @RequestBody CreateListRequest request) {
+ return listUseCase.create(CurrentUser.requireId(authentication), request);
+ }
+
+ @GetMapping("/me")
+ public PagedResult getMine(
+ Authentication authentication,
+ @RequestParam(defaultValue = "0") @Min(0) int page,
+ @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size) {
+ return listUseCase.getMine(CurrentUser.requireId(authentication), page, size);
+ }
+
+ @GetMapping("/public")
+ public PagedResult getPublic(
+ @RequestParam(defaultValue = "0") @Min(0) int page,
+ @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size) {
+ return listUseCase.getPublic(page, size);
+ }
+
+ @GetMapping("/{listId}")
+ public CustomListView getById(Authentication authentication, @PathVariable int listId) {
+ return listUseCase.getById(listId, CurrentUser.idOf(authentication));
+ }
+
+ @PutMapping("/{listId}")
+ public void update(Authentication authentication, @PathVariable int listId,
+ @Valid @RequestBody com.ludovictemgoua.imdb.application.rest.UpdateListRequest request) {
+ listUseCase.update(listId, CurrentUser.requireId(authentication), request);
+ }
+
+ @DeleteMapping("/{listId}")
+ @ResponseStatus(HttpStatus.NO_CONTENT)
+ public void delete(Authentication authentication, @PathVariable int listId, @RequestParam int expectedVersion) {
+ listUseCase.delete(listId, CurrentUser.requireId(authentication), expectedVersion);
+ }
+
+ @PostMapping("/{listId}/items")
+ @ResponseStatus(HttpStatus.CREATED)
+ public void addItem(Authentication authentication, @PathVariable int listId,
+ @Valid @RequestBody AddListItemRequest request) {
+ listUseCase.addItem(listId, CurrentUser.requireId(authentication), request.titleId());
+ }
+
+ @DeleteMapping("/{listId}/items/{titleId}")
+ @ResponseStatus(HttpStatus.NO_CONTENT)
+ public void removeItem(Authentication authentication, @PathVariable int listId, @PathVariable String titleId) {
+ listUseCase.removeItem(listId, CurrentUser.requireId(authentication), titleId);
+ }
+}
+```
+
+Add `import org.springframework.web.bind.annotation.RequestMapping;` to the imports above (used by the
+class-level `@RequestMapping("/api/v1/lists")` annotation).
+
+`GET /api/v1/lists/public` and `GET /api/v1/lists/{listId}` (i.e. `/api/v1/lists/*`) must be in the
+security filter chain's `permitAll()` GET list (Task 1.3) - both already are; `GET /api/v1/lists/me`
+must **not** be public (it's already excluded, since `/api/v1/lists/*` with a single path segment does not
+match the two-segment... actually `/api/v1/lists/*` DOES match `/api/v1/lists/me` as a single-segment
+wildcard. Fix this now: change `SecurityConfig`'s pattern from `"/api/v1/lists/*"` to a request-matcher
+that excludes `me` - simplest fix is to list `/api/v1/lists/me` explicitly under `.authenticated()` by
+declaring it **before** the broader permit rule (Spring evaluates matchers in order, first match wins):
+
+```java
+ .requestMatchers(HttpMethod.GET, "/api/v1/lists/me").authenticated()
+ .requestMatchers(HttpMethod.GET,
+ "/api/v1/titles/**", "/api/v1/genres/**", "/api/v1/people/six-degrees",
+ "/api/v1/lists/public", "/api/v1/lists/*", "/api/v1/users/*",
+ "/api/v1/users/*/watchlist", "/api/v1/users/*/reviews").permitAll()
+```
+
+(Move the new `/api/v1/lists/me` line to sit above the existing broad-permit block in `SecurityConfig`.)
+
+- [ ] **Step 8: Run the test to verify it passes**
+
+Run: `JAVA_HOME="/c/Program Files/Java/jdk-21" ./mvnw -q test -Dtest=ListControllerTest`
+Expected: PASS, 3 tests green.
+
+- [ ] **Step 9: Add a `getMineRequiresAuthentication` regression test**
+
+Add to `ListControllerTest`:
+
+```java
+ @Test
+ void getMineRequiresAuthentication() throws Exception {
+ mockMvc.perform(get("/api/v1/lists/me"))
+ .andExpect(status().isUnauthorized());
+ }
+```
+
+Run: `JAVA_HOME="/c/Program Files/Java/jdk-21" ./mvnw -q test -Dtest=ListControllerTest`
+Expected: PASS - this is exactly the matcher-ordering fix from Step 7 being verified.
+
+- [ ] **Step 10: Run the full unit and integration suites**
+
+Run: `JAVA_HOME="/c/Program Files/Java/jdk-21" ./mvnw -q test && JAVA_HOME="/c/Program Files/Java/jdk-21" ./mvnw -q failsafe:integration-test failsafe:verify`
+Expected: PASS.
+
+- [ ] **Step 11: Commit**
+
+```bash
+git add src/main/java/com/ludovictemgoua/imdb/application/contracts/ListUseCase.java src/main/java/com/ludovictemgoua/imdb/application/ListUseCaseImpl.java src/main/java/com/ludovictemgoua/imdb/application/CreateListRequest.java src/main/java/com/ludovictemgoua/imdb/application/UpdateListRequest.java src/main/java/com/ludovictemgoua/imdb/application/AddListItemRequest.java src/main/java/com/ludovictemgoua/imdb/presentation/ListController.java src/main/java/com/ludovictemgoua/imdb/infrastructure/security/SecurityConfig.java src/test/java/com/ludovictemgoua/imdb/application/ListUseCaseImplTest.java src/test/java/com/ludovictemgoua/imdb/presentation/ListControllerTest.java
+git commit -m "Add ListUseCase/ListController with ownership and visibility rules"
+```
+
+**Phase 8 checkpoint**: all four new user-generated resources (users/watchlist/reviews/lists) and all
+admin CRUD are complete. Only E2E contract tests and a final full-suite pass remain.
+
+---
+
+## Phase 9: E2E/Postman Additions and Final Verification
+
+### Task 9.1: `docker-compose.e2e.yaml` env vars for auth
+
+**Files:**
+- Modify: `docker-compose.e2e.yaml`
+
+**Interfaces:**
+- Produces: the e2e stack's `imdb-service` boots with a valid `JWT_SECRET` and a bootstrap admin, so the
+ Postman collection's admin-gated requests (Task 9.2) have real ADMIN credentials to authenticate with.
+
+- [ ] **Step 1: Add the env vars to the `imdb-service` block in `docker-compose.e2e.yaml`**
+
+```yaml
+ JWT_SECRET: "e2e-test-only-secret-not-for-real-deployments-32bytes-plus"
+ IMDB_BOOTSTRAP_ADMIN_EMAIL: "admin@imdb.local"
+ IMDB_BOOTSTRAP_ADMIN_PASSWORD: "e2e-test-admin-password"
+```
+
+- [ ] **Step 2: Bring up the e2e stack locally and confirm the bootstrap admin can log in**
+
+Run:
+```bash
+docker compose -f docker-compose.e2e.yaml -p imdb-e2e up -d --build
+# wait for imdb-service to report healthy (curl -sf http://localhost:8080/actuator/health), then:
+curl -s -X POST http://localhost:8080/api/v1/auth/login -H "Content-Type: application/json" \
+ -d '{"email":"admin@imdb.local","password":"e2e-test-admin-password"}'
+```
+Expected: a JSON body with a real `accessToken`/`refreshToken`, not an error.
+
+- [ ] **Step 3: Tear down**
+
+Run: `docker compose -f docker-compose.e2e.yaml -p imdb-e2e down -v`
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add docker-compose.e2e.yaml
+git commit -m "Add JWT/bootstrap-admin env vars to the e2e stack"
+```
+
+### Task 9.2: Postman/Newman collection additions
+
+**Files:**
+- Modify: `postman/imdb-e2e.postman_collection.json`
+
+**Interfaces:**
+- Produces: new collection items covering the full auth flow, one full CRUD lifecycle for each new
+ resource, and the three negative cases called out in `docs/crud-expansion-design.md` §9 (403 for a
+ non-admin admin-write attempt, 409 for a stale-version update, 404 for a private list a stranger
+ requests). Chained via Postman's collection-level variables (`{{accessToken}}` etc., set in each
+ request's Tests script from the previous response) - the existing collection's `variable` array already
+ has `baseUrl`; add `accessToken`, `adminAccessToken`, `createdTitleId` alongside it, all with an empty
+ starting `value`.
+
+- [ ] **Step 1: Add auth-flow items** (append to the collection's top-level `item` array, after the
+ existing nine items)
+
+```json
+{
+ "name": "Register a new user",
+ "request": {
+ "method": "POST",
+ "url": "{{baseUrl}}/api/v1/auth/register",
+ "header": [{"key": "Content-Type", "value": "application/json"}],
+ "body": {
+ "mode": "raw",
+ "raw": "{\"email\":\"e2e-user@example.com\",\"password\":\"password123\",\"displayName\":\"E2E User\"}"
+ }
+ },
+ "event": [{
+ "listen": "test",
+ "script": {"exec": [
+ "pm.test('status is 201', () => pm.response.to.have.status(201));",
+ "const body = pm.response.json();",
+ "pm.collectionVariables.set('accessToken', body.accessToken);",
+ "pm.collectionVariables.set('refreshToken', body.refreshToken);"
+ ]}
+ }]
+},
+{
+ "name": "Admin logs in",
+ "request": {
+ "method": "POST",
+ "url": "{{baseUrl}}/api/v1/auth/login",
+ "header": [{"key": "Content-Type", "value": "application/json"}],
+ "body": {
+ "mode": "raw",
+ "raw": "{\"email\":\"admin@imdb.local\",\"password\":\"e2e-test-admin-password\"}"
+ }
+ },
+ "event": [{
+ "listen": "test",
+ "script": {"exec": [
+ "pm.test('status is 200', () => pm.response.to.have.status(200));",
+ "pm.collectionVariables.set('adminAccessToken', pm.response.json().accessToken);"
+ ]}
+ }]
+},
+{
+ "name": "Refresh the access token",
+ "request": {
+ "method": "POST",
+ "url": "{{baseUrl}}/api/v1/auth/refresh",
+ "header": [{"key": "Content-Type", "value": "application/json"}],
+ "body": {"mode": "raw", "raw": "{\"refreshToken\":\"{{refreshToken}}\"}"}
+ },
+ "event": [{
+ "listen": "test",
+ "script": {"exec": ["pm.test('status is 200', () => pm.response.to.have.status(200));"]}
+ }]
+}
+```
+
+- [ ] **Step 2: Add admin CRUD + negative-case items**
+
+```json
+{
+ "name": "Non-admin cannot create a title",
+ "request": {
+ "method": "POST",
+ "url": "{{baseUrl}}/api/v1/titles",
+ "header": [
+ {"key": "Content-Type", "value": "application/json"},
+ {"key": "Authorization", "value": "Bearer {{accessToken}}"}
+ ],
+ "body": {"mode": "raw", "raw": "{\"primaryTitle\":\"Nope\",\"originalTitle\":\"Nope\",\"titleType\":\"movie\",\"genres\":[]}"}
+ },
+ "event": [{
+ "listen": "test",
+ "script": {"exec": ["pm.test('status is 403', () => pm.response.to.have.status(403));"]}
+ }]
+},
+{
+ "name": "Admin creates a title",
+ "request": {
+ "method": "POST",
+ "url": "{{baseUrl}}/api/v1/titles",
+ "header": [
+ {"key": "Content-Type", "value": "application/json"},
+ {"key": "Authorization", "value": "Bearer {{adminAccessToken}}"}
+ ],
+ "body": {"mode": "raw", "raw": "{\"primaryTitle\":\"E2E Test Movie\",\"originalTitle\":\"E2E Test Movie\",\"titleType\":\"movie\",\"startYear\":2024,\"genres\":[\"Drama\"]}"}
+ },
+ "event": [{
+ "listen": "test",
+ "script": {"exec": [
+ "pm.test('status is 201', () => pm.response.to.have.status(201));",
+ "pm.collectionVariables.set('createdTitleId', pm.response.json().id);"
+ ]}
+ }]
+},
+{
+ "name": "Stale-version update returns 409",
+ "request": {
+ "method": "PUT",
+ "url": "{{baseUrl}}/api/v1/titles/{{createdTitleId}}",
+ "header": [
+ {"key": "Content-Type", "value": "application/json"},
+ {"key": "Authorization", "value": "Bearer {{adminAccessToken}}"}
+ ],
+ "body": {"mode": "raw", "raw": "{\"primaryTitle\":\"Renamed\",\"originalTitle\":\"Renamed\",\"titleType\":\"movie\",\"startYear\":2024,\"genres\":[],\"version\":99}"}
+ },
+ "event": [{
+ "listen": "test",
+ "script": {"exec": ["pm.test('status is 409', () => pm.response.to.have.status(409));"]}
+ }]
+}
+```
+
+- [ ] **Step 3: Add watchlist/review/list lifecycle items**
+
+```json
+{
+ "name": "Add the new title to the watchlist",
+ "request": {
+ "method": "POST",
+ "url": "{{baseUrl}}/api/v1/watchlist/items",
+ "header": [
+ {"key": "Content-Type", "value": "application/json"},
+ {"key": "Authorization", "value": "Bearer {{accessToken}}"}
+ ],
+ "body": {"mode": "raw", "raw": "{\"titleId\":\"{{createdTitleId}}\"}"}
+ },
+ "event": [{
+ "listen": "test",
+ "script": {"exec": ["pm.test('status is 201', () => pm.response.to.have.status(201));"]}
+ }]
+},
+{
+ "name": "Review the new title",
+ "request": {
+ "method": "POST",
+ "url": "{{baseUrl}}/api/v1/titles/{{createdTitleId}}/reviews",
+ "header": [
+ {"key": "Content-Type", "value": "application/json"},
+ {"key": "Authorization", "value": "Bearer {{accessToken}}"}
+ ],
+ "body": {"mode": "raw", "raw": "{\"rating\":9,\"body\":\"E2E-tested and great\",\"version\":0}"}
+ },
+ "event": [{
+ "listen": "test",
+ "script": {"exec": ["pm.test('status is 201', () => pm.response.to.have.status(201));"]}
+ }]
+},
+{
+ "name": "Create a private list",
+ "request": {
+ "method": "POST",
+ "url": "{{baseUrl}}/api/v1/lists",
+ "header": [
+ {"key": "Content-Type", "value": "application/json"},
+ {"key": "Authorization", "value": "Bearer {{accessToken}}"}
+ ],
+ "body": {"mode": "raw", "raw": "{\"name\":\"E2E Private List\",\"visibility\":\"PRIVATE\"}"}
+ },
+ "event": [{
+ "listen": "test",
+ "script": {"exec": [
+ "pm.test('status is 201', () => pm.response.to.have.status(201));",
+ "pm.collectionVariables.set('createdListId', pm.response.json().id);"
+ ]}
+ }]
+},
+{
+ "name": "A stranger cannot view the private list",
+ "request": {
+ "method": "GET",
+ "url": "{{baseUrl}}/api/v1/lists/{{createdListId}}",
+ "header": [{"key": "Authorization", "value": "Bearer {{adminAccessToken}}"}]
+ },
+ "event": [{
+ "listen": "test",
+ "script": {"exec": ["pm.test('status is 404', () => pm.response.to.have.status(404));"]}
+ }]
+}
+```
+
+- [ ] **Step 4: Add the three new empty-string collection variables**
+
+Add to the collection's top-level `variable` array (alongside the existing `baseUrl` entry):
+
+```json
+{"key": "accessToken", "value": ""},
+{"key": "refreshToken", "value": ""},
+{"key": "adminAccessToken", "value": ""},
+{"key": "createdTitleId", "value": ""},
+{"key": "createdListId", "value": ""}
+```
+
+- [ ] **Step 5: Validate the JSON and run it against a live e2e stack**
+
+Run (PowerShell, avoiding the pyenv `python3` shim issue noted earlier this project):
+```powershell
+Get-Content postman/imdb-e2e.postman_collection.json -Raw | ConvertFrom-Json | Out-Null
+```
+Expected: no error (valid JSON). Then, with the e2e stack from Task 9.1 still up:
+```bash
+npx --yes newman run postman/imdb-e2e.postman_collection.json --env-var baseUrl=http://localhost:8080
+```
+Expected: all assertions pass, including the new ones (403/409/404 cases and the full auth/watchlist/
+review/list lifecycle).
+
+- [ ] **Step 6: Tear down and commit**
+
+```bash
+docker compose -f docker-compose.e2e.yaml -p imdb-e2e down -v
+git add postman/imdb-e2e.postman_collection.json
+git commit -m "Add auth, admin-CRUD, and user-content e2e contract tests to the Postman collection"
+```
+
+### Task 9.3: Final full-suite verification and documentation updates
+
+**Files:**
+- Modify: `imdb/docs/low-level-design.md`
+- Modify: `imdb/README.md`
+
+**Interfaces:** none - this task only verifies and documents; no new production code.
+
+- [ ] **Step 1: Run the complete local verification sequence**
+
+```bash
+JAVA_HOME="/c/Program Files/Java/jdk-21" ./mvnw -q test
+JAVA_HOME="/c/Program Files/Java/jdk-21" ./mvnw -q failsafe:integration-test failsafe:verify
+```
+Expected: PASS for every unit and integration test across all nine phases (including every existing test
+from before this plan - none should have been weakened or deleted to make this pass).
+
+- [ ] **Step 2: Run the full `imdb-ci.yml` sequence locally** (mirrors the `e2e` CI job exactly)
+
+```bash
+docker compose -f docker-compose.e2e.yaml -p imdb-e2e up -d --build
+# poll /actuator/health, then poll the seed container's exit code (see imdb-ci.yml's own steps)
+npx --yes newman run postman/imdb-e2e.postman_collection.json --env-var baseUrl=http://localhost:8080
+docker compose -f docker-compose.e2e.yaml -p imdb-e2e down -v
+```
+Expected: PASS, matching what CI will do on push.
+
+- [ ] **Step 3: Update `docs/low-level-design.md`**
+
+Add a new `§12. CRUD Expansion` section (or renumber to insert it before the existing `§11. Open Items`,
+whichever reads better once you're looking at the live document) summarizing: the two new layers, the
+auth mechanism, the `version`/`deleted_at` convention now present on every writable table, the cache
+eviction rules per region, and a pointer to `docs/crud-expansion-design.md` for full rationale - mirroring
+how §7.1 was written for the earlier dashboard-fix work (a real, verified summary, not a restatement of
+the design doc).
+
+- [ ] **Step 4: Update `README.md`**
+
+Extend the **API** section with the new endpoints (or a pointer to the design doc's §4/§5 tables rather
+than duplicating them in full), add **Authentication** as a new top-level section describing the JWT
+register/login/refresh flow and the bootstrap-admin mechanism, and add a note under **Known limitations**
+if Step 1/2 surfaced anything not already called out in `docs/crud-expansion-design.md` §11.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add docs/low-level-design.md README.md
+git commit -m "Document the CRUD expansion in the LLD and README"
+```
+
+**Phase 9 checkpoint - and plan complete**: every endpoint in `docs/crud-expansion-design.md` is
+implemented, tested at all three tiers (unit/integration/e2e), and documented.
+
diff --git a/imdb/docs/implementation-guide.md b/imdb/docs/implementation-guide.md
new file mode 100644
index 0000000..52085b5
--- /dev/null
+++ b/imdb/docs/implementation-guide.md
@@ -0,0 +1,1205 @@
+# IMDb Copycat API - Implementation Guide
+
+| | |
+|---|---|
+| Author | Ludovic Temgoua Abanda |
+| Status | Draft |
+| Date | 2026-07-05 |
+| Related docs | `low-level-design.md` (the design these classes implement), `product-design.md` |
+
+## How to use this document
+
+This is a typing companion, not a code drop - every class below is meant to be typed into your IDE
+yourself so the patterns actually stick, not pasted. The order matters: each step only depends on
+previous steps, so if you type them in order, the project should compile (and often run) at every
+checkpoint rather than only at the very end.
+
+Where a pattern repeats (four nearly-identical DTOs, three controllers with the same shape), full code is
+given once and the repeats are described rather than spelled out again - filling those in yourself is
+where the pattern actually gets internalized, not where it gets lost.
+
+A few corrections surfaced while working through this that aren't yet reflected anywhere except here and
+the LLD edits made alongside it - notably: Spring Boot 4.1's OTLP tracing property is
+`management.opentelemetry.tracing.export.otlp.endpoint`, not the Boot-3-era `management.otlp.tracing.*`;
+`springdoc-openapi` and `datasource-micrometer` aren't yet available for Boot 4.1 (both dropped, see LLD
+§11); and a `V0` Flyway migration was added to create the base tables that only `abanda/imdb-postgresql`
+normally provides (LLD §3.4), since a fresh Testcontainers Postgres has none of them.
+
+---
+
+## Step 0: Fix the generated scaffolding
+
+Two small edits to what Spring Initializr already generated, before writing anything new.
+
+**`src/test/java/com/ludovictemgoua/imdb/TestcontainersConfiguration.java`** - pin Postgres to match
+production (`abanda/imdb-postgresql` runs Postgres 17):
+
+```java
+@Bean
+@ServiceConnection
+PostgreSQLContainer postgresContainer() {
+ return new PostgreSQLContainer(DockerImageName.parse("postgres:17"));
+}
+```
+
+Leave the `LgtmStackContainer` and Redis beans as generated - they're genuinely useful for
+`TestImdbApplication`'s local dev-run convenience (full tracing/metrics without needing
+`docker-compose up`). Flyway will run automatically against whichever Postgres connection is active
+(the Testcontainers one here, or `docker-compose.yaml`'s real one at runtime) - that's what makes `V0`
+(next step) necessary.
+
+**`src/main/resources/application.yaml`** - replace the generated one-liner with:
+
+```yaml
+spring:
+ application:
+ name: imdb
+ datasource:
+ url: jdbc:postgresql://localhost:5432/imdb
+ username: imdb
+ password: password
+ data:
+ redis:
+ host: localhost
+ port: 6379
+ flyway:
+ enabled: true
+
+management:
+ endpoints:
+ web:
+ exposure:
+ include: health, prometheus
+ tracing:
+ sampling:
+ probability: 1.0
+ opentelemetry:
+ tracing:
+ export:
+ otlp:
+ endpoint: http://localhost:4318/v1/traces
+
+six-degrees:
+ side-cap: 4
+ absolute-max-degree: 7
+ fan-out-cap: 200
+ query-timeout-seconds: 2
+
+top-rated:
+ default-min-votes: 1000
+```
+
+The `spring.datasource`/`data.redis` values here are the `localhost` defaults for running the app
+directly against `docker-compose up`'s exposed ports; `docker-compose.yaml`'s `imdb-service` environment
+variables (already in place) override these to the in-network hostnames (`postgres`, `redis`, `tempo`)
+when the app itself runs as a compose service instead. The `six-degrees.*` and `top-rated.*` blocks are
+our own custom properties (LLD §5.2, §11) - Spring Boot automatically makes any `key: value` under a
+namespace bindable via `@ConfigurationProperties`, which the services below use instead of hardcoding
+these numbers.
+
+**Checkpoint**: `./mvnw compile` should still succeed (nothing new to compile yet, just config).
+
+---
+
+## Step 1: Database migrations
+
+Three files under `src/main/resources/db/migration/`. Flyway orders by version number, not filename
+creation order, so `V0` runs first even though it's documented last in the LLD.
+
+- `V0__base_schema.sql` - copy verbatim from LLD §3.4.
+- `V1__extensions_and_search_indexes.sql` - copy verbatim from LLD §3.2 (note the `genres` index is on
+ the *expression* `(genres::text[])`, not the bare column - LLD §3.2 explains why).
+- `V2__co_star_edges_materialized_view.sql` - copy verbatim from LLD §3.3.
+
+**Checkpoint**: run `TestImdbApplication.main()` (or `./mvnw spring-boot:test-run`). It should start
+cleanly, spinning up Postgres/Redis/Grafana-LGTM Testcontainers and applying all three migrations. Check
+the logs for `Successfully applied 3 migrations`.
+
+---
+
+## Step 2: `ImdbIds` - the id-translation utility
+
+`src/main/java/com/ludovictemgoua/imdb/ImdbIds.java` (root package - both the `web` and `repository`
+layers need it, so it doesn't belong to either):
+
+```java
+package com.ludovictemgoua.imdb;
+
+public final class ImdbIds {
+
+ private ImdbIds() {}
+
+ public static int parseTitleId(String tt) {
+ return Integer.parseInt(requirePrefix(tt, "tt"));
+ }
+
+ public static int parsePersonId(String nm) {
+ return Integer.parseInt(requirePrefix(nm, "nm"));
+ }
+
+ public static String formatTitleId(int tconst) {
+ return "tt" + pad7(tconst);
+ }
+
+ public static String formatPersonId(int nconst) {
+ return "nm" + pad7(nconst);
+ }
+
+ private static String requirePrefix(String id, String prefix) {
+ if (id == null || !id.startsWith(prefix) || id.length() <= prefix.length()) {
+ throw new IllegalArgumentException("Expected an id starting with '" + prefix + "': " + id);
+ }
+ return id.substring(prefix.length());
+ }
+
+ private static String pad7(int value) {
+ return String.format("%07d", value);
+ }
+}
+```
+
+`IllegalArgumentException` is deliberate here, not a custom exception type - `ApiExceptionHandler` (Step
+5) maps it straight to a 400, and there's no other behavior anyone would ever want from a malformed id.
+
+---
+
+## Step 3: DTOs
+
+All records, all in `src/main/java/com/ludovictemgoua/imdb/web/dto/`. One file per record, matching this
+monorepo's `votee` convention.
+
+```java
+public record TitleSummary(String id, String primaryTitle, String originalTitle, String titleType,
+ Integer startYear, Integer endYear) {}
+
+public record RatingView(double average, int numVotes) {}
+
+public record CreditedPerson(String id, String name) {}
+
+public record CastMember(String id, String name, String category, java.util.List characters,
+ int ordering) {}
+
+public record TitleDetail(String id, String primaryTitle, String originalTitle, String titleType,
+ Integer startYear, Integer endYear, Integer runtimeMinutes,
+ java.util.List genres, RatingView rating,
+ java.util.List directors, java.util.List writers,
+ java.util.List cast, int castTotalCount) {}
+
+public record GenreTopRatedItem(String id, String primaryTitle, Integer startYear, double averageRating,
+ int numVotes, double weightedRating) {}
+
+public record PersonRef(String id, String name) {}
+
+public record SharedTitle(String id, String primaryTitle) {}
+
+public record PathStep(String id, String name, SharedTitle sharedTitle) {}
+
+public record SixDegreesResult(PersonRef personA, PersonRef personB, Integer degree,
+ boolean withinRequestedMax, java.util.List path) {}
+
+public record PersonCandidate(String id, String name, Integer birthYear, java.util.List knownFor) {}
+
+public record PagedResult(java.util.List content, long totalElements, int page, int size) {}
+```
+
+Two things worth noticing rather than just typing past:
+
+- `SixDegreesResult.degree` is `Integer`, not `int` - `null` means "no connection exists within the
+ absolute 7-degree cap at all," which is a different thing from `withinRequestedMax=false` (a connection
+ exists, just beyond what the caller asked for).
+- `PagedResult` replaces the `Page` the LLD described in prose (§4.1) - `org.springframework.data.domain.Page`
+ is a Spring Data type, and §3.1 already decided against pulling in Spring Data's abstractions for a
+ plain-JDBC codebase. It also sidesteps a real gotcha: Jackson cannot cleanly deserialize into the `Page`
+ *interface* on a Redis cache read (it needs a concrete, directly-instantiable type), which our own
+ simple record has no trouble with.
+
+Use fully-qualified `java.util.List` inline above only to keep this guide's code blocks self-contained -
+in your actual files, add a normal `import java.util.List;` and drop the prefix, same for every DTO.
+
+---
+
+## Step 4: Repositories
+
+`src/main/java/com/ludovictemgoua/imdb/repository/`. This is where `NamedParameterJdbcTemplate` (already
+autoconfigured by `spring-boot-starter-jdbc` - no config class needed for it) does all the work.
+
+### `TitleRepository`
+
+```java
+package com.ludovictemgoua.imdb.repository;
+
+import com.ludovictemgoua.imdb.ImdbIds;
+import com.ludovictemgoua.imdb.web.dto.*;
+import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
+import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
+import org.springframework.stereotype.Repository;
+
+import java.sql.Array;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+@Repository
+public class TitleRepository {
+
+ private final NamedParameterJdbcTemplate jdbc;
+
+ public TitleRepository(NamedParameterJdbcTemplate jdbc) {
+ this.jdbc = jdbc;
+ }
+
+ public PagedResult search(String query, int page, int size) {
+ String dataSql = """
+ SELECT tconst, primary_title, original_title, title_type, start_year, end_year
+ FROM title_basics
+ WHERE primary_title % :query OR original_title % :query
+ ORDER BY similarity(primary_title, :query) DESC
+ LIMIT :limit OFFSET :offset
+ """;
+ String countSql = """
+ SELECT count(*) FROM title_basics
+ WHERE primary_title % :query OR original_title % :query
+ """;
+ var params = new MapSqlParameterSource()
+ .addValue("query", query)
+ .addValue("limit", size)
+ .addValue("offset", (long) page * size);
+
+ List content = jdbc.query(dataSql, params, TitleRepository::mapSummary);
+ Long total = jdbc.queryForObject(countSql, params, Long.class);
+ return new PagedResult<>(content, total == null ? 0 : total, page, size);
+ }
+
+ public Optional findCore(int tconst) {
+ String sql = """
+ SELECT tb.tconst, tb.primary_title, tb.original_title, tb.title_type,
+ tb.start_year, tb.end_year, tb.runtime_minutes, tb.genres,
+ tr.average_rating, tr.num_votes
+ FROM title_basics tb
+ LEFT JOIN title_ratings tr ON tr.tconst = tb.tconst
+ WHERE tb.tconst = :tconst
+ """;
+ return jdbc.query(sql, Map.of("tconst", tconst), TitleRepository::mapCore).stream().findFirst();
+ }
+
+ public List findDirectors(int tconst) {
+ return findCrew(tconst, "directors");
+ }
+
+ public List findWriters(int tconst) {
+ return findCrew(tconst, "writers");
+ }
+
+ public List findTopCast(int tconst, int limit) {
+ String sql = """
+ SELECT tp.nconst, nb.primary_name, tp.category, tp.characters, tp.ordering
+ FROM title_principals tp
+ JOIN name_basics nb ON nb.nconst = tp.nconst
+ WHERE tp.tconst = :tconst
+ ORDER BY tp.ordering
+ LIMIT :limit
+ """;
+ var params = new MapSqlParameterSource().addValue("tconst", tconst).addValue("limit", limit);
+ return jdbc.query(sql, params, TitleRepository::mapCastMember);
+ }
+
+ public int countCast(int tconst) {
+ Integer count = jdbc.queryForObject(
+ "SELECT count(*) FROM title_principals WHERE tconst = :tconst",
+ Map.of("tconst", tconst), Integer.class);
+ return count == null ? 0 : count;
+ }
+
+ public List findTopRated(String genre, int limit, int minVotes) {
+ String sql = """
+ WITH pool AS (
+ SELECT tb.tconst, tb.primary_title, tb.start_year, tr.average_rating, tr.num_votes
+ FROM title_basics tb
+ JOIN title_ratings tr ON tr.tconst = tb.tconst
+ WHERE tb.title_type = 'movie'
+ AND tb.genres::text[] @> ARRAY[:genre]::text[]
+ AND tr.num_votes >= :minVotes
+ ),
+ stats AS (SELECT AVG(average_rating) AS mean_rating FROM pool)
+ SELECT p.tconst, p.primary_title, p.start_year, p.average_rating, p.num_votes,
+ (p.num_votes::numeric / (p.num_votes + :minVotes)) * p.average_rating
+ + (:minVotes::numeric / (p.num_votes + :minVotes)) * s.mean_rating AS weighted_rating
+ FROM pool p CROSS JOIN stats s
+ ORDER BY weighted_rating DESC
+ LIMIT :limit
+ """;
+ var params = new MapSqlParameterSource()
+ .addValue("genre", genre).addValue("minVotes", minVotes).addValue("limit", limit);
+ return jdbc.query(sql, params, TitleRepository::mapTopRated);
+ }
+
+ public Optional findAnyCommonTitle(int personA, int personB) {
+ String sql = """
+ SELECT tb.tconst, tb.primary_title
+ FROM title_principals p1
+ JOIN title_principals p2 ON p1.tconst = p2.tconst
+ JOIN title_basics tb ON tb.tconst = p1.tconst
+ WHERE p1.nconst = :personA AND p2.nconst = :personB
+ LIMIT 1
+ """;
+ var params = new MapSqlParameterSource().addValue("personA", personA).addValue("personB", personB);
+ return jdbc.query(sql, params, (rs, rowNum) ->
+ new SharedTitle(ImdbIds.formatTitleId(rs.getInt("tconst")), rs.getString("primary_title")))
+ .stream().findFirst();
+ }
+
+ private List findCrew(int tconst, String column) {
+ // column is only ever "directors" or "writers" below - both fixed internal literals, never
+ // user input - so string-formatting it into the SQL here isn't an injection risk. Bind
+ // parameters can't stand in for column/identifier names, only values.
+ String sql = """
+ SELECT nb.nconst, nb.primary_name
+ FROM title_crew tc
+ CROSS JOIN LATERAL unnest(tc.%s) AS crew(nconst)
+ JOIN name_basics nb ON nb.nconst = crew.nconst
+ WHERE tc.tconst = :tconst
+ """.formatted(column);
+ return jdbc.query(sql, Map.of("tconst", tconst),
+ (rs, rowNum) -> new CreditedPerson(
+ ImdbIds.formatPersonId(rs.getInt("nconst")), rs.getString("primary_name")));
+ }
+
+ private static TitleSummary mapSummary(ResultSet rs, int rowNum) throws SQLException {
+ return new TitleSummary(
+ ImdbIds.formatTitleId(rs.getInt("tconst")),
+ rs.getString("primary_title"), rs.getString("original_title"), rs.getString("title_type"),
+ (Integer) rs.getObject("start_year"), (Integer) rs.getObject("end_year"));
+ }
+
+ private static TitleCore mapCore(ResultSet rs, int rowNum) throws SQLException {
+ List genres = toStringList(rs.getArray("genres"));
+ var avgRating = rs.getBigDecimal("average_rating");
+ return new TitleCore(
+ ImdbIds.formatTitleId(rs.getInt("tconst")),
+ rs.getString("primary_title"), rs.getString("original_title"), rs.getString("title_type"),
+ (Integer) rs.getObject("start_year"), (Integer) rs.getObject("end_year"),
+ (Integer) rs.getObject("runtime_minutes"), genres,
+ avgRating == null ? null : avgRating.doubleValue(),
+ (Integer) rs.getObject("num_votes"));
+ }
+
+ private static CastMember mapCastMember(ResultSet rs, int rowNum) throws SQLException {
+ return new CastMember(
+ ImdbIds.formatPersonId(rs.getInt("nconst")), rs.getString("primary_name"),
+ rs.getString("category"), toStringList(rs.getArray("characters")), rs.getInt("ordering"));
+ }
+
+ private static GenreTopRatedItem mapTopRated(ResultSet rs, int rowNum) throws SQLException {
+ return new GenreTopRatedItem(
+ ImdbIds.formatTitleId(rs.getInt("tconst")), rs.getString("primary_title"),
+ (Integer) rs.getObject("start_year"), rs.getBigDecimal("average_rating").doubleValue(),
+ rs.getInt("num_votes"), rs.getBigDecimal("weighted_rating").doubleValue());
+ }
+
+ private static List toStringList(Array sqlArray) throws SQLException {
+ if (sqlArray == null) return List.of();
+ return List.of((String[]) sqlArray.getArray());
+ }
+
+ public record TitleCore(String id, String primaryTitle, String originalTitle, String titleType,
+ Integer startYear, Integer endYear, Integer runtimeMinutes,
+ List genres, Double averageRating, Integer numVotes) {}
+}
+```
+
+`TitleCore` is `public` (nested inside `TitleRepository`) rather than living in `web.dto` - it's an
+internal assembly shape `TitleDetailService` composes into the real `TitleDetail` DTO, not something we
+ever hand back over the wire directly.
+
+### `PersonRepository`
+
+```java
+package com.ludovictemgoua.imdb.repository;
+
+import com.ludovictemgoua.imdb.ImdbIds;
+import com.ludovictemgoua.imdb.web.dto.PersonCandidate;
+import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
+import org.springframework.stereotype.Repository;
+
+import java.sql.Array;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.util.*;
+import java.util.stream.Collectors;
+
+@Repository
+public class PersonRepository {
+
+ private final NamedParameterJdbcTemplate jdbc;
+
+ public PersonRepository(NamedParameterJdbcTemplate jdbc) {
+ this.jdbc = jdbc;
+ }
+
+ public List findByName(String name) {
+ String sql = """
+ SELECT nconst, primary_name, birth_year, known_for_titles
+ FROM name_basics
+ WHERE primary_name % :name
+ ORDER BY similarity(primary_name, :name) DESC
+ LIMIT 10
+ """;
+ return jdbc.query(sql, Map.of("name", name), PersonRepository::mapCandidate);
+ }
+
+ public Optional findNameById(int nconst) {
+ return jdbc.query("SELECT primary_name FROM name_basics WHERE nconst = :nconst",
+ Map.of("nconst", nconst), (rs, rowNum) -> rs.getString("primary_name"))
+ .stream().findFirst();
+ }
+
+ public Map findNamesByIds(Collection nconsts) {
+ if (nconsts.isEmpty()) return Map.of();
+ String sql = "SELECT nconst, primary_name FROM name_basics WHERE nconst IN (:nconsts)";
+ List> rows = jdbc.query(sql, Map.of("nconsts", nconsts),
+ (rs, rowNum) -> Map.entry(rs.getInt("nconst"), rs.getString("primary_name")));
+ return rows.stream().collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
+ }
+
+ private static PersonCandidate mapCandidate(ResultSet rs, int rowNum) throws SQLException {
+ Array knownForArr = rs.getArray("known_for_titles");
+ List knownFor = knownForArr == null ? List.of()
+ : Arrays.stream((Integer[]) knownForArr.getArray())
+ .filter(Objects::nonNull).map(ImdbIds::formatTitleId).limit(3).toList();
+ return new PersonCandidate(
+ ImdbIds.formatPersonId(rs.getInt("nconst")), rs.getString("primary_name"),
+ (Integer) rs.getObject("birth_year"), knownFor);
+ }
+}
+```
+
+### `CoStarGraphRepository` - the centerpiece
+
+This is the class the entire Six Degrees comparison in the LLD (§9, §5) exists to justify. Type it
+carefully - it's the one place a subtle SQL mistake changes correctness, not just style.
+
+```java
+package com.ludovictemgoua.imdb.repository;
+
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
+import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
+import org.springframework.stereotype.Repository;
+
+import java.sql.Array;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Optional;
+
+@Repository
+public class CoStarGraphRepository {
+
+ private final NamedParameterJdbcTemplate jdbc;
+ private final int sideCap;
+ private final int fanOutCap;
+ private final int absoluteMaxDegree;
+
+ public CoStarGraphRepository(
+ NamedParameterJdbcTemplate jdbc,
+ @Value("${six-degrees.side-cap}") int sideCap,
+ @Value("${six-degrees.fan-out-cap}") int fanOutCap,
+ @Value("${six-degrees.absolute-max-degree}") int absoluteMaxDegree,
+ @Value("${six-degrees.query-timeout-seconds}") int queryTimeoutSeconds) {
+ this.jdbc = jdbc;
+ this.sideCap = sideCap;
+ this.fanOutCap = fanOutCap;
+ this.absoluteMaxDegree = absoluteMaxDegree;
+ // Only this query gets a tight timeout - it's the one query in the whole app whose cost
+ // depends on graph shape (hub actors) rather than a bounded index lookup. There's no
+ // `spring.jdbc.template.query-timeout` property to set this declaratively (checked against
+ // the Boot 4.1 reference docs - it doesn't exist), so it's set directly on the underlying
+ // JdbcTemplate instead.
+ ((JdbcTemplate) jdbc.getJdbcOperations()).setQueryTimeout(queryTimeoutSeconds);
+ }
+
+ public Optional findShortestPath(int personA, int personB) {
+ String sql = """
+ WITH RECURSIVE forward(person, depth, path) AS (
+ SELECT :personA, 0, ARRAY[:personA]
+ UNION ALL
+ SELECT nbr.person_b, f.depth + 1, f.path || nbr.person_b
+ FROM forward f
+ CROSS JOIN LATERAL (
+ SELECT e.person_b
+ FROM co_star_edges e
+ WHERE e.person_a = f.person
+ ORDER BY e.person_b
+ LIMIT :fanOutCap
+ ) nbr
+ WHERE f.depth < :sideCap
+ AND NOT nbr.person_b = ANY(f.path)
+ ),
+ backward(person, depth, path) AS (
+ SELECT :personB, 0, ARRAY[:personB]
+ UNION ALL
+ SELECT nbr.person_b, b.depth + 1, b.path || nbr.person_b
+ FROM backward b
+ CROSS JOIN LATERAL (
+ SELECT e.person_b
+ FROM co_star_edges e
+ WHERE e.person_a = b.person
+ ORDER BY e.person_b
+ LIMIT :fanOutCap
+ ) nbr
+ WHERE b.depth < :sideCap
+ AND NOT nbr.person_b = ANY(b.path)
+ )
+ SELECT f.depth + b.depth AS degree, f.path AS forward_path, b.path AS backward_path
+ FROM forward f
+ JOIN backward b ON b.person = f.person
+ WHERE f.depth + b.depth <= :absoluteMaxDegree
+ ORDER BY degree ASC
+ LIMIT 1
+ """;
+ var params = new MapSqlParameterSource()
+ .addValue("personA", personA).addValue("personB", personB)
+ .addValue("sideCap", sideCap).addValue("fanOutCap", fanOutCap)
+ .addValue("absoluteMaxDegree", absoluteMaxDegree);
+
+ return jdbc.query(sql, params, CoStarGraphRepository::mapRawMatch).stream().findFirst();
+ }
+
+ private static RawMatch mapRawMatch(ResultSet rs, int rowNum) throws SQLException {
+ return new RawMatch(rs.getInt("degree"),
+ toIntList(rs.getArray("forward_path")), toIntList(rs.getArray("backward_path")));
+ }
+
+ private static List toIntList(Array sqlArray) throws SQLException {
+ return Arrays.asList((Integer[]) sqlArray.getArray());
+ }
+
+ public record RawMatch(int degree, List forwardPath, List backwardPath) {}
+}
+```
+
+**Checkpoint**: `./mvnw compile` should succeed. This is a good point to write a throwaway `main` method
+or a quick `@SpringBootTest` calling `findShortestPath` against two people you know are connected, just
+to see actual rows come back, before building anything on top of it.
+
+---
+
+## Step 5: Error handling
+
+`src/main/java/com/ludovictemgoua/imdb/error/`.
+
+```java
+package com.ludovictemgoua.imdb.error;
+
+public class NotFoundException extends RuntimeException {
+ public NotFoundException(String message) {
+ super(message);
+ }
+}
+```
+
+```java
+package com.ludovictemgoua.imdb.error;
+
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ProblemDetail;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.RestControllerAdvice;
+
+@RestControllerAdvice
+public class ApiExceptionHandler {
+
+ @ExceptionHandler(NotFoundException.class)
+ public ProblemDetail handleNotFound(NotFoundException ex) {
+ return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
+ }
+
+ @ExceptionHandler(IllegalArgumentException.class)
+ public ProblemDetail handleBadId(IllegalArgumentException ex) {
+ return ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, ex.getMessage());
+ }
+}
+```
+
+That's deliberately shorter than you might expect: `@Min`/`@Max` violations on `@RequestParam` (the
+`maxDegree` bound, pagination sizes) already get an automatic 400 `ProblemDetail` from Spring MVC itself
+(via `HandlerMethodValidationException`, handled by default since Spring Framework 6.1/Boot 3.2) - no
+handler needed here for that case at all. There's no handler yet for an invalid `genre` path segment
+either (production's `genre` column is a real Postgres enum, and casting an invalid value to it throws a
+`DataAccessException` subtype) - add one once you've actually triggered it and seen which exact subtype
+Spring's JDBC exception translator produces; guessing it here would be worse than leaving it a gap.
+
+---
+
+## Step 6: Caching
+
+`src/main/java/com/ludovictemgoua/imdb/config/CacheConfig.java`:
+
+```java
+package com.ludovictemgoua.imdb.config;
+
+import org.springframework.cache.annotation.EnableCaching;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.data.redis.cache.RedisCacheConfiguration;
+import org.springframework.data.redis.cache.RedisCacheManager;
+import org.springframework.data.redis.connection.RedisConnectionFactory;
+import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
+import org.springframework.data.redis.serializer.RedisSerializationContext;
+
+import java.time.Duration;
+
+@Configuration
+@EnableCaching
+public class CacheConfig {
+
+ @Bean
+ public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) {
+ RedisCacheConfiguration defaults = RedisCacheConfiguration.defaultCacheConfig()
+ .entryTtl(Duration.ofHours(24))
+ .serializeValuesWith(RedisSerializationContext.SerializationPair
+ .fromSerializer(new GenericJackson2JsonRedisSerializer()));
+
+ return RedisCacheManager.builder(connectionFactory)
+ .cacheDefaults(defaults)
+ .build();
+ }
+}
+```
+
+All four cache regions (`title-search`, `title-detail`, `top-rated`, `six-degrees`) share the same 24h TTL
+per the LLD §6 table, so there's nothing to register per-name yet - `@Cacheable(cacheNames = "...")` on
+the service methods (next step) creates each cache on first use from these defaults. If a region ever
+needs its own TTL, `.withCacheConfiguration("name", customConfig)` is the extension point.
+
+Using `GenericJackson2JsonRedisSerializer` (JSON in Redis, human-readable with `redis-cli GET`) instead of
+the default JDK serialization is why every DTO being a plain record (Step 3) matters - records serialize
+to/from JSON with zero extra configuration.
+
+---
+
+## Step 7: Services
+
+`src/main/java/com/ludovictemgoua/imdb/service/`.
+
+### The two sealed result types
+
+```java
+package com.ludovictemgoua.imdb.service;
+
+import com.ludovictemgoua.imdb.web.dto.PersonCandidate;
+
+import java.util.List;
+
+public sealed interface PersonResolution {
+ record Resolved(int nconst, String name) implements PersonResolution {}
+ record Ambiguous(List candidates) implements PersonResolution {}
+ record NotFound() implements PersonResolution {}
+}
+```
+
+```java
+package com.ludovictemgoua.imdb.service;
+
+import com.ludovictemgoua.imdb.web.dto.PersonCandidate;
+import com.ludovictemgoua.imdb.web.dto.SixDegreesResult;
+
+import java.util.List;
+
+public sealed interface SixDegreesOutcome {
+ record Found(SixDegreesResult result) implements SixDegreesOutcome {}
+ record Ambiguous(String query, List candidates) implements SixDegreesOutcome {}
+ record PersonNotFound(String query) implements SixDegreesOutcome {}
+}
+```
+
+These replace the `AmbiguousPersonException` the LLD's module layout (§2) originally sketched. Throwing
+an exception for an *expected*, 200-status outcome (LLD §4.4 - disambiguation isn't an error) was always
+a slight mismatch; a sealed interface plus an exhaustive `switch` (used in the controller, Step 8) makes
+every caller handle every case at compile time instead, with no exception-as-control-flow.
+
+### `PersonResolutionService`
+
+```java
+package com.ludovictemgoua.imdb.service;
+
+import com.ludovictemgoua.imdb.ImdbIds;
+import com.ludovictemgoua.imdb.repository.PersonRepository;
+import com.ludovictemgoua.imdb.web.dto.PersonCandidate;
+import org.springframework.stereotype.Service;
+
+import java.util.List;
+
+@Service
+public class PersonResolutionService {
+
+ private final PersonRepository personRepository;
+
+ public PersonResolutionService(PersonRepository personRepository) {
+ this.personRepository = personRepository;
+ }
+
+ public PersonResolution resolve(String query) {
+ if (query.startsWith("nm")) {
+ int nconst = ImdbIds.parsePersonId(query);
+ return personRepository.findNameById(nconst)
+ .map(name -> new PersonResolution.Resolved(nconst, name))
+ .orElseGet(PersonResolution.NotFound::new);
+ }
+ List candidates = personRepository.findByName(query);
+ return switch (candidates.size()) {
+ case 0 -> new PersonResolution.NotFound();
+ case 1 -> {
+ PersonCandidate only = candidates.get(0);
+ yield new PersonResolution.Resolved(ImdbIds.parsePersonId(only.id()), only.name());
+ }
+ default -> new PersonResolution.Ambiguous(candidates);
+ };
+ }
+}
+```
+
+### `DistanceCache` - why this is its own tiny class
+
+```java
+package com.ludovictemgoua.imdb.service;
+
+import com.ludovictemgoua.imdb.repository.CoStarGraphRepository;
+import com.ludovictemgoua.imdb.repository.CoStarGraphRepository.RawMatch;
+import org.springframework.cache.annotation.Cacheable;
+import org.springframework.stereotype.Component;
+
+@Component
+class DistanceCache {
+
+ private final CoStarGraphRepository graphRepository;
+
+ DistanceCache(CoStarGraphRepository graphRepository) {
+ this.graphRepository = graphRepository;
+ }
+
+ @Cacheable(cacheNames = "six-degrees",
+ key = "T(java.lang.Math).min(#personA, #personB) + '-' + T(java.lang.Math).max(#personA, #personB)")
+ RawMatch trueShortestPath(int personA, int personB) {
+ return graphRepository.findShortestPath(personA, personB).orElse(null);
+ }
+}
+```
+
+This isn't a style choice - it's a real Spring pitfall being avoided. `@Cacheable` works by wrapping the
+bean in a proxy; a call from *within the same bean* (`this.trueShortestPath(...)`) bypasses that proxy
+entirely and silently never hits the cache. Putting the cached method on its own small collaborator,
+called from `SixDegreesService` through the injected reference, sidesteps the problem rather than
+requiring everyone who touches `SixDegreesService` later to remember not to call this method internally.
+The cache key deliberately ignores argument order (`min`/`max`) and any `maxDegree` - per LLD §6, the
+cached value is the *true* shortest distance up to the absolute 7-degree cap, independent of what bound
+any particular caller asked for.
+
+Returning `null` (not `Optional`) for "no path found" matches this codebase's existing convention
+(`votee`'s LLD flags the same choice for `Candidate.party`) - `Optional` is for return types callers
+branch on, not for storing in a field or, here, a cache entry.
+
+### The straightforward services
+
+```java
+package com.ludovictemgoua.imdb.service;
+
+import com.ludovictemgoua.imdb.repository.TitleRepository;
+import com.ludovictemgoua.imdb.web.dto.PagedResult;
+import com.ludovictemgoua.imdb.web.dto.TitleSummary;
+import org.springframework.cache.annotation.Cacheable;
+import org.springframework.stereotype.Service;
+
+@Service
+public class TitleSearchService {
+
+ private final TitleRepository titleRepository;
+
+ public TitleSearchService(TitleRepository titleRepository) {
+ this.titleRepository = titleRepository;
+ }
+
+ @Cacheable(cacheNames = "title-search", key = "#query + ':' + #page + ':' + #size")
+ public PagedResult search(String query, int page, int size) {
+ return titleRepository.search(query, page, size);
+ }
+}
+```
+
+`TitleDetailService` and `TopRatedService` follow the exact same one-method, `@Cacheable`-wrapping shape:
+
+- `TitleDetailService.getDetail(String titleId)`: parse the id with `ImdbIds`, call
+ `titleRepository.findCore(...)` (throwing `NotFoundException` on empty), then
+ `findDirectors`/`findWriters`/`findTopCast`/`countCast`, and assemble a `TitleDetail` from the pieces.
+ Cache key is just `#titleId`.
+- `TopRatedService.findTopRated(String genre, int limit, Integer minVotes)`: if `minVotes` is `null`, fall
+ back to the injected `${top-rated.default-min-votes}` value (this is the LLD §11/PDD §11 open item -
+ 1000 is a placeholder until you've looked at the real vote-count distribution), then delegate to
+ `titleRepository.findTopRated(...)`. Cache key is `#genre + ':' + #limit + ':' + #minVotes`.
+
+Writing these two yourself is the point - `TitleSearchService` above is the complete pattern; there's
+nothing left to discover from having it typed out a second time.
+
+### `SixDegreesService` - the orchestrator
+
+```java
+package com.ludovictemgoua.imdb.service;
+
+import com.ludovictemgoua.imdb.ImdbIds;
+import com.ludovictemgoua.imdb.graph.PathStitcher;
+import com.ludovictemgoua.imdb.repository.CoStarGraphRepository.RawMatch;
+import com.ludovictemgoua.imdb.repository.PersonRepository;
+import com.ludovictemgoua.imdb.repository.TitleRepository;
+import com.ludovictemgoua.imdb.web.dto.*;
+import org.springframework.stereotype.Service;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+@Service
+public class SixDegreesService {
+
+ private final PersonResolutionService personResolution;
+ private final DistanceCache distanceCache;
+ private final PersonRepository personRepository;
+ private final TitleRepository titleRepository;
+
+ SixDegreesService(PersonResolutionService personResolution, DistanceCache distanceCache,
+ PersonRepository personRepository, TitleRepository titleRepository) {
+ this.personResolution = personResolution;
+ this.distanceCache = distanceCache;
+ this.personRepository = personRepository;
+ this.titleRepository = titleRepository;
+ }
+
+ public SixDegreesOutcome compute(String queryA, String queryB, int maxDegree) {
+ PersonResolution resolvedA = personResolution.resolve(queryA);
+ if (resolvedA instanceof PersonResolution.Ambiguous a) {
+ return new SixDegreesOutcome.Ambiguous(queryA, a.candidates());
+ }
+ if (resolvedA instanceof PersonResolution.NotFound) {
+ return new SixDegreesOutcome.PersonNotFound(queryA);
+ }
+ PersonResolution resolvedB = personResolution.resolve(queryB);
+ if (resolvedB instanceof PersonResolution.Ambiguous b) {
+ return new SixDegreesOutcome.Ambiguous(queryB, b.candidates());
+ }
+ if (resolvedB instanceof PersonResolution.NotFound) {
+ return new SixDegreesOutcome.PersonNotFound(queryB);
+ }
+
+ var a = (PersonResolution.Resolved) resolvedA;
+ var b = (PersonResolution.Resolved) resolvedB;
+ PersonRef personA = new PersonRef(ImdbIds.formatPersonId(a.nconst()), a.name());
+ PersonRef personB = new PersonRef(ImdbIds.formatPersonId(b.nconst()), b.name());
+
+ if (a.nconst() == b.nconst()) {
+ PathStep onlyStep = new PathStep(personA.id(), personA.name(), null);
+ return new SixDegreesOutcome.Found(
+ new SixDegreesResult(personA, personB, 0, true, List.of(onlyStep)));
+ }
+
+ RawMatch match = distanceCache.trueShortestPath(a.nconst(), b.nconst());
+ if (match == null) {
+ return new SixDegreesOutcome.Found(
+ new SixDegreesResult(personA, personB, null, false, List.of()));
+ }
+
+ boolean withinMax = match.degree() <= maxDegree;
+ List path = withinMax ? buildPath(PathStitcher.stitch(match)) : List.of();
+ return new SixDegreesOutcome.Found(
+ new SixDegreesResult(personA, personB, match.degree(), withinMax, path));
+ }
+
+ private List buildPath(List nconsts) {
+ Map names = personRepository.findNamesByIds(nconsts);
+ List steps = new ArrayList<>();
+ for (int i = 0; i < nconsts.size(); i++) {
+ int nconst = nconsts.get(i);
+ SharedTitle sharedTitle = i == 0 ? null
+ : titleRepository.findAnyCommonTitle(nconsts.get(i - 1), nconst).orElse(null);
+ steps.add(new PathStep(ImdbIds.formatPersonId(nconst), names.get(nconst), sharedTitle));
+ }
+ return steps;
+ }
+}
+```
+
+Note the early returns for `Ambiguous`/`NotFound` on `resolvedA` happen *before* resolving `queryB` at
+all - no point spending a second trigram query if the first side already failed.
+
+### `graph/PathStitcher`
+
+`src/main/java/com/ludovictemgoua/imdb/graph/PathStitcher.java`:
+
+```java
+package com.ludovictemgoua.imdb.graph;
+
+import com.ludovictemgoua.imdb.repository.CoStarGraphRepository.RawMatch;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+public final class PathStitcher {
+
+ private PathStitcher() {}
+
+ public static List stitch(RawMatch match) {
+ List backward = new ArrayList<>(match.backwardPath());
+ Collections.reverse(backward);
+ // forward's last element and reversed-backward's first element are the same node (where the
+ // two searches met) - drop the duplicate before concatenating.
+ List stitched = new ArrayList<>(match.forwardPath());
+ stitched.addAll(backward.subList(1, backward.size()));
+ return stitched;
+ }
+}
+```
+
+**Checkpoint**: `./mvnw compile` should succeed with the full service layer in place.
+
+---
+
+## Step 8: Web layer
+
+`src/main/java/com/ludovictemgoua/imdb/web/`.
+
+```java
+package com.ludovictemgoua.imdb.web;
+
+import com.ludovictemgoua.imdb.service.TitleDetailService;
+import com.ludovictemgoua.imdb.service.TitleSearchService;
+import com.ludovictemgoua.imdb.web.dto.PagedResult;
+import com.ludovictemgoua.imdb.web.dto.TitleDetail;
+import com.ludovictemgoua.imdb.web.dto.TitleSummary;
+import jakarta.validation.constraints.Max;
+import jakarta.validation.constraints.Min;
+import jakarta.validation.constraints.NotBlank;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.*;
+
+@RestController
+@RequestMapping("/api/v1/titles")
+@Validated
+public class TitleController {
+
+ private final TitleSearchService searchService;
+ private final TitleDetailService detailService;
+
+ public TitleController(TitleSearchService searchService, TitleDetailService detailService) {
+ this.searchService = searchService;
+ this.detailService = detailService;
+ }
+
+ @GetMapping("/search")
+ public PagedResult search(
+ @RequestParam @NotBlank String title,
+ @RequestParam(defaultValue = "0") @Min(0) int page,
+ @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size) {
+ return searchService.search(title, page, size);
+ }
+
+ @GetMapping("/{titleId}")
+ public TitleDetail get(@PathVariable String titleId) {
+ return detailService.getDetail(titleId);
+ }
+}
+```
+
+`GenreController` follows the identical shape - `@RestController` + `@RequestMapping("/api/v1/genres")` +
+`@Validated`, one `@GetMapping("/{genre}/top-rated")` method taking `genre` as a `@PathVariable` and
+`limit`/`minVotes` as validated `@RequestParam`s, delegating to `TopRatedService`.
+
+`PersonController` is the one worth typing out in full, since it's where the sealed `SixDegreesOutcome`
+from Step 7 actually gets consumed:
+
+```java
+package com.ludovictemgoua.imdb.web;
+
+import com.ludovictemgoua.imdb.service.SixDegreesOutcome;
+import com.ludovictemgoua.imdb.service.SixDegreesService;
+import jakarta.validation.constraints.Max;
+import jakarta.validation.constraints.Min;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ProblemDetail;
+import org.springframework.http.ResponseEntity;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.Map;
+
+@RestController
+@RequestMapping("/api/v1/people")
+@Validated
+public class PersonController {
+
+ private final SixDegreesService sixDegreesService;
+
+ public PersonController(SixDegreesService sixDegreesService) {
+ this.sixDegreesService = sixDegreesService;
+ }
+
+ @GetMapping("/six-degrees")
+ public ResponseEntity> sixDegrees(
+ @RequestParam String personA,
+ @RequestParam String personB,
+ @RequestParam(defaultValue = "7") @Min(1) @Max(7) int maxDegree) {
+
+ SixDegreesOutcome outcome = sixDegreesService.compute(personA, personB, maxDegree);
+ return switch (outcome) {
+ case SixDegreesOutcome.Found found -> ResponseEntity.ok(found.result());
+ case SixDegreesOutcome.Ambiguous amb -> ResponseEntity.ok(Map.of(
+ "requiresDisambiguation", true, "query", amb.query(), "candidates", amb.candidates()));
+ case SixDegreesOutcome.PersonNotFound nf -> ResponseEntity.status(HttpStatus.NOT_FOUND)
+ .body(ProblemDetail.forStatusAndDetail(
+ HttpStatus.NOT_FOUND, "No person matching: " + nf.query()));
+ };
+ }
+}
+```
+
+The `switch` over `outcome` needs no `default` branch - `SixDegreesOutcome` is `sealed` with exactly
+three implementations, so the compiler already knows the switch is exhaustive. Delete one of the `case`
+branches as an experiment and watch it fail to compile - that guarantee is the entire reason to reach for
+a sealed interface over a plain exception or a nullable field here.
+
+**Checkpoint**: `./mvnw spring-boot:test-run` should let you `curl localhost:8080/api/v1/people/six-degrees?personA=nm...&personB=nm...` end to end.
+
+---
+
+## Step 9: Tests
+
+One example per layer - `votee`'s existing test style (JUnit 5, Mockito, AssertJ) carries over directly.
+
+### Unit test (mocked collaborators, no Spring context)
+
+```java
+package com.ludovictemgoua.imdb.service;
+
+import com.ludovictemgoua.imdb.repository.PersonRepository;
+import com.ludovictemgoua.imdb.repository.TitleRepository;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.BDDMockito.given;
+
+@ExtendWith(MockitoExtension.class)
+class SixDegreesServiceTest {
+
+ @Mock PersonResolutionService personResolution;
+ @Mock DistanceCache distanceCache;
+ @Mock PersonRepository personRepository;
+ @Mock TitleRepository titleRepository;
+
+ @Test
+ void sameResolvedPersonIsDegreeZero() {
+ var service = new SixDegreesService(personResolution, distanceCache, personRepository, titleRepository);
+ given(personResolution.resolve("nm0000102"))
+ .willReturn(new PersonResolution.Resolved(102, "Kevin Bacon"));
+ given(personResolution.resolve("Kevin Bacon"))
+ .willReturn(new PersonResolution.Resolved(102, "Kevin Bacon"));
+
+ var outcome = service.compute("nm0000102", "Kevin Bacon", 7);
+
+ assertThat(outcome).isInstanceOfSatisfying(SixDegreesOutcome.Found.class,
+ found -> assertThat(found.result().degree()).isZero());
+ }
+}
+```
+
+### Controller test (`@WebMvcTest`, real HTTP-shaped request, mocked service)
+
+```java
+package com.ludovictemgoua.imdb.web;
+
+import com.ludovictemgoua.imdb.service.SixDegreesService;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
+import org.springframework.test.context.bean.override.mockito.MockitoBean;
+import org.springframework.test.web.servlet.MockMvc;
+
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+@WebMvcTest(PersonController.class)
+class PersonControllerTest {
+
+ @Autowired MockMvc mockMvc;
+ @MockitoBean SixDegreesService sixDegreesService;
+
+ @Test
+ void rejectsMaxDegreeAboveSeven() throws Exception {
+ mockMvc.perform(get("/api/v1/people/six-degrees")
+ .param("personA", "nm0000102")
+ .param("personB", "nm0000158")
+ .param("maxDegree", "9"))
+ .andExpect(status().isBadRequest());
+ }
+}
+```
+
+`@MockitoBean` (Spring Framework's own annotation, `org.springframework.test.context.bean.override.mockito`)
+is the current replacement for the older `@MockBean` - checked against the Boot 4.1 testing reference
+docs directly rather than assumed, since `@MockBean` doesn't appear there anymore.
+
+### Integration test (Testcontainers, real Flyway migrations, fixture data)
+
+```java
+package com.ludovictemgoua.imdb.integration;
+
+import com.ludovictemgoua.imdb.TestcontainersConfiguration;
+import com.ludovictemgoua.imdb.repository.CoStarGraphRepository;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.context.annotation.Import;
+import org.springframework.test.context.jdbc.Sql;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+@Import(TestcontainersConfiguration.class)
+@SpringBootTest
+@Sql("/fixtures/fixture-data.sql")
+class CoStarGraphRepositoryIntegrationTest {
+
+ @Autowired CoStarGraphRepository repository;
+
+ @Test
+ void findsShortestPathAcrossMultipleHopsOnBothSides() {
+ // pick two nconst values from your fixture-data.sql whose true shortest path requires
+ // more than one hop on each side of the bidirectional search
+ var result = repository.findShortestPath(1001, 1006);
+
+ assertThat(result).isPresent();
+ assertThat(result.get().degree()).isEqualTo(4);
+ }
+}
+```
+
+`@Sql` runs *after* the Spring context (and therefore Flyway's `V0`/`V1`/`V2`) is already up, so by the
+time it executes, `co_star_edges` and the rest of the schema already exist - `fixture-data.sql` only ever
+needs `INSERT` statements. Write `src/test/resources/fixtures/fixture-data.sql` yourself: a small,
+hand-picked set of `name_basics`/`title_basics`/`title_principals` rows forming a co-star chain you know
+the answer to, per the LLD §10 test plan - designing that fixture (rather than having it handed to you)
+is arguably the best way to prove to yourself the bidirectional CTE actually does what §5 claims.
+
+---
+
+## What's left
+
+This covers every class in the LLD's module layout (§2) except the ones that turned out unnecessary along
+the way (`JdbcConfig` - autoconfigured already; `AmbiguousPersonException` - replaced by the sealed
+`SixDegreesOutcome`; `OpenApiConfig` - springdoc isn't available for Boot 4.1 yet, LLD §11). Once this
+compiles and the checkpoints above all pass, the remaining LLD §11 open items apply: the k6 scripts, the
+Grafana dashboard JSON, and tuning `fan-out-cap`/`side-cap`/`default-min-votes` against real data and real
+load-test results instead of the placeholders used here.
diff --git a/imdb/docs/low-level-design.md b/imdb/docs/low-level-design.md
new file mode 100644
index 0000000..3515622
--- /dev/null
+++ b/imdb/docs/low-level-design.md
@@ -0,0 +1,1104 @@
+# IMDb Copycat API - Low-Level Design Document
+
+| | |
+|---|---|
+| Author | Ludovic Temgoua Abanda |
+| Status | Draft |
+| Date | 2026-07-06 |
+| Related docs | `imdb/docs/product-design.md` (PDD, approved), `imdb/docs/REQUIREMENTS.md` |
+| Data source | [`abanda/imdb-postgresql`](https://github.com/icemc/imdb-postgresql) |
+
+## 1. Purpose and Scope
+
+The PDD defines what is being built and why. This document defines how: the onion-layered package
+structure, schema additions on top of the seeded database, endpoint contracts, the bidirectional-CTE SQL
+for Six Degrees, the caching strategy, docker-compose topology, observability wiring, and the test plan.
+The Maven project has been scaffolded and the core layers (§2) are implemented and compiling; what
+remains is tracked in §11 (dashboards, k6 scripts, the Dockerfile, and a handful of tuning decisions that
+need real data/load-test results to answer responsibly).
+
+## 2. Architecture & Module Layout
+
+### 2.1 Onion layers and the dependency rule
+
+The initial pass at this codebase used a flat package structure (`repository`, `service`, `web`) where
+every service directly `new`'d or `@Autowired` concrete JDBC/Redis classes. That worked, but it violated
+the Dependency Inversion Principle badly enough to matter: nothing could be tested without a real
+Postgres/Redis behind it, and swapping either one - say, to evaluate the Neo4j alternative from PDD §9 -
+would have meant editing the use-case classes themselves. This section replaces that with a proper onion
+architecture.
+
+**Layers, outermost to innermost**: `presentation` → `infrastructure` → `application` → `domain`, plus a
+standalone `utils` layer with no dependencies at all. The dependency rule is enforced by import direction
+alone (no build-tool module boundaries yet - see the open item in §11):
+
+| Layer | May import | Contains |
+|---|---|---|
+| `presentation` | `application`, `infrastructure`, `domain`, `utils` | Controllers, request validation, `ApiExceptionHandler`, `RequestLoggingFilter` |
+| `infrastructure` | `application`, `domain`, `utils` | JDBC repository implementations, Redis caching decorators, Spring `@Configuration` |
+| `application` | `domain`, `utils` | Use-case interfaces + implementations, use-case-specific result types |
+| `domain` | *(nothing)* | Entities/value objects, repository **interfaces**, domain exceptions |
+| `utils` | *(nothing)* | `ImdbIds`, `HeaderSanitizer` - pure, framework-free helpers |
+
+Never: `domain` importing `application`/`infrastructure`; `application` importing `infrastructure`. The
+second rule is the one that actually matters day to day - application code calls domain interfaces only,
+and Spring's IoC container wires in the concrete infrastructure bean at runtime without a single
+`application`-package `import` ever naming a JDBC or Redis class.
+
+**Where interfaces earn their keep**: every `domain.repository` interface has exactly one JDBC
+implementation today, so on its own that's DIP for its own sake. The payoff shows up in `application`,
+where every top-level use case a controller depends on is *also* an interface
+(`TitleSearchUseCase`, `TitleDetailUseCase`, `TopRatedUseCase`, `SixDegreesUseCase`), each with a plain
+`*Impl` and - where caching applies - an `infrastructure.cache` decorator implementing the same
+interface, marked `@Primary`. These four interfaces (plus `SixDegreesOutcome`, the sealed result type of
+the `SixDegreesUseCase` contract) live in `application.contracts`, separated from their implementations
+in `application` directly - the same "interfaces apart from implementations" convention `domain.repository`
+already uses one layer in:
+
+```
+application.contracts.TitleSearchUseCase (interface)
+ ├─ application.TitleSearchUseCaseImpl - plain orchestration, no caching
+ └─ infrastructure.cache.CachingTitleSearchUseCase - @Primary, @Cacheable, delegates to the Impl above
+```
+
+A controller depends on `TitleSearchUseCase` and never learns which one it got. Swapping cache
+technology, or removing caching entirely, is a change to `infrastructure.cache` alone. Swapping Postgres
+for something else is a new `infrastructure.persistence` class implementing the same `domain.repository`
+interface - `application` and `presentation` are untouched either way. `PersonResolutionUseCase` is the
+one exception: it's an internal collaborator used only by `SixDegreesUseCaseImpl`, never injected into a
+controller or wrapped by a decorator, so it stays a concrete class - not every class needs an interface,
+only the ones that are an actual seam.
+
+**Where caching attaches, and why it isn't uniform**: `CachingTitleSearchUseCase`,
+`CachingTitleDetailUseCase`, and `CachingTopRatedUseCase` decorate the **use-case** interfaces.
+`CachingCoStarGraphRepository` decorates the **repository** interface instead, one layer further in. The
+reason is the shape of the work each one does: `TitleDetailUseCase.getDetail` fans out to five repository
+calls and assembles one `TitleDetail` - caching the assembled result in one entry (matching the four
+regions in §6) is both truer to the original design and cheaper than five separate repository-level cache
+entries per title. `SixDegreesUseCase.compute`, on the other hand, takes raw query strings that might be
+names needing disambiguation and a `maxDegree` that varies per request - a poor, fragile cache key.
+`CoStarGraphRepository.findShortestPath(int, int)`, one level in, takes two clean integer ids and returns
+the *true* shortest distance independent of any of that - exactly the key described in §6. This is also
+why the domain interface returns a clean `GraphPath`, not the bidirectional search's raw forward/backward
+arrays: see §5.2 for what that cleanup actually removed.
+
+**A caching bug that no longer needs remembering**: the original design's `DistanceCache` component
+existed solely to work around a Spring AOP pitfall - `@Cacheable` uses a proxy, and a method calling
+another method on `this` bypasses that proxy silently. Now that every cached method lives on a decorator
+that is structurally a *different bean* from the thing it wraps, that failure mode isn't possible by
+construction. Nobody has to remember not to call a cached method from within its own class, because there
+is no such call anywhere in this codebase anymore.
+
+### 2.2 Package layout (current state)
+
+```
+imdb/
+ pom.xml
+ Dockerfile
+ docker-compose.yaml
+ docker-compose.e2e.yaml # lightweight plain-postgres stack for the CI e2e stage, §10
+ postman/
+ imdb-e2e.postman_collection.json # Newman-run contract tests against the e2e stack, §10
+ docs/
+ REQUIREMENTS.md
+ product-design.md
+ low-level-design.md
+ observability/
+ prometheus/prometheus.yml
+ tempo/tempo.yaml
+ alloy/config.alloy
+ grafana/provisioning/
+ datasources/datasources.yml
+ dashboards/dashboards.yml
+ dashboards/json/ # dashboard JSON models, still to author - see §11
+ k6/
+ search.js # still to author - see §11
+ title-detail.js
+ top-rated.js
+ six-degrees.js
+ data/sampled-people.csv # actor pool used by six-degrees.js to defeat caching
+ src/
+ main/
+ java/com/ludovictemgoua/imdb/
+ ImdbApplication.java
+ utils/
+ ImdbIds.java
+ HeaderSanitizer.java # redacts sensitive headers before logging (§7)
+ domain/
+ model/
+ TitleSummary.java
+ TitleCore.java
+ TitleDetail.java
+ RatingView.java
+ CastMember.java
+ CreditedPerson.java
+ GenreTopRatedItem.java
+ SharedTitle.java
+ PersonCandidate.java
+ PagedResult.java
+ GraphPath.java
+ PersonResolution.java
+ repository/
+ TitleRepository.java # interface
+ PersonRepository.java # interface
+ CoStarGraphRepository.java # interface
+ exception/
+ NotFoundException.java
+ application/
+ contracts/
+ TitleSearchUseCase.java # interface
+ TitleDetailUseCase.java # interface
+ TopRatedUseCase.java # interface
+ SixDegreesUseCase.java # interface
+ SixDegreesOutcome.java # sealed result type of the SixDegreesUseCase contract
+ TitleSearchUseCaseImpl.java
+ TitleDetailUseCaseImpl.java
+ TopRatedUseCaseImpl.java
+ SixDegreesUseCaseImpl.java
+ PersonResolutionUseCase.java # concrete - internal collaborator, not a public seam
+ PersonRef.java
+ PathStep.java
+ SixDegreesResult.java
+ infrastructure/
+ persistence/
+ JdbcTitleRepository.java
+ JdbcPersonRepository.java
+ JdbcCoStarGraphRepository.java # thin wrapper - the actual BFS lives in the DB, §5.2-5.3
+ cache/
+ CachingTitleSearchUseCase.java
+ CachingTitleDetailUseCase.java
+ CachingTopRatedUseCase.java
+ CachingCoStarGraphRepository.java
+ CacheConfig.java
+ presentation/
+ TitleController.java
+ GenreController.java
+ PersonController.java
+ ApiExceptionHandler.java
+ RequestLoggingFilter.java # request id + sanitized request/response logging (§7)
+ resources/
+ application.yaml
+ db/migration/
+ V0__base_schema.sql
+ V1__extensions_and_search_indexes.sql
+ V2__co_star_edges_materialized_view.sql
+ V3__shortest_co_star_path_function.sql # find_shortest_co_star_path PL/pgSQL BFS, §5.2-5.3
+ V4__title_principals_nconst_index.sql # full nconst index - findAnyCommonTitle needs it
+ # across all categories, not just the partial
+ # acting-only index V1 created
+ test/
+ java/com/ludovictemgoua/imdb/
+ application/ # unit tests, mocked domain.repository interfaces - §10
+ infrastructure/
+ persistence/*IntegrationTest.java # Testcontainers Postgres - §10
+ cache/*Test.java # unit: mocked delegate, no Spring context - §10
+ cache/*IntegrationTest.java # integration: Testcontainers Redis, real (de)serialization - §10
+ presentation/ # MockMvc controller tests, mocked use cases
+ TestcontainersConfiguration.java
+ resources/
+ fixtures/
+ fixture-data.sql # single source of truth: integration tests (@Sql) and
+ # the e2e stage's seed service both load this same file
+```
+
+`application.yaml`, not `.properties`: the config surface is more nested than a typical CRUD app -
+`management.metrics.*`, `management.tracing.*`, and `management.opentelemetry.*` (§7) sit several levels
+deep alongside a custom `six-degrees.*` group (§5/§6), and YAML's multi-document `---` profiles let the
+Testcontainers-backed integration tests (§10) override just the datasource block against the fixture
+container instead of `docker-compose.yaml`'s Postgres, without duplicating everything else.
+
+## 3. Data Layer
+
+### 3.1 Why plain JDBC, not JPA/Hibernate
+
+Every query this API needs is a hand-tuned native query: trigram similarity search, GIN array containment
+for genres, a Bayesian weighted-rating computation, and a recursive CTE for graph traversal. None of these
+benefit from Hibernate's object-relational mapping or dirty-checking - there is no object graph being
+mutated, only read projections. Introducing JPA here would mean fighting it (native `@Query` everywhere)
+for zero benefit. The design instead uses Spring's `NamedParameterJdbcTemplate` with explicit `RowMapper`s
+throughout, and Flyway for the migrations below - one data-access style, not two.
+
+This is orthogonal to (and compatible with) the onion architecture in §2: `NamedParameterJdbcTemplate` is
+an implementation detail entirely confined to `infrastructure.persistence`. Nothing in `domain` or
+`application` knows JDBC exists - they see only the `TitleRepository`/`PersonRepository`/
+`CoStarGraphRepository` interfaces in `domain.repository`.
+
+### 3.2 Migration V1: extensions and search indexes
+
+```sql
+CREATE EXTENSION IF NOT EXISTS pg_trgm;
+
+CREATE INDEX idx_title_basics_primary_title_trgm
+ ON title_basics USING gin (primary_title gin_trgm_ops);
+
+CREATE INDEX idx_title_basics_original_title_trgm
+ ON title_basics USING gin (original_title gin_trgm_ops);
+
+CREATE INDEX idx_title_basics_genres
+ ON title_basics USING gin ((genres::text[]));
+
+CREATE INDEX idx_title_ratings_rank
+ ON title_ratings (average_rating DESC, num_votes DESC);
+
+CREATE INDEX idx_title_principals_nconst_acting
+ ON title_principals (nconst)
+ WHERE category IN ('actor', 'actress', 'self');
+
+CREATE INDEX idx_name_basics_primary_name_trgm
+ ON name_basics USING gin (primary_name gin_trgm_ops);
+```
+
+The `genres` index is built on the **expression** `(genres::text[])`, not the bare column. Production's
+`genres` is a Postgres `GENRE` enum array, but the V0 test fixture (§3.4) simplifies it to plain
+`TEXT[]`. Casting to `text[]` in the top-rated query (§4.3) lets one query run unmodified against both
+schemas - but Postgres only matches a query's cast expression against an index built on that *same*
+expression, not against a plain index on the uncast column. Indexing `(genres::text[])` directly (a
+no-op cast in the test fixture, a real enum-to-text cast in production) keeps the index usable in both
+places instead of silently falling back to a sequential scan in production.
+
+### 3.3 Migration V2: the co-star edge view
+
+The recursive CTE in §5 needs to expand "who has this person co-starred with" on every hop. Doing that
+directly against `title_principals` means a self-join over the full table (tens of millions of rows,
+filtered twice) on every single hop of every query. Instead, a materialized view precomputes the edge list
+once:
+
+```sql
+CREATE MATERIALIZED VIEW co_star_edges AS
+SELECT DISTINCT p1.nconst AS person_a, p2.nconst AS person_b
+FROM title_principals p1
+JOIN title_principals p2
+ ON p1.tconst = p2.tconst
+ AND p1.nconst <> p2.nconst
+WHERE p1.category IN ('actor', 'actress', 'self')
+ AND p2.category IN ('actor', 'actress', 'self');
+
+CREATE UNIQUE INDEX idx_co_star_edges_pk ON co_star_edges (person_a, person_b);
+```
+
+Note this is deliberately stored **directionally symmetric** (both `(a, b)` and `(b, a)` rows exist,
+since the self-join naturally produces both orderings) rather than deduplicated with `LEAST`/`GREATEST`.
+That trades roughly 2x storage for a simpler, single-direction lookup (`WHERE person_a = ?`) in the hot
+path, instead of a `UNION ALL` on every hop of the recursive CTE.
+
+**Refresh strategy**: `REFRESH MATERIALIZED VIEW CONCURRENTLY co_star_edges` once after the one-time
+dataset import completes (there is no write path afterward - see PDD §11's open question on this). A
+`REFRESH ... CONCURRENTLY` requires the unique index above, which is already in place.
+
+### 3.4 Migration V0: base schema for local/test parity (runs *before* V1, documented last)
+
+A gap surfaced while working out the test setup (§10): `V1`/`V2` above only add indexes and a view *on
+top of* the seven base tables (`name_basics`, `title_basics`, `title_ratings`, `title_crew`,
+`title_principals`, plus the two unused ones). Those base tables are created by the upstream Python
+loader baked into `abanda/imdb-postgresql`, not by anything in this project - which is fine for
+`docker-compose.yaml`'s Postgres, but a freshly-started Testcontainers `postgres:17` (used by the
+integration tests and by the `TestcontainersConfiguration` Spring Initializr already generated) has none
+of these tables, and `V1` would fail immediately trying to index them.
+
+The fix is a migration numbered `V0` - Flyway runs migrations in version order regardless of when they
+were authored, so `V0__base_schema.sql` runs before `V1` even though it's documented after it here - that
+creates the base tables **idempotently**:
+
+```sql
+CREATE TABLE IF NOT EXISTS name_basics (
+ nconst INTEGER PRIMARY KEY,
+ primary_name TEXT NOT NULL,
+ birth_year INTEGER,
+ death_year INTEGER,
+ primary_profession TEXT[],
+ known_for_titles INTEGER[]
+);
+
+CREATE TABLE IF NOT EXISTS title_basics (
+ tconst INTEGER PRIMARY KEY,
+ title_type TEXT NOT NULL,
+ primary_title TEXT NOT NULL,
+ original_title TEXT NOT NULL,
+ is_adult BOOLEAN NOT NULL DEFAULT FALSE,
+ start_year INTEGER,
+ end_year INTEGER,
+ runtime_minutes INTEGER,
+ genres TEXT[]
+);
+
+CREATE TABLE IF NOT EXISTS title_ratings (
+ tconst INTEGER PRIMARY KEY REFERENCES title_basics (tconst),
+ average_rating NUMERIC NOT NULL,
+ num_votes INTEGER NOT NULL
+);
+
+CREATE TABLE IF NOT EXISTS title_crew (
+ tconst INTEGER PRIMARY KEY REFERENCES title_basics (tconst),
+ directors INTEGER[],
+ writers INTEGER[]
+);
+
+CREATE TABLE IF NOT EXISTS title_principals (
+ tconst INTEGER NOT NULL REFERENCES title_basics (tconst),
+ ordering INTEGER NOT NULL,
+ nconst INTEGER NOT NULL REFERENCES name_basics (nconst),
+ category TEXT NOT NULL,
+ job TEXT,
+ characters TEXT[],
+ PRIMARY KEY (tconst, ordering)
+);
+```
+
+Two deliberate simplifications versus the real schema, both safe because of how `IF NOT EXISTS` behaves:
+
+- **Enum columns become `TEXT`/`TEXT[]`** (`title_type`, `genres`, `category`) instead of the real
+ Postgres `ENUM` types. Our own queries only ever compare these as strings/arrays, so behavior is
+ identical for anything this project does; only faithfully replicating the upstream loader's exact enum
+ constraints would need the real types, which isn't a goal here.
+- **Foreign keys are always enforced here**, whereas the real loader's own comments note its
+ `add_references` step isn't consistently applied (PDD §11). Harmless for fixture data we control.
+- Against `docker-compose.yaml`'s `abanda/imdb-postgresql` Postgres, every table already exists with the
+ real (enum-based) schema, so every `CREATE TABLE IF NOT EXISTS` above is a no-op - `V0` only ever does
+ real work against a blank Testcontainers instance.
+
+### 3.5 ID translation at the boundary
+
+`tconst`/`nconst` are stored as plain `INTEGER`. The API never exposes these directly. `ImdbIds` lives in
+the standalone `utils` layer (§2.1) - pure string/int parsing, no dependency on anything else in the
+codebase, usable from `infrastructure` (mapping JDBC rows) and `application` (formatting ids into result
+records) alike:
+
+```java
+public final class ImdbIds {
+ public static int parseTitleId(String tt) { // "tt0111161" -> 111161
+ return Integer.parseInt(requirePrefix(tt, "tt"));
+ }
+ public static int parsePersonId(String nm) { // "nm0000102" -> 102
+ return Integer.parseInt(requirePrefix(nm, "nm"));
+ }
+ public static String formatTitleId(int tconst) { return "tt" + pad7(tconst); }
+ public static String formatPersonId(int nconst) { return "nm" + pad7(nconst); }
+}
+```
+
+A malformed ID (wrong prefix, non-numeric suffix) is a `400` at the controller boundary via a
+`@RequestParam`/`@PathVariable` converter, before it ever reaches a use case or query.
+
+## 4. Endpoint Specifications
+
+### 4.1 `GET /api/v1/titles/search?title={q}&page=&size=`
+
+Trigram similarity search, ordered by match quality:
+
+```sql
+SELECT tconst, primary_title, original_title, title_type, start_year, end_year,
+ similarity(primary_title, :query) AS score
+FROM title_basics
+WHERE primary_title % :query OR original_title % :query
+ORDER BY score DESC
+LIMIT :size OFFSET :offset;
+```
+
+(`%` is `pg_trgm`'s similarity operator, index-backed by the GIN indexes in §3.2.) Response: a
+`PagedResult` (`domain.model` - a small hand-rolled pagination wrapper, not Spring Data's
+`Page`, consistent with §3.1's decision to keep Spring Data out of this codebase entirely) with `id`,
+`primaryTitle`, `originalTitle`, `titleType`, `startYear`, `endYear` per item.
+
+### 4.2 `GET /api/v1/titles/{titleId}`
+
+Three queries (title metadata + rating; directors/writers via `title_crew` joined to `name_basics`;
+top-billed cast via `title_principals` joined to `name_basics`, ordered by `ordering`, capped at 20 with a
+total count), composed into one `TitleDetail`:
+
+```json
+{
+ "id": "tt0111161",
+ "primaryTitle": "The Shawshank Redemption",
+ "originalTitle": "The Shawshank Redemption",
+ "titleType": "movie",
+ "startYear": 1994,
+ "runtimeMinutes": 142,
+ "genres": ["Drama"],
+ "rating": { "average": 9.3, "numVotes": 2900000 },
+ "directors": [{ "id": "nm0001104", "name": "Frank Darabont" }],
+ "writers": [{ "id": "nm0001104", "name": "Frank Darabont" }],
+ "cast": [
+ { "id": "nm0000209", "name": "Tim Robbins", "category": "actor", "characters": ["Andy Dufresne"], "ordering": 1 }
+ ],
+ "castTotalCount": 20
+}
+```
+
+`404` (via `domain.exception.NotFoundException` -> `ProblemDetail`, thrown by `TitleDetailUseCaseImpl` and
+mapped by `presentation.ApiExceptionHandler`) if `tconst` doesn't exist in `title_basics`.
+
+### 4.3 `GET /api/v1/genres/{genre}/top-rated?limit=&minVotes=`
+
+Restricted to `title_type = 'movie'`, ranked by weighted rating rather than raw average (PDD §9):
+
+```sql
+WITH pool AS (
+ SELECT tb.tconst, tb.primary_title, tb.start_year, tr.average_rating, tr.num_votes
+ FROM title_basics tb
+ JOIN title_ratings tr ON tr.tconst = tb.tconst
+ WHERE tb.title_type = 'movie'
+ AND tb.genres::text[] @> ARRAY[:genre]::text[]
+ AND tr.num_votes >= :minVotes
+),
+stats AS (
+ SELECT AVG(average_rating) AS mean_rating FROM pool
+)
+SELECT p.tconst, p.primary_title, p.start_year, p.average_rating, p.num_votes,
+ (p.num_votes::numeric / (p.num_votes + :minVotes)) * p.average_rating
+ + (:minVotes::numeric / (p.num_votes + :minVotes)) * s.mean_rating AS weighted_rating
+FROM pool p CROSS JOIN stats s
+ORDER BY weighted_rating DESC
+LIMIT :limit;
+```
+
+`minVotes` (the Bayesian `m`) defaults to a value chosen from the actual vote-count distribution once the
+dataset is loaded (PDD §11 open question) rather than an arbitrary round number; `mean_rating` (the
+Bayesian `C`) is computed live over the qualifying pool so it self-adjusts per genre rather than using a
+single global constant.
+
+### 4.4 `GET /api/v1/people/six-degrees?personA=&personB=&maxDegree=`
+
+Accepts either `personId` (`nm...`) or `name` for each side. See §5 for the full algorithm; response
+shape:
+
+```json
+{
+ "personA": { "id": "nm0000102", "name": "Kevin Bacon" },
+ "personB": { "id": "nm0000158", "name": "Tom Hanks" },
+ "degree": 2,
+ "withinRequestedMax": true,
+ "path": [
+ { "id": "nm0000102", "name": "Kevin Bacon" },
+ { "id": "nm0000129", "name": "Tom Cruise", "sharedTitle": { "id": "tt0100405", "primaryTitle": "A Few Good Men" } },
+ { "id": "nm0000158", "name": "Tom Hanks", "sharedTitle": { "id": "tt0181689", "primaryTitle": "The Terminal", "note": "illustrative" } }
+ ]
+}
+```
+
+If a `name` matches more than one person, the response is a disambiguation payload instead
+(`{"requiresDisambiguation": true, "query": "...", "candidates": [...]}`, HTTP `200`) rather than an
+error - it's an expected, common case (many people share a name), not a client mistake. `maxDegree` is
+validated to `1..=7` (`400` outside that range).
+
+## 5. Six Degrees: Bidirectional BFS as a PL/pgSQL Function
+
+### 5.1 Why bidirectional, not the naive one-sided walk
+
+A one-sided recursive walk from person A, expanding until person B is found, pays the graph's full
+branching factor for every hop - and this graph has extreme hub nodes (some credited actors have
+thousands of co-stars; talk-show hosts exceed 8,000). Meeting in the middle from both ends roughly
+squares down the search space (`b^(d/2)` instead of `b^d`), and combined with the product's own 7-degree
+cap, each side only ever needs to expand `⌈7/2⌉ = 4` hops. See PDD §9 for the full comparison against
+precomputed BFS, in-memory BFS, and Pruned Landmark Labeling.
+
+### 5.2 Why this isn't a single recursive CTE anymore
+
+The first working version *was* a single bidirectional recursive CTE (each side's frontier expansion
+and cycle check expressed declaratively, path arrays carried through the recursion). It passed review
+and its own unit tests, then failed under real data and load in two distinct ways:
+
+1. **Correctness**: fan-out was capped with `ORDER BY person_b LIMIT :fanOutCap`, always keeping the
+ same fixed (lowest-id) subset of a hub's neighbors and silently dropping the rest. If the actual
+ connecting co-star wasn't in that arbitrary subset, the query reported "no path found" (or a longer
+ path) even though a real, shorter path existed. This is a wrong-answer bug, not a slow-answer bug.
+2. **Performance**: cycle prevention only checked that a single path didn't revisit its own history
+ (`NOT nbr.person_b = ANY(path)`) - there was no *shared* visited set. The same node gets rediscovered
+ by many different paths in a small-world co-star graph, and each rediscovery independently
+ re-expanded from that node again. A real hub-to-hub query (two talk-show hosts, ~8,000 co-stars each)
+ took 3+ minutes and spilled to disk with `fanOutCap=200`/`sideCap=4` - and no fan-out cap small enough
+ to avoid that blowup was also large enough to not risk bug 1.
+
+Both bugs share one root cause: a plain recursive CTE has no way to check a candidate node against
+*everything already discovered so far* - only against the single path being extended. Fixing that
+requires genuine iterative state (a real visited set per side), which SQL's `WITH RECURSIVE` can't
+express on its own. The fix keeps this a pure SQL/PL-pgSQL solution (per-endpoint choice - see PDD §9
+for algorithms that mix in Java or a graph database instead) by moving it into a PL/pgSQL function:
+`find_shortest_co_star_path` (`V3__shortest_co_star_path_function.sql`), called from
+`infrastructure.persistence.JdbcCoStarGraphRepository` as a single `SELECT * FROM
+find_shortest_co_star_path(:personA, :personB, :sideCap, :absoluteMaxDegree)`. The
+`domain.repository.CoStarGraphRepository` interface is unchanged - still exactly one method,
+`Optional findShortestPath(int personA, int personB)` - so this swap was invisible to
+`application` and `presentation`.
+
+### 5.3 How the function works
+
+- Two `TEMP TABLE`s (`visited_forward`, `visited_backward`, each `(person, parent)`) hold the real,
+ de-duplicated visited set and parent pointer per side - created `IF NOT EXISTS` and `TRUNCATE`d at
+ the start of each call rather than `ON COMMIT DROP`, since a caller that runs the function twice
+ within one open transaction (e.g. a `@Transactional` test) would otherwise hit "relation already
+ exists" on the second call.
+- Each iteration expands whichever side currently has the smaller frontier (the standard
+ bidirectional-BFS optimization), inserting every neighbor *not already visited on that side* - no
+ arbitrary cap, so no real neighbor is ever silently dropped. On a tie, a `v_prefer_forward` flag
+ alternates which side wins, flipped after every tie-driven choice - without this, a plain "prefer
+ forward on ties" rule starves the backward side completely on any non-branching chain (frontier size
+ stays 1 = 1 every iteration), a real bug caught by the `findsTheShortestPathAcrossMultipleHopsOnBoth-
+ SidesOfTheBidirectionalSearch` integration test before this shipped.
+- After each expansion, only the *newly* discovered nodes are checked against the other side's full
+ visited set - the loop exits the instant they intersect, so most real pairs (small-world graph, most
+ people connect within 1-2 hops) resolve almost immediately rather than exploring to `sideCap` on both
+ sides unconditionally.
+- Once a meeting node is found, the path is reconstructed by walking the `parent` pointers from the
+ meeting node back to each root - a plain linear parent-chain walk (not the combinatorial multi-path
+ search above), so a small recursive CTE is the right, cheap tool for that specific step.
+- `:sideCap` = 4 and `:absoluteMaxDegree` = 7 bound the loop exactly as before (§6 for why these are
+ fixed independent of the caller's requested `maxDegree`); `personA == personB` short-circuits inside
+ the function (degree 0, no expansion at all), mirroring the special case `SixDegreesUseCaseImpl`
+ already has above it.
+- A query timeout is still set directly on the underlying `JdbcTemplate` inside
+ `JdbcCoStarGraphRepository`'s constructor (no declarative `spring.jdbc.template.query-timeout`
+ property exists in Boot 4.1) as a last-resort circuit breaker, though early termination on
+ intersection means it's rarely exercised in practice now.
+- Verified against the exact pathological pair that broke the old query (two ~8,000-co-star hub nodes,
+ no direct edge): 29-44ms and a correct degree-2 result, versus 3+ minutes and a disk spill before.
+
+### 5.4 Person resolution
+
+`PersonResolutionUseCase` (application layer, an internal collaborator of `SixDegreesUseCaseImpl` - see
+§2.1) resolves a `name` query against `name_basics` via the trigram index from §3.2, through the
+`domain.repository.PersonRepository` interface. Exactly one strong match (similarity above a threshold
+and no close runner-up) proceeds directly; multiple plausible matches return the disambiguation payload
+from §4.4, including `birthYear` and a couple of `knownForTitles` entries per candidate so a human can
+tell "Michael J. Fox" from another same-named person at a glance. The result is the sealed
+`domain.model.PersonResolution` (`Resolved` / `Ambiguous` / `NotFound`), matched exhaustively via a Java
+21 `switch` in `SixDegreesUseCaseImpl`.
+
+## 6. Caching Strategy
+
+Redis, cache-aside, via Spring's `@Cacheable`/`CacheManager` - but unlike a typical Spring tutorial, the
+`@Cacheable` annotations never sit on the same classes that contain business logic. Per §2.1, caching is
+implemented entirely as `infrastructure.cache` decorators around `application`-layer use-case interfaces
+(three of the four regions) or a `domain.repository` interface (the fourth):
+
+| Cache | Decorator | Key | TTL | Notes |
+|---|---|---|---|---|
+| `title-search` | `CachingTitleSearchUseCase` | `query, page, size` | 24h | Small enough result sets that full-parameter keying is fine |
+| `title-detail` | `CachingTitleDetailUseCase` | `titleId` | 24h | One entry for the fully-assembled `TitleDetail`, even though the use case fans out to five repository calls to build it |
+| `top-rated` | `CachingTopRatedUseCase` | `genre, limit, minVotes` | 24h | |
+| `six-degrees` | `CachingCoStarGraphRepository` | `min(personA,personB)-max(personA,personB)` (**not** including `maxDegree`) | 24h | Stores the true shortest distance up to the absolute 7-degree cap; a request with a smaller `maxDegree` is served from the same cache entry and simply reports "beyond requested max" without recomputation (PDD §9). Cached at the repository level, not the use-case level - see §2.1 for why this one region breaks the pattern of the other three. |
+
+All TTLs are long because the underlying dataset only changes when the Docker image is reloaded - there
+is no write path invalidating these entries mid-flight. A full `FLUSHDB` on redeploy is the accepted
+invalidation strategy, documented rather than automated, since there's no signal in the running system
+that would tell it the data changed underneath it.
+
+Each decorator is a Spring bean implementing the same interface as its plain counterpart, annotated
+`@Primary` so it's what gets autowired everywhere the interface is requested; its constructor takes the
+*concrete* plain implementation class (e.g. `CachingTitleSearchUseCase(TitleSearchUseCaseImpl delegate)`),
+which is the one place `infrastructure` code names a specific implementation class rather than an
+interface - unavoidable, since something has to construct the delegate unambiguously, and it's a
+same-layer (`infrastructure` → `infrastructure`, effectively) reference, not a boundary violation.
+
+**Jackson 3, not Jackson 2**: `CacheConfig` serializes cache values with
+`org.springframework.data.redis.serializer.GenericJacksonJsonRedisSerializer`, not the more commonly
+documented `GenericJackson2JsonRedisSerializer`. Boot 4.1's default Jackson is Jackson 3
+(`tools.jackson.core`/`tools.jackson.databind` - a different Maven groupId and root package than Jackson
+2.x's `com.fasterxml.jackson.*`, not just a version bump), and `spring-boot-starter-webmvc` doesn't pull
+in a Jackson implementation at all by default anymore - `spring-boot-starter-jackson` has to be added
+explicitly (present in `pom.xml`; missing it produces a `NoClassDefFoundError` for Jackson types, not a
+compile error, since nothing in this codebase references Jackson classes directly - Spring's own
+autoconfiguration and `GenericJacksonJsonRedisSerializer` are the only things that touch it). The "2"
+variant fails at runtime with `ClassNotFoundException: com.fasterxml.jackson.databind...` since that
+package genuinely isn't on the classpath. `enableSpringCacheNullValueSupport()` also has to be requested
+explicitly on the Jackson 3 serializer's builder - it was on by default on the old serializer's
+constructor - which matters here since caching a "no path found" result as `null` is deliberate, not
+incidental.
+
+**Load-test interaction** (carried over from the PDD discussion): the `six-degrees` k6 script must draw a
+different, pre-sampled person pair per iteration specifically so it exercises the bidirectional CTE
+instead of just measuring a warm Redis round-trip after the first request - see §8.
+
+## 7. Observability Wiring
+
+- **Metrics**: `micrometer-registry-prometheus`. Default HTTP (`http.server.requests`, percentile-histogram
+ enabled - §7.1) and JVM/process metrics come from Boot's own auto-configuration; there is no separate
+ custom `Timer` for the six-degrees CTE's DB-query phase specifically - `uri`-filtering the standard
+ `http_server_requests_seconds` histogram (six-degrees latency dashboard, §7.1) already isolates it from
+ the other three endpoints without needing one, since this API has exactly one repository call per
+ request on the hot path anyway. Cache hit/miss/put `Counter`s per region are manually bound
+ (`infrastructure.cache.CacheConfig.cacheStatisticsMeterBinder`, §7.1) - Boot 4.1 removed the
+ auto-binding this design originally assumed.
+- **Tracing**: the `spring-boot-starter-opentelemetry` starter (Boot 4.1's unified tracing starter -
+ supersedes the Boot 3-era `micrometer-tracing-bridge-otel` + `opentelemetry-exporter-otlp` combo)
+ exports to Tempo via `management.opentelemetry.tracing.export.otlp.endpoint=http://tempo:4318/v1/traces`
+ - note this property lives under `management.opentelemetry.*`, not the old `management.otlp.*`
+ namespace, matching `docker-compose.yaml`. `management.tracing.sampling.probability=1.0` for this
+ exercise (full sampling; would be tuned down in a real production deployment under real traffic volume).
+- **Logging**: structured JSON via Spring Boot 4.1's native structured logging (`logging.structured.
+ format.console: logstash` - confirmed present directly in the spring-boot-4.1.0 jar,
+ `org.springframework.boot.logging.structured.*`; no `logstash-logback-encoder` or other dependency
+ needed), shipped to Loki via Grafana Alloy (already wired in `docker-compose.yaml` /
+ `observability/alloy/config.alloy`). "logstash" format chosen over the built-in ECS/GELF
+ alternatives since it's schema-agnostic and Loki doesn't care about a specific schema, unlike ECS
+ (Elastic-specific) or GELF (Graylog-specific).
+ - **Correlation IDs**: every MDC entry is automatically included in each JSON line, which is what
+ carries two independent identifiers with no per-log-statement wiring: `traceId`/`spanId`
+ (populated by micrometer-tracing-bridge-otel's `Slf4JEventListener` whenever a span's scope is
+ open - see §7.2 for why this needed a filter-order fix to actually cover every log line) and
+ `requestId` - a dedicated id assigned by `presentation.RequestLoggingFilter`, deliberately
+ independent of tracing so it stays a reliable per-request identifier even if trace sampling is
+ later turned down from today's 100% (`management.tracing.sampling.probability`). The filter
+ honors an incoming `X-Request-Id` header if the caller already generated one, otherwise generates
+ a UUID; echoes it back as a response header; and clears it from MDC in a `finally` block so it
+ can't leak onto the next request handled by the same pooled thread. Runs at
+ `Ordered.HIGHEST_PRECEDENCE + 2` (§7.2) so the id is set as early as possible in the chain while
+ still nesting inside the span whose scope populates `traceId`/`spanId`.
+ - **Sanitizer**: `utils.HeaderSanitizer` redacts a deny-list (`Authorization`, `Cookie`,
+ `Set-Cookie`, `X-Api-Key`) before `RequestLoggingFilter` logs request headers. This API has no
+ auth today, so nothing sensitive flows through yet - the point is that logging is already correct
+ the moment auth (or any header carrying a secret) is added, not something to retrofit later.
+ Framework-agnostic by design (operates on a plain `Map`, not
+ `HttpServletRequest`) so it stays usable from `utils` without pulling in the Servlet API.
+ - **Level policy** (`logging.level.com.ludovictemgoua.imdb`, default `INFO`, overridable per
+ environment via the standard `LOGGING_LEVEL_COM_LUDOVICTEMGOUA_IMDB` env var with no code change):
+ INFO for request start/end (`RequestLoggingFilter`) and business-significant outcomes worth their
+ own line beyond the request summary (`SixDegreesUseCaseImpl` - person-resolution ambiguity, and
+ the computed degree/timing, since this is the one use case with real, previously-invisible
+ performance variance all session); DEBUG for query params/row counts in
+ `infrastructure.persistence` and cache-miss events in `infrastructure.cache` (`@Cacheable` only
+ invokes the annotated method on a miss, so a log statement inside it is inherently a miss signal -
+ aggregate hit ratio is already tracked via Micrometer's cache metrics, the Cache Hit Ratio
+ dashboard); WARN for `find_shortest_co_star_path` calls over 1 second
+ (`JdbcCoStarGraphRepository`); DEBUG for expected 4xx conditions and ERROR (with the full
+ exception) for anything unhandled reaching `ApiExceptionHandler`'s catch-all, so an unexpected
+ failure is never silently reduced to a bare 500 with no trace of the real cause.
+- **Correlation**: `observability/grafana/provisioning/datasources/datasources.yml` already wires
+ Loki-derived-fields -> Tempo and Tempo -> Loki/Prometheus, so a trace opened in Grafana click-throughs to
+ its log lines and vice versa.
+- **Dashboards**: provisioned via `observability/grafana/provisioning/dashboards/dashboards.yml`, pointing
+ at a `dashboards/json/` folder. Actual dashboard JSON models (HTTP overview, six-degrees CTE latency
+ breakdown, cache hit ratio, k6 load-test results) are authored during implementation, not shipped as
+ part of this design (see §11).
+
+### 7.1 Making the dashboards actually work: four real bugs, found by querying Prometheus directly
+
+All four dashboards were authored against assumed metric shapes and shipped without a live scrape ever
+confirming them (§11's own caveat on the cache dashboard: "not confirmed against a live scrape at
+authoring time"). Once actually queried against a running stack, every dashboard except three panels of
+HTTP Overview turned out to be empty - not from a dashboard-JSON bug in most cases, but because the
+metrics themselves didn't exist yet. Fixed in order of discovery:
+
+1. **No `_bucket` series at all, anywhere** - every `histogram_quantile(...)` panel (HTTP Overview's p95,
+ all three Six Degrees Latency panels) was empty because Micrometer only emits a Timer's `_count`/`_sum`/
+ `_max`, never `_bucket`, unless percentile histograms are explicitly turned on. `application.yaml` never
+ set this. Fix: `management.metrics.distribution.percentiles-histogram.http.server.requests: true`.
+ Verified: `http_server_requests_seconds_bucket` appears in `/actuator/prometheus` immediately after
+ restart, and `histogram_quantile(0.95, ...)` returns real numbers once the target `uri` has recent
+ traffic in the query window (a `uri` with none still returns `NaN` - expected Prometheus behavior for
+ an all-zero-rate histogram, not a bug).
+2. **`cache_gets_total`/`cache_puts_total` never existed at all** - not a naming or label mismatch, the
+ metrics were simply never registered. Root cause, confirmed by decompiling the actual jars rather than
+ trusting memory of older Boot versions: `spring-boot-actuator-autoconfigure-4.1.0.jar` contains no
+ `cache` package whatsoever - the `CacheMetricsRegistrar`/`CacheMeterBinderProvider` auto-binding this
+ design assumed (a real feature in Boot 2/3) does not exist in Boot 4.1. Fix, replicated manually in
+ `CacheConfig` rather than reintroducing Boot's removed mechanism: build the `RedisCacheManager`'s
+ `RedisCacheWriter` explicitly with `.collectStatistics()` (Spring Data Redis's own, independent
+ `CacheStatisticsCollector` - unaffected by the Boot-side removal), and register a `MeterBinder` bean
+ that exposes each region's `getHits()`/`getMisses()`/`getPuts()` as `FunctionCounter`s named
+ `cache.gets`/`cache.puts`, tagged `cache`/`result` to match the dashboard's existing (correctly
+ anticipated) query shape exactly. `initialCacheNames(...)` on the manager builder (added alongside this
+ fix) makes all four regions exist from startup rather than being created lazily on first use.
+ Verified: exercising each cached endpoint exactly twice produces precisely 1 miss + 1 hit + 1 put per
+ region in `/actuator/prometheus`.
+3. **Prometheus's own remote-write receiver was never actually on** - `docker-compose.yaml` passed
+ `--enable-feature=remote-write-receiver`, which was never a valid `--enable-feature` value; Prometheus
+ 3.11.3's own startup log said so outright ("Unknown option for --enable-feature"), and the write
+ endpoint 404'd accordingly. The correct, and long-standing, flag is the dedicated
+ `--web.enable-remote-write-receiver` (confirmed against `prometheus --help` directly). This is why the
+ k6 dashboard had zero data even before its query-shape bugs (below) mattered at all.
+4. **k6's `experimental-prometheus-rw` output doesn't emit what the dashboard assumed**, on two counts,
+ both confirmed by actually running `k6 run --out experimental-prometheus-rw` against the live stack and
+ inspecting exactly what landed in Prometheus:
+ - `K6_PROMETHEUS_RW_SERVER_URL` alone does not activate the output - a run with only that env var set
+ completed with no errors and pushed nothing. `K6_OUT: experimental-prometheus-rw` (the env-var
+ equivalent of `k6 run --out experimental-prometheus-rw`) is now set directly on the `k6` service in
+ `docker-compose.yaml`, so the plain documented invocation (`docker compose --profile load-test run k6
+ run /scripts/.js`, §8) works without anyone needing to type that flag by hand.
+ - There is no `k6_http_req_duration_seconds_bucket` or `k6_http_req_failed_total` - k6's trend metrics
+ (`http_req_duration`, etc.) are exported as pre-computed percentile *gauges* per request URL
+ (`k6_http_req_duration_p99{name=...}` by default - `p99` only, until
+ `K6_PROMETHEUS_RW_TREND_STATS: "p(95),p(99)"` is set to also get `_p95`; the bare form `"p95"` is
+ rejected at k6 startup with "invalid trend stat", confirmed empirically - it has to be k6's own
+ `p(95)` threshold syntax), and `http_req_failed` - a k6 "rate" metric (a 0..1 boolean average, not a
+ counter) - only ever exists as `k6_http_req_failed_rate`, never a `_total` counter. Fixed
+ `k6-load-test.json`'s two affected panels to query `avg(k6_http_req_duration_p95)` and
+ `avg(k6_http_req_failed_rate)` respectively (averaged across the per-URL series, since `search.js`
+ deliberately spreads requests across many distinct query terms - §8), with the failed-rate panel's
+ unit corrected from `reqps` to `percentunit` to match.
+
+All four dashboards were re-verified after these fixes: queried every panel's exact PromQL expression
+directly against Prometheus (both directly and through Grafana's own datasource-proxy API, to rule out a
+Grafana-side provisioning-cache issue) with real traffic generated live (`curl` loops for the HTTP/cache
+panels, an actual short `k6 run` for the k6 panels), and confirmed real, non-`NaN`, non-empty values back
+for every one of them.
+
+### 7.2 End-to-end request tracing: closing the MDC gap and instrumenting DB/cache/controller
+
+Every request already produced a real trace reaching Tempo - confirmed by querying Tempo directly. What
+didn't work, despite an earlier (incorrect) claim in this document that it already did: `traceId`/
+`spanId` on every log line, and any span coverage below the HTTP/Security layer. Full design:
+[`docs/tracing-design.md`](tracing-design.md). Four changes, all verified live against the running stack
+(a real trace pulled from Tempo's API, not just "it compiled"):
+
+1. **`RequestLoggingFilter` reordered to `Ordered.HIGHEST_PRECEDENCE + 2`.** Root cause (confirmed by
+ decompiling the actual Boot 4.1 jars, not assumed): the MDC-population mechanism
+ (`OpenTelemetryTracingAutoConfiguration.otelSlf4JEventListener()`, auto-registered) genuinely works,
+ but Spring's own `ServerHttpObservationFilter` - the filter that opens the span whose scope triggers
+ it - registers at exactly `HIGHEST_PRECEDENCE + 1`. `RequestLoggingFilter` ran at
+ `HIGHEST_PRECEDENCE` itself, one step *before* it, so "request started" logged before the span
+ existed and "request completed" logged after it had already closed - the only two log lines in a
+ request's entire lifecycle missing `traceId`. Verified live: both lines now carry it (also covered
+ by a new integration test, `RequestTracingIntegrationTest`, asserting this via a Logback
+ `ListAppender` against a real `MockMvc` request through the full filter chain).
+2. **Database spans**: `net.ttddyy.observation:datasource-micrometer-spring-boot` wraps the HikariCP
+ `DataSource` with zero repository code changes. A live trace shows `connection` (tagged with the
+ actual `HikariPool-1` pool name - `jdbc.datasource.pool`), `query` (the real SQL text,
+ `jdbc.query[0]`), and `result-set` (`jdbc.row-count`) as separate spans - connection-acquisition
+ time is now directly visible, the exact thing that would have made this session's earlier Hikari
+ pool-exhaustion incidents (§8) obvious from a trace instead of log archaeology.
+3. **Cache spans**: no new dependency needed - Boot 4.1's `spring-boot-data-redis` module ships
+ `LettuceObservationAutoConfiguration` already wired to the existing `ObservationRegistry`, so real
+ Redis commands (`get`, `set`, ...) were *already* becoming spans automatically, discovered by
+ checking a live trace before writing any code for this. What genuinely needed building:
+ `infrastructure.cache.ObservingRedisCacheWriter`, a full delegating wrapper around the
+ `redisCacheWriter` bean that tags the current Observation with `cache.result=hit`/`miss` from
+ inside `get(...)` - the same hit/miss signal `cacheStatisticsMeterBinder` already tracks in
+ aggregate, now attached to a single request's trace. Lands on the enclosing controller span (below)
+ rather than the Redis span itself, since Lettuce closes its own span synchronously inside the
+ `get()` call, before this wrapper's code runs.
+4. **Controller spans**: `infrastructure.observability.ControllerObservationInterceptor`, a
+ `HandlerInterceptor` registered via `ObservabilityWebMvcConfig`, brackets each resolved controller
+ method (e.g. `TitleController#get`) as its own span - nested inside the Security filter-chain spans,
+ around the DB/cache spans above. Needed a fallback to `ObservationRegistry.NOOP` via
+ `ObjectProvider` (`ObservabilityWebMvcConfig`'s constructor) - `WebMvcConfigurer` beans are pulled
+ into every `@WebMvcTest` slice regardless of what else that slice excludes, but `@WebMvcTest` also
+ explicitly disables tracing autoconfiguration, so all eight `@WebMvcTest` controller test classes
+ failed context startup with a constructor `UnsatisfiedDependencyException` before this fix.
+
+Resulting trace shape for a real request (`GET /api/v1/titles/tt9000009`, a 404 - pulled directly from
+Tempo, not illustrative):
+`http get /api/v1/titles/{titleId}` -> `secured request` -> `TitleController#get`
+(`cache.result=miss`) -> `get` (Redis, `db.system=redis`) and, since it missed,
+`connection`/`query`/`result-set` (Postgres, real SQL text, `jdbc.row-count=0`) - all under one
+`traceId`, and every `RequestLoggingFilter` log line for that request carrying the same one.
+
+## 8. k6 Load Testing Plan
+
+One script per endpoint under `imdb/k6/`, run one at a time (never concurrently) so each run's metrics
+are attributable to a single endpoint:
+
+| Script | Pattern | Notes |
+|---|---|---|
+| `search.js` | Ramping VUs (0 -> 50 -> 100 -> 0), random query terms from a word list | |
+| `title-detail.js` | Ramping VUs, random `tconst` sampled from a pre-fetched pool | |
+| `top-rated.js` | Ramping VUs, cycles through all genre enum values | |
+| `six-degrees.js` | Ramping VUs, **each iteration picks a distinct person pair** from `data/sampled-people.csv` (mix of ordinary and high-degree "hub" actors) | Deliberately defeats the Redis cache (§6) so the bidirectional CTE's real behavior under load is what gets measured, not cache round-trip time |
+
+Each script: `p(95) < ` and `error rate < 1%` thresholds, tuned per endpoint (the six-degrees
+threshold is expected to be materially higher than the others - that gap *is* the finding). All four
+output via `--out experimental-prometheus-rw` (`K6_PROMETHEUS_RW_SERVER_URL` already set in
+`docker-compose.yaml`), so a load-test run is visible in Grafana alongside the application's own
+traces/metrics for that time window. `k6` runs behind the `load-test` compose profile:
+
+```
+docker-compose --profile load-test run k6 run /scripts/six-degrees.js
+```
+
+**`all-endpoints.js`** is a second, deliberately different testing philosophy alongside the isolated
+per-endpoint scripts above, not a replacement for them: three `scenarios` (`browsing`, `userJourney`,
+`adminWrites`) run **simultaneously** in one `k6 run`, covering all 48 endpoints (the original 4
+read-only ones plus every CRUD-expansion endpoint added since), including JWT-authenticated user and
+admin flows. The isolated scripts answer "how does endpoint X behave under load"; this one answers "how
+does the system behave when every endpoint is loaded at once" - a materially different question, since
+endpoints share the same HikariCP pool and Tomcat thread pool. `setup()` registers `USER_COUNT`
+throwaway users and logs in as the bootstrap admin once, up front; custom `Rate` metrics
+(`browsing_errors`, `user_journey_errors`, `admin_write_errors`) are used instead of the built-in
+`http_req_failed`, since several scenarios have legitimate non-2xx outcomes (six-degrees 404/504,
+optimistic-lock 409s). Run the same way, via the `load-test` compose profile:
+
+```
+docker-compose --profile load-test run k6 run /scripts/all-endpoints.js
+```
+
+Running this for the first time against the full combined peak load (up to ~105 VUs across the three
+scenarios at once) surfaced five real, previously-latent bugs, none of which the isolated single-endpoint
+scripts above could have found since none of them exercise more than one endpoint's worth of concurrent
+DB load at a time:
+
+1. **HikariCP pool exhaustion under combined concurrency** - the pool (already bumped from 10 to 30 by an
+ earlier isolated-search-load incident, see `application.yaml`) was sized for one scenario's peak, not
+ several at once; `CannotGetJdbcConnectionException` started appearing across nearly every repository,
+ not just one endpoint. Bumped to 60.
+2. **`title_type` and `category` are Postgres enums on the real, `imdblib`-imported database, but plain
+ `TEXT` in this project's own `V0__base_schema.sql` fallback** (used only by fresh Testcontainers
+ schemas) - a bare varchar bind parameter isn't implicitly cast to an enum, so every admin title/
+ principal create or update failed. Closed the drift by having V0 declare the same enums production
+ already has (confirmed via `information_schema` against the live container), and added explicit
+ `::title_type` / `::category` casts at every write site.
+3. **`genres` has the identical drift** (`GENRE[]` in production, `TEXT[]` in V0) - already known and
+ partially worked around for reads (`genres_as_text()`, V1), but never fixed for writes. Same treatment:
+ V0 now declares a real `genre` enum and `genre[]` column, write sites cast `::genre[]`.
+4. **`title_basics.is_adult` is `NOT NULL` with no default on the real database** (unlike V0's own
+ `DEFAULT FALSE`) and was never part of `insertTitle`'s column list at all, since it isn't modeled
+ anywhere else in the API - every admin-created title failed the not-null constraint. Fixed by
+ explicitly inserting `false`.
+5. **`title_id_seq` collided with orphaned `title_principals` rows** - V6 seeded the sequence from
+ `max(tconst) FROM title_basics` alone, but raw IMDb exports are independently-snapshotted files, so
+ `title.principals.tsv` can reference a tconst `title.basics.tsv` doesn't have a row for (confirmed:
+ 6,830 such orphaned rows on this dataset). Once admin title creation reached one of those ids,
+ `POST .../principals` failed with a `title_principals_pkey` duplicate-key error against a real,
+ already-imported credit. `V11__fix_admin_id_sequence_baselines.sql` recomputes both id sequences'
+ baselines as the greatest id referenced *anywhere* in the schema, not just each id's own table.
+
+After all five fixes, `admin_write_errors` dropped from 100% to 0%. One finding remains open, not a bug
+but a real capacity/latency characteristic: at ~80 concurrent `browsing` VUs, six-degrees' inherently slow
+queries (already a documented, accepted limitation in isolation - `six-degrees.js`'s own threshold is
+deliberately looser than the other three scripts) can hold a shared DB connection long enough to push
+*other*, normally-fast endpoints in the same scenario (e.g. `top-rated`) past k6's client-side request
+timeout. Isolating six-degrees onto its own connection pool, or reducing its share of combined-scenario
+traffic, would be the next thing to try if this needs to be closed rather than accepted.
+
+## 9. Error Handling & API Conventions
+
+- All errors are RFC 7807 `ProblemDetail`: `404` for unknown IDs (`domain.exception.NotFoundException`,
+ thrown by `application` use cases, mapped by `presentation.ApiExceptionHandler`), `400` for malformed
+ IDs (`IllegalArgumentException` from `utils.ImdbIds`) / out-of-range `maxDegree` (handled automatically
+ by Spring MVC's built-in `HandlerMethodValidationException` support, no custom handler needed) /
+ missing required query params.
+- Six-degrees disambiguation is `200`, not an error status - see §4.4.
+- Pagination via the hand-rolled `PagedResult` (§4.1), not Spring Data's `Page`.
+- Swagger/OpenAPI UI is deferred - `springdoc-openapi` isn't yet available for Boot 4.1 (§11).
+
+## 10. Test Plan
+
+The seeded `abanda/imdb-postgresql` image is ~20GB and takes 20-30 minutes to import - unusable as a CI
+dependency. Testing splits into three tiers - unit, integration, e2e/contract - run as three sequential
+jobs in `.github/workflows/imdb-ci.yml` (`unit` -> `integration` -> `e2e`, each gated on the previous one
+passing), and the onion split from §2 changes *what* "unit test" means here for the better: use-case
+implementations are tested against mocked `domain.repository` **interfaces** with plain Mockito, no Spring
+context and no Testcontainers required at all.
+
+### 10.1 Unit tests (Maven Surefire, `mvn test`)
+
+- **Use-case tests**: `application`-layer `*UseCaseImpl` classes against mocked `domain.repository`
+ interfaces (JUnit 5 + Mockito + AssertJ, matching `votee`'s existing testing style in this monorepo).
+ Because these are pure interface mocks, none of these tests touch Postgres, Redis, or Spring at all.
+- **Decorator unit tests** (`infrastructure.cache.Caching*Test`): verify each decorator delegates on a
+ cache miss and doesn't re-invoke the delegate on a hit, against a mocked delegate with a plain
+ `ConcurrentMapCacheManager` (or an outright mock `Cache`) - no Spring context, no real Redis. Previously
+ this reasoning needed care (the old `DistanceCache` design in §2.1 had a self-invocation pitfall to
+ avoid); now it's a simple, mechanical test. **What this tier structurally cannot catch**: an in-memory
+ map never serializes anything, so a real Redis (de)serialization bug - e.g. the `GenericJacksonJsonRedis-
+ Serializer` type-metadata `ClassCastException` this project hit earlier - passes every one of these tests
+ while still breaking in production. That gap is closed in §10.2, not here.
+- **Controller tests**: `MockMvc` against mocked `application`-layer use-case interfaces (`@MockitoBean` -
+ the current replacement for the older `@MockBean`, checked against the Boot 4.1 testing reference docs
+ directly), covering request validation and `ProblemDetail` error-shape contracts.
+
+Classified by naming convention, not by directory: `maven-surefire-plugin` is configured in `pom.xml` to
+exclude `**/*IntegrationTest.java` and `**/ImdbApplicationTests.java` (the Testcontainers-backed Spring
+Boot smoke test), so everything else under `src/test/java` runs here by default - no file relocation
+needed, matching the existing `*IntegrationTest.java` suffix convention already in place before this work.
+
+### 10.2 Integration tests (Maven Failsafe, `mvn failsafe:integration-test failsafe:verify`)
+
+- **Persistence integration tests** (`infrastructure.persistence.*IntegrationTest`): Testcontainers
+ running a plain `postgres:17` image, exercising the `infrastructure.persistence` implementations
+ directly against their `domain.repository` interface contracts. Flyway runs `V0`/`V1`/`V2` from §3
+ automatically on context startup (`V0` is what actually creates the base tables here - see §3.4), then
+ the shared `fixture-data.sql` (loaded via `@Sql` on the test class, which runs after the context - and
+ therefore Flyway - is already up) seeds rows covering: a known multi-hop co-star chain (to exercise the
+ bidirectional CTE end-to-end, including a deliberate case where the true path requires more than one hop
+ on each side), a tied weighted-rating case, and an ambiguous shared name. Fast and deterministic.
+- **Cache integration tests** (`infrastructure.cache.Caching*IntegrationTest`, one per decorator): the
+ same four decorators as §10.1, but wired against a **real Redis** via Testcontainers
+ (`TestcontainersConfiguration`) and a real `RedisCacheManager`, not `ConcurrentMapCacheManager`. Each
+ test calls the decorated method twice inside a `@Transactional @Sql("/fixtures/fixture-data.sql")`
+ context, asserts the second call's result equals the first, and asserts the entry is actually present in
+ the named cache region under its documented key (§6) via `CacheManager.getCache(region).get(key)`. This
+ tier exists specifically because §10.1's mock-backed decorator tests cannot exercise real serialization -
+ a `RedisCacheManager` is the only thing in this test plan that actually round-trips a value through
+ `GenericJacksonJsonRedisSerializer` the way production does. Four tests, one per region: `title-search`
+ (`search("Few Good Men", 0, 20)`), `title-detail` (`getDetail("tt0000100")`), `top-rated`
+ (`findTopRated("Action", 10, 100)`), `six-degrees` (`findShortestPath(1, 2)`, keyed `"1-2"` per §6's
+ `min-max` convention).
+- `ImdbApplicationTests` (the Spring Boot context-loads smoke test, Testcontainers-backed) also runs here,
+ alongside the two tiers above - it doesn't match the `*IntegrationTest.java` suffix, so it's named
+ explicitly in both the Surefire exclude and the Failsafe include.
+
+`maven-failsafe-plugin` is configured with the mirror-image `` (`**/*IntegrationTest.java`,
+`**/ImdbApplicationTests.java`) and bound to the `integration-test`/`verify` goals; `spring-boot-starter-
+parent` manages both plugins' versions, so no explicit `` is pinned in `pom.xml` for either.
+
+### 10.3 E2E / contract tests (Postman + Newman, against `docker-compose.e2e.yaml`)
+
+A third tier below Testcontainers: a fully-built `imdb-service` Docker image, talking to real (if
+minimally-seeded) Postgres and Redis containers over the network, hit with real HTTP requests - the only
+tier that exercises the actual `Dockerfile` image and the full request path end-to-end, including the
+`RequestLoggingFilter`/`ApiExceptionHandler` wiring from §7/§9.
+
+- **Stack**: `docker-compose.e2e.yaml`, a deliberately separate file from the main `docker-compose.yaml`
+ (own `name: imdb-e2e` and `imdb-e2e-net` network - see the file's own header comment for the project-name
+ collision this guards against). `postgres` is plain `postgres:17`, not `abanda/imdb-postgresql` - the
+ real image's 20-30 minute import is fine for local dev, not for a stage that runs on every push. Schema
+ is built the same way the integration tests build it: `imdb-service`'s own Flyway migrations
+ (`V0`-`V4`), not a hand-copied schema dump.
+- **Seed**: a one-shot `seed` service (plain `postgres:17` image as a `psql` client, `depends_on:
+ imdb-service: condition: service_healthy` so Flyway has already run) loads the *same*
+ `src/test/resources/fixtures/fixture-data.sql` the integration tests use via a read-only bind mount -
+ one fixture dataset, not two to keep in sync, per the design discussion that settled on this over
+ hand-authoring a separate e2e-only dataset.
+- **Contract tests**: `imdb/postman/imdb-e2e.postman_collection.json`, a Postman Collection v2.1 with
+ inline `pm.test`/`pm.expect` assertions, run via `npx --yes newman run ... --env-var
+ baseUrl=http://localhost:8080` (no separate npm install needed - GitHub-hosted runners ship Node.js).
+ Nine requests covering every endpoint in §4 plus their documented edge cases: health, fuzzy search,
+ title detail (found and 404), top-rated (weighted-rating ordering, not raw average), six-degrees direct
+ / multi-hop / no-path / ambiguous-name. Verified locally: 22/22 assertions passing.
+- **CI orchestration** (`imdb-ci.yml`'s `e2e` job): bring the stack up with `-p imdb-e2e` (matching the
+ compose file's own `name:`), poll `/actuator/health` until healthy, poll `docker inspect` on the seed
+ container until it exits (failing loudly with its logs if the exit code is non-zero), run Newman, dump
+ `docker compose logs` on failure, and always tear the stack down (`down -v`) regardless of outcome.
+
+### 10.4 CI summary
+
+| Job | Command | Depends on |
+|---|---|---|
+| `unit` | `mvn -B test` | - |
+| `integration` | `mvn -B failsafe:integration-test failsafe:verify` | `unit` |
+| `e2e` | compose up -> health/seed poll -> `newman run` -> teardown | `integration` |
+
+Each job fails fast for the ones after it (`needs:` in GitHub Actions), so a broken unit test never pays
+for a Docker build, and a broken integration test never pays for the e2e stack's bring-up time.
+
+- **Load tests**: k6 (§8), run manually against the fully-seeded, production-image stack, not part of the
+ CI gate - a different concern (performance under load) from the correctness tiers above.
+
+## 11. Open Items for Implementation
+
+The Maven project itself (Java 21, Spring Boot 4.1.0, group `com.ludovictemgoua`, artifact `imdb`) is
+scaffolded and the dependency set below is already in `pom.xml`, generated directly from Initializr with
+one post-generation addition needed (`spring-boot-starter-jackson`, see below):
+
+| Dependency (Initializr label) | Include? | Why |
+|---|---|---|
+| Spring Web | Yes | REST controllers |
+| JDBC API | Yes | `NamedParameterJdbcTemplate` access - **not** Spring Data JDBC, see §3.1 (avoids reintroducing a repository/entity abstraction) |
+| Jackson | Added post-generation | Not selected on Initializr because it wasn't obviously needed - but `spring-boot-starter-webmvc` doesn't transitively pull in a Jackson implementation on Boot 4.1 the way `spring-boot-starter-web` did on Boot 3, so JSON responses and `GenericJacksonJsonRedisSerializer` (§6) both need it added explicitly |
+| PostgreSQL Driver | Yes | Connects to `abanda/imdb-postgresql` |
+| Flyway Migration | Yes | Runs the `V0`/`V1`/`V2` migrations from §3 |
+| Spring Data Redis | Yes | Cache-aside layer, §6 |
+| Validation | Yes | `maxDegree` range / malformed-ID checks at the controller boundary |
+| Spring Boot Actuator | Yes | Health, `/actuator/prometheus` |
+| Prometheus | Yes | Micrometer -> Prometheus format, scraped by the `prometheus` compose service |
+| Distributed Tracing | Yes | Span/trace IDs in logs - log<->trace correlation, §7 |
+| OpenTelemetry | Yes | Publishes traces via OTLP to Tempo, pairs with Distributed Tracing - both selections resolve to the single unified `spring-boot-starter-opentelemetry` artifact on Boot 4.1 |
+| springdoc-openapi | Revisited | Excluded at Initializr time (`versionRange` `[3.5.0.RELEASE, 4.1.0.M1)`, not yet ported to Boot 4.1). Added later, directly as `springdoc-openapi-starter-webmvc-ui` 3.0.3 (not via Initializr), once empirically confirmed compatible with the real Boot 4.1.0 - see the OpenAPI UI docs. |
+| Testcontainers | Yes | Backs the integration-test plan, §10 |
+| datasource-micrometer | Revisited | Same original reasoning as springdoc-openapi above (Initializr `versionRange` not yet covering Boot 4.1). Added later as `net.ttddyy.observation:datasource-micrometer-spring-boot` 2.2.1, once empirically confirmed compatible with the real Boot 4.1.0 - see §7.2. |
+| Lombok | No | This monorepo uses Java records for value types (see `votee`), not Lombok-generated boilerplate |
+| Docker Compose (dev-tool) | No | Conflicts with our hand-authored `docker-compose.yaml`, which already includes the app itself as a service |
+| Zipkin | No | Redundant trace backend - traces already go via OpenTelemetry/OTLP to Tempo |
+| otlp-metrics | No | Redundant metrics path - metrics already go via Prometheus pull-scrape |
+
+Done, verified against a real running stack (not just written and assumed correct):
+
+- **`Dockerfile`**: multi-stage build (Maven build stage -> JRE runtime stage), confirmed with a real
+ `docker build` (549MB final image, no errors).
+- **Test suite** (§10): 32 unit tests (Surefire: use-case, decorator-unit, controller) and 22 integration
+ tests (Failsafe: persistence, the 4 Redis-Testcontainers cache tests, and the Spring Boot smoke test),
+ all passing - plus the e2e tier's 9-request/22-assertion Postman/Newman collection, verified against a
+ live `docker-compose.e2e.yaml` stack. All three tiers wired into `imdb-ci.yml` as sequential
+ `unit` -> `integration` -> `e2e` jobs.
+- **Grafana dashboards** under `observability/grafana/provisioning/dashboards/json/` - four dashboards
+ (HTTP overview, six-degrees latency breakdown, cache hit ratio, k6 load-test results), confirmed by
+ actually bringing up Grafana and checking the provisioned dashboards via its API (`/api/search`,
+ `/api/dashboards/uid/...`), and - going further than schema-correctness - by generating real traffic
+ and confirming every panel's exact PromQL expression returns real, non-empty data through Prometheus
+ and through Grafana's own datasource-proxy API. Four real bugs surfaced by that verification and were
+ fixed (missing percentile-histogram config, Boot 4.1 having quietly dropped automatic cache-metrics
+ binding entirely, a Prometheus flag that was never valid, and wrong assumptions about k6's remote-write
+ metric shapes) - full writeup in §7.1.
+- **k6 scripts and `data/sampled-people.csv`** under `imdb/k6/` - `title-detail.js` and `six-degrees.js`
+ discover real ids/people from the live API at `setup()` time rather than shipping a
+ dataset-snapshot-specific list of hardcoded ids (a first attempt at hardcoding a few "well-known" real
+ IMDb ids for the CSV turned out to be exactly the kind of unverified claim worth avoiding - see
+ `data/generate-sampled-people.sql` for how to build a proper large sample once real data is loaded).
+ Syntax and control flow verified with real `k6 run` invocations (the setup functions' own error
+ messages fired exactly as designed against no server).
+- **Two real bugs surfaced by actually running the stack, not by review**:
+ 1. `abanda/imdb-postgresql` keeps bouncing its own Postgres listener throughout the entire ~20-30 minute
+ background import (config tuning at startup, and further blips under the heavy `COPY` load) - well
+ after the container healthcheck has reported healthy once. `depends_on: condition: service_healthy`
+ only gates the *first* startup attempt, not ongoing availability. Fixed at the right layer - Flyway's
+ own `connect-retries`/`connect-retries-interval` (§ above, `application.yaml`) so the connection
+ attempt itself retries for up to 30 minutes, rather than the whole Spring context failing once and
+ needing an external restart.
+ 2. `imdb-service` had no restart policy at all in `docker-compose.yaml`; added `restart: on-failure:5`
+ as a second line of defense on top of the Flyway fix, so a genuinely bad startup still self-heals
+ instead of sitting crashed until someone notices.
+
+Still open - these need the real, fully-imported dataset, which takes 20-30 minutes and is a deliberate
+choice to run, not something to trigger incidentally:
+
+- Pick the concrete `minVotes` default for the weighted-rating formula from the real vote-count
+ distribution once the dataset is loaded (PDD §11).
+- Tune `sideCap` in §5.3 against real k6 results if it's ever found to be too conservative or too
+ loose - `fanOutCap` no longer exists (§5.2: the BFS rewrite has a real visited set, so it never needs
+ to arbitrarily drop neighbors to bound the search).
+- Decide the `co_star_edges` refresh cadence (PDD §11 open question) if this ever moves beyond a
+ single-import deployment.
+- The onion layering in §2 is enforced today only by convention (import direction) and code review, not
+ by a build-tool boundary. If this project grows past its current size, splitting `domain`/`application`/
+ `infrastructure`/`presentation` into separate Maven modules would make the dependency rule
+ compiler-enforced instead of convention-enforced - not done now since a 4-module split is disproportionate
+ to this project's actual size, but the package structure is already shaped to make that split
+ mechanical later if it's ever worth it.
+
+## 12. CRUD Expansion
+
+Extends the read-only API above into a full CRUD service, in two layers: JWT auth plus admin CRUD over
+the core IMDb entities (titles/people/cast-crew), and a new user-generated-content layer (watchlists/
+reviews/custom lists) built on top of it. Full rationale, endpoint tables, and the negative-case
+contract (403 vs 404 vs 409) live in `docs/crud-expansion-design.md`; this section summarizes what was
+actually built and verified.
+
+- **Auth**: stateless JWT (`JJWT` 0.12.6, `HS384`), issued by `POST /api/v1/auth/register`/`login`,
+ renewed via `POST /api/v1/auth/refresh`. `JwtAuthenticationFilter` (registered as a `@Bean` inside
+ `SecurityConfig`, not `@Component`-scanned, so `@WebMvcTest` slices never accidentally construct it)
+ populates the `SecurityContext`; `@PreAuthorize("hasRole('ADMIN')")` gates every admin write.
+ `BootstrapAdminRunner` creates a single admin account from `IMDB_BOOTSTRAP_ADMIN_EMAIL`/`_PASSWORD` on
+ startup when both are set, so a fresh environment always has one admin able to promote others.
+ `@PreAuthorize` denials and filter-level `.anyRequest().authenticated()` denials are two genuinely
+ distinct Spring Security code paths - the former surfaces inside `DispatcherServlet`'s own exception
+ resolution, the latter through `ExceptionTranslationFilter` - both need their own handler
+ (`ApiExceptionHandler`'s `AccessDeniedException` mapping and `ProblemDetailAccessDeniedHandler`
+ respectively) or one of the two silently falls through to a bare 500.
+- **Versioning and soft delete**: every writable table gained `version INTEGER NOT NULL DEFAULT 0` and
+ `deleted_at TIMESTAMPTZ` (migrations `V7`-`V10`). Writes use optimistic locking - an `UPDATE ... WHERE
+ id = :id AND version = :expectedVersion` that affects 0 rows is reported as `WriteResult.VERSION_CONFLICT`
+ and surfaced as `409`, distinguishing it from a genuine `404` at the repository boundary rather than
+ each use case re-deriving that distinction. Reads filter `deleted_at IS NULL`; historical references
+ (cast/crew credits pointing at a soft-deleted title or person) deliberately do not, so deleting a title
+ doesn't retroactively break someone else's existing credit record.
+- **Admin-writable-row id sequences**: `title_id_seq`/`person_id_seq` (`V6`) let admin-created titles and
+ people get ids from the same numeric space as the imported IMDb dataset without colliding with it -
+ seeded to start above the highest imported id in production (where the full `abanda/imdb-postgresql`
+ import completes before `imdb-service`'s own migrations ever run) and, in Testcontainers tests where
+ that ordering is reversed, advanced explicitly via `setval()` at the end of the shared fixture file.
+- **Cache eviction per region** (`CacheConfig`'s four regions, §6): `title-detail` and `six-degrees` are
+ evicted precisely by key on any write that affects that exact title or person/co-star-path.
+ `top-rated` has no cheap per-write key to target (arbitrary `genre:limit:minVotes` combinations), so an
+ admin rating write clears the whole region (`allEntries = true`) instead - accepted as an infrequent,
+ cheap operation rather than defeating the cache on every title write. `title-search` accepts a bounded
+ 15-minute staleness window instead of eviction, for the same reason. Verified against real Redis, not
+ just the `@CacheEvict` annotations, by `CacheEvictionIntegrationTest`.
+- **User-generated content**: watchlists, reviews, and custom lists all share one ownership/visibility
+ rule, applied consistently rather than ad hoc per resource - viewing a `PRIVATE` resource you don't own
+ is `404` (existence hidden), writing to a resource you don't own is `403` if it's `PUBLIC` (existence
+ already visible, only the action is denied) and `404` if it's `PRIVATE`. `TitleDetail` gained
+ `userRatingAverage`/`userRatingCount`, aggregated from `reviews` alongside the original IMDb rating.
+- **Verification**: 43 test classes across Surefire (unit) and Failsafe (Testcontainers integration,
+ including the four pre-existing Redis-Testcontainers cache tests plus the new `CacheEvictionIntegrationTest`)
+ all green, plus the Postman/Newman e2e collection (§10.3) extended with the full auth flow, a
+ create/stale-update/403 admin-write lifecycle, and one write-then-view lifecycle per new resource -
+ verified against a live `docker-compose.e2e.yaml` stack, 19 requests / 32 assertions passing.
diff --git a/imdb/docs/openapi-ui-design.md b/imdb/docs/openapi-ui-design.md
new file mode 100644
index 0000000..9770e5c
--- /dev/null
+++ b/imdb/docs/openapi-ui-design.md
@@ -0,0 +1,88 @@
+# OpenAPI UI Design
+
+## 1. Goal
+
+Give the `imdb` API an interactive, browsable API reference. README/LLD both flagged this as deferred
+("Swagger/OpenAPI UI is deferred: springdoc-openapi's Initializr `versionRange` doesn't yet cover Spring
+Boot 4.1") - that gap has since closed and this design resolves it, adding **both** a Swagger UI and a
+Redoc UI, generated from the same OpenAPI document.
+
+## 2. What's confirmed already
+
+`springdoc-openapi-starter-webmvc-ui` 3.0.3 (GitHub release notes: targets Spring Boot 4.0.5) was added to
+this project's `pom.xml` as a spike and the full `ImdbApplicationTests` Spring context was started against
+it: the context loads cleanly against this project's actual Spring Boot 4.1.0, with no dependency
+conflicts, and springdoc auto-registers `/v3/api-docs` and `/swagger-ui.html` on startup (confirmed via
+its own startup log lines). The spike change was reverted before this design was written; nothing is
+merged yet.
+
+## 3. Approach
+
+Two UIs, one OpenAPI source:
+
+- **Swagger UI**: `springdoc-openapi-starter-webmvc-ui` generates the OpenAPI 3.1 document from the
+ existing controllers/DTOs (reflection + the `@Valid`/Bean Validation annotations already present, no
+ new annotations required for a baseline result) and serves both the raw document (`/v3/api-docs`) and
+ its own interactive UI (`/swagger-ui/index.html`) with zero custom code.
+- **Redoc**: a static HTML page (`src/main/resources/static/redoc.html`) that loads the
+ `redoc.standalone.js` bundle from its CDN and points it at `spec-url="/v3/api-docs"` - the same document
+ Swagger UI reads. Spring Boot serves `src/main/resources/static/**` automatically; no extra dependency
+ or controller needed. This is the standard, minimal-effort way to add Redoc to a Spring Boot app.
+
+Both UIs are read-only views over the same generated spec, so there's no risk of them drifting apart -
+whichever one a consumer prefers, they see the same contract.
+
+## 4. Security
+
+`SecurityConfig`'s `authorizeHttpRequests` currently permits an explicit allow-list of `GET` paths and
+falls back to `.anyRequest().authenticated()` for everything else (Task 1.3's convention, followed by
+every public route added since). Without an explicit permit, `/v3/api-docs`, `/swagger-ui/**`, and
+`/redoc.html` would all 401. Add to the existing permitAll `GET` list:
+
+```java
+"/v3/api-docs/**", "/swagger-ui/**", "/redoc.html"
+```
+
+No ordering conflict with existing matchers (none of the current permitAll/authenticated patterns overlap
+these paths), so this is a pure addition, not a reordering.
+
+## 5. Metadata
+
+Minimal `application.yaml` properties (`springdoc.swagger-ui.path`, and `OpenAPI` info fields - title,
+description, version) via a small `@Bean OpenAPI` in a new `OpenApiConfig`. `infrastructure` is organized
+into one subpackage per concern (`cache`, `persistence`, `security`); this adds a new `infrastructure.openapi`
+subpackage for the same reason, rather than dropping a generic `config` package in or overloading an
+unrelated existing one. Cosmetic only - doesn't change what's documented, just how the title/description
+read.
+
+## 6. Testing
+
+One integration test (`OpenApiIntegrationTest`, matching the `*IntegrationTest` naming convention so it
+runs under Failsafe against a real Spring context) asserting, unauthenticated:
+
+- `GET /v3/api-docs` returns `200` with a body containing `"openapi"`
+- `GET /swagger-ui/index.html` returns `200`
+- `GET /redoc.html` returns `200`
+
+This matches the project's established practice of verifying every new route actually works rather than
+assuming the wiring is correct from the annotations/static file alone.
+
+## 7. Files touched
+
+- `pom.xml` - one new dependency
+- `src/main/java/.../infrastructure/openapi/OpenApiConfig.java` (new) - the `OpenAPI` metadata bean
+- `src/main/java/.../infrastructure/security/SecurityConfig.java` - three new permitAll path patterns
+- `src/main/resources/static/redoc.html` (new)
+- `src/main/resources/application.yaml` - optional `springdoc.*` properties if defaults aren't desired
+- `src/test/java/.../infrastructure/OpenApiIntegrationTest.java` (new)
+- `README.md` - one line under **API** pointing at `/swagger-ui/index.html` and `/redoc.html`
+
+## 8. Out of scope
+
+- Annotating every controller method with `@Operation`/`@ApiResponse` for richer descriptions - springdoc
+ produces a correct, useful document from the existing code without them; hand-annotating every endpoint
+ is a much larger, separate effort with no functional benefit, and can be layered on incrementally later
+ if desired.
+- Authentication for the docs UI itself - the API's own read endpoints are already public; the docs
+ describing them are treated the same way, consistent with the rest of this project's public-GET
+ convention.
diff --git a/imdb/docs/openapi-ui-plan.md b/imdb/docs/openapi-ui-plan.md
new file mode 100644
index 0000000..8547cb3
--- /dev/null
+++ b/imdb/docs/openapi-ui-plan.md
@@ -0,0 +1,265 @@
+# OpenAPI UI Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Add a browsable, interactive API reference to the `imdb` service - both Swagger UI and Redoc, generated from one OpenAPI document, publicly accessible like the rest of the API's read endpoints.
+
+**Architecture:** `springdoc-openapi-starter-webmvc-ui` introspects the existing controllers/DTOs to generate the OpenAPI 3.1 document at `/v3/api-docs` and serves Swagger UI itself; a static `redoc.html` page (Spring Boot's default static-resource serving, no extra dependency) renders the same document via the Redoc CDN bundle. `SecurityConfig` gets three new public path patterns.
+
+**Tech Stack:** `springdoc-openapi-starter-webmvc-ui` 3.0.3, Redoc (CDN-hosted `redoc.standalone.js`, no new dependency).
+
+## Global Constraints
+
+- Spring Boot parent version: `4.1.0` (do not upgrade as part of this work).
+- `springdoc-openapi-starter-webmvc-ui` version: `3.0.3` exactly - confirmed via GitHub release notes and an empirical spike (full Spring context start) to work against this project's Boot 4.1.0; do not substitute another version without re-verifying the same way.
+- Every new public path must be added to `SecurityConfig`'s existing `permitAll()` allow-list convention (Task 1.3's pattern, followed by every public route in this project) - never add a broad catch-all, never disable security for a whole path prefix beyond what's needed.
+- Tests use AssertJ (`org.assertj.core.api.Assertions.assertThat`), matching every existing test in this codebase - do not introduce Hamcrest or other assertion libraries.
+- Integration tests follow the `*IntegrationTest.java` naming convention (Failsafe-run, Testcontainers-backed) - see `pom.xml`'s Surefire/Failsafe include/exclude configuration.
+
+---
+
+### Task 1: Swagger UI (dependency, metadata bean, security permit)
+
+**Files:**
+- Modify: `pom.xml`
+- Create: `src/main/java/com/ludovictemgoua/imdb/infrastructure/openapi/OpenApiConfig.java`
+- Modify: `src/main/java/com/ludovictemgoua/imdb/infrastructure/security/SecurityConfig.java`
+- Test: `src/test/java/com/ludovictemgoua/imdb/infrastructure/openapi/OpenApiIntegrationTest.java`
+
+**Interfaces:**
+- Consumes: nothing new from elsewhere in the codebase - `OpenApiConfig` only depends on `io.swagger.v3.oas.models.OpenAPI`/`Info`, transitively provided by the new dependency.
+- Produces: a public `GET /v3/api-docs` (the generated OpenAPI document) and a public `GET /swagger-ui/index.html` (the interactive UI). Task 2's Redoc page reads the same `/v3/api-docs` document this task makes public.
+
+- [ ] **Step 1: Write the failing test**
+
+```java
+package com.ludovictemgoua.imdb.infrastructure.openapi;
+
+import com.ludovictemgoua.imdb.TestcontainersConfiguration;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
+import org.springframework.context.annotation.Import;
+import org.springframework.test.web.servlet.MockMvc;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+@Import(TestcontainersConfiguration.class)
+@SpringBootTest
+@AutoConfigureMockMvc
+class OpenApiIntegrationTest {
+
+ @Autowired
+ MockMvc mockMvc;
+
+ @Test
+ void apiDocsIsPubliclyAccessible() throws Exception {
+ var result = mockMvc.perform(get("/v3/api-docs"))
+ .andExpect(status().isOk())
+ .andReturn();
+
+ assertThat(result.getResponse().getContentAsString()).contains("\"openapi\"");
+ }
+
+ @Test
+ void swaggerUiIsPubliclyAccessible() throws Exception {
+ mockMvc.perform(get("/swagger-ui/index.html"))
+ .andExpect(status().isOk());
+ }
+}
+```
+
+This test needs a real Spring context (not a `@WebMvcTest` slice) so the real `SecurityFilterChain` and the real springdoc auto-configuration are both exercised together - matching `ImdbApplicationTests`' `@Import(TestcontainersConfiguration.class) @SpringBootTest` pattern, plus `@AutoConfigureMockMvc` (no `addFilters = false` - security must actually run for this test to mean anything).
+
+- [ ] **Step 2: Run the test to verify it fails**
+
+Run: `JAVA_HOME="/c/Program Files/Java/jdk-21" ./mvnw -q failsafe:integration-test failsafe:verify -Dit.test=OpenApiIntegrationTest`
+Expected: FAIL - both requests return `401` (Spring Security's filter chain intercepts every request before `DispatcherServlet` gets a chance to resolve a handler, so an unprotected path with no permit rule is rejected regardless of whether springdoc is even on the classpath yet).
+
+- [ ] **Step 3: Add the dependency**
+
+In `pom.xml`, add immediately after the `flyway-database-postgresql` dependency (before the `micrometer-registry-prometheus` block):
+
+```xml
+
+ org.springdoc
+ springdoc-openapi-starter-webmvc-ui
+ 3.0.3
+
+```
+
+- [ ] **Step 4: Create `OpenApiConfig`**
+
+```java
+package com.ludovictemgoua.imdb.infrastructure.openapi;
+
+import io.swagger.v3.oas.models.OpenAPI;
+import io.swagger.v3.oas.models.info.Info;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+@Configuration
+public class OpenApiConfig {
+
+ @Bean
+ public OpenAPI imdbOpenApi() {
+ return new OpenAPI()
+ .info(new Info()
+ .title("imdb API")
+ .description("Search, ratings, and six-degrees over the IMDb dataset, plus JWT auth, "
+ + "admin CRUD over titles/people/credits, and user watchlists/reviews/lists.")
+ .version("v1"));
+ }
+}
+```
+
+`infrastructure` is organized into one subpackage per concern (`cache`, `persistence`, `security`) - this adds a new `infrastructure.openapi` subpackage for the same reason, rather than a generic `config` package.
+
+- [ ] **Step 5: Add the security permit**
+
+In `SecurityConfig.java`, change:
+
+```java
+ .requestMatchers("/actuator/**", "/api/v1/auth/**").permitAll()
+```
+
+to:
+
+```java
+ .requestMatchers("/actuator/**", "/api/v1/auth/**",
+ "/v3/api-docs/**", "/swagger-ui.html", "/swagger-ui/**").permitAll()
+```
+
+(`/swagger-ui.html` is a distinct single-segment path springdoc registers as a redirect entry point, separate from the `/swagger-ui/**`-prefixed static assets - both need permitting; confirmed via the earlier spike's own startup log: `"SpringDoc /swagger-ui.html endpoint is enabled by default"`.)
+
+- [ ] **Step 6: Run the test to verify it passes**
+
+Run: `JAVA_HOME="/c/Program Files/Java/jdk-21" ./mvnw -q failsafe:integration-test failsafe:verify -Dit.test=OpenApiIntegrationTest`
+Expected: PASS, both tests green.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add pom.xml src/main/java/com/ludovictemgoua/imdb/infrastructure/openapi/OpenApiConfig.java src/main/java/com/ludovictemgoua/imdb/infrastructure/security/SecurityConfig.java src/test/java/com/ludovictemgoua/imdb/infrastructure/openapi/OpenApiIntegrationTest.java
+git commit -m "Add Swagger UI via springdoc-openapi, public API docs endpoints"
+```
+
+---
+
+### Task 2: Redoc
+
+**Files:**
+- Create: `src/main/resources/static/redoc.html`
+- Modify: `src/main/java/com/ludovictemgoua/imdb/infrastructure/security/SecurityConfig.java`
+- Modify: `src/test/java/com/ludovictemgoua/imdb/infrastructure/openapi/OpenApiIntegrationTest.java`
+
+**Interfaces:**
+- Consumes: the `GET /v3/api-docs` document Task 1 made public (Redoc fetches it client-side via `spec-url`).
+- Produces: a public `GET /redoc.html`.
+
+- [ ] **Step 1: Extend the test with the failing case**
+
+Add to `OpenApiIntegrationTest`:
+
+```java
+ @Test
+ void redocIsPubliclyAccessible() throws Exception {
+ mockMvc.perform(get("/redoc.html"))
+ .andExpect(status().isOk());
+ }
+```
+
+- [ ] **Step 2: Run the test to verify it fails**
+
+Run: `JAVA_HOME="/c/Program Files/Java/jdk-21" ./mvnw -q failsafe:integration-test failsafe:verify -Dit.test=OpenApiIntegrationTest`
+Expected: FAIL - `redocIsPubliclyAccessible` returns `401` (no permit rule and no file yet); the two Task 1 tests still pass.
+
+- [ ] **Step 3: Create the static Redoc page**
+
+```html
+
+
+
+ imdb API - Redoc
+
+
+
+
+
+
+
+
+
+```
+
+Spring Boot serves `src/main/resources/static/**` automatically at the site root - no controller or extra dependency needed.
+
+- [ ] **Step 4: Add the security permit**
+
+In `SecurityConfig.java`, change:
+
+```java
+ .requestMatchers("/actuator/**", "/api/v1/auth/**",
+ "/v3/api-docs/**", "/swagger-ui.html", "/swagger-ui/**").permitAll()
+```
+
+to:
+
+```java
+ .requestMatchers("/actuator/**", "/api/v1/auth/**",
+ "/v3/api-docs/**", "/swagger-ui.html", "/swagger-ui/**", "/redoc.html").permitAll()
+```
+
+- [ ] **Step 5: Run the test to verify it passes**
+
+Run: `JAVA_HOME="/c/Program Files/Java/jdk-21" ./mvnw -q failsafe:integration-test failsafe:verify -Dit.test=OpenApiIntegrationTest`
+Expected: PASS, all three tests green.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add src/main/resources/static/redoc.html src/main/java/com/ludovictemgoua/imdb/infrastructure/security/SecurityConfig.java src/test/java/com/ludovictemgoua/imdb/infrastructure/openapi/OpenApiIntegrationTest.java
+git commit -m "Add a public Redoc page reading the same generated OpenAPI document"
+```
+
+---
+
+### Task 3: Documentation and final verification
+
+**Files:**
+- Modify: `README.md`
+
+**Interfaces:** none - this task only verifies and documents; no new production code.
+
+- [ ] **Step 1: Run the full local verification sequence**
+
+```bash
+JAVA_HOME="/c/Program Files/Java/jdk-21" ./mvnw -q test
+JAVA_HOME="/c/Program Files/Java/jdk-21" ./mvnw -q failsafe:integration-test failsafe:verify
+```
+Expected: PASS for every unit and integration test in the project (nothing from before this plan should have been weakened or broken).
+
+- [ ] **Step 2: Update `README.md`**
+
+In the **API** section, find this exact line:
+
+```markdown
+All errors are RFC 7807 `ProblemDetail` (404 for unknown IDs, 400 for malformed IDs/out-of-range `maxDegree`/missing params, 405 for the wrong HTTP method). Full contracts, request/response shapes, and error handling: [`docs/low-level-design.md`](docs/low-level-design.md) §4/§9.
+```
+
+Insert this new paragraph immediately after it:
+
+```markdown
+
+Interactive API docs, generated from the live controllers: [`/swagger-ui/index.html`](http://localhost:8080/swagger-ui/index.html) (Swagger UI) or [`/redoc.html`](http://localhost:8080/redoc.html) (Redoc), both reading the same generated document at `/v3/api-docs`.
+```
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add README.md
+git commit -m "Document the new Swagger UI and Redoc endpoints"
+```
diff --git a/imdb/docs/product-design.md b/imdb/docs/product-design.md
new file mode 100644
index 0000000..b13ccd4
--- /dev/null
+++ b/imdb/docs/product-design.md
@@ -0,0 +1,218 @@
+# IMDb Copycat API - Product Design Document
+
+| | |
+|---|---|
+| **Author** | Ludovic Temgoua Abanda |
+| **Status** | Draft |
+| **Date** | 2026-07-05 |
+| **Related docs** | `imdb/docs/low-level-design.md` (follow-up), `imdb/docs/REQUIREMENTS.md` (requirements + concrete setup) |
+| **Data source** | [`abanda/imdb-postgresql`](https://github.com/icemc/imdb-postgresql) (full IMDb Non-Commercial Dataset in PostgreSQL 17) |
+
+## 1. Overview
+
+`imdb` is a production-shaped Spring Boot REST API over the IMDb Non-Commercial Dataset, covering title
+search with cast/crew, top-rated movies by genre, and the "Six Degrees of Kevin Bacon" graph problem. Like
+`votee` (see `votee/docs/product-design.md`), this is a revision exercise as part of a deliberate return
+to the Java ecosystem (see the [root README](../../README.md)) - the point isn't just to satisfy the three
+functional requirements, but to do so the way a real production service would: proper indexing instead of
+full table scans, a defensible algorithm choice for the graph problem instead of "whatever is simplest,"
+caching, and full observability (metrics/logs/traces) rather than bolt-on logging.
+
+This document defines *what* is being built and *why*. The follow-up Low-Level Design document defines
+*how* - schema mapping, endpoint contracts, the recursive-CTE SQL, docker-compose topology, and the test
+plan.
+
+## 2. Background
+
+A common approach to sourcing the IMDb dataset locally truncates it (e.g. dropping `tvEpisodes`) to speed
+up import - but the requirements (`imdb/docs/REQUIREMENTS.md`) deliberately call for working against the
+real, untruncated data volume, since that's where indexing and algorithm choices actually get tested. This
+implementation uses a personal Docker image, `abanda/imdb-postgresql`, which loads the **full** dataset
+(including TV episodes) across seven tables: `name_basics`, `title_basics`, `title_ratings`, `title_crew`, `title_episode`,
+`title_principals`, `title_akas`. A notable schema detail that shapes several downstream decisions: IDs
+are stored as plain `INTEGER`, not IMDb's public `tt`/`nm` string format - the loader strips the prefix
+and leading zeros on import (`tt0111161` -> `111161`). No indexes beyond primary keys exist out of the
+box, and referential integrity between `title_principals` and `name_basics`/`title_basics` is not
+enforced by the loader.
+
+The three requirements sit on the same underlying co-occurrence data (people, titles, and their
+relationships) but stress genuinely different parts of the stack: requirement 1 is a search/read problem,
+requirement 2 is a ranking problem, and requirement 3 is a graph-traversal problem at a scale (millions of
+people, tens of millions of co-star edges) where the naive approach breaks down under load. That last
+point is why this document spends real space on comparing algorithms rather than just picking one.
+
+## 3. Goals
+
+- Three read endpoints (plus one supporting search endpoint) that fully satisfy the three functional
+ requirements against the real, unmodified dataset - no synthetic subset, per the requirements' own
+ guideline against truncating data.
+- A **defensible, literature-grounded** algorithm choice for Six Degrees of Kevin Bacon that holds up under
+ load for arbitrary person-to-person queries (not just "distance to Kevin Bacon" - see §9), bounded to a
+ configurable maximum of 7 degrees.
+- Full observability: metrics, structured logs, and distributed traces correlated in a single Grafana
+ instance, not just `println`-style logging.
+- A demonstrated, load-tested understanding of where this design's performance risk actually lives (the
+ graph-traversal endpoint), backed by k6 results per endpoint.
+- A fully containerized local environment (`docker-compose up`) that brings up the database, cache,
+ application, and the entire observability stack with no manual setup steps beyond that one command.
+
+## 4. Non-Goals
+
+- Authentication/authorization. This is a read-only public API for the purposes of this exercise; see §12.
+- Write endpoints of any kind. The dataset is seeded once via the Docker image; there is nothing to
+ mutate.
+- General-purpose graph database infrastructure (e.g. Neo4j) as a *running* dependency. It's evaluated and
+ documented as an alternative in §9 but not built, since it isn't justified at this dataset's scale.
+- A UI. This is an API-only deliverable, consistent with the requirements' "endpoint" framing.
+- Exact production-grade horizontal scalability (sharding, read replicas, multi-region). The design is
+ production-*shaped* - correct indexing, caching, observability, load-tested - but scoped to a
+ single-node deployment appropriate for a revision project.
+
+## 5. Target Users & Use Cases
+
+A single consumer persona: a client application (or a developer exercising the API directly via HTTP)
+wanting to:
+look up a movie by title and see who was in it and who made it; find the best-reviewed movies in a genre;
+and find out how closely connected two people in the film industry are - the classic "Six Degrees" party
+game, generalized to any two people rather than fixed to Kevin Bacon.
+
+## 6. Functional Requirements
+
+### 6.1 Data model (mapped from the seeded schema)
+
+| Table | Responsibility |
+|---|---|
+| `title_basics` | Core title data: type, primary/original title, years, runtime, genres |
+| `title_ratings` | Average rating and vote count per title |
+| `title_crew` | Directors and writers per title |
+| `title_principals` | Cast/crew billing per title (person, ordering, category, job, character) |
+| `title_episode` | TV episode -> parent series linkage (out of scope for this pass; see §12) |
+| `title_akas` | Localized alternate titles (out of scope for this pass; see §12) |
+| `name_basics` | Person data: name, birth/death year, professions, known-for titles |
+
+None of these are modified by the application. Additions on top (indexes, a materialized co-star-edge
+view) are described in the low-level design.
+
+### 6.2 Endpoints
+
+| # | Endpoint | Requirement | Summary |
+|---|---|---|---|
+| 1 | `GET /api/v1/titles/search` | #1 | Fuzzy search by primary or original title; paginated list of lightweight matches |
+| 2 | `GET /api/v1/titles/{titleId}` | #1 | Full title detail: metadata, rating, directors/writers, top-billed cast |
+| 3 | `GET /api/v1/genres/{genre}/top-rated` | #2 | Top-rated movies in a genre, ranked by weighted (Bayesian) rating, not raw average |
+| 4 | `GET /api/v1/people/six-degrees` | #3 | Degree of separation between any two people (name or ID), bounded to a configurable max (default and hard cap: 7) |
+
+Endpoint 2 exists because requirement 1 explicitly asks for "related information...including cast and
+crew," which doesn't fit in a search-result list item without making every search response expensive; a
+search-then-detail split is the standard REST shape for this. Full contracts (request/response DTOs,
+error shapes) are in the low-level design, §5.
+
+## 7. Non-Functional Requirements
+
+- **Correctness under load, not just correctness**: every endpoint must remain within its latency
+ threshold under the k6 load profiles in the low-level design, not just return the right answer for a
+ single request.
+- **No unbounded queries**: search is paginated; top-rated has a `limit`; six-degrees has a hard-capped
+ traversal depth. Nothing in this API can trigger an unbounded table scan or unbounded graph walk.
+- **Read-mostly caching**: since the underlying data only changes when the Docker image is reloaded (no
+ write path), aggressive Redis caching is a legitimate default, not a premature optimization.
+- **Observability by default**: every request is traceable end-to-end (HTTP -> service -> DB/cache) via
+ correlated metrics, logs, and traces - not added after the fact.
+- **ID stability at the API boundary**: the API exposes IMDb-style `tt`/`nm` string IDs, never the
+ internal integer PKs, so the public contract doesn't leak a storage detail that's specific to this one
+ Docker image's schema choices.
+
+## 8. High-Level Architecture
+
+- **Application**: Spring Boot 3, Java 21, Maven (consistent with `votee`'s build-tool choice elsewhere in
+ this monorepo).
+- **Database**: PostgreSQL 17 via `abanda/imdb-postgresql`, read-only from the application's perspective.
+- **Cache**: Redis, cache-aside pattern via Spring's cache abstraction, in front of all four endpoints.
+- **Observability**: Prometheus (metrics) + Loki (logs, shipped via Grafana Alloy) + Tempo (traces, via
+ OTLP) + Grafana (single pane of glass, datasources provisioned with trace/log/metric correlation), plus
+ Postgres and Redis Prometheus exporters so database- and cache-level behavior is visible alongside
+ application metrics.
+- **Load testing**: Grafana k6, one script per endpoint, results pushed to Prometheus so a load-test run
+ is visible in the same Grafana instance as the application's own telemetry for that time window.
+- **Local orchestration**: a single `docker-compose.yaml` brings up the entire stack (database, cache,
+ application, observability, and an opt-in `load-test` profile for k6).
+
+```
+Client -> imdb-service (Spring Boot)
+ |-- reads/caches --> Redis
+ |-- queries --------> PostgreSQL (abanda/imdb-postgresql)
+ |-- metrics --------> Prometheus <-- postgres-exporter, redis-exporter
+ |-- logs (stdout) --> Grafana Alloy --> Loki
+ |-- traces (OTLP) --> Tempo
+ \
+ --> Grafana (dashboards, correlated across all three)
+
+k6 (one script per endpoint, run in isolation) --> imdb-service, results --> Prometheus
+```
+
+## 9. Key Design Decisions & Rationale
+
+| Decision | Chosen approach | Alternatives considered | Rationale |
+|---|---|---|---|
+| Six Degrees algorithm | Bidirectional recursive CTE in Postgres (two capped `WITH RECURSIVE` queries, one from each person, meeting in the middle), plus Redis-cached results | (a) One-sided recursive CTE from a fixed Kevin-Bacon root; (b) precomputed single-source BFS from Kevin Bacon; (c) in-memory bidirectional BFS in the JVM; (d) Pruned Landmark Labeling (PLL); (e) dedicated graph database (Neo4j) | The requirement isn't fixed to Kevin Bacon - any two people can be queried - which rules out precomputing from one root. A naive one-sided traversal risks combinatorial blowup through high-degree "hub" actors; meeting in the middle halves the exponent and, combined with the max-7-degree cap (so each side only expands ~4 hops), keeps worst-case cost bounded without new infrastructure. PLL (Akiba et al., SIGMOD 2013) is the literature's actual state-of-the-art for this class of problem - hub-labeling with microsecond exact queries at hundreds-of-millions-of-edges scale - but it's a bespoke indexing engine, disproportionate to build for this exercise; documented as the answer if this had to serve real production query volume. Neo4j is the standard "just use a graph database" answer but adds a second datastore to operate and keep in sync for no benefit at this data size. |
+| Result caching | Redis, cache-aside, keyed by unordered person pair, storing the **true shortest distance up to the absolute 7-degree cap** regardless of the caller's requested max | Cache per (pair, requested-max) combination | A distance of, say, 5 is a fact independent of whether the caller asked for `maxDegree=3` or `maxDegree=7`; caching the true distance once lets every future request for that pair reuse it and simply filter against its own bound, instead of fragmenting the cache by request parameter. |
+| Top-rated ranking | IMDb-style weighted (Bayesian) rating: `WR = (v/(v+m))*R + (m/(v+m))*C` | Sort by raw `average_rating` | A raw-average sort lets a movie with 3 votes at 10/10 outrank one with 500,000 votes at 8.9. The weighted formula is IMDb's own published approach for exactly this reason, and demonstrates the same ranking-under-uncertainty thinking the Six Degrees decision does. |
+| Title search | PostgreSQL `pg_trgm` fuzzy/similarity search over `primary_title`/`original_title`, GIN-indexed | Exact/prefix `ILIKE` match | Users don't reliably know a title's exact casing/wording; trigram similarity tolerates typos and partial matches while still being index-backed (not a sequential scan), which matters given `title_basics` has millions of rows. |
+| API ID format | Public API uses IMDb-style `tt`/`nm` string IDs; translated to the internal integer PK at the repository boundary | Expose the internal integer IDs directly | The integer PK is an artifact of this specific Docker image's import script, not a stable public contract. Consumers of an "IMDb copycat" API expect IMDb's own ID format. |
+| Caching layer | Redis (new dependency) | In-process cache (e.g. Caffeine) | The load-testing goal explicitly requires observing cache behavior (hit/miss ratio) as a first-class signal via a Redis Prometheus exporter, and a shared external cache is the realistic production shape if this API ever ran more than one instance. |
+| Build tool | Maven | Gradle | Consistent with `votee` elsewhere in this monorepo. |
+| Six Degrees scope | Generalized: `personA`/`personB` are both arbitrary query inputs | Literal brief wording: fixed target = Kevin Bacon | Once a bidirectional traversal is the implementation (required regardless, to bound hub-actor blowup - see the algorithm row above), accepting two arbitrary people costs nothing extra - a Kevin-Bacon-fixed version would just be this same endpoint with one side pre-filled. Generalizing is strictly more capable for the same engineering cost, so there's no reason to artificially narrow it back to the literal brief wording. |
+
+## 10. Success Criteria
+
+- All three functional requirements are satisfied against the full (untruncated) dataset via the four
+ endpoints in §6.2.
+- The Six Degrees endpoint correctly computes degrees of separation between arbitrary people (not just
+ relative to Kevin Bacon), respects the caller's `maxDegree` bound, and returns within its k6-tested
+ latency threshold even for high-degree "hub" actors.
+- `docker-compose up` brings up the full stack (database, cache, application, full observability stack)
+ with no manual steps beyond that one command (excluding the one-time 20-30 minute dataset import).
+- Every endpoint has a Grafana-visible trace, and its k6 load-test run is visible in the same Grafana
+ instance correlated against application/DB/cache metrics for that time window.
+- Integration tests pass in CI against a lightweight fixture dataset (not the full 20GB image - see the
+ low-level design's test plan for why).
+
+## 11. Risks & Open Questions
+
+- **Hub-actor blowup risk remains partially empirical**: the bidirectional-CTE mitigation is
+ literature-grounded, but its actual worst-case latency on this specific dataset's most prolific actors
+ (some have thousands of credits) is only proven by the k6 results, not by design alone. If load testing
+ reveals the recursive CTE still misbehaves at the extreme tail, the documented fallback is the
+ precomputed/in-memory BFS alternative from §9, promoted from "documented" to "implemented."
+- **`title_principals` has no enforced foreign keys** (the loader's `add_references` step is not
+ consistently applied per its own source comments), so orphaned `nconst`/`tconst` references are
+ possible in principle; queries need to tolerate missing joins rather than assume referential integrity.
+- **Open question**: whether the `co_star_edges` materialized view (low-level design §4) needs a
+ scheduled refresh in a longer-lived deployment, or whether "refresh once after the one-time data import"
+ is sufficient given there's no write path. Deferred - not required for this pass's success criteria.
+- **Open question**: exact `minVotes` default for the weighted-rating formula on top-rated movies. Needs a
+ quick data-driven look at the vote-count distribution once the dataset is loaded, rather than guessing a
+ round number up front - flagged as an implementation-time task in the low-level design.
+
+## 12. Out of Scope / Future Work
+
+- `title_akas` (localized alternate titles) and `title_episode` (TV episode hierarchy) are loaded but not
+ surfaced by any endpoint in this pass.
+- Authentication/authorization of any kind.
+- Promoting Pruned Landmark Labeling or a dedicated graph database from "documented alternative" to
+ "implemented," should load testing prove the chosen bidirectional-CTE approach insufficient at real
+ production query volume.
+- Multi-instance/horizontal scaling of the application tier (the design is cache/observability-ready for
+ it, but it isn't exercised here).
+- Swagger/OpenAPI documentation UI: `springdoc-openapi`'s Initializr `versionRange` doesn't yet cover
+ Spring Boot 4.1 (see low-level design §11). Revisit once springdoc ships 4.1 support.
+
+## 13. References
+
+- Data source: [`abanda/imdb-postgresql`](https://github.com/icemc/imdb-postgresql)
+- Requirements: [`REQUIREMENTS.md`](REQUIREMENTS.md)
+- Akiba, Iwata, Yoshida, "Fast Exact Shortest-Path Distance Queries on Large Networks by Pruned Landmark
+ Labeling," SIGMOD 2013 - [arXiv:1304.4661](https://arxiv.org/abs/1304.4661)
+- Goldberg et al., "Reach for A*: Efficient Point-to-Point Shortest Path Algorithms" -
+ [Microsoft Research](https://www.microsoft.com/en-us/research/wp-content/uploads/2006/01/tr-2005-132.pdf)
+- Root repository context: [`/README.md`](../../README.md)
diff --git a/imdb/docs/tracing-design.md b/imdb/docs/tracing-design.md
new file mode 100644
index 0000000..76d00e4
--- /dev/null
+++ b/imdb/docs/tracing-design.md
@@ -0,0 +1,162 @@
+# End-to-End Request Tracing — Design
+
+## Problem
+
+Every request already produces a real OpenTelemetry trace that reaches Tempo successfully - confirmed
+by querying Tempo directly for a live trace during this design's investigation. But two things are
+missing:
+
+1. **Log correlation is broken for the two most important log lines.** The LLD (§7) claims `traceId`/
+ `spanId` are "already populated by Micrometer Tracing... included in every line automatically." This
+ is false today: tested empirically (real requests, inspected the actual JSON log output) and found
+ `traceId` on *zero* log lines, including error logs. Root cause (confirmed by decompiling the actual
+ Spring Boot 4.1 jars, not assumed): the MDC-population mechanism genuinely exists and works
+ (`OpenTelemetryTracingAutoConfiguration.otelSlf4JEventListener()` is a real, auto-registered bean),
+ but `RequestLoggingFilter` runs at `Ordered.HIGHEST_PRECEDENCE` - *outside* the filter
+ (`ServerHttpObservationFilter`) that actually opens the span whose scope triggers MDC population. Its
+ "request started" log fires before the span exists; its "request completed" log fires in a `finally`
+ block after the span has already closed. Everything logged from *within* the request (repository
+ DEBUG logs, `ApiExceptionHandler`'s error log) already gets a correct `traceId` - it's specifically the
+ two lines bracketing the entire request that don't.
+
+2. **Nothing below the HTTP/security layer is instrumented.** A trace today shows "this request took
+ 220ms" with zero visibility into how much of that was a SQL query, a Redis round-trip, or actual
+ business logic. This app uses raw JDBC (HikariCP + `NamedParameterJdbcTemplate`, no ORM) and Spring's
+ declarative `@Cacheable`/`@CacheEvict` over Redis (no manual `RedisTemplate` calls) - neither gets
+ automatic span coverage from Spring Boot's own auto-instrumentation, which only covers the HTTP/
+ Security filter chain.
+
+## Goals
+
+- Every log line for a request - from "request started" through business logic through "request
+ completed" - carries the same `traceId`.
+- A trace visible in Tempo shows the full breakdown: HTTP/security (already works) → controller →
+ cache → database, all properly nested under one trace.
+- Reuse Spring Boot's own, already-correct distributed-trace-context extraction (an incoming W3C
+ `traceparent` header from an upstream caller) rather than reimplementing it.
+
+## Non-goals
+
+- New Grafana dashboards. Tempo's own trace-search UI and the existing Loki↔Tempo↔Prometheus
+ datasource correlation (already provisioned) are sufficient for this work; nothing new is proposed
+ here.
+- Reducing the existing Spring Security filter-chain span verbosity (12+ small spans per request). Left
+ as-is - useful if auth itself ever needs debugging.
+- Tuning trace sampling (`management.tracing.sampling.probability: 1.0` stays as-is, matching the
+ existing "full sampling for this exercise" decision).
+
+## Design
+
+### 1. Fix log correlation: reorder `RequestLoggingFilter`
+
+Reorder `RequestLoggingFilter` to run just *inside* `ServerHttpObservationFilter` instead of around it
+(exact numeric `@Order` value to be confirmed empirically against the running app during
+implementation, the same way every other Boot-4.1-specific detail this session has been confirmed
+against real behavior rather than assumed). Its "request started" and "request completed" logs then
+execute within the already-open span scope, so both automatically pick up `traceId`/`spanId` via the
+existing (already-working, just previously out-of-scope) `Slf4JEventListener` mechanism.
+
+Deliberately **not** implemented by having `RequestLoggingFilter` open its own span: that would mean
+reimplementing Boot's incoming-header trace-context extraction ourselves, which
+`ServerHttpObservationFilter` already does correctly. Reordering reuses that logic instead of
+duplicating (and risking subtly breaking) it.
+
+### 2. Database spans: `datasource-micrometer`
+
+Add `net.ttddyy.observation:datasource-micrometer-spring-boot`, which wraps the HikariCP-backed
+`DataSource` bean (via a `BeanPostProcessor`, no manual `DataSource` bean redefinition) so every
+connection acquisition and every SQL statement becomes its own Observation/span automatically -
+zero changes needed across the ~8 repository classes.
+
+This gives connection-acquisition its own visible span duration - directly relevant given this
+session's earlier HikariCP pool-exhaustion incidents (found only through log/stack-trace archaeology
+under load); with this in place, that class of problem would show up immediately as an outsized
+connection-acquire span in a trace waterfall.
+
+Exact span/tag names and compatibility with this project's exact Boot 4.1.0 / HikariCP versions to be
+verified empirically during implementation before relying on them, matching the diligence already
+applied to the springdoc integration - the "connection acquire" / "INSERT INTO ..." labels in the trace
+shape below are illustrative of the expected granularity, not a confirmed exact API contract yet.
+
+### 3. Cache spans: Lettuce native tracing + hit/miss tagging
+
+Two parts, no new dependency (`lettuce-core:7.5.2.RELEASE` is already on the classpath transitively):
+
+- Configure the `LettuceConnectionFactory`'s `ClientResources` with the existing `ObservationRegistry`
+ bean, so every real Redis command (`GET`, `SET`, `DEL`) becomes its own span with real round-trip
+ latency. Chosen over wrapping the `Caching*UseCase` decorator methods (thin `@Cacheable`/`@CacheEvict`
+ pass-throughs) because instrumenting Lettuce directly isolates actual Redis latency from DB-fallthrough
+ latency on a miss, which a method-level span around the decorator wouldn't distinguish.
+- Decorate the `redisCacheWriter` bean (`CacheConfig`, currently a plain
+ `RedisCacheWriter.create(connectionFactory, ...::collectStatistics)` call with no wrapper) so its
+ `get(...)` method, after delegating, tags the *currently active* Observation with
+ `cache.result=hit`/`miss` based on whether the returned value was null, via the injected
+ `ObservationRegistry`.
+
+ Known limitation, accepted deliberately: because Lettuce closes its own Redis-command span
+ synchronously inside the `get()` call, by the time the decorator's code runs the tag is more likely
+ to land on the parent controller span (§4) than on the specific `GET` command span underneath it.
+ Still useful in practice - the controller span shows `cache.result=miss` as an attribute, with the
+ Redis `GET` and any subsequent DB spans visible as its children, so the outcome and the timing
+ breakdown are both visible together even if not on the literal same span.
+
+### 4. Controller span: a `HandlerInterceptor`
+
+A `HandlerInterceptor` (`preHandle`/`afterCompletion`) starts an Observation right before the resolved
+controller method is invoked and stops it right after the response is fully written - nested inside the
+security filter-chain spans (auth has already happened by the time a handler is resolved) and around
+everything in §2/§3, since DB and cache calls happen during the controller method's execution. Named
+from the resolved `HandlerMethod` (e.g. `PersonController#create`), readable in Tempo without
+cross-referencing source code.
+
+Chosen over annotating every controller method with `@Observed`: the 8 controllers already carry heavy
+OpenAPI annotations (`@Operation`, `@ApiResponses`, etc.) on all 48 endpoint methods from an earlier
+documentation pass. One interceptor, registered once via `WebMvcConfigurer`, covers all of them
+automatically and stays correct as endpoints are added, without growing already-large controller files
+further.
+
+### Resulting trace shape
+
+For a cache-miss request, end to end:
+
+```
+request (traceId X, every span below shares it)
+└─ http post /api/v1/titles (Boot's existing auto span)
+ └─ security filterchain ... (existing, unchanged)
+ └─ PersonController#create (new, §4)
+ ├─ cache.result=miss (new, §3 - attribute, not a span)
+ ├─ redis GET title-detail::... (new, §3)
+ ├─ connection acquire (new, §2)
+ ├─ INSERT INTO title_basics ... (new, §2)
+ └─ redis SET title-detail::... (new, §3)
+```
+
+Every log line from "request started" through this whole tree to "request completed" carries
+`traceId=X` (§1).
+
+## Testing & verification
+
+- **Automated**: an integration test attaching a Logback `ListAppender` during a real request
+ (`@SpringBootTest`/`@AutoConfigureMockMvc`, matching `OpenApiIntegrationTest`'s existing pattern),
+ asserting the captured "request started" and "request completed" log entries both carry a non-blank
+ `traceId`. This is the one piece of this design with a crisp, automatable pass/fail.
+- **Live verification** for §2-§4 (actual span presence/shape): generate real traffic against the live
+ containers, then query Tempo's own API directly for the resulting trace and confirm the expected span
+ names appear (`connection acquire`, `redis GET ...`, `PersonController#create`, etc.) - the same
+ method already used to confirm the *current* gap during this design's own investigation. Not a
+ permanent automated test: these are third-party library integrations being wired up, not custom logic
+ worth unit-testing in isolation, and Tempo isn't part of the Testcontainers test environment.
+
+## Dependencies
+
+- **New**: `net.ttddyy.observation:datasource-micrometer-spring-boot` (version/Boot-4.1 compatibility to
+ be confirmed empirically during implementation).
+- **None needed** for Lettuce tracing or the controller interceptor - both use what's already on the
+ classpath (`lettuce-core`, `micrometer-observation`, Spring MVC).
+
+## Documentation
+
+Update LLD §7 (Observability Wiring) to describe the new span coverage and correct its current
+inaccurate claim about automatic MDC population, following this project's established pattern of
+documenting the real root cause of every non-obvious fix (§7.1's four dashboard bugs, §8's five
+load-test bugs).
diff --git a/imdb/k6/all-endpoints.js b/imdb/k6/all-endpoints.js
new file mode 100644
index 0000000..22ae1c9
--- /dev/null
+++ b/imdb/k6/all-endpoints.js
@@ -0,0 +1,405 @@
+import http from 'k6/http';
+import { check, sleep, group } from 'k6';
+import { Rate } from 'k6/metrics';
+
+const BASE_URL = __ENV.BASE_URL || 'http://localhost:8080';
+const ADMIN_EMAIL = __ENV.ADMIN_EMAIL || 'admin@imdb.local';
+const ADMIN_PASSWORD = __ENV.ADMIN_PASSWORD || 'change-me-please';
+const USER_COUNT = Number(__ENV.LOAD_TEST_USER_COUNT || 15);
+
+// Runs every endpoint group simultaneously in one k6 invocation, unlike search.js/title-detail.js/
+// top-rated.js/six-degrees.js (LLD §8), which are deliberately run one at a time so each run's
+// metrics are attributable to a single endpoint. This script exists for the opposite reason: to
+// surface *interaction* effects a single-endpoint run can't - admin writes evicting caches while
+// reads are in flight, JWT verification overhead under combined load, connection-pool contention
+// across very different query shapes at once. Both testing styles stay valid; this is additive, not
+// a replacement.
+//
+// Every VU-created row is tagged and self-cleaning where the API allows it:
+// - Admin-created titles/people are named "K6 Load Test -" and soft-deleted by
+// the same iteration that created them, so the run doesn't leave the admin id sequence and the
+// titles/people tables growing unbounded across repeated runs.
+// - Load-test user accounts are registered as k6-loadtest-user--@example.com so they're
+// trivially greppable and never collide with a real or previous run's accounts.
+// Nothing here issues a hard delete (the API has none, by design - soft-delete only, LLD §3.4), so
+// re-running this script repeatedly against the same dev database is safe but not zero-footprint:
+// soft-deleted admin rows and the load-test user accounts/reviews/lists/watchlists persist. That's
+// intentional - the point is realistic write traffic, not a spotless database afterward.
+
+const runId = `${Date.now()}-${Math.floor(Math.random() * 100000)}`;
+
+// SMOKE_TEST=1 shrinks every scenario to a few seconds at 1-2 VUs - for verifying the whole script
+// (setup(), every request shape, every scenario function) actually runs end to end before committing
+// to the full multi-minute, tens-of-VUs run below. Not a separate script: same code path, same
+// endpoint coverage, just a much smaller dial.
+const SMOKE_TEST = __ENV.SMOKE_TEST === '1';
+function stages(smoke, real) {
+ return SMOKE_TEST ? smoke : real;
+}
+
+// open() must run in k6's init context (top-level module scope) - see six-degrees.js for why this
+// can't be called lazily inside setup().
+let rawPeopleCsv = null;
+try {
+ rawPeopleCsv = open('./data/sampled-people.csv');
+} catch (e) {
+ rawPeopleCsv = null;
+}
+
+function parsePeopleCsv(csv) {
+ if (!csv) return [];
+ return csv
+ .split('\n')
+ .slice(1)
+ .map((line) => line.trim())
+ .filter((line) => line.length > 0 && !line.startsWith('#'))
+ .map((line) => {
+ const [id, name] = line.split(',');
+ return { id, name };
+ });
+}
+
+const SEARCH_TERMS = ['love', 'war', 'man', 'night', 'king', 'world', 'star', 'dark', 'life', 'city'];
+const GENRES = ['Action', 'Comedy', 'Drama', 'Horror', 'Romance', 'Thriller', 'Sci-Fi', 'Adventure'];
+
+function jsonHeaders(token) {
+ const headers = { 'Content-Type': 'application/json' };
+ if (token) headers.Authorization = `Bearer ${token}`;
+ return { headers };
+}
+
+// Runs once before any scenario's VUs start iterating. Assembles everything every scenario needs:
+// a pool of real title/person ids to browse (same discovery approach as title-detail.js/
+// six-degrees.js), a pool of freshly-registered regular-user sessions for the userJourney scenario,
+// and one admin session (the bootstrap admin, IMDB_BOOTSTRAP_ADMIN_EMAIL/_PASSWORD in
+// docker-compose.yaml) for the adminWrites scenario.
+export function setup() {
+ const titleIds = new Set();
+ for (const term of SEARCH_TERMS) {
+ const res = http.get(`${BASE_URL}/api/v1/titles/search?title=${encodeURIComponent(term)}&size=50`);
+ if (res.status === 200) {
+ for (const item of res.json().content) titleIds.add(item.id);
+ }
+ }
+ const titlePool = Array.from(titleIds);
+ if (titlePool.length === 0) {
+ throw new Error('setup() found no title ids - is the database seeded?');
+ }
+
+ const peoplePool = parsePeopleCsv(rawPeopleCsv);
+ if (peoplePool.length < 2) {
+ throw new Error(
+ 'data/sampled-people.csv has fewer than 2 entries - see data/generate-sampled-people.sql. ' +
+ 'six-degrees.js falls back to live discovery when this is empty; this script requires the ' +
+ 'CSV since it also needs stable ids for the userJourney scenario, not just any two people.'
+ );
+ }
+
+ const userSessions = [];
+ for (let i = 0; i < USER_COUNT; i++) {
+ const email = `k6-loadtest-user-${i}-${runId}@example.com`;
+ const res = http.post(
+ `${BASE_URL}/api/v1/auth/register`,
+ JSON.stringify({ email, password: 'k6-load-test-password', displayName: `K6 Load Test User ${i}` }),
+ jsonHeaders()
+ );
+ if (res.status !== 201) {
+ throw new Error(`setup() failed to register load-test user ${email}: ${res.status} ${res.body}`);
+ }
+ userSessions.push({ token: res.json().accessToken });
+ }
+
+ const adminLogin = http.post(
+ `${BASE_URL}/api/v1/auth/login`,
+ JSON.stringify({ email: ADMIN_EMAIL, password: ADMIN_PASSWORD }),
+ jsonHeaders()
+ );
+ if (adminLogin.status !== 200) {
+ throw new Error(
+ `setup() failed to log in as the bootstrap admin (${ADMIN_EMAIL}): ${adminLogin.status} ${adminLogin.body} - ` +
+ 'is IMDB_BOOTSTRAP_ADMIN_EMAIL/_PASSWORD set the same way in docker-compose.yaml and this script\'s ' +
+ 'ADMIN_EMAIL/ADMIN_PASSWORD env vars?'
+ );
+ }
+ const adminToken = adminLogin.json().accessToken;
+
+ return { titlePool, peoplePool, userSessions, adminToken };
+}
+
+const browsingErrors = new Rate('browsing_errors');
+const userJourneyErrors = new Rate('user_journey_errors');
+const adminWriteErrors = new Rate('admin_write_errors');
+
+export const options = {
+ scenarios: {
+ // Anonymous read traffic: the same four endpoints search.js/title-detail.js/top-rated.js/
+ // six-degrees.js each exercise in isolation, merged into one randomized mix - the biggest slice
+ // of VUs, matching real traffic being read-heavy.
+ browsing: {
+ executor: 'ramping-vus',
+ exec: 'browsing',
+ startVUs: 0,
+ stages: stages(
+ [{ duration: '5s', target: 2 }],
+ [
+ { duration: '30s', target: 40 },
+ { duration: '1m', target: 80 },
+ { duration: '30s', target: 0 },
+ ]
+ ),
+ },
+ // Authenticated regular users: browse a title, then work it into their watchlist, a review, and
+ // a personal list - the full user-generated-content surface (Phases 6-8 of the CRUD expansion).
+ userJourney: {
+ executor: 'ramping-vus',
+ exec: 'userJourney',
+ startVUs: 0,
+ stages: stages(
+ [{ duration: '5s', target: 2 }],
+ [
+ { duration: '30s', target: 10 },
+ { duration: '1m', target: 20 },
+ { duration: '30s', target: 0 },
+ ]
+ ),
+ },
+ // Admin writes: deliberately the smallest slice of VUs, matching how infrequent admin
+ // operations are relative to reads/user-content in real usage - but each iteration still
+ // touches every admin-write endpoint over titles, people, crew, and cast/crew credits.
+ adminWrites: {
+ executor: 'ramping-vus',
+ exec: 'adminWrites',
+ startVUs: 0,
+ stages: stages(
+ [{ duration: '5s', target: 1 }],
+ [
+ { duration: '30s', target: 2 },
+ { duration: '1m', target: 5 },
+ { duration: '30s', target: 0 },
+ ]
+ ),
+ },
+ },
+ thresholds: {
+ // Duration budgets per scenario, loosest for browsing since it includes six-degrees calls
+ // (LLD §8 already documents that endpoint's cost depends on graph shape, not a bounded lookup).
+ 'http_req_duration{scenario:browsing}': ['p(95)<3000'],
+ 'http_req_duration{scenario:userJourney}': ['p(95)<1000'],
+ 'http_req_duration{scenario:adminWrites}': ['p(95)<1000'],
+ // Custom error rates, not the built-in http_req_failed - a mixed-status-code workload like
+ // userJourney's reviews (a repeat review on the same title is a correct 409, not a failure) and
+ // browsing's six-degrees calls (an expected 504 on a hard pair) would otherwise be misclassified
+ // as failures by k6's default "any 4xx/5xx is a failure" rule. Each scenario's own function
+ // below records failure only for a genuinely unexpected status code.
+ browsing_errors: ['rate<0.05'],
+ user_journey_errors: ['rate<0.02'],
+ admin_write_errors: ['rate<0.01'],
+ },
+};
+
+function randomOf(arr) {
+ return arr[Math.floor(Math.random() * arr.length)];
+}
+
+export function browsing(data) {
+ group('search', () => {
+ const res = http.get(`${BASE_URL}/api/v1/titles/search?title=${encodeURIComponent(randomOf(SEARCH_TERMS))}&size=20`);
+ browsingErrors.add(!check(res, { 'search: status is 200': (r) => r.status === 200 }));
+ });
+
+ group('title detail', () => {
+ const res = http.get(`${BASE_URL}/api/v1/titles/${randomOf(data.titlePool)}`);
+ browsingErrors.add(!check(res, { 'title detail: status is 200': (r) => r.status === 200 }));
+ });
+
+ group('top rated', () => {
+ const res = http.get(`${BASE_URL}/api/v1/genres/${encodeURIComponent(randomOf(GENRES))}/top-rated?limit=25`);
+ browsingErrors.add(!check(res, { 'top rated: status is 200': (r) => r.status === 200 }));
+ });
+
+ group('six degrees', () => {
+ const a = randomOf(data.peoplePool);
+ let b = randomOf(data.peoplePool);
+ while (b.id === a.id && data.peoplePool.length > 1) b = randomOf(data.peoplePool);
+ const res = http.get(`${BASE_URL}/api/v1/people/six-degrees?personA=${a.id}&personB=${b.id}&maxDegree=7`);
+ // 504 is a real, accepted outcome for a genuinely hard pair (ApiExceptionHandler.handleQueryTimeout),
+ // not a bug - matching six-degrees.js's own acceptance of 200/404 as both valid.
+ browsingErrors.add(!check(res, { 'six degrees: status is 200, 404, or 504': (r) => [200, 404, 504].includes(r.status) }));
+ });
+
+ sleep(1);
+}
+
+export function userJourney(data) {
+ const session = randomOf(data.userSessions);
+ const auth = jsonHeaders(session.token);
+ const titleId = randomOf(data.titlePool);
+
+ group('browse then watchlist', () => {
+ const detail = http.get(`${BASE_URL}/api/v1/titles/${titleId}`);
+ userJourneyErrors.add(!check(detail, { 'title detail: status is 200': (r) => r.status === 200 }));
+
+ const add = http.post(`${BASE_URL}/api/v1/watchlist/items`, JSON.stringify({ titleId }), auth);
+ userJourneyErrors.add(!check(add, { 'add to watchlist: status is 201': (r) => r.status === 201 }));
+
+ const mine = http.get(`${BASE_URL}/api/v1/watchlist`, auth);
+ userJourneyErrors.add(!check(mine, { 'get own watchlist: status is 200': (r) => r.status === 200 }));
+
+ const visibility = http.put(
+ `${BASE_URL}/api/v1/watchlist/visibility`,
+ JSON.stringify({ visibility: Math.random() < 0.5 ? 'PUBLIC' : 'PRIVATE' }),
+ auth
+ );
+ userJourneyErrors.add(!check(visibility, { 'update watchlist visibility: status is 200': (r) => r.status === 200 }));
+
+ const remove = http.del(`${BASE_URL}/api/v1/watchlist/items/${titleId}`, null, auth);
+ userJourneyErrors.add(!check(remove, { 'remove from watchlist: status is 204': (r) => r.status === 204 }));
+ });
+
+ group('review', () => {
+ const existing = http.get(`${BASE_URL}/api/v1/titles/${titleId}/reviews/me`, auth);
+ userJourneyErrors.add(!check(existing, { 'get my review: status is 200 or 404': (r) => [200, 404].includes(r.status) }));
+
+ if (existing.status === 404) {
+ const create = http.post(
+ `${BASE_URL}/api/v1/titles/${titleId}/reviews`,
+ JSON.stringify({ rating: 1 + Math.floor(Math.random() * 10), body: 'Load-tested review', version: 0 }),
+ auth
+ );
+ // 409 happens if a concurrent iteration for this same session/title raced us between the GET
+ // above and this POST - a real, correct outcome under concurrency, not a bug.
+ userJourneyErrors.add(!check(create, { 'create review: status is 201 or 409': (r) => [201, 409].includes(r.status) }));
+ } else {
+ const current = existing.json();
+ const update = http.put(
+ `${BASE_URL}/api/v1/titles/${titleId}/reviews/me`,
+ JSON.stringify({ rating: 1 + Math.floor(Math.random() * 10), body: 'Updated by load test', version: current.version }),
+ auth
+ );
+ userJourneyErrors.add(!check(update, { 'update review: status is 200 or 409': (r) => [200, 409].includes(r.status) }));
+ }
+ });
+
+ group('list', () => {
+ const create = http.post(
+ `${BASE_URL}/api/v1/lists`,
+ JSON.stringify({ name: `K6 Load Test List (${session.token.slice(-8)})`, visibility: 'PUBLIC' }),
+ auth
+ );
+ userJourneyErrors.add(!check(create, { 'create list: status is 201': (r) => r.status === 201 }));
+ if (create.status !== 201) return;
+ const listId = create.json().id;
+
+ const addItem = http.post(`${BASE_URL}/api/v1/lists/${listId}/items`, JSON.stringify({ titleId }), auth);
+ userJourneyErrors.add(!check(addItem, { 'add list item: status is 201': (r) => r.status === 201 }));
+
+ const view = http.get(`${BASE_URL}/api/v1/lists/${listId}`, auth);
+ userJourneyErrors.add(!check(view, { 'get list: status is 200': (r) => r.status === 200 }));
+
+ const removeItem = http.del(`${BASE_URL}/api/v1/lists/${listId}/items/${titleId}`, null, auth);
+ userJourneyErrors.add(!check(removeItem, { 'remove list item: status is 204': (r) => r.status === 204 }));
+ });
+
+ sleep(1);
+}
+
+export function adminWrites(data) {
+ const auth = jsonHeaders(data.adminToken);
+ const tag = `K6 Load Test ${Date.now()}-${__VU}-${__ITER}`;
+
+ group('title lifecycle', () => {
+ const createTitle = http.post(
+ `${BASE_URL}/api/v1/titles`,
+ JSON.stringify({ primaryTitle: tag, originalTitle: tag, titleType: 'movie', startYear: 2024, genres: ['Drama'] }),
+ auth
+ );
+ adminWriteErrors.add(!check(createTitle, { 'create title: status is 201': (r) => r.status === 201 }));
+ if (createTitle.status !== 201) return;
+ const title = createTitle.json();
+
+ const createPerson = http.post(
+ `${BASE_URL}/api/v1/people`,
+ JSON.stringify({ primaryName: tag, birthYear: 1980, deathYear: null, primaryProfession: ['actor'] }),
+ auth
+ );
+ adminWriteErrors.add(!check(createPerson, { 'create person: status is 201': (r) => r.status === 201 }));
+ if (createPerson.status !== 201) {
+ http.del(`${BASE_URL}/api/v1/titles/${title.id}`, null, auth);
+ return;
+ }
+ const person = createPerson.json();
+
+ const crew = http.put(
+ `${BASE_URL}/api/v1/titles/${title.id}/crew`,
+ JSON.stringify({ directors: [person.id], writers: [] }),
+ auth
+ );
+ adminWriteErrors.add(!check(crew, { 'upsert crew: status is 200': (r) => r.status === 200 }));
+
+ const addPrincipal = http.post(
+ `${BASE_URL}/api/v1/titles/${title.id}/principals`,
+ JSON.stringify({ personId: person.id, category: 'actor', job: null, characters: ['Load Test Character'], ordering: 1 }),
+ auth
+ );
+ adminWriteErrors.add(!check(addPrincipal, { 'add principal: status is 201': (r) => r.status === 201 }));
+
+ const principals = http.get(`${BASE_URL}/api/v1/titles/${title.id}/principals`);
+ adminWriteErrors.add(!check(principals, { 'get principals: status is 200': (r) => r.status === 200 }));
+
+ const updatePrincipal = http.put(
+ `${BASE_URL}/api/v1/titles/${title.id}/principals/1?expectedVersion=0`,
+ JSON.stringify({ personId: person.id, category: 'actor', job: null, characters: ['Updated Character'], ordering: 1 }),
+ auth
+ );
+ adminWriteErrors.add(!check(updatePrincipal, { 'update principal: status is 200': (r) => r.status === 200 }));
+
+ const deletePrincipal = http.del(`${BASE_URL}/api/v1/titles/${title.id}/principals/1`, null, auth);
+ adminWriteErrors.add(!check(deletePrincipal, { 'delete principal: status is 204': (r) => r.status === 204 }));
+
+ const updateTitle = http.put(
+ `${BASE_URL}/api/v1/titles/${title.id}`,
+ JSON.stringify({ primaryTitle: tag, originalTitle: tag, titleType: 'movie', startYear: 2024, genres: ['Drama', 'Thriller'], version: title.version }),
+ auth
+ );
+ adminWriteErrors.add(!check(updateTitle, { 'update title: status is 200': (r) => r.status === 200 }));
+
+ const patchTitle = http.patch(
+ `${BASE_URL}/api/v1/titles/${title.id}`,
+ JSON.stringify({ runtimeMinutes: 120, version: 1 }),
+ auth
+ );
+ adminWriteErrors.add(!check(patchTitle, { 'patch title: status is 200': (r) => r.status === 200 }));
+
+ const upsertRating = http.put(
+ `${BASE_URL}/api/v1/titles/${title.id}/rating`,
+ JSON.stringify({ averageRating: 7.5, numVotes: 1000 }),
+ auth
+ );
+ adminWriteErrors.add(!check(upsertRating, { 'upsert rating: status is 200': (r) => r.status === 200 }));
+
+ const deleteRating = http.del(`${BASE_URL}/api/v1/titles/${title.id}/rating`, null, auth);
+ adminWriteErrors.add(!check(deleteRating, { 'delete rating: status is 204': (r) => r.status === 204 }));
+
+ const updatePerson = http.put(
+ `${BASE_URL}/api/v1/people/${person.id}`,
+ JSON.stringify({ primaryName: tag, birthYear: 1980, deathYear: null, primaryProfession: ['actor'], version: person.version }),
+ auth
+ );
+ adminWriteErrors.add(!check(updatePerson, { 'update person: status is 200': (r) => r.status === 200 }));
+
+ const patchPerson = http.patch(
+ `${BASE_URL}/api/v1/people/${person.id}`,
+ JSON.stringify({ deathYear: 2020, version: 1 }),
+ auth
+ );
+ adminWriteErrors.add(!check(patchPerson, { 'patch person: status is 200': (r) => r.status === 200 }));
+
+ const deleteTitle = http.del(`${BASE_URL}/api/v1/titles/${title.id}`, null, auth);
+ adminWriteErrors.add(!check(deleteTitle, { 'delete title: status is 204': (r) => r.status === 204 }));
+
+ const deletePerson = http.del(`${BASE_URL}/api/v1/people/${person.id}`, null, auth);
+ adminWriteErrors.add(!check(deletePerson, { 'delete person: status is 204': (r) => r.status === 204 }));
+ });
+
+ sleep(1);
+}
diff --git a/imdb/k6/data/generate-sampled-people.sql b/imdb/k6/data/generate-sampled-people.sql
new file mode 100644
index 0000000..d1419ea
--- /dev/null
+++ b/imdb/k6/data/generate-sampled-people.sql
@@ -0,0 +1,22 @@
+-- Run against the fully-seeded database to produce a realistic load-test sample: a mix of
+-- ordinary actors and high-degree "hub" actors (LLD §8 - six-degrees.js needs both to exercise
+-- realistic and worst-case fan-out through co_star_edges under load).
+--
+-- psql -h localhost -U imdb -d imdb -f generate-sampled-people.sql --csv -o sampled-people.csv
+
+WITH credit_counts AS (
+ SELECT nconst, count(*) AS credits
+ FROM title_principals
+ WHERE category IN ('actor', 'actress', 'self')
+ GROUP BY nconst
+),
+hub_actors AS (
+ SELECT nconst FROM credit_counts WHERE credits > 200 ORDER BY random() LIMIT 100
+),
+ordinary_actors AS (
+ SELECT nconst FROM credit_counts WHERE credits BETWEEN 2 AND 50 ORDER BY random() LIMIT 400
+)
+SELECT 'nm' || lpad(nb.nconst::text, 7, '0') AS id, nb.primary_name AS name
+FROM name_basics nb
+JOIN (SELECT nconst FROM hub_actors UNION SELECT nconst FROM ordinary_actors) sampled
+ ON sampled.nconst = nb.nconst;
diff --git a/imdb/k6/data/sampled-people.csv b/imdb/k6/data/sampled-people.csv
new file mode 100644
index 0000000..113c118
--- /dev/null
+++ b/imdb/k6/data/sampled-people.csv
@@ -0,0 +1,501 @@
+id,name
+nm9330281,Lotte Grondahl
+nm1577641,Hannes Gieseler
+nm0668359,Joel Christopher Payne
+nm2102963,Josef Mattes
+nm0300041,Gerhard Garbers
+nm3991520,Tristen Yap
+nm3644680,Des Brittain
+nm9452648,Abdullatif Alsheti
+nm0928390,Colette Wilda
+nm3228990,Aleksander Mazur
+nm3180848,Kate Sanford
+nm5200264,Julia Christina Ray
+nm1443437,Eric Lamp
+nm0379382,Patricia Hermenier
+nm0298310,Mike Fuller
+nm1038305,Sami Huttunen
+nm0497853,Mason Lee
+nm1105859,Daniela Rathana
+nm0408017,Grant Imahara
+nm1524400,Travis Stockman
+nm1547163,Brennan Weir
+nm5441406,Alex Brooker
+nm2083199,Gary Pease
+nm1161316,Cris King
+nm4358982,Jamie Jurju
+nm5735979,Nathanial Jacobs
+nm1471923,Fikrat Salem
+nm1677486,Robert Markle
+nm0341659,Derek Griffiths
+nm5550107,Chresten
+nm3120419,Richard Condo
+nm8586788,Jeet Raidutt
+nm1301323,Valery Carranza
+nm7330294,Kenta Kojiri
+nm1447033,Giselle Samson
+nm1048030,Trem da Alegria
+nm9199784,McKenna Allen
+nm5211665,Jimena Torres Cautivo
+nm1234143,Anurag Thakur
+nm2832137,Xander Denke
+nm2499292,North E. West
+nm1230316,Marc Babin
+nm0735884,Iván Rodríguez
+nm1714424,Red Panda
+nm1646605,Anthony J Cruz
+nm1773391,Vedana Kinchevska
+nm7555178,Before Dark
+nm1983394,Christy Little
+nm1700610,Marco Alcaraz
+nm4868822,Paul Greenberg
+nm8627787,Anthony Selemidis
+nm7855993,Keyvan
+nm1184236,Andrea Gomez
+nm7586587,Alana LeBlanc Barnett
+nm1082770,Beau Danner
+nm8939814,Wesley Weigel
+nm0733289,María Luisa Robledo
+nm1699430,Adelowo A. Richard
+nm1635373,Piotr Muszynski
+nm5833432,Tetsuhito Aoki
+nm0640573,Peter O'Crotty
+nm1068023,Harald Jähner
+nm9637898,Rob Ebner
+nm4904649,Arturo Versaci
+nm1738504,Deepti Gujral
+nm7759926,Elijah Lucian
+nm2981567,Patrik Plesinger
+nm1274011,Berrie
+nm9937505,Brooke Sheppard
+nm0452834,Akiko Kikuchi
+nm2574512,Hristos Fragos
+nm1375949,Allyssa Anderson
+nm9174470,Harro Siegel
+nm1539988,Jake Lawrence Coronado
+nm3381431,Sammy Spear and His Orchestra
+nm3056623,NeNe Leakes
+nm5470854,Keya Hamilton
+nm1080163,PriyomNaziba Bashar
+nm1690743,April Yvette Thompson
+nm1546492,Werner Kramer
+nm1616345,Cher Lourens
+nm2209279,Mikkel Kryger Rasmussen
+nm0766733,Anne Saunders
+nm1254239,Yuri Dimitrov
+nm8084664,Ömer Acar
+nm2865320,Gerald Vincent
+nm1709523,Kirsty Wirth
+nm0151321,Ben Morris
+nm4464857,Elizabeth Henstridge
+nm1836488,Alex Ganster
+nm2763822,Shaghayegh Mohammadali
+nm0885779,Catherine Van Bree
+nm1354673,Kale Cox
+nm4898556,Todd Aiken
+nm5255619,Robert Vogel
+nm8625013,Jim Ritter
+nm1634105,Mel Alcalde
+nm0631462,Joe Nightingale
+nm6453159,Alyssa Chase
+nm1053428,Nàcara Huélamo
+nm3311160,Yûsuke Kobayashi
+nm1810883,K. Alexander Michael
+nm3576847,Tom Brogan
+nm1115651,Yura Yelin
+nm1372956,Jon Santos
+nm1690282,Janine Miramas
+nm0660400,Lars Pape
+nm1120193,Adam Boor
+nm1364226,Dora Malo
+nm0096234,Edwin Bordo
+nm0798041,Sergei Silkin
+nm3796733,Ellie Frankel Sextet
+nm0239894,Graham Duckett
+nm1111672,Havana Chapman-Edwards
+nm4859632,Steph McGovern
+nm0607847,Beryl Mortimer
+nm0141140,Isabelle Carré
+nm5829250,Timothy Paul McCray
+nm9539908,Gianfranco Miconi
+nm1265876,Jon Courtney
+nm7209979,Desmar Guevara
+nm0126594,István Bársony
+nm0426980,Dorrie Joiner
+nm2713185,Manu Onraita
+nm2951900,Peter Flihan
+nm1082287,Shaquille Mathurin
+nm4790855,Bengey Asse
+nm7160617,Caelan Benn
+nm3736634,Ana Shaw
+nm1124364,Bay Eaton
+nm3030804,Manel Esteller
+nm1893726,Malka Braun
+nm4448700,Peter Mueller
+nm0293299,Ben Freeman
+nm3716645,Matt Cobb
+nm2177405,Clara Ponsot
+nm8294938,Rami Boraie
+nm2717025,Gabriel Winter
+nm1247177,Benjamin Alford Jr.
+nm2686199,Dennis Kilcoyne
+nm9046337,Alessio Di Domenicantonio
+nm7437055,Ibragimov Ali
+nm1033082,Andreas Mundt
+nm0717086,Poul Reichhardt
+nm7426879,Sebastiano Pestoni
+nm1352786,Samira Halal
+nm8036630,Chris Chandler
+nm1151731,Bianca Medeiros Krainson
+nm1425326,Gerardo Larrosa
+nm1493168,Kristin Kaspersen
+nm5278103,Willie Soon
+nm8515276,Camila Maia
+nm1383162,Isaac Stanley-Becker
+nm0938772,Maximilian Wolters
+nm2121770,Douglas Nabors
+nm0406904,Barry Baz Idoine
+nm4144181,Darren Fleming
+nm5535554,Ken'ichi Tsukada
+nm2971900,Wei-Wei Chang
+nm7497701,Lore Lons
+nm0882939,Nicolas Vachon
+nm3618107,Mosharraf Karim
+nm7600010,Sethu Lakshmi
+nm9915967,Omid Ebrahimi
+nm0179179,Marjorie Corbett
+nm0059176,Joe B. Barton
+nm1064745,Jacques Servolin
+nm7080282,Tara Morgan
+nm2141966,Marc Basany
+nm0174760,Brian Conley
+nm8483590,Alicja Gescinska
+nm0509470,Gundula Liebisch
+nm1074912,Will Clempner
+nm0062709,Clive Baxter
+nm0370243,Trish Hawkins
+nm4803690,Avril Lennox
+nm2164919,Debra Burlingame
+nm3907852,Emma Rogers
+nm1100751,Mang Udel
+nm3446681,Charles Coburn
+nm9822268,Deepak Joshi
+nm1657108,Aika Kumawat
+nm4864942,Elizabeth The Koala
+nm3198091,Ben Matheny
+nm1363379,Blanca Martínez
+nm2176630,Catherine Waller
+nm3277955,Kalup Linzy
+nm2929559,Ted Stokes
+nm2296989,Osiris Larkin
+nm0610468,Bill Moyers
+nm2237399,Danica Dias
+nm6148301,Cari Spinnler
+nm7654212,Lucas Lefevre
+nm0017644,Sophie Aldred
+nm6493917,Rachel Kushner
+nm1035020,Toni Albà
+nm1053032,Evi Hoste
+nm1143976,Cindrella D. Cruz
+nm4462778,Eny Autran
+nm3403880,Roozbeh Behtaji
+nm1371588,Hazuki Nishikawa
+nm1229640,Hoon Lee
+nm0005286,Haley Joel Osment
+nm9483662,António Alvarinho
+nm6495624,Kevin Gonzalez
+nm1533853,Louai El Amrousy
+nm5153052,Magen Hudak
+nm5122304,Johannes Vetter
+nm3702655,Cyntia Botello
+nm4484082,Emanuele Dainotti
+nm0065721,Stephen Beckett
+nm4065451,Shaneequa Thelissen
+nm1368505,Keith Flint
+nm1994167,Jordan Carlos
+nm9422149,Kevin Ashford
+nm7022882,Stewart Bloomfield
+nm5948509,Jason Sackel
+nm9254291,Kessir Riliniki
+nm4372868,Tom Berdine
+nm8455937,Adrián Baena
+nm2304840,Dominic L. Santana
+nm8545863,Tariq Hanna
+nm8414597,Grace Tormis
+nm4863181,Roberto Velasco
+nm5625020,Natasha Rothwell
+nm3592766,Anjali
+nm4055042,Bohdan Pomahac
+nm1222809,Lucy Ricketts
+nm1059529,Domingos Coimbra
+nm0858828,Frank M. Thomas
+nm1815894,Jace Hinson
+nm3821762,Marc Murphy
+nm1299775,Bente Hansen
+nm1082117,Mad Clip
+nm1111839,Ian Shen
+nm5849849,Heather Seaman
+nm7409442,Riley Chandler
+nm5056980,Dakota Buchanan
+nm1190669,Cicily Stone
+nm9948834,Tinoco Alves
+nm1764201,Carole Malone
+nm1151013,Robert Blake Bryant
+nm1186315,Victor Taïeb
+nm1176043,Uriya Elkayam
+nm1462684,Mary T Lynch
+nm3109659,Nancy Cordes
+nm0241009,Yvette Duguay
+nm1121644,Michael Bedard
+nm1092036,Daniela Hubloher
+nm0921835,Adolf Wessely
+nm0122036,Dennis Burkley
+nm4163847,Sibel Meriç
+nm1242653,Tzion Azoulay
+nm1103255,Walter Günther
+nm8718067,Tais Gadea Lara
+nm2197299,Seneca Ramirez
+nm4151111,Whitney Lavaux
+nm0279605,Jules Fisher
+nm1843265,Ako Mitchell
+nm0591123,Cristiano Minellono
+nm1708220,Baron Angeles
+nm4684947,Liandra Sadzo
+nm3005882,Mel Counts
+nm4678159,Greg Giuliano
+nm5472911,Mike Rhodes
+nm1029059,David Argoeti
+nm0556036,Masahiro Matsuoka
+nm2971684,David Zory
+nm6576659,Nilze Carvalho
+nm0203922,Rebekah Davies
+nm1466378,Qwerty121
+nm5564707,Lizzie Leeds
+nm1243644,Oveq
+nm2450891,Miho Tsuji
+nm6762114,Stine Omar
+nm0501461,Filo Lemoine
+nm1360696,Matthew Andrew Gonzalez
+nm1300508,Sarah von Racknitz
+nm4050779,Dominik Reynolds
+nm0902702,Hubert von Meyerinck
+nm0429274,Star Jones
+nm0733577,Víctor Roca
+nm0812405,Nodar Sokhadze
+nm1492158,Doug Biro
+nm5503695,Oldrich Kulhánek
+nm0031779,Miguel Anzures
+nm1766984,Adi Dassler
+nm9543598,Yuexi Wang
+nm1376857,Amin Yoma
+nm9467028,Francisco Pascual
+nm8293499,Victor Pourcel
+nm1016348,The Candy Dates
+nm4233401,Mary Rose Bonello
+nm1473097,Hugo Almeida
+nm2424236,Yasuyuki Maekawa
+nm1008957,Silver van Sprundel
+nm0262763,Fred Evans
+nm3144553,Nicolas Grard
+nm2572540,Michael Nalder
+nm0870780,Christian Tramitz
+nm8097288,Gianni Spezzano
+nm9142049,Reina Lee
+nm4740113,Saara Chaudry
+nm1195110,Evelyn Hines
+nm1552301,Milan Tocinovski
+nm1612417,Geraldine Aherne
+nm2639074,Gökçe Gürsoy
+nm1262300,Kerstin Schweiger
+nm4949546,Todd Bobenrieth
+nm1667469,Kylie Morgan
+nm0341400,William Griffin
+nm1784952,Nicola Vidotto
+nm0676940,Robert Petersen
+nm2100311,Aymeline Valade
+nm1680852,Hugues Saint Louis
+nm0001557,Viggo Mortensen
+nm6432430,Michael Alvarado
+nm6554537,Puneet
+nm0294689,Patxi Freytez
+nm6622872,Leila Gurruwiwi
+nm1222324,Emanuele Maggi
+nm1310603,Aaron Jagielski
+nm1516513,Jerry Belant
+nm2098968,Jean-Louis Froment
+nm5286652,Georgina Windsor
+nm0077727,Dru Berrymore
+nm0263457,Christine Ever
+nm0221474,Paul Desmond
+nm1286265,John Fox
+nm2023033,Karen Yelverton
+nm0856720,Josephine Tewson
+nm1642617,Roy Weissinger
+nm3908931,Andrew MacLarty
+nm1111958,Lori Biggs
+nm0510275,Rebecka Liljeberg
+nm0169809,Tatiana Cohen
+nm0261208,Tony Esposito
+nm0165310,Charlie Clausen
+nm6085926,Jim Green
+nm6603464,Paula Phelan
+nm0285464,Bjarne Forchhammer
+nm1157445,Ting Wei Guo
+nm1331291,Bert Doorn
+nm1669076,Alexandra Cat
+nm2208245,Masako Yashiro
+nm1167166,Ross MacFarlane
+nm0005192,Kellie Martin
+nm0001360,John Holmes
+nm0722023,David Reynoso
+nm1722996,Thezni Khan
+nm0004933,Faith Ford
+nm0005324,Maury Povich
+nm0001394,Derek Jacobi
+nm7690217,Derek Johnson
+nm8761191,Gemma Tognini
+nm5099766,Troy Parker
+nm1353235,Dakota Yandle
+nm1616477,Pietro Cattani
+nm1337764,Tajahi Cooke
+nm1089766,Annuska Fényes
+nm6639293,Jessie Pettit
+nm2404113,Anna Luca Biani
+nm0371381,Cynthia Haymon
+nm2064629,Daniel Bell
+nm8410537,Felix Heezemans
+nm7287744,George Ray
+nm1055833,Christopher Pittman Smith
+nm0575675,Dayanara Medina
+nm6890006,Briana Lacuesta
+nm1278677,Anders Breinholt
+nm0900867,Carol Vogel
+nm1028561,Soneros de Verdad
+nm8125221,Jeon Ik-ryeong
+nm0327235,Pierre Gondard
+nm1000570,Lincoln Chafee
+nm1419448,Max Caden
+nm0524640,Alvin Lucier
+nm6745967,Joanne Davis
+nm8731455,James Taylor-Watts
+nm7011917,Gustav Stork Jangaard
+nm0814130,Ferri Somogyi
+nm5007018,Deanna Navarro
+nm9028943,Katharina Willinger
+nm1059726,Carmen Kassovitz
+nm0370905,Bill Hayes
+nm1237408,Marco Binder
+nm7220702,Katalin Lightner
+nm0855278,Vicente Tepedino
+nm1939947,Diogo Savala
+nm4857744,Eric Houde
+nm0010684,Archi Adamos
+nm5670593,Susanna Karvinen
+nm1012627,Ling Chen
+nm6371231,Chris Garcia
+nm0181971,Renée Cossette
+nm1479734,Valeriy Nikulin
+nm0476851,Sergei Kuznetsov
+nm6967886,Dahlia White
+nm0817573,Norbert Speer
+nm1659547,Bill Temple
+nm1230334,Danit Livnat
+nm1003887,Dexter Gawel
+nm1195646,Pavlo Fondera
+nm0417938,Vitold Janpavlis
+nm4424993,Karan Kundrra
+nm3657729,Juan A. Mingrone
+nm5563725,Thomas Gamble
+nm8984434,Lolita Pop
+nm2430121,Ursula Schwarzer
+nm0467082,Cezary Kosiński
+nm7294634,Ariel Rodríguez
+nm7115741,Gervasio Díaz Castelli
+nm0582615,Hugo Metsers
+nm3564713,Petroc Trelawny
+nm1064339,Dan Rue
+nm2426057,Edi Zanidache
+nm1371900,Nethmi Roshel
+nm6662430,Boris Dergachev
+nm1613362,Lily Armah
+nm2554072,Haley Scarnato
+nm4316821,Haraldur Ágústsson
+nm7532272,Elena Stecca
+nm2591542,Malou von Sivers
+nm5993784,Deborah O'Donnel
+nm8350110,Anne Mary Ziegler
+nm9712004,Daniel Wild
+nm2895357,Sheila Scott
+nm2322802,Shawna Beesley
+nm4321090,Iakovos Panotas
+nm9101165,Shivani Tomar
+nm3027117,Matti Myllykoski
+nm0388578,Isa Hoes
+nm1784385,Rocket Bretherton
+nm1670944,Elisa Cantonetti
+nm1361194,Corey Weist
+nm1013757,Alejandro Henriquez
+nm1266866,Laurent Dupuis
+nm1349147,Size 12
+nm1947060,Jeong Ae-yeon
+nm3539201,Daniel Fischer
+nm1207210,Anita Martínez
+nm0350080,Luis Roberto Guzmán
+nm5991382,Chiu Hung
+nm0503482,Arnfried Lerche
+nm7439612,Rio Sirah
+nm9720157,Hadar Karako
+nm8541237,Shane Madej
+nm1102088,Adrián Otero
+nm1041648,Bodine Jeske
+nm0883781,Jorge Valdés García
+nm1070295,Monika Kulczyk
+nm0533306,Gisele MacKenzie
+nm5552375,Arie Kruglanski
+nm1689260,Manfred Sangel
+nm1631527,Yves Duteil
+nm3388031,Jonathan Maxwell Silver
+nm1435036,Andy Harvey
+nm2006248,Randy Robinson
+nm3674708,David Serra
+nm1000761,Per Thomsen
+nm3550951,Ella Dale Lewis
+nm1554305,Nikhil Angrish
+nm0797705,Givi Sikharulidze
+nm3802115,Rena Matsui
+nm0001783,Sally Struthers
+nm7863589,Irmgard Schwaetzer
+nm1191822,Tommy Harris
+nm3545544,Martin Harrington
+nm1227486,Laura-Julie Perreault
+nm1919300,Dana Galinsky
+nm1045496,Julia Rajsp
+nm8542276,Lisa Kennedy
+nm1116177,Michiko Kichise
+nm2075058,Douglas Hansell
+nm4496001,Nathan Felix
+nm3721224,Ion Ionut Ciocia
+nm6769189,Benjamin Carlton
+nm3395372,Viljo Kajava
+nm1481134,Barbara Gagrinsky
+nm0300494,Nacho Gadano
+nm3725018,Andris Bulis
+nm3232011,Rachel Jayson
+nm9562083,Maria Comstock
+nm1812334,Louise Bolton
+nm1097527,Markus Koch
+nm0363650,Åke Harnesk
+nm1572939,Vivian
+nm5328132,Abhishek Tiwari
+nm2705287,Ben Tibbles
+nm7179118,José Dos Santos
+nm4866394,Sylvie Cohen
+nm2064561,Knut Storberget
+nm1103846,Olga Lifentseva
+nm1045460,Henry Gründler
+nm9863271,Andrea Horan
+nm2915204,Dave Butz
+nm1344112,Jawhar Al Sourchi
+nm5764304,Nataly Mega
+nm0149702,Maurice Chaillot
+nm2060329,Eduardo Coma
diff --git a/imdb/k6/search.js b/imdb/k6/search.js
new file mode 100644
index 0000000..c0c6b2e
--- /dev/null
+++ b/imdb/k6/search.js
@@ -0,0 +1,33 @@
+import http from 'k6/http';
+import { check, sleep } from 'k6';
+
+const BASE_URL = __ENV.BASE_URL || 'http://localhost:8080';
+
+// Fuzzy trigram search terms - deliberately generic word fragments likely to partial-match a
+// wide spread of real primary/original titles, not exact titles.
+const QUERY_TERMS = [
+ 'love', 'war', 'man', 'night', 'king', 'world', 'story', 'life', 'day',
+ 'dark', 'star', 'shadow', 'girl', 'house', 'time', 'city', 'game', 'dream',
+ 'last', 'new', 'red', 'black', 'blue', 'home', 'land', 'wind',
+];
+
+export const options = {
+ stages: [
+ { duration: '30s', target: 50 },
+ { duration: '1m', target: 100 },
+ { duration: '30s', target: 0 },
+ ],
+ thresholds: {
+ http_req_duration: ['p(95)<300'],
+ http_req_failed: ['rate<0.01'],
+ },
+};
+
+export default function () {
+ const term = QUERY_TERMS[Math.floor(Math.random() * QUERY_TERMS.length)];
+ const res = http.get(`${BASE_URL}/api/v1/titles/search?title=${encodeURIComponent(term)}&size=20`);
+ check(res, {
+ 'status is 200': (r) => r.status === 200,
+ });
+ sleep(1);
+}
diff --git a/imdb/k6/six-degrees.js b/imdb/k6/six-degrees.js
new file mode 100644
index 0000000..e5fc4de
--- /dev/null
+++ b/imdb/k6/six-degrees.js
@@ -0,0 +1,108 @@
+import http from 'k6/http';
+import { check, sleep } from 'k6';
+
+const BASE_URL = __ENV.BASE_URL || 'http://localhost:8080';
+const MAX_DEGREE = __ENV.MAX_DEGREE || 7;
+
+// Common two-word names likely to be shared by more than one real person in a dataset this size -
+// used only as a bootstrap when no curated CSV is present (see setup() below).
+const SEED_QUERIES = [
+ 'John Smith', 'Michael Johnson', 'David Miller', 'Robert Brown', 'James Wilson',
+ 'Mary Johnson', 'John Williams', 'Michael Smith', 'David Jones', 'Maria Garcia',
+ 'James Smith', 'John Davis', 'Robert Miller', 'Michael Brown', 'John Miller',
+];
+
+// open() must be called from k6's init context (top-level script scope, executed once when the
+// script is parsed) - calling it from inside a function invoked during setup() throws "open() can
+// only be called in the init context", silently caught by the try/catch below and falling through
+// to the slow HTTP-based SEED_QUERIES discovery every single run, undetected until a real load
+// test's setup() timed out after 60s despite a valid, populated CSV sitting right there.
+let rawCsv = null;
+try {
+ rawCsv = open('./data/sampled-people.csv');
+} catch (e) {
+ rawCsv = null;
+}
+
+function parseCsv(csv) {
+ if (!csv) return [];
+ return csv
+ .split('\n')
+ .slice(1) // header
+ .map((line) => line.trim())
+ .filter((line) => line.length > 0 && !line.startsWith('#'))
+ .map((line) => {
+ const [id, name] = line.split(',');
+ return { id, name };
+ });
+}
+
+// Runs once before the load test. Prefers a curated data/sampled-people.csv - see
+// data/generate-sampled-people.sql for how to build a large, realistic one from the real loaded
+// dataset, mixing ordinary and high-degree "hub" actors (LLD §8). Falls back to discovering real
+// person ids live from the API's own disambiguation responses when no CSV has been populated yet,
+// so this script still runs meaningfully against a freshly-seeded database with zero setup.
+export function setup() {
+ const fromCsv = parseCsv(rawCsv);
+ if (fromCsv.length >= 2) {
+ return { people: fromCsv };
+ }
+
+ const pool = new Map();
+ for (const name of SEED_QUERIES) {
+ const res = http.get(
+ `${BASE_URL}/api/v1/people/six-degrees?personA=${encodeURIComponent(name)}&personB=${encodeURIComponent(name)}&maxDegree=1`
+ );
+ if (res.status !== 200) continue;
+ const body = res.json();
+ if (body.requiresDisambiguation && Array.isArray(body.candidates)) {
+ for (const c of body.candidates) pool.set(c.id, c.name);
+ } else if (body.personA) {
+ pool.set(body.personA.id, body.personA.name);
+ }
+ }
+
+ const people = Array.from(pool, ([id, name]) => ({ id, name }));
+ if (people.length < 2) {
+ throw new Error(
+ 'Could not assemble at least 2 distinct people (empty data/sampled-people.csv and no ' +
+ 'ambiguous-name discovery hits) - is the database seeded? See data/generate-sampled-people.sql.'
+ );
+ }
+ return { people };
+}
+
+export const options = {
+ stages: [
+ { duration: '30s', target: 20 },
+ { duration: '1m', target: 50 },
+ { duration: '30s', target: 0 },
+ ],
+ thresholds: {
+ // Deliberately far looser than the other three endpoints (LLD §8): this is the one endpoint
+ // whose cost depends on graph shape (hub actors), not a bounded index lookup - the gap between
+ // this threshold and the others' is itself the finding this load test exists to produce.
+ http_req_duration: ['p(95)<3000'],
+ http_req_failed: ['rate<0.02'],
+ },
+};
+
+// Picks two distinct people per iteration so the bidirectional CTE - and the six-degrees cache,
+// keyed per unordered pair (LLD §6) - is genuinely exercised under load instead of collapsing
+// into repeated hits on one warm cache entry.
+export default function (data) {
+ const people = data.people;
+ const a = people[Math.floor(Math.random() * people.length)];
+ let b = people[Math.floor(Math.random() * people.length)];
+ while (b.id === a.id && people.length > 1) {
+ b = people[Math.floor(Math.random() * people.length)];
+ }
+
+ const res = http.get(
+ `${BASE_URL}/api/v1/people/six-degrees?personA=${encodeURIComponent(a.id)}&personB=${encodeURIComponent(b.id)}&maxDegree=${MAX_DEGREE}`
+ );
+ check(res, {
+ 'status is 200 or 404': (r) => r.status === 200 || r.status === 404,
+ });
+ sleep(1);
+}
diff --git a/imdb/k6/title-detail.js b/imdb/k6/title-detail.js
new file mode 100644
index 0000000..b7de91e
--- /dev/null
+++ b/imdb/k6/title-detail.js
@@ -0,0 +1,48 @@
+import http from 'k6/http';
+import { check, sleep } from 'k6';
+
+const BASE_URL = __ENV.BASE_URL || 'http://localhost:8080';
+
+const SEED_QUERIES = ['the', 'man', 'love', 'war', 'star', 'night', 'life', 'day', 'girl', 'king'];
+
+// Runs once before the load test: gathers a pool of real title ids from the running system's own
+// search endpoint, rather than shipping a dataset-snapshot-specific list of hardcoded ids that
+// would go stale the moment the seeded database is reloaded.
+export function setup() {
+ const ids = new Set();
+ for (const term of SEED_QUERIES) {
+ const res = http.get(`${BASE_URL}/api/v1/titles/search?title=${encodeURIComponent(term)}&size=100`);
+ if (res.status === 200) {
+ const body = res.json();
+ for (const item of body.content) {
+ ids.add(item.id);
+ }
+ }
+ }
+ const pool = Array.from(ids);
+ if (pool.length === 0) {
+ throw new Error('setup() found no title ids to sample from /api/v1/titles/search - is the database seeded?');
+ }
+ return { pool };
+}
+
+export const options = {
+ stages: [
+ { duration: '30s', target: 50 },
+ { duration: '1m', target: 100 },
+ { duration: '30s', target: 0 },
+ ],
+ thresholds: {
+ http_req_duration: ['p(95)<300'],
+ http_req_failed: ['rate<0.01'],
+ },
+};
+
+export default function (data) {
+ const id = data.pool[Math.floor(Math.random() * data.pool.length)];
+ const res = http.get(`${BASE_URL}/api/v1/titles/${id}`);
+ check(res, {
+ 'status is 200': (r) => r.status === 200,
+ });
+ sleep(1);
+}
diff --git a/imdb/k6/top-rated.js b/imdb/k6/top-rated.js
new file mode 100644
index 0000000..605013a
--- /dev/null
+++ b/imdb/k6/top-rated.js
@@ -0,0 +1,36 @@
+import http from 'k6/http';
+import { check, sleep } from 'k6';
+
+const BASE_URL = __ENV.BASE_URL || 'http://localhost:8080';
+
+// The standard 28 IMDb genre values. Verify these match the real seeded GENRE enum exactly
+// before a serious run - a mismatched spelling/hyphenation silently returns an empty result
+// rather than an error (the query filters `genres::text[] @> ARRAY[:genre]::text[]`, LLD §4.3):
+// SELECT unnest(enum_range(NULL::genre)) ORDER BY 1;
+const GENRES = [
+ 'Action', 'Adult', 'Adventure', 'Animation', 'Biography', 'Comedy', 'Crime',
+ 'Documentary', 'Drama', 'Family', 'Fantasy', 'Film-Noir', 'Game-Show',
+ 'History', 'Horror', 'Music', 'Musical', 'Mystery', 'News', 'Reality-TV',
+ 'Romance', 'Sci-Fi', 'Short', 'Sport', 'Talk-Show', 'Thriller', 'War', 'Western',
+];
+
+export const options = {
+ stages: [
+ { duration: '30s', target: 50 },
+ { duration: '1m', target: 100 },
+ { duration: '30s', target: 0 },
+ ],
+ thresholds: {
+ http_req_duration: ['p(95)<500'],
+ http_req_failed: ['rate<0.01'],
+ },
+};
+
+export default function () {
+ const genre = GENRES[Math.floor(Math.random() * GENRES.length)];
+ const res = http.get(`${BASE_URL}/api/v1/genres/${encodeURIComponent(genre)}/top-rated?limit=25`);
+ check(res, {
+ 'status is 200': (r) => r.status === 200,
+ });
+ sleep(1);
+}
diff --git a/imdb/mvnw b/imdb/mvnw
new file mode 100644
index 0000000..bd8896b
--- /dev/null
+++ b/imdb/mvnw
@@ -0,0 +1,295 @@
+#!/bin/sh
+# ----------------------------------------------------------------------------
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+# ----------------------------------------------------------------------------
+
+# ----------------------------------------------------------------------------
+# Apache Maven Wrapper startup batch script, version 3.3.4
+#
+# Optional ENV vars
+# -----------------
+# JAVA_HOME - location of a JDK home dir, required when download maven via java source
+# MVNW_REPOURL - repo url base for downloading maven distribution
+# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
+# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output
+# ----------------------------------------------------------------------------
+
+set -euf
+[ "${MVNW_VERBOSE-}" != debug ] || set -x
+
+# OS specific support.
+native_path() { printf %s\\n "$1"; }
+case "$(uname)" in
+CYGWIN* | MINGW*)
+ [ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")"
+ native_path() { cygpath --path --windows "$1"; }
+ ;;
+esac
+
+# set JAVACMD and JAVACCMD
+set_java_home() {
+ # For Cygwin and MinGW, ensure paths are in Unix format before anything is touched
+ if [ -n "${JAVA_HOME-}" ]; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ]; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD="$JAVA_HOME/jre/sh/java"
+ JAVACCMD="$JAVA_HOME/jre/sh/javac"
+ else
+ JAVACMD="$JAVA_HOME/bin/java"
+ JAVACCMD="$JAVA_HOME/bin/javac"
+
+ if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then
+ echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2
+ echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2
+ return 1
+ fi
+ fi
+ else
+ JAVACMD="$(
+ 'set' +e
+ 'unset' -f command 2>/dev/null
+ 'command' -v java
+ )" || :
+ JAVACCMD="$(
+ 'set' +e
+ 'unset' -f command 2>/dev/null
+ 'command' -v javac
+ )" || :
+
+ if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then
+ echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2
+ return 1
+ fi
+ fi
+}
+
+# hash string like Java String::hashCode
+hash_string() {
+ str="${1:-}" h=0
+ while [ -n "$str" ]; do
+ char="${str%"${str#?}"}"
+ h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296))
+ str="${str#?}"
+ done
+ printf %x\\n $h
+}
+
+verbose() { :; }
+[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; }
+
+die() {
+ printf %s\\n "$1" >&2
+ exit 1
+}
+
+trim() {
+ # MWRAPPER-139:
+ # Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds.
+ # Needed for removing poorly interpreted newline sequences when running in more
+ # exotic environments such as mingw bash on Windows.
+ printf "%s" "${1}" | tr -d '[:space:]'
+}
+
+scriptDir="$(dirname "$0")"
+scriptName="$(basename "$0")"
+
+# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties
+while IFS="=" read -r key value; do
+ case "${key-}" in
+ distributionUrl) distributionUrl=$(trim "${value-}") ;;
+ distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;;
+ esac
+done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties"
+[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
+
+case "${distributionUrl##*/}" in
+maven-mvnd-*bin.*)
+ MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/
+ case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in
+ *AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;;
+ :Darwin*x86_64) distributionPlatform=darwin-amd64 ;;
+ :Darwin*arm64) distributionPlatform=darwin-aarch64 ;;
+ :Linux*x86_64*) distributionPlatform=linux-amd64 ;;
+ *)
+ echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2
+ distributionPlatform=linux-amd64
+ ;;
+ esac
+ distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip"
+ ;;
+maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;;
+*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;;
+esac
+
+# apply MVNW_REPOURL and calculate MAVEN_HOME
+# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/
+[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}"
+distributionUrlName="${distributionUrl##*/}"
+distributionUrlNameMain="${distributionUrlName%.*}"
+distributionUrlNameMain="${distributionUrlNameMain%-bin}"
+MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}"
+MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")"
+
+exec_maven() {
+ unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || :
+ exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD"
+}
+
+if [ -d "$MAVEN_HOME" ]; then
+ verbose "found existing MAVEN_HOME at $MAVEN_HOME"
+ exec_maven "$@"
+fi
+
+case "${distributionUrl-}" in
+*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;;
+*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;;
+esac
+
+# prepare tmp dir
+if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then
+ clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; }
+ trap clean HUP INT TERM EXIT
+else
+ die "cannot create temp dir"
+fi
+
+mkdir -p -- "${MAVEN_HOME%/*}"
+
+# Download and Install Apache Maven
+verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
+verbose "Downloading from: $distributionUrl"
+verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
+
+# select .zip or .tar.gz
+if ! command -v unzip >/dev/null; then
+ distributionUrl="${distributionUrl%.zip}.tar.gz"
+ distributionUrlName="${distributionUrl##*/}"
+fi
+
+# verbose opt
+__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR=''
+[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v
+
+# normalize http auth
+case "${MVNW_PASSWORD:+has-password}" in
+'') MVNW_USERNAME='' MVNW_PASSWORD='' ;;
+has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;;
+esac
+
+if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then
+ verbose "Found wget ... using wget"
+ wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl"
+elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then
+ verbose "Found curl ... using curl"
+ curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl"
+elif set_java_home; then
+ verbose "Falling back to use Java to download"
+ javaSource="$TMP_DOWNLOAD_DIR/Downloader.java"
+ targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName"
+ cat >"$javaSource" <<-END
+ public class Downloader extends java.net.Authenticator
+ {
+ protected java.net.PasswordAuthentication getPasswordAuthentication()
+ {
+ return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() );
+ }
+ public static void main( String[] args ) throws Exception
+ {
+ setDefault( new Downloader() );
+ java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() );
+ }
+ }
+ END
+ # For Cygwin/MinGW, switch paths to Windows format before running javac and java
+ verbose " - Compiling Downloader.java ..."
+ "$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java"
+ verbose " - Running Downloader.java ..."
+ "$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")"
+fi
+
+# If specified, validate the SHA-256 sum of the Maven distribution zip file
+if [ -n "${distributionSha256Sum-}" ]; then
+ distributionSha256Result=false
+ if [ "$MVN_CMD" = mvnd.sh ]; then
+ echo "Checksum validation is not supported for maven-mvnd." >&2
+ echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
+ exit 1
+ elif command -v sha256sum >/dev/null; then
+ if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then
+ distributionSha256Result=true
+ fi
+ elif command -v shasum >/dev/null; then
+ if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then
+ distributionSha256Result=true
+ fi
+ else
+ echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2
+ echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
+ exit 1
+ fi
+ if [ $distributionSha256Result = false ]; then
+ echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2
+ echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2
+ exit 1
+ fi
+fi
+
+# unzip and move
+if command -v unzip >/dev/null; then
+ unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip"
+else
+ tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar"
+fi
+
+# Find the actual extracted directory name (handles snapshots where filename != directory name)
+actualDistributionDir=""
+
+# First try the expected directory name (for regular distributions)
+if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then
+ if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then
+ actualDistributionDir="$distributionUrlNameMain"
+ fi
+fi
+
+# If not found, search for any directory with the Maven executable (for snapshots)
+if [ -z "$actualDistributionDir" ]; then
+ # enable globbing to iterate over items
+ set +f
+ for dir in "$TMP_DOWNLOAD_DIR"/*; do
+ if [ -d "$dir" ]; then
+ if [ -f "$dir/bin/$MVN_CMD" ]; then
+ actualDistributionDir="$(basename "$dir")"
+ break
+ fi
+ fi
+ done
+ set -f
+fi
+
+if [ -z "$actualDistributionDir" ]; then
+ verbose "Contents of $TMP_DOWNLOAD_DIR:"
+ verbose "$(ls -la "$TMP_DOWNLOAD_DIR")"
+ die "Could not find Maven distribution directory in extracted archive"
+fi
+
+verbose "Found extracted Maven distribution directory: $actualDistributionDir"
+printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url"
+mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME"
+
+clean || :
+exec_maven "$@"
diff --git a/imdb/mvnw.cmd b/imdb/mvnw.cmd
new file mode 100644
index 0000000..92450f9
--- /dev/null
+++ b/imdb/mvnw.cmd
@@ -0,0 +1,189 @@
+<# : batch portion
+@REM ----------------------------------------------------------------------------
+@REM Licensed to the Apache Software Foundation (ASF) under one
+@REM or more contributor license agreements. See the NOTICE file
+@REM distributed with this work for additional information
+@REM regarding copyright ownership. The ASF licenses this file
+@REM to you under the Apache License, Version 2.0 (the
+@REM "License"); you may not use this file except in compliance
+@REM with the License. You may obtain a copy of the License at
+@REM
+@REM http://www.apache.org/licenses/LICENSE-2.0
+@REM
+@REM Unless required by applicable law or agreed to in writing,
+@REM software distributed under the License is distributed on an
+@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+@REM KIND, either express or implied. See the License for the
+@REM specific language governing permissions and limitations
+@REM under the License.
+@REM ----------------------------------------------------------------------------
+
+@REM ----------------------------------------------------------------------------
+@REM Apache Maven Wrapper startup batch script, version 3.3.4
+@REM
+@REM Optional ENV vars
+@REM MVNW_REPOURL - repo url base for downloading maven distribution
+@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
+@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output
+@REM ----------------------------------------------------------------------------
+
+@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0)
+@SET __MVNW_CMD__=
+@SET __MVNW_ERROR__=
+@SET __MVNW_PSMODULEP_SAVE=%PSModulePath%
+@SET PSModulePath=
+@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @(
+ IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B)
+)
+@SET PSModulePath=%__MVNW_PSMODULEP_SAVE%
+@SET __MVNW_PSMODULEP_SAVE=
+@SET __MVNW_ARG0_NAME__=
+@SET MVNW_USERNAME=
+@SET MVNW_PASSWORD=
+@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*)
+@echo Cannot start maven from wrapper >&2 && exit /b 1
+@GOTO :EOF
+: end batch / begin powershell #>
+
+$ErrorActionPreference = "Stop"
+if ($env:MVNW_VERBOSE -eq "true") {
+ $VerbosePreference = "Continue"
+}
+
+# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties
+$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl
+if (!$distributionUrl) {
+ Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
+}
+
+switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) {
+ "maven-mvnd-*" {
+ $USE_MVND = $true
+ $distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip"
+ $MVN_CMD = "mvnd.cmd"
+ break
+ }
+ default {
+ $USE_MVND = $false
+ $MVN_CMD = $script -replace '^mvnw','mvn'
+ break
+ }
+}
+
+# apply MVNW_REPOURL and calculate MAVEN_HOME
+# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/
+if ($env:MVNW_REPOURL) {
+ $MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" }
+ $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')"
+}
+$distributionUrlName = $distributionUrl -replace '^.*/',''
+$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$',''
+
+$MAVEN_M2_PATH = "$HOME/.m2"
+if ($env:MAVEN_USER_HOME) {
+ $MAVEN_M2_PATH = "$env:MAVEN_USER_HOME"
+}
+
+if (-not (Test-Path -Path $MAVEN_M2_PATH)) {
+ New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null
+}
+
+$MAVEN_WRAPPER_DISTS = $null
+if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) {
+ $MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists"
+} else {
+ $MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists"
+}
+
+$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain"
+$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join ''
+$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME"
+
+if (Test-Path -Path "$MAVEN_HOME" -PathType Container) {
+ Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME"
+ Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
+ exit $?
+}
+
+if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) {
+ Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl"
+}
+
+# prepare tmp dir
+$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile
+$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir"
+$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null
+trap {
+ if ($TMP_DOWNLOAD_DIR.Exists) {
+ try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
+ catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
+ }
+}
+
+New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null
+
+# Download and Install Apache Maven
+Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
+Write-Verbose "Downloading from: $distributionUrl"
+Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
+
+$webclient = New-Object System.Net.WebClient
+if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) {
+ $webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD)
+}
+[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
+$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null
+
+# If specified, validate the SHA-256 sum of the Maven distribution zip file
+$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum
+if ($distributionSha256Sum) {
+ if ($USE_MVND) {
+ Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties."
+ }
+ Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash
+ if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) {
+ Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property."
+ }
+}
+
+# unzip and move
+Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null
+
+# Find the actual extracted directory name (handles snapshots where filename != directory name)
+$actualDistributionDir = ""
+
+# First try the expected directory name (for regular distributions)
+$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain"
+$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD"
+if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) {
+ $actualDistributionDir = $distributionUrlNameMain
+}
+
+# If not found, search for any directory with the Maven executable (for snapshots)
+if (!$actualDistributionDir) {
+ Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object {
+ $testPath = Join-Path $_.FullName "bin/$MVN_CMD"
+ if (Test-Path -Path $testPath -PathType Leaf) {
+ $actualDistributionDir = $_.Name
+ }
+ }
+}
+
+if (!$actualDistributionDir) {
+ Write-Error "Could not find Maven distribution directory in extracted archive"
+}
+
+Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir"
+Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null
+try {
+ Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null
+} catch {
+ if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) {
+ Write-Error "fail to move MAVEN_HOME"
+ }
+} finally {
+ try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
+ catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
+}
+
+Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
diff --git a/imdb/observability/alloy/config.alloy b/imdb/observability/alloy/config.alloy
new file mode 100644
index 0000000..03df2a6
--- /dev/null
+++ b/imdb/observability/alloy/config.alloy
@@ -0,0 +1,31 @@
+discovery.docker "containers" {
+ host = "unix:///var/run/docker.sock"
+}
+
+// Loki 3.x indexes streams by service_name (falling back to "unknown_service" when a stream
+// doesn't set one) rather than by arbitrary per-container labels - discovered when every
+// container's logs were landing in one undifferentiated unknown_service stream, making them
+// unqueryable per service. This extracts the container name (Docker prefixes it with "/") into
+// service_name so each container is its own filterable stream in Grafana.
+discovery.relabel "containers" {
+ targets = []
+
+ rule {
+ source_labels = ["__meta_docker_container_name"]
+ regex = "/(.*)"
+ target_label = "service_name"
+ }
+}
+
+loki.source.docker "default" {
+ host = "unix:///var/run/docker.sock"
+ targets = discovery.docker.containers.targets
+ relabel_rules = discovery.relabel.containers.rules
+ forward_to = [loki.write.default.receiver]
+}
+
+loki.write "default" {
+ endpoint {
+ url = "http://loki:3100/loki/api/v1/push"
+ }
+}
diff --git a/imdb/observability/grafana/provisioning/dashboards/dashboards.yml b/imdb/observability/grafana/provisioning/dashboards/dashboards.yml
new file mode 100644
index 0000000..1f04fa5
--- /dev/null
+++ b/imdb/observability/grafana/provisioning/dashboards/dashboards.yml
@@ -0,0 +1,10 @@
+apiVersion: 1
+
+providers:
+ - name: imdb
+ orgId: 1
+ folder: IMDb
+ type: file
+ updateIntervalSeconds: 30
+ options:
+ path: /etc/grafana/provisioning/dashboards/json
diff --git a/imdb/observability/grafana/provisioning/dashboards/json/cache-hit-ratio.json b/imdb/observability/grafana/provisioning/dashboards/json/cache-hit-ratio.json
new file mode 100644
index 0000000..d6a6a4b
--- /dev/null
+++ b/imdb/observability/grafana/provisioning/dashboards/json/cache-hit-ratio.json
@@ -0,0 +1,60 @@
+{
+ "title": "IMDb - Cache Hit Ratio",
+ "uid": "imdb-cache-hit-ratio",
+ "tags": ["imdb"],
+ "timezone": "browser",
+ "schemaVersion": 39,
+ "refresh": "10s",
+ "time": { "from": "now-15m", "to": "now" },
+ "description": "cache.gets/cache.puts are manually bound (CacheConfig.cacheStatisticsMeterBinder, LLD §6/§7), not Spring Boot auto-binding - decompiling spring-boot-actuator-autoconfigure-4.1.0.jar showed the cache-metrics package Boot 2/3 used to ship (CacheMetricsRegistrar et al.) no longer exists in Boot 4.1 at all. The replacement reads Spring Data Redis's own RedisCacheWriter statistics collector directly. Verified live: exercising each cached endpoint twice produces the expected 1 miss + 1 hit + 1 put per region.",
+ "panels": [
+ {
+ "id": 1,
+ "title": "Hit ratio by cache region",
+ "type": "timeseries",
+ "gridPos": { "h": 8, "w": 24, "x": 0, "y": 0 },
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "targets": [
+ {
+ "refId": "A",
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "expr": "sum by (cache) (rate(cache_gets_total{job=\"imdb-service\", result=\"hit\"}[$__rate_interval])) / sum by (cache) (rate(cache_gets_total{job=\"imdb-service\"}[$__rate_interval]))",
+ "legendFormat": "{{cache}}"
+ }
+ ],
+ "fieldConfig": { "defaults": { "unit": "percentunit", "min": 0, "max": 1 }, "overrides": [] }
+ },
+ {
+ "id": 2,
+ "title": "Gets by region and result (hit/miss)",
+ "type": "timeseries",
+ "gridPos": { "h": 8, "w": 12, "x": 0, "y": 8 },
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "targets": [
+ {
+ "refId": "A",
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "expr": "sum by (cache, result) (rate(cache_gets_total{job=\"imdb-service\"}[$__rate_interval]))",
+ "legendFormat": "{{cache}} - {{result}}"
+ }
+ ],
+ "fieldConfig": { "defaults": { "unit": "ops" }, "overrides": [] }
+ },
+ {
+ "id": 3,
+ "title": "Puts by region",
+ "type": "timeseries",
+ "gridPos": { "h": 8, "w": 12, "x": 12, "y": 8 },
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "targets": [
+ {
+ "refId": "A",
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "expr": "sum by (cache) (rate(cache_puts_total{job=\"imdb-service\"}[$__rate_interval]))",
+ "legendFormat": "{{cache}}"
+ }
+ ],
+ "fieldConfig": { "defaults": { "unit": "ops" }, "overrides": [] }
+ }
+ ]
+}
diff --git a/imdb/observability/grafana/provisioning/dashboards/json/http-overview.json b/imdb/observability/grafana/provisioning/dashboards/json/http-overview.json
new file mode 100644
index 0000000..ff903c9
--- /dev/null
+++ b/imdb/observability/grafana/provisioning/dashboards/json/http-overview.json
@@ -0,0 +1,75 @@
+{
+ "title": "IMDb - HTTP Overview",
+ "uid": "imdb-http-overview",
+ "tags": ["imdb"],
+ "timezone": "browser",
+ "schemaVersion": 39,
+ "refresh": "10s",
+ "time": { "from": "now-15m", "to": "now" },
+ "panels": [
+ {
+ "id": 1,
+ "title": "Request rate by endpoint",
+ "type": "timeseries",
+ "gridPos": { "h": 8, "w": 12, "x": 0, "y": 0 },
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "targets": [
+ {
+ "refId": "A",
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "expr": "sum by (uri) (rate(http_server_requests_seconds_count{job=\"imdb-service\"}[$__rate_interval]))",
+ "legendFormat": "{{uri}}"
+ }
+ ],
+ "fieldConfig": { "defaults": { "unit": "reqps" }, "overrides": [] }
+ },
+ {
+ "id": 2,
+ "title": "p95 latency by endpoint",
+ "type": "timeseries",
+ "gridPos": { "h": 8, "w": 12, "x": 12, "y": 0 },
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "targets": [
+ {
+ "refId": "A",
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "expr": "histogram_quantile(0.95, sum by (le, uri) (rate(http_server_requests_seconds_bucket{job=\"imdb-service\"}[$__rate_interval])))",
+ "legendFormat": "{{uri}}"
+ }
+ ],
+ "fieldConfig": { "defaults": { "unit": "s" }, "overrides": [] }
+ },
+ {
+ "id": 3,
+ "title": "5xx error rate by endpoint",
+ "type": "timeseries",
+ "gridPos": { "h": 8, "w": 12, "x": 0, "y": 8 },
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "targets": [
+ {
+ "refId": "A",
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "expr": "sum by (uri) (rate(http_server_requests_seconds_count{job=\"imdb-service\", status=~\"5..\"}[$__rate_interval]))",
+ "legendFormat": "{{uri}}"
+ }
+ ],
+ "fieldConfig": { "defaults": { "unit": "reqps" }, "overrides": [] }
+ },
+ {
+ "id": 4,
+ "title": "JVM heap used",
+ "type": "timeseries",
+ "gridPos": { "h": 8, "w": 12, "x": 12, "y": 8 },
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "targets": [
+ {
+ "refId": "A",
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "expr": "sum(jvm_memory_used_bytes{job=\"imdb-service\", area=\"heap\"})",
+ "legendFormat": "heap used"
+ }
+ ],
+ "fieldConfig": { "defaults": { "unit": "bytes" }, "overrides": [] }
+ }
+ ]
+}
diff --git a/imdb/observability/grafana/provisioning/dashboards/json/k6-load-test.json b/imdb/observability/grafana/provisioning/dashboards/json/k6-load-test.json
new file mode 100644
index 0000000..fb0f43d
--- /dev/null
+++ b/imdb/observability/grafana/provisioning/dashboards/json/k6-load-test.json
@@ -0,0 +1,77 @@
+{
+ "title": "IMDb - k6 Load Test Results",
+ "uid": "imdb-k6-load-test",
+ "tags": ["imdb", "k6"],
+ "timezone": "browser",
+ "schemaVersion": 39,
+ "refresh": "5s",
+ "time": { "from": "now-15m", "to": "now" },
+ "description": "Fed by k6's --out experimental-prometheus-rw output (LLD §8). Run one script at a time (search.js, title-detail.js, top-rated.js, six-degrees.js) so a given time window's results are attributable to a single endpoint - do not read this dashboard while more than one k6 run is active.",
+ "panels": [
+ {
+ "id": 1,
+ "title": "Virtual users",
+ "type": "timeseries",
+ "gridPos": { "h": 8, "w": 12, "x": 0, "y": 0 },
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "targets": [
+ {
+ "refId": "A",
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "expr": "k6_vus",
+ "legendFormat": "VUs"
+ }
+ ]
+ },
+ {
+ "id": 2,
+ "title": "Request rate",
+ "type": "timeseries",
+ "gridPos": { "h": 8, "w": 12, "x": 12, "y": 0 },
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "targets": [
+ {
+ "refId": "A",
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "expr": "sum(rate(k6_http_reqs_total[$__rate_interval]))",
+ "legendFormat": "req/s"
+ }
+ ],
+ "fieldConfig": { "defaults": { "unit": "reqps" }, "overrides": [] }
+ },
+ {
+ "id": 3,
+ "title": "p95 request duration",
+ "type": "timeseries",
+ "description": "k6's experimental-prometheus-rw output pre-computes trend percentiles client-side and exports them as plain gauges per request URL (k6_http_req_duration_p95{name=...}, no _bucket series at all) - averaged across URLs here rather than histogram_quantile'd, since there's no histogram to quantile. K6_PROMETHEUS_RW_TREND_STATS=\"p(95),p(99)\" (docker-compose.yaml) is what makes the p95 series exist; the default is p99 only. Note the k6() call syntax - a bare \"p95\" is rejected at k6 startup with \"invalid trend stat\", confirmed empirically.",
+ "gridPos": { "h": 8, "w": 12, "x": 0, "y": 8 },
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "targets": [
+ {
+ "refId": "A",
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "expr": "avg(k6_http_req_duration_p95)",
+ "legendFormat": "p95"
+ }
+ ],
+ "fieldConfig": { "defaults": { "unit": "s" }, "overrides": [] }
+ },
+ {
+ "id": 4,
+ "title": "Failed request rate",
+ "type": "timeseries",
+ "description": "k6's http_req_failed is itself a \"rate\" metric (a 0..1 boolean average, not a counter) - k6_http_req_failed_total never exists, only k6_http_req_failed_rate.",
+ "gridPos": { "h": 8, "w": 12, "x": 12, "y": 8 },
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "targets": [
+ {
+ "refId": "A",
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "expr": "avg(k6_http_req_failed_rate)",
+ "legendFormat": "failure rate"
+ }
+ ],
+ "fieldConfig": { "defaults": { "unit": "percentunit", "min": 0, "max": 1 }, "overrides": [] }
+ }
+ ]
+}
diff --git a/imdb/observability/grafana/provisioning/dashboards/json/six-degrees-latency.json b/imdb/observability/grafana/provisioning/dashboards/json/six-degrees-latency.json
new file mode 100644
index 0000000..1836ee8
--- /dev/null
+++ b/imdb/observability/grafana/provisioning/dashboards/json/six-degrees-latency.json
@@ -0,0 +1,73 @@
+{
+ "title": "IMDb - Six Degrees Latency Breakdown",
+ "uid": "imdb-six-degrees-latency",
+ "tags": ["imdb"],
+ "timezone": "browser",
+ "schemaVersion": 39,
+ "refresh": "10s",
+ "time": { "from": "now-15m", "to": "now" },
+ "description": "Six-degrees is the one endpoint whose cost depends on graph shape (hub actors), not a bounded index lookup - LLD §5/§8. This dashboard exists to make that gap visible against the other three endpoints, not just eyeball a single number.",
+ "panels": [
+ {
+ "id": 1,
+ "title": "p50 / p95 / p99 latency - six-degrees only",
+ "type": "timeseries",
+ "gridPos": { "h": 8, "w": 24, "x": 0, "y": 0 },
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "targets": [
+ {
+ "refId": "A",
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "expr": "histogram_quantile(0.50, sum by (le) (rate(http_server_requests_seconds_bucket{job=\"imdb-service\", uri=\"/api/v1/people/six-degrees\"}[$__rate_interval])))",
+ "legendFormat": "p50"
+ },
+ {
+ "refId": "B",
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "expr": "histogram_quantile(0.95, sum by (le) (rate(http_server_requests_seconds_bucket{job=\"imdb-service\", uri=\"/api/v1/people/six-degrees\"}[$__rate_interval])))",
+ "legendFormat": "p95"
+ },
+ {
+ "refId": "C",
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "expr": "histogram_quantile(0.99, sum by (le) (rate(http_server_requests_seconds_bucket{job=\"imdb-service\", uri=\"/api/v1/people/six-degrees\"}[$__rate_interval])))",
+ "legendFormat": "p99"
+ }
+ ],
+ "fieldConfig": { "defaults": { "unit": "s" }, "overrides": [] }
+ },
+ {
+ "id": 2,
+ "title": "p95 latency: six-degrees vs the other three endpoints",
+ "type": "timeseries",
+ "gridPos": { "h": 8, "w": 24, "x": 0, "y": 8 },
+ "description": "The gap between this line and the other three is the finding the k6 load test (LLD §8) exists to produce.",
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "targets": [
+ {
+ "refId": "A",
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "expr": "histogram_quantile(0.95, sum by (le, uri) (rate(http_server_requests_seconds_bucket{job=\"imdb-service\"}[$__rate_interval])))",
+ "legendFormat": "{{uri}}"
+ }
+ ],
+ "fieldConfig": { "defaults": { "unit": "s" }, "overrides": [] }
+ },
+ {
+ "id": 3,
+ "title": "six-degrees request rate",
+ "type": "timeseries",
+ "gridPos": { "h": 8, "w": 24, "x": 0, "y": 16 },
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "targets": [
+ {
+ "refId": "A",
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "expr": "sum(rate(http_server_requests_seconds_count{job=\"imdb-service\", uri=\"/api/v1/people/six-degrees\"}[$__rate_interval]))",
+ "legendFormat": "requests/sec"
+ }
+ ],
+ "fieldConfig": { "defaults": { "unit": "reqps" }, "overrides": [] }
+ }
+ ]
+}
diff --git a/imdb/observability/grafana/provisioning/datasources/datasources.yml b/imdb/observability/grafana/provisioning/datasources/datasources.yml
new file mode 100644
index 0000000..c0c8458
--- /dev/null
+++ b/imdb/observability/grafana/provisioning/datasources/datasources.yml
@@ -0,0 +1,38 @@
+apiVersion: 1
+
+datasources:
+ - name: Prometheus
+ uid: prometheus
+ type: prometheus
+ access: proxy
+ url: http://prometheus:9090
+ isDefault: true
+ jsonData:
+ exemplarTraceIdDestinations:
+ - name: trace_id
+ datasourceUid: tempo
+
+ - name: Loki
+ uid: loki
+ type: loki
+ access: proxy
+ url: http://loki:3100
+ jsonData:
+ derivedFields:
+ - datasourceUid: tempo
+ matcherRegex: 'trace_id=(\w+)'
+ name: TraceID
+ url: "$${__value.raw}"
+
+ - name: Tempo
+ uid: tempo
+ type: tempo
+ access: proxy
+ url: http://tempo:3200
+ jsonData:
+ tracesToLogsV2:
+ datasourceUid: loki
+ tracesToMetrics:
+ datasourceUid: prometheus
+ serviceMap:
+ datasourceUid: prometheus
diff --git a/imdb/observability/prometheus/prometheus.yml b/imdb/observability/prometheus/prometheus.yml
new file mode 100644
index 0000000..2a20d0c
--- /dev/null
+++ b/imdb/observability/prometheus/prometheus.yml
@@ -0,0 +1,21 @@
+global:
+ scrape_interval: 15s
+ evaluation_interval: 15s
+
+scrape_configs:
+ - job_name: prometheus
+ static_configs:
+ - targets: ["localhost:9090"]
+
+ - job_name: imdb-service
+ metrics_path: /actuator/prometheus
+ static_configs:
+ - targets: ["imdb-service:8080"]
+
+ - job_name: postgres
+ static_configs:
+ - targets: ["postgres-exporter:9187"]
+
+ - job_name: redis
+ static_configs:
+ - targets: ["redis-exporter:9121"]
diff --git a/imdb/observability/tempo/tempo.yaml b/imdb/observability/tempo/tempo.yaml
new file mode 100644
index 0000000..cc24899
--- /dev/null
+++ b/imdb/observability/tempo/tempo.yaml
@@ -0,0 +1,23 @@
+server:
+ http_listen_port: 3200
+
+distributor:
+ receivers:
+ otlp:
+ protocols:
+ grpc:
+ endpoint: 0.0.0.0:4317
+ http:
+ endpoint: 0.0.0.0:4318
+
+storage:
+ trace:
+ backend: local
+ local:
+ path: /var/tempo/traces
+ wal:
+ path: /var/tempo/wal
+
+compactor:
+ compaction:
+ block_retention: 48h
diff --git a/imdb/pom.xml b/imdb/pom.xml
new file mode 100644
index 0000000..14f9eda
--- /dev/null
+++ b/imdb/pom.xml
@@ -0,0 +1,230 @@
+
+
+ 4.0.0
+
+ org.springframework.boot
+ spring-boot-starter-parent
+ 4.1.0
+
+
+ com.ludovictemgoua
+ imdb
+ 0.0.1-SNAPSHOT
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 21
+
+
+
+ org.springframework.boot
+ spring-boot-starter-actuator
+
+
+ org.springframework.boot
+ spring-boot-starter-data-redis
+
+
+ org.springframework.boot
+ spring-boot-starter-flyway
+
+
+ org.springframework.boot
+ spring-boot-starter-jackson
+
+
+ org.springframework.boot
+ spring-boot-starter-jdbc
+
+
+ org.springframework.boot
+ spring-boot-starter-opentelemetry
+
+
+ org.springframework.boot
+ spring-boot-starter-security
+
+
+ org.springframework.boot
+ spring-boot-starter-validation
+
+
+ org.springframework.boot
+ spring-boot-starter-webmvc
+
+
+ org.flywaydb
+ flyway-database-postgresql
+
+
+ org.springdoc
+ springdoc-openapi-starter-webmvc-ui
+ 3.0.3
+
+
+ net.ttddyy.observation
+ datasource-micrometer-spring-boot
+ 2.2.1
+
+
+
+ io.micrometer
+ micrometer-registry-prometheus
+ runtime
+
+
+ org.postgresql
+ postgresql
+ runtime
+
+
+ io.jsonwebtoken
+ jjwt-api
+ 0.12.6
+
+
+ io.jsonwebtoken
+ jjwt-impl
+ 0.12.6
+ runtime
+
+
+ io.jsonwebtoken
+ jjwt-jackson
+ 0.12.6
+ runtime
+
+
+ org.springframework.boot
+ spring-boot-starter-actuator-test
+ test
+
+
+ org.springframework.boot
+ spring-boot-starter-data-redis-test
+ test
+
+
+ org.springframework.boot
+ spring-boot-starter-flyway-test
+ test
+
+
+ org.springframework.boot
+ spring-boot-starter-jdbc-test
+ test
+
+
+ org.springframework.boot
+ spring-boot-starter-opentelemetry-test
+ test
+
+
+ org.springframework.boot
+ spring-boot-starter-security-test
+ test
+
+
+ org.springframework.boot
+ spring-boot-starter-validation-test
+ test
+
+
+ org.springframework.boot
+ spring-boot-starter-webmvc-test
+ test
+
+
+ org.springframework.boot
+ spring-boot-testcontainers
+ test
+
+
+ org.testcontainers
+ testcontainers-grafana
+ test
+
+
+ org.testcontainers
+ testcontainers-junit-jupiter
+ test
+
+
+ org.testcontainers
+ testcontainers-postgresql
+ test
+
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-maven-plugin
+
+
+
+ org.apache.maven.plugins
+ maven-surefire-plugin
+
+
+ **/*IntegrationTest.java
+ **/ImdbApplicationTests.java
+
+
+
+ test-only-secret-not-for-real-deployments-32bytes-plus
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-failsafe-plugin
+
+
+ **/*IntegrationTest.java
+ **/ImdbApplicationTests.java
+
+
+ test-only-secret-not-for-real-deployments-32bytes-plus
+
+
+
+
+
+ integration-test
+ verify
+
+
+
+
+
+
+
+
diff --git a/imdb/postman/imdb-e2e.postman_collection.json b/imdb/postman/imdb-e2e.postman_collection.json
new file mode 100644
index 0000000..f26dc0b
--- /dev/null
+++ b/imdb/postman/imdb-e2e.postman_collection.json
@@ -0,0 +1,426 @@
+{
+ "info": {
+ "name": "imdb e2e",
+ "_postman_id": "b6a1e6f0-6b3e-4b6a-9f0a-imdb-e2e-collection",
+ "description": "Contract/e2e tests against a really-running imdb-service (docker-compose.e2e.yaml), asserting on the same known fixture dataset the Testcontainers integration tests use (src/test/resources/fixtures/fixture-data.sql) - one dataset kept in sync, not two. Run with Newman: newman run imdb-e2e.postman_collection.json --env-var baseUrl=http://localhost:8080",
+ "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
+ },
+ "variable": [
+ { "key": "baseUrl", "value": "http://localhost:8080" },
+ { "key": "accessToken", "value": "" },
+ { "key": "refreshToken", "value": "" },
+ { "key": "adminAccessToken", "value": "" },
+ { "key": "createdTitleId", "value": "" },
+ { "key": "createdListId", "value": "" }
+ ],
+ "item": [
+ {
+ "name": "Health check is UP",
+ "request": {
+ "method": "GET",
+ "url": "{{baseUrl}}/actuator/health"
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "exec": [
+ "pm.test('status is 200', () => pm.response.to.have.status(200));",
+ "pm.test('status is UP', () => pm.expect(pm.response.json().status).to.eql('UP'));"
+ ]
+ }
+ }
+ ]
+ },
+ {
+ "name": "Search finds a title by fuzzy title match",
+ "request": {
+ "method": "GET",
+ "url": "{{baseUrl}}/api/v1/titles/search?title=Few Good Men&page=0&size=20"
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "exec": [
+ "pm.test('status is 200', () => pm.response.to.have.status(200));",
+ "const body = pm.response.json();",
+ "pm.test('finds the fixture title', () => {",
+ " pm.expect(body.content.map(t => t.id)).to.include('tt0000100');",
+ "});"
+ ]
+ }
+ }
+ ]
+ },
+ {
+ "name": "Title detail returns cast, crew and rating",
+ "request": {
+ "method": "GET",
+ "url": "{{baseUrl}}/api/v1/titles/tt0000100"
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "exec": [
+ "pm.test('status is 200', () => pm.response.to.have.status(200));",
+ "const body = pm.response.json();",
+ "pm.test('primary title matches fixture', () => pm.expect(body.primaryTitle).to.eql('A Few Good Men'));",
+ "pm.test('directors include Rob Reiner', () => {",
+ " pm.expect(body.directors.map(d => d.name)).to.include('Rob Reiner');",
+ "});",
+ "pm.test('writers include Aaron Sorkin', () => {",
+ " pm.expect(body.writers.map(w => w.name)).to.include('Aaron Sorkin');",
+ "});",
+ "pm.test('cast includes both principals', () => {",
+ " const names = body.cast.map(c => c.name);",
+ " pm.expect(names).to.include('Kevin Bacon');",
+ " pm.expect(names).to.include('Tom Cruise');",
+ "});"
+ ]
+ }
+ }
+ ]
+ },
+ {
+ "name": "Title detail 404s for an unknown id",
+ "request": {
+ "method": "GET",
+ "url": "{{baseUrl}}/api/v1/titles/tt9999999"
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "exec": [
+ "pm.test('status is 404', () => pm.response.to.have.status(404));"
+ ]
+ }
+ }
+ ]
+ },
+ {
+ "name": "Top rated ranks by weighted rating, not raw average",
+ "request": {
+ "method": "GET",
+ "url": "{{baseUrl}}/api/v1/genres/Action/top-rated?limit=10&minVotes=100"
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "exec": [
+ "pm.test('status is 200', () => pm.response.to.have.status(200));",
+ "const body = pm.response.json();",
+ "pm.test('200 outranks 201 once vote-count shrinkage is applied', () => {",
+ " pm.expect(body[0].id).to.eql('tt0000200');",
+ " pm.expect(body[1].id).to.eql('tt0000201');",
+ "});"
+ ]
+ }
+ }
+ ]
+ },
+ {
+ "name": "Six degrees finds a direct co-star (degree 1)",
+ "request": {
+ "method": "GET",
+ "url": "{{baseUrl}}/api/v1/people/six-degrees?personA=nm0000001&personB=nm0000002"
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "exec": [
+ "pm.test('status is 200', () => pm.response.to.have.status(200));",
+ "const body = pm.response.json();",
+ "pm.test('degree is 1', () => pm.expect(body.degree).to.eql(1));",
+ "pm.test('within requested max', () => pm.expect(body.withinRequestedMax).to.eql(true));"
+ ]
+ }
+ }
+ ]
+ },
+ {
+ "name": "Six degrees finds a multi-hop path (degree 5)",
+ "request": {
+ "method": "GET",
+ "url": "{{baseUrl}}/api/v1/people/six-degrees?personA=nm0000001&personB=nm0000006"
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "exec": [
+ "pm.test('status is 200', () => pm.response.to.have.status(200));",
+ "const body = pm.response.json();",
+ "pm.test('degree is 5 - the full co-star chain', () => pm.expect(body.degree).to.eql(5));",
+ "pm.test('path has 6 people', () => pm.expect(body.path).to.have.lengthOf(6));"
+ ]
+ }
+ }
+ ]
+ },
+ {
+ "name": "Six degrees reports no path for the isolated actor",
+ "request": {
+ "method": "GET",
+ "url": "{{baseUrl}}/api/v1/people/six-degrees?personA=nm0000001&personB=nm0000007"
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "exec": [
+ "pm.test('status is 200', () => pm.response.to.have.status(200));",
+ "const body = pm.response.json();",
+ "pm.test('no path found', () => {",
+ " pm.expect(body.degree).to.be.null;",
+ " pm.expect(body.withinRequestedMax).to.eql(false);",
+ "});"
+ ]
+ }
+ }
+ ]
+ },
+ {
+ "name": "Six degrees requires disambiguation for an ambiguous name",
+ "request": {
+ "method": "GET",
+ "url": "{{baseUrl}}/api/v1/people/six-degrees?personA=Jamie Lee&personB=nm0000001"
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "exec": [
+ "pm.test('status is 200', () => pm.response.to.have.status(200));",
+ "const body = pm.response.json();",
+ "pm.test('requires disambiguation between the two fixture Jamie Lees', () => {",
+ " pm.expect(body.requiresDisambiguation).to.eql(true);",
+ " pm.expect(body.candidates).to.have.lengthOf(2);",
+ "});"
+ ]
+ }
+ }
+ ]
+ },
+ {
+ "name": "Register a new user",
+ "request": {
+ "method": "POST",
+ "url": "{{baseUrl}}/api/v1/auth/register",
+ "header": [{ "key": "Content-Type", "value": "application/json" }],
+ "body": {
+ "mode": "raw",
+ "raw": "{\"email\":\"e2e-user@example.com\",\"password\":\"password123\",\"displayName\":\"E2E User\"}"
+ }
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "exec": [
+ "pm.test('status is 201', () => pm.response.to.have.status(201));",
+ "const body = pm.response.json();",
+ "pm.collectionVariables.set('accessToken', body.accessToken);",
+ "pm.collectionVariables.set('refreshToken', body.refreshToken);"
+ ]
+ }
+ }
+ ]
+ },
+ {
+ "name": "Admin logs in",
+ "request": {
+ "method": "POST",
+ "url": "{{baseUrl}}/api/v1/auth/login",
+ "header": [{ "key": "Content-Type", "value": "application/json" }],
+ "body": {
+ "mode": "raw",
+ "raw": "{\"email\":\"admin@imdb.local\",\"password\":\"e2e-test-admin-password\"}"
+ }
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "exec": [
+ "pm.test('status is 200', () => pm.response.to.have.status(200));",
+ "pm.collectionVariables.set('adminAccessToken', pm.response.json().accessToken);"
+ ]
+ }
+ }
+ ]
+ },
+ {
+ "name": "Refresh the access token",
+ "request": {
+ "method": "POST",
+ "url": "{{baseUrl}}/api/v1/auth/refresh",
+ "header": [{ "key": "Content-Type", "value": "application/json" }],
+ "body": { "mode": "raw", "raw": "{\"refreshToken\":\"{{refreshToken}}\"}" }
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "exec": ["pm.test('status is 200', () => pm.response.to.have.status(200));"]
+ }
+ }
+ ]
+ },
+ {
+ "name": "Non-admin cannot create a title",
+ "request": {
+ "method": "POST",
+ "url": "{{baseUrl}}/api/v1/titles",
+ "header": [
+ { "key": "Content-Type", "value": "application/json" },
+ { "key": "Authorization", "value": "Bearer {{accessToken}}" }
+ ],
+ "body": {
+ "mode": "raw",
+ "raw": "{\"primaryTitle\":\"Nope\",\"originalTitle\":\"Nope\",\"titleType\":\"movie\",\"genres\":[]}"
+ }
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "exec": ["pm.test('status is 403', () => pm.response.to.have.status(403));"]
+ }
+ }
+ ]
+ },
+ {
+ "name": "Admin creates a title",
+ "request": {
+ "method": "POST",
+ "url": "{{baseUrl}}/api/v1/titles",
+ "header": [
+ { "key": "Content-Type", "value": "application/json" },
+ { "key": "Authorization", "value": "Bearer {{adminAccessToken}}" }
+ ],
+ "body": {
+ "mode": "raw",
+ "raw": "{\"primaryTitle\":\"E2E Test Movie\",\"originalTitle\":\"E2E Test Movie\",\"titleType\":\"movie\",\"startYear\":2024,\"genres\":[\"Drama\"]}"
+ }
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "exec": [
+ "pm.test('status is 201', () => pm.response.to.have.status(201));",
+ "pm.collectionVariables.set('createdTitleId', pm.response.json().id);"
+ ]
+ }
+ }
+ ]
+ },
+ {
+ "name": "Stale-version update returns 409",
+ "request": {
+ "method": "PUT",
+ "url": "{{baseUrl}}/api/v1/titles/{{createdTitleId}}",
+ "header": [
+ { "key": "Content-Type", "value": "application/json" },
+ { "key": "Authorization", "value": "Bearer {{adminAccessToken}}" }
+ ],
+ "body": {
+ "mode": "raw",
+ "raw": "{\"primaryTitle\":\"Renamed\",\"originalTitle\":\"Renamed\",\"titleType\":\"movie\",\"startYear\":2024,\"genres\":[],\"version\":99}"
+ }
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "exec": ["pm.test('status is 409', () => pm.response.to.have.status(409));"]
+ }
+ }
+ ]
+ },
+ {
+ "name": "Add the new title to the watchlist",
+ "request": {
+ "method": "POST",
+ "url": "{{baseUrl}}/api/v1/watchlist/items",
+ "header": [
+ { "key": "Content-Type", "value": "application/json" },
+ { "key": "Authorization", "value": "Bearer {{accessToken}}" }
+ ],
+ "body": { "mode": "raw", "raw": "{\"titleId\":\"{{createdTitleId}}\"}" }
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "exec": ["pm.test('status is 201', () => pm.response.to.have.status(201));"]
+ }
+ }
+ ]
+ },
+ {
+ "name": "Review the new title",
+ "request": {
+ "method": "POST",
+ "url": "{{baseUrl}}/api/v1/titles/{{createdTitleId}}/reviews",
+ "header": [
+ { "key": "Content-Type", "value": "application/json" },
+ { "key": "Authorization", "value": "Bearer {{accessToken}}" }
+ ],
+ "body": { "mode": "raw", "raw": "{\"rating\":9,\"body\":\"E2E-tested and great\",\"version\":0}" }
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "exec": ["pm.test('status is 201', () => pm.response.to.have.status(201));"]
+ }
+ }
+ ]
+ },
+ {
+ "name": "Create a private list",
+ "request": {
+ "method": "POST",
+ "url": "{{baseUrl}}/api/v1/lists",
+ "header": [
+ { "key": "Content-Type", "value": "application/json" },
+ { "key": "Authorization", "value": "Bearer {{accessToken}}" }
+ ],
+ "body": { "mode": "raw", "raw": "{\"name\":\"E2E Private List\",\"visibility\":\"PRIVATE\"}" }
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "exec": [
+ "pm.test('status is 201', () => pm.response.to.have.status(201));",
+ "pm.collectionVariables.set('createdListId', pm.response.json().id);"
+ ]
+ }
+ }
+ ]
+ },
+ {
+ "name": "A stranger cannot view the private list",
+ "request": {
+ "method": "GET",
+ "url": "{{baseUrl}}/api/v1/lists/{{createdListId}}",
+ "header": [{ "key": "Authorization", "value": "Bearer {{adminAccessToken}}" }]
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "exec": ["pm.test('status is 404', () => pm.response.to.have.status(404));"]
+ }
+ }
+ ]
+ }
+ ]
+}
diff --git a/imdb/src/main/java/com/ludovictemgoua/imdb/ImdbApplication.java b/imdb/src/main/java/com/ludovictemgoua/imdb/ImdbApplication.java
new file mode 100644
index 0000000..42a4579
--- /dev/null
+++ b/imdb/src/main/java/com/ludovictemgoua/imdb/ImdbApplication.java
@@ -0,0 +1,13 @@
+package com.ludovictemgoua.imdb;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+@SpringBootApplication
+public class ImdbApplication {
+
+ public static void main(String[] args) {
+ SpringApplication.run(ImdbApplication.class, args);
+ }
+
+}
diff --git a/imdb/src/main/java/com/ludovictemgoua/imdb/application/AuthUseCaseImpl.java b/imdb/src/main/java/com/ludovictemgoua/imdb/application/AuthUseCaseImpl.java
new file mode 100644
index 0000000..6a50e5e
--- /dev/null
+++ b/imdb/src/main/java/com/ludovictemgoua/imdb/application/AuthUseCaseImpl.java
@@ -0,0 +1,70 @@
+package com.ludovictemgoua.imdb.application;
+
+import com.ludovictemgoua.imdb.application.contracts.AuthUseCase;
+import com.ludovictemgoua.imdb.application.rest.LoginRequest;
+import com.ludovictemgoua.imdb.application.rest.RegisterRequest;
+import com.ludovictemgoua.imdb.application.rest.TokenPair;
+import com.ludovictemgoua.imdb.domain.exception.ConflictException;
+import com.ludovictemgoua.imdb.domain.exception.ForbiddenException;
+import com.ludovictemgoua.imdb.domain.model.Role;
+import com.ludovictemgoua.imdb.domain.model.User;
+import com.ludovictemgoua.imdb.domain.repository.UserRepository;
+import com.ludovictemgoua.imdb.infrastructure.security.JwtService;
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.stereotype.Service;
+
+import java.util.Set;
+
+@Service
+public class AuthUseCaseImpl implements AuthUseCase {
+
+ private final UserRepository userRepository;
+ private final JwtService jwtService;
+ private final PasswordEncoder passwordEncoder;
+
+ public AuthUseCaseImpl(UserRepository userRepository, JwtService jwtService, PasswordEncoder passwordEncoder) {
+ this.userRepository = userRepository;
+ this.jwtService = jwtService;
+ this.passwordEncoder = passwordEncoder;
+ }
+
+ @Override
+ public TokenPair register(RegisterRequest request) {
+ if (userRepository.existsByEmail(request.email())) {
+ throw new ConflictException("An account with this email already exists");
+ }
+ String hash = passwordEncoder.encode(request.password());
+ User user = userRepository.insert(request.email(), hash, request.displayName(), Role.USER);
+ return issueTokens(user);
+ }
+
+ @Override
+ public TokenPair login(LoginRequest request) {
+ User user = userRepository.findByEmail(request.email())
+ .orElseThrow(() -> new ForbiddenException("Invalid email or password"));
+ if (!passwordEncoder.matches(request.password(), user.passwordHash())) {
+ throw new ForbiddenException("Invalid email or password");
+ }
+ return issueTokens(user);
+ }
+
+ @Override
+ public TokenPair refresh(String refreshToken) {
+ // Without the refreshToken() filter, a caller's own (short-lived) access token would also
+ // parse successfully here and mint a brand new token pair, extending their session past the
+ // access token's intended TTL without ever holding a real refresh token. Found by Copilot
+ // code review - the mirror image of the same gap in JwtAuthenticationFilter.
+ var parsed = jwtService.parse(refreshToken)
+ .filter(JwtService.Parsed::refreshToken)
+ .orElseThrow(() -> new ForbiddenException("Invalid or expired refresh token"));
+ User user = userRepository.findById(parsed.userId())
+ .orElseThrow(() -> new ForbiddenException("Invalid or expired refresh token"));
+ return issueTokens(user);
+ }
+
+ private TokenPair issueTokens(User user) {
+ String access = jwtService.issueAccessToken(user.id(), Set.of(user.role()));
+ String refresh = jwtService.issueRefreshToken(user.id());
+ return new TokenPair(access, refresh);
+ }
+}
diff --git a/imdb/src/main/java/com/ludovictemgoua/imdb/application/ListUseCaseImpl.java b/imdb/src/main/java/com/ludovictemgoua/imdb/application/ListUseCaseImpl.java
new file mode 100644
index 0000000..1af17a8
--- /dev/null
+++ b/imdb/src/main/java/com/ludovictemgoua/imdb/application/ListUseCaseImpl.java
@@ -0,0 +1,101 @@
+package com.ludovictemgoua.imdb.application;
+
+import com.ludovictemgoua.imdb.application.contracts.ListUseCase;
+import com.ludovictemgoua.imdb.application.rest.CreateListRequest;
+import com.ludovictemgoua.imdb.application.rest.UpdateListRequest;
+import com.ludovictemgoua.imdb.domain.exception.ConflictException;
+import com.ludovictemgoua.imdb.domain.exception.ForbiddenException;
+import com.ludovictemgoua.imdb.domain.exception.NotFoundException;
+import com.ludovictemgoua.imdb.domain.model.CustomList;
+import com.ludovictemgoua.imdb.domain.model.CustomListView;
+import com.ludovictemgoua.imdb.domain.model.PagedResult;
+import com.ludovictemgoua.imdb.domain.model.Visibility;
+import com.ludovictemgoua.imdb.domain.repository.CustomListRepository;
+import com.ludovictemgoua.imdb.domain.repository.WriteResult;
+import com.ludovictemgoua.imdb.utils.ImdbIds;
+import org.springframework.stereotype.Service;
+
+import java.util.Optional;
+
+@Service
+public class ListUseCaseImpl implements ListUseCase {
+
+ private final CustomListRepository customListRepository;
+
+ public ListUseCaseImpl(CustomListRepository customListRepository) {
+ this.customListRepository = customListRepository;
+ }
+
+ @Override
+ public CustomList create(int userId, CreateListRequest request) {
+ return customListRepository.insert(userId, request.name(), request.visibility());
+ }
+
+ @Override
+ public CustomListView getById(int listId, Optional viewerUserId) {
+ CustomListView list = findOrThrow(listId);
+ boolean isOwner = viewerUserId.isPresent() && viewerUserId.get() == list.userId();
+ if (list.visibility() == Visibility.PRIVATE && !isOwner) {
+ throw new NotFoundException("No list with id " + listId);
+ }
+ return list;
+ }
+
+ @Override
+ public PagedResult getMine(int userId, int page, int size) {
+ return customListRepository.findByUser(userId, page, size);
+ }
+
+ @Override
+ public PagedResult getPublic(int page, int size) {
+ return customListRepository.findPublic(page, size);
+ }
+
+ @Override
+ public void update(int listId, int userId, UpdateListRequest request) {
+ CustomListView list = requireOwner(listId, userId);
+ WriteResult result = customListRepository.update(listId, request.name(), request.visibility(), request.version());
+ if (result == WriteResult.VERSION_CONFLICT) {
+ throw new ConflictException("List " + list.id() + " was modified concurrently - refresh and retry");
+ }
+ }
+
+ @Override
+ public void delete(int listId, int userId, int expectedVersion) {
+ requireOwner(listId, userId);
+ WriteResult result = customListRepository.softDelete(listId, expectedVersion);
+ if (result == WriteResult.VERSION_CONFLICT) {
+ throw new ConflictException("List " + listId + " was modified concurrently - refresh and retry");
+ }
+ }
+
+ @Override
+ public void addItem(int listId, int userId, String titleId) {
+ requireOwner(listId, userId);
+ customListRepository.addItem(listId, ImdbIds.parseTitleId(titleId));
+ }
+
+ @Override
+ public void removeItem(int listId, int userId, String titleId) {
+ requireOwner(listId, userId);
+ customListRepository.removeItem(listId, ImdbIds.parseTitleId(titleId));
+ }
+
+ private CustomListView findOrThrow(int listId) {
+ return customListRepository.findById(listId)
+ .orElseThrow(() -> new NotFoundException("No list with id " + listId));
+ }
+
+ // A non-owner writing to a PRIVATE list gets 404 (existence hidden, same as a read); a non-owner
+ // writing to a PUBLIC list gets 403 (existence is already visible, the action is what's denied).
+ private CustomListView requireOwner(int listId, int userId) {
+ CustomListView list = findOrThrow(listId);
+ if (list.userId() == userId) {
+ return list;
+ }
+ if (list.visibility() == Visibility.PRIVATE) {
+ throw new NotFoundException("No list with id " + listId);
+ }
+ throw new ForbiddenException("You do not own list " + listId);
+ }
+}
diff --git a/imdb/src/main/java/com/ludovictemgoua/imdb/application/PersonAdminUseCaseImpl.java b/imdb/src/main/java/com/ludovictemgoua/imdb/application/PersonAdminUseCaseImpl.java
new file mode 100644
index 0000000..2536f11
--- /dev/null
+++ b/imdb/src/main/java/com/ludovictemgoua/imdb/application/PersonAdminUseCaseImpl.java
@@ -0,0 +1,65 @@
+package com.ludovictemgoua.imdb.application;
+
+import com.ludovictemgoua.imdb.application.contracts.PersonAdminUseCase;
+import com.ludovictemgoua.imdb.application.rest.CreatePersonRequest;
+import com.ludovictemgoua.imdb.application.rest.PatchPersonRequest;
+import com.ludovictemgoua.imdb.application.rest.UpdatePersonRequest;
+import com.ludovictemgoua.imdb.domain.exception.ConflictException;
+import com.ludovictemgoua.imdb.domain.exception.NotFoundException;
+import com.ludovictemgoua.imdb.domain.model.PersonCore;
+import com.ludovictemgoua.imdb.domain.repository.PersonRepository;
+import com.ludovictemgoua.imdb.domain.repository.WriteResult;
+import com.ludovictemgoua.imdb.utils.ImdbIds;
+import org.springframework.stereotype.Service;
+
+@Service
+public class PersonAdminUseCaseImpl implements PersonAdminUseCase {
+
+ private final PersonRepository personRepository;
+
+ public PersonAdminUseCaseImpl(PersonRepository personRepository) {
+ this.personRepository = personRepository;
+ }
+
+ @Override
+ public PersonCore create(CreatePersonRequest request) {
+ return personRepository.insertPerson(
+ request.primaryName(), request.birthYear(), request.deathYear(), request.primaryProfession());
+ }
+
+ @Override
+ public PersonCore update(String personId, UpdatePersonRequest request) {
+ int nconst = ImdbIds.parsePersonId(personId);
+ handle(personRepository.updatePerson(nconst, request.primaryName(), request.birthYear(),
+ request.deathYear(), request.primaryProfession(), request.version()), personId);
+ return personRepository.findCore(nconst).orElseThrow();
+ }
+
+ @Override
+ public PersonCore patch(String personId, PatchPersonRequest request) {
+ int nconst = ImdbIds.parsePersonId(personId);
+ PersonCore current = personRepository.findCore(nconst)
+ .orElseThrow(() -> new NotFoundException("No person with id " + personId));
+ handle(personRepository.updatePerson(nconst,
+ request.primaryName() != null ? request.primaryName() : current.primaryName(),
+ request.birthYear() != null ? request.birthYear() : current.birthYear(),
+ request.deathYear() != null ? request.deathYear() : current.deathYear(),
+ request.primaryProfession() != null ? request.primaryProfession() : current.primaryProfession(),
+ request.version()), personId);
+ return personRepository.findCore(nconst).orElseThrow();
+ }
+
+ @Override
+ public void delete(String personId) {
+ handle(personRepository.softDeletePerson(ImdbIds.parsePersonId(personId)), personId);
+ }
+
+ private static void handle(WriteResult result, String personId) {
+ switch (result) {
+ case NOT_FOUND -> throw new NotFoundException("No person with id " + personId);
+ case VERSION_CONFLICT -> throw new ConflictException(
+ "Person " + personId + " was modified by someone else - refresh and retry");
+ case SUCCESS -> { }
+ }
+ }
+}
diff --git a/imdb/src/main/java/com/ludovictemgoua/imdb/application/PersonResolutionUseCase.java b/imdb/src/main/java/com/ludovictemgoua/imdb/application/PersonResolutionUseCase.java
new file mode 100644
index 0000000..0a7c085
--- /dev/null
+++ b/imdb/src/main/java/com/ludovictemgoua/imdb/application/PersonResolutionUseCase.java
@@ -0,0 +1,41 @@
+package com.ludovictemgoua.imdb.application;
+
+import com.ludovictemgoua.imdb.domain.model.PersonCandidate;
+import com.ludovictemgoua.imdb.domain.model.PersonResolution;
+import com.ludovictemgoua.imdb.domain.repository.PersonRepository;
+import com.ludovictemgoua.imdb.utils.ImdbIds;
+import org.springframework.stereotype.Service;
+
+import java.util.List;
+
+// Not an interface: it's an internal collaborator used only by SixDegreesUseCaseImpl, not a seam any
+// controller or infrastructure decorator needs to substitute. Interfaces earn their keep at the
+// boundaries that actually get swapped or mocked in isolation (repositories, the top-level use cases
+// below) - not by default on every class.
+@Service
+class PersonResolutionUseCase {
+
+ private final PersonRepository personRepository;
+
+ PersonResolutionUseCase(PersonRepository personRepository) {
+ this.personRepository = personRepository;
+ }
+
+ PersonResolution resolve(String query) {
+ if (query.startsWith("nm")) {
+ int nconst = ImdbIds.parsePersonId(query);
+ return personRepository.findNameById(nconst)
+ .map(name -> new PersonResolution.Resolved(nconst, name))
+ .orElseGet(PersonResolution.NotFound::new);
+ }
+ List candidates = personRepository.findByName(query);
+ return switch (candidates.size()) {
+ case 0 -> new PersonResolution.NotFound();
+ case 1 -> {
+ PersonCandidate only = candidates.get(0);
+ yield new PersonResolution.Resolved(ImdbIds.parsePersonId(only.id()), only.name());
+ }
+ default -> new PersonResolution.Ambiguous(candidates);
+ };
+ }
+}
diff --git a/imdb/src/main/java/com/ludovictemgoua/imdb/application/ReviewUseCaseImpl.java b/imdb/src/main/java/com/ludovictemgoua/imdb/application/ReviewUseCaseImpl.java
new file mode 100644
index 0000000..4468f9a
--- /dev/null
+++ b/imdb/src/main/java/com/ludovictemgoua/imdb/application/ReviewUseCaseImpl.java
@@ -0,0 +1,66 @@
+package com.ludovictemgoua.imdb.application;
+
+import com.ludovictemgoua.imdb.application.contracts.ReviewUseCase;
+import com.ludovictemgoua.imdb.application.rest.ReviewRequest;
+import com.ludovictemgoua.imdb.domain.exception.ConflictException;
+import com.ludovictemgoua.imdb.domain.exception.NotFoundException;
+import com.ludovictemgoua.imdb.domain.model.PagedResult;
+import com.ludovictemgoua.imdb.domain.model.Review;
+import com.ludovictemgoua.imdb.domain.repository.ReviewRepository;
+import com.ludovictemgoua.imdb.domain.repository.WriteResult;
+import com.ludovictemgoua.imdb.utils.ImdbIds;
+import org.springframework.stereotype.Service;
+
+@Service
+public class ReviewUseCaseImpl implements ReviewUseCase {
+
+ private final ReviewRepository reviewRepository;
+
+ public ReviewUseCaseImpl(ReviewRepository reviewRepository) {
+ this.reviewRepository = reviewRepository;
+ }
+
+ @Override
+ public Review create(int userId, String titleId, ReviewRequest request) {
+ int tconst = ImdbIds.parseTitleId(titleId);
+ if (reviewRepository.findByUserAndTitle(userId, tconst).isPresent()) {
+ throw new ConflictException("You already reviewed this title - use PUT to update it");
+ }
+ return reviewRepository.insert(userId, tconst, request.rating(), request.body());
+ }
+
+ @Override
+ public Review getMine(int userId, String titleId) {
+ return reviewRepository.findByUserAndTitle(userId, ImdbIds.parseTitleId(titleId))
+ .orElseThrow(() -> new NotFoundException("You haven't reviewed this title"));
+ }
+
+ @Override
+ public Review update(int userId, String titleId, ReviewRequest request) {
+ Review existing = getMine(userId, titleId);
+ WriteResult result = reviewRepository.update(existing.id(), request.rating(), request.body(), request.version());
+ if (result == WriteResult.VERSION_CONFLICT) {
+ throw new ConflictException("Your review was modified concurrently - refresh and retry");
+ }
+ return getMine(userId, titleId);
+ }
+
+ @Override
+ public void delete(int userId, String titleId, int expectedVersion) {
+ Review existing = getMine(userId, titleId);
+ WriteResult result = reviewRepository.softDelete(existing.id(), expectedVersion);
+ if (result == WriteResult.VERSION_CONFLICT) {
+ throw new ConflictException("Your review was modified concurrently - refresh and retry");
+ }
+ }
+
+ @Override
+ public PagedResult listForTitle(String titleId, int page, int size) {
+ return reviewRepository.findByTitle(ImdbIds.parseTitleId(titleId), page, size);
+ }
+
+ @Override
+ public PagedResult listForUser(int userId, int page, int size) {
+ return reviewRepository.findByUser(userId, page, size);
+ }
+}
diff --git a/imdb/src/main/java/com/ludovictemgoua/imdb/application/SixDegreesUseCaseImpl.java b/imdb/src/main/java/com/ludovictemgoua/imdb/application/SixDegreesUseCaseImpl.java
new file mode 100644
index 0000000..83a16bf
--- /dev/null
+++ b/imdb/src/main/java/com/ludovictemgoua/imdb/application/SixDegreesUseCaseImpl.java
@@ -0,0 +1,109 @@
+package com.ludovictemgoua.imdb.application;
+
+import com.ludovictemgoua.imdb.application.contracts.SixDegreesOutcome;
+import com.ludovictemgoua.imdb.application.contracts.SixDegreesUseCase;
+import com.ludovictemgoua.imdb.application.rest.PathStep;
+import com.ludovictemgoua.imdb.application.rest.PersonRef;
+import com.ludovictemgoua.imdb.application.rest.SixDegreesResult;
+import com.ludovictemgoua.imdb.domain.model.GraphPath;
+import com.ludovictemgoua.imdb.domain.model.PersonResolution;
+import com.ludovictemgoua.imdb.domain.model.SharedTitle;
+import com.ludovictemgoua.imdb.domain.repository.CoStarGraphRepository;
+import com.ludovictemgoua.imdb.domain.repository.PersonRepository;
+import com.ludovictemgoua.imdb.domain.repository.TitleRepository;
+import com.ludovictemgoua.imdb.utils.ImdbIds;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Service;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+@Service
+public class SixDegreesUseCaseImpl implements SixDegreesUseCase {
+
+ private static final Logger log = LoggerFactory.getLogger(SixDegreesUseCaseImpl.class);
+
+ private final PersonResolutionUseCase personResolution;
+ private final CoStarGraphRepository graphRepository;
+ private final PersonRepository personRepository;
+ private final TitleRepository titleRepository;
+
+ public SixDegreesUseCaseImpl(PersonResolutionUseCase personResolution, CoStarGraphRepository graphRepository,
+ PersonRepository personRepository, TitleRepository titleRepository) {
+ this.personResolution = personResolution;
+ this.graphRepository = graphRepository;
+ this.personRepository = personRepository;
+ this.titleRepository = titleRepository;
+ }
+
+ @Override
+ public SixDegreesOutcome compute(String queryA, String queryB, int maxDegree) {
+ PersonResolution resolvedA = personResolution.resolve(queryA);
+ if (resolvedA instanceof PersonResolution.Ambiguous a) {
+ log.info("person resolution ambiguous: query={} candidateCount={}", queryA, a.candidates().size());
+ return new SixDegreesOutcome.Ambiguous(queryA, a.candidates());
+ }
+ if (resolvedA instanceof PersonResolution.NotFound) {
+ log.info("person resolution not found: query={}", queryA);
+ return new SixDegreesOutcome.PersonNotFound(queryA);
+ }
+ PersonResolution resolvedB = personResolution.resolve(queryB);
+ if (resolvedB instanceof PersonResolution.Ambiguous b) {
+ log.info("person resolution ambiguous: query={} candidateCount={}", queryB, b.candidates().size());
+ return new SixDegreesOutcome.Ambiguous(queryB, b.candidates());
+ }
+ if (resolvedB instanceof PersonResolution.NotFound) {
+ log.info("person resolution not found: query={}", queryB);
+ return new SixDegreesOutcome.PersonNotFound(queryB);
+ }
+
+ var a = (PersonResolution.Resolved) resolvedA;
+ var b = (PersonResolution.Resolved) resolvedB;
+ PersonRef personA = new PersonRef(ImdbIds.formatPersonId(a.nconst()), a.name());
+ PersonRef personB = new PersonRef(ImdbIds.formatPersonId(b.nconst()), b.name());
+
+ if (a.nconst() == b.nconst()) {
+ PathStep onlyStep = new PathStep(personA.id(), personA.name(), null);
+ return new SixDegreesOutcome.Found(
+ new SixDegreesResult(personA, personB, 0, true, List.of(onlyStep)));
+ }
+
+ // Timed explicitly (not just left to the request-level duration in RequestLoggingFilter) -
+ // this is the one query in the whole app whose cost varies by graph shape rather than being
+ // a bounded index lookup (LLD §5), so its own duration is worth a dedicated log line to spot
+ // a slow pair without having to cross-reference the trace.
+ long startMillis = System.currentTimeMillis();
+ Optional match = graphRepository.findShortestPath(a.nconst(), b.nconst());
+ long durationMs = System.currentTimeMillis() - startMillis;
+
+ if (match.isEmpty()) {
+ log.info("six degrees not found: personA={} personB={} durationMs={}",
+ personA.id(), personB.id(), durationMs);
+ return new SixDegreesOutcome.Found(
+ new SixDegreesResult(personA, personB, null, false, List.of()));
+ }
+
+ GraphPath path = match.get();
+ boolean withinMax = path.degree() <= maxDegree;
+ log.info("six degrees computed: personA={} personB={} degree={} withinMax={} durationMs={}",
+ personA.id(), personB.id(), path.degree(), withinMax, durationMs);
+ List steps = withinMax ? buildPath(path.personIds()) : List.of();
+ return new SixDegreesOutcome.Found(
+ new SixDegreesResult(personA, personB, path.degree(), withinMax, steps));
+ }
+
+ private List buildPath(List nconsts) {
+ Map names = personRepository.findNamesByIds(nconsts);
+ List steps = new ArrayList<>();
+ for (int i = 0; i < nconsts.size(); i++) {
+ int nconst = nconsts.get(i);
+ SharedTitle sharedTitle = i == 0 ? null
+ : titleRepository.findAnyCommonTitle(nconsts.get(i - 1), nconst).orElse(null);
+ steps.add(new PathStep(ImdbIds.formatPersonId(nconst), names.get(nconst), sharedTitle));
+ }
+ return steps;
+ }
+}
diff --git a/imdb/src/main/java/com/ludovictemgoua/imdb/application/TitleAdminUseCaseImpl.java b/imdb/src/main/java/com/ludovictemgoua/imdb/application/TitleAdminUseCaseImpl.java
new file mode 100644
index 0000000..e0e9ec2
--- /dev/null
+++ b/imdb/src/main/java/com/ludovictemgoua/imdb/application/TitleAdminUseCaseImpl.java
@@ -0,0 +1,119 @@
+package com.ludovictemgoua.imdb.application;
+
+import com.ludovictemgoua.imdb.application.contracts.TitleAdminUseCase;
+import com.ludovictemgoua.imdb.application.rest.*;
+import com.ludovictemgoua.imdb.domain.exception.ConflictException;
+import com.ludovictemgoua.imdb.domain.exception.NotFoundException;
+import com.ludovictemgoua.imdb.domain.model.PrincipalCredit;
+import com.ludovictemgoua.imdb.domain.model.TitleCore;
+import com.ludovictemgoua.imdb.domain.repository.TitleRepository;
+import com.ludovictemgoua.imdb.domain.repository.WriteResult;
+import com.ludovictemgoua.imdb.utils.ImdbIds;
+import org.springframework.stereotype.Service;
+
+import java.util.List;
+import java.util.stream.Collectors;
+
+@Service
+public class TitleAdminUseCaseImpl implements TitleAdminUseCase {
+
+ private final TitleRepository titleRepository;
+
+ public TitleAdminUseCaseImpl(TitleRepository titleRepository) {
+ this.titleRepository = titleRepository;
+ }
+
+ @Override
+ public TitleCore create(CreateTitleRequest request) {
+ return titleRepository.insertTitle(request.primaryTitle(), request.originalTitle(), request.titleType(),
+ request.startYear(), request.endYear(), request.runtimeMinutes(), request.genres());
+ }
+
+ @Override
+ public TitleCore update(String titleId, UpdateTitleRequest request) {
+ int tconst = ImdbIds.parseTitleId(titleId);
+ handle(titleRepository.updateTitle(tconst, request.primaryTitle(), request.originalTitle(),
+ request.titleType(), request.startYear(), request.endYear(), request.runtimeMinutes(),
+ request.genres(), request.version()), titleId);
+ return titleRepository.findCore(tconst).orElseThrow();
+ }
+
+ @Override
+ public TitleCore patch(String titleId, PatchTitleRequest request) {
+ int tconst = ImdbIds.parseTitleId(titleId);
+ TitleCore current = titleRepository.findCore(tconst)
+ .orElseThrow(() -> new NotFoundException("No title with id " + titleId));
+ handle(titleRepository.updateTitle(tconst,
+ request.primaryTitle() != null ? request.primaryTitle() : current.primaryTitle(),
+ request.originalTitle() != null ? request.originalTitle() : current.originalTitle(),
+ request.titleType() != null ? request.titleType() : current.titleType(),
+ request.startYear() != null ? request.startYear() : current.startYear(),
+ request.endYear() != null ? request.endYear() : current.endYear(),
+ request.runtimeMinutes() != null ? request.runtimeMinutes() : current.runtimeMinutes(),
+ request.genres() != null ? request.genres() : current.genres(),
+ request.version()), titleId);
+ return titleRepository.findCore(tconst).orElseThrow();
+ }
+
+ @Override
+ public void delete(String titleId) {
+ handle(titleRepository.softDeleteTitle(ImdbIds.parseTitleId(titleId)), titleId);
+ }
+
+ @Override
+ public void upsertCrew(String titleId, CrewRequest request) {
+ int tconst = ImdbIds.parseTitleId(titleId);
+ List directorIds = toPersonIds(request.directors());
+ List writerIds = toPersonIds(request.writers());
+ handle(titleRepository.upsertCrew(tconst, directorIds, writerIds), titleId);
+ }
+
+ @Override
+ public void upsertRating(String titleId, RatingRequest request) {
+ handle(titleRepository.upsertRating(ImdbIds.parseTitleId(titleId),
+ request.averageRating(), request.numVotes()), titleId);
+ }
+
+ @Override
+ public void deleteRating(String titleId) {
+ handle(titleRepository.deleteRating(ImdbIds.parseTitleId(titleId)), titleId);
+ }
+
+ @Override
+ public List getAllPrincipals(String titleId) {
+ return titleRepository.findAllPrincipals(ImdbIds.parseTitleId(titleId));
+ }
+
+ @Override
+ public void addPrincipal(String titleId, PrincipalRequest request) {
+ int tconst = ImdbIds.parseTitleId(titleId);
+ handle(titleRepository.insertPrincipal(tconst, ImdbIds.parsePersonId(request.personId()),
+ request.category(), request.job(), request.characters(), request.ordering()), titleId);
+ }
+
+ @Override
+ public void updatePrincipal(String titleId, int ordering, PrincipalRequest request, int expectedVersion) {
+ int tconst = ImdbIds.parseTitleId(titleId);
+ handle(titleRepository.updatePrincipal(tconst, ordering, request.category(), request.job(),
+ request.characters(), expectedVersion), titleId);
+ }
+
+ @Override
+ public void deletePrincipal(String titleId, int ordering) {
+ handle(titleRepository.softDeletePrincipal(ImdbIds.parseTitleId(titleId), ordering), titleId);
+ }
+
+ private static List toPersonIds(List personIds) {
+ return personIds == null ? List.of()
+ : personIds.stream().map(ImdbIds::parsePersonId).collect(Collectors.toList());
+ }
+
+ private static void handle(WriteResult result, String titleId) {
+ switch (result) {
+ case NOT_FOUND -> throw new NotFoundException("No title with id " + titleId);
+ case VERSION_CONFLICT -> throw new ConflictException(
+ "Title " + titleId + " was modified by someone else - refresh and retry");
+ case SUCCESS -> { }
+ }
+ }
+}
diff --git a/imdb/src/main/java/com/ludovictemgoua/imdb/application/TitleDetailUseCaseImpl.java b/imdb/src/main/java/com/ludovictemgoua/imdb/application/TitleDetailUseCaseImpl.java
new file mode 100644
index 0000000..c96c422
--- /dev/null
+++ b/imdb/src/main/java/com/ludovictemgoua/imdb/application/TitleDetailUseCaseImpl.java
@@ -0,0 +1,43 @@
+package com.ludovictemgoua.imdb.application;
+
+import com.ludovictemgoua.imdb.application.contracts.TitleDetailUseCase;
+import com.ludovictemgoua.imdb.domain.exception.NotFoundException;
+import com.ludovictemgoua.imdb.domain.model.RatingView;
+import com.ludovictemgoua.imdb.domain.model.TitleDetail;
+import com.ludovictemgoua.imdb.domain.repository.ReviewRepository;
+import com.ludovictemgoua.imdb.domain.repository.TitleRepository;
+import com.ludovictemgoua.imdb.utils.ImdbIds;
+import org.springframework.stereotype.Service;
+
+@Service
+public class TitleDetailUseCaseImpl implements TitleDetailUseCase {
+
+ private static final int CAST_LIMIT = 20;
+
+ private final TitleRepository titleRepository;
+ private final ReviewRepository reviewRepository;
+
+ public TitleDetailUseCaseImpl(TitleRepository titleRepository, ReviewRepository reviewRepository) {
+ this.titleRepository = titleRepository;
+ this.reviewRepository = reviewRepository;
+ }
+
+ @Override
+ public TitleDetail getDetail(String titleId) {
+ int tconst = ImdbIds.parseTitleId(titleId);
+ var core = titleRepository.findCore(tconst)
+ .orElseThrow(() -> new NotFoundException("No title with id " + titleId));
+ var directors = titleRepository.findDirectors(tconst);
+ var writers = titleRepository.findWriters(tconst);
+ var cast = titleRepository.findTopCast(tconst, CAST_LIMIT);
+ int castTotal = titleRepository.countCast(tconst);
+ var userRating = reviewRepository.aggregateForTitle(tconst);
+ return new TitleDetail(
+ core.id(), core.primaryTitle(), core.originalTitle(), core.titleType(),
+ core.startYear(), core.endYear(), core.runtimeMinutes(), core.genres(),
+ new RatingView(core.averageRating() == null ? 0 : core.averageRating(),
+ core.numVotes() == null ? 0 : core.numVotes()),
+ directors, writers, cast, castTotal,
+ userRating.average(), userRating.count());
+ }
+}
diff --git a/imdb/src/main/java/com/ludovictemgoua/imdb/application/TitleSearchUseCaseImpl.java b/imdb/src/main/java/com/ludovictemgoua/imdb/application/TitleSearchUseCaseImpl.java
new file mode 100644
index 0000000..7bf68db
--- /dev/null
+++ b/imdb/src/main/java/com/ludovictemgoua/imdb/application/TitleSearchUseCaseImpl.java
@@ -0,0 +1,22 @@
+package com.ludovictemgoua.imdb.application;
+
+import com.ludovictemgoua.imdb.application.contracts.TitleSearchUseCase;
+import com.ludovictemgoua.imdb.domain.model.PagedResult;
+import com.ludovictemgoua.imdb.domain.model.TitleSummary;
+import com.ludovictemgoua.imdb.domain.repository.TitleRepository;
+import org.springframework.stereotype.Service;
+
+@Service
+public class TitleSearchUseCaseImpl implements TitleSearchUseCase {
+
+ private final TitleRepository titleRepository;
+
+ public TitleSearchUseCaseImpl(TitleRepository titleRepository) {
+ this.titleRepository = titleRepository;
+ }
+
+ @Override
+ public PagedResult search(String query, int page, int size) {
+ return titleRepository.search(query, page, size);
+ }
+}
diff --git a/imdb/src/main/java/com/ludovictemgoua/imdb/application/TopRatedUseCaseImpl.java b/imdb/src/main/java/com/ludovictemgoua/imdb/application/TopRatedUseCaseImpl.java
new file mode 100644
index 0000000..a600578
--- /dev/null
+++ b/imdb/src/main/java/com/ludovictemgoua/imdb/application/TopRatedUseCaseImpl.java
@@ -0,0 +1,28 @@
+package com.ludovictemgoua.imdb.application;
+
+import com.ludovictemgoua.imdb.application.contracts.TopRatedUseCase;
+import com.ludovictemgoua.imdb.domain.model.GenreTopRatedItem;
+import com.ludovictemgoua.imdb.domain.repository.TitleRepository;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+
+import java.util.List;
+
+@Service
+public class TopRatedUseCaseImpl implements TopRatedUseCase {
+
+ private final TitleRepository titleRepository;
+ private final int defaultMinVotes;
+
+ public TopRatedUseCaseImpl(TitleRepository titleRepository,
+ @Value("${top-rated.default-min-votes}") int defaultMinVotes) {
+ this.titleRepository = titleRepository;
+ this.defaultMinVotes = defaultMinVotes;
+ }
+
+ @Override
+ public List findTopRated(String genre, int limit, Integer minVotes) {
+ int effectiveMinVotes = minVotes == null ? defaultMinVotes : minVotes;
+ return titleRepository.findTopRated(genre, limit, effectiveMinVotes);
+ }
+}
diff --git a/imdb/src/main/java/com/ludovictemgoua/imdb/application/UserUseCaseImpl.java b/imdb/src/main/java/com/ludovictemgoua/imdb/application/UserUseCaseImpl.java
new file mode 100644
index 0000000..bc043a9
--- /dev/null
+++ b/imdb/src/main/java/com/ludovictemgoua/imdb/application/UserUseCaseImpl.java
@@ -0,0 +1,78 @@
+package com.ludovictemgoua.imdb.application;
+
+import com.ludovictemgoua.imdb.application.contracts.UserUseCase;
+import com.ludovictemgoua.imdb.application.rest.UpdateProfileRequest;
+import com.ludovictemgoua.imdb.domain.exception.ConflictException;
+import com.ludovictemgoua.imdb.domain.exception.NotFoundException;
+import com.ludovictemgoua.imdb.domain.model.PagedResult;
+import com.ludovictemgoua.imdb.domain.model.PublicUserProfile;
+import com.ludovictemgoua.imdb.domain.model.Role;
+import com.ludovictemgoua.imdb.domain.model.User;
+import com.ludovictemgoua.imdb.domain.model.UserProfile;
+import com.ludovictemgoua.imdb.domain.repository.UserRepository;
+import com.ludovictemgoua.imdb.domain.repository.WriteResult;
+import org.springframework.stereotype.Service;
+
+@Service
+public class UserUseCaseImpl implements UserUseCase {
+
+ private final UserRepository userRepository;
+
+ public UserUseCaseImpl(UserRepository userRepository) {
+ this.userRepository = userRepository;
+ }
+
+ @Override
+ public UserProfile getOwnProfile(int userId) {
+ return toProfile(findOrThrow(userId));
+ }
+
+ @Override
+ public UserProfile updateOwnProfile(int userId, UpdateProfileRequest request) {
+ WriteResult result = userRepository.updateProfile(userId, request.displayName(), request.bio(), request.version());
+ switch (result) {
+ case NOT_FOUND -> throw new NotFoundException("No user with id " + userId);
+ case VERSION_CONFLICT -> throw new ConflictException("Your profile was modified concurrently - refresh and retry");
+ case SUCCESS -> { }
+ }
+ return getOwnProfile(userId);
+ }
+
+ @Override
+ public void deleteOwnAccount(int userId) {
+ userRepository.softDelete(userId);
+ }
+
+ @Override
+ public PublicUserProfile getPublicProfile(int userId) {
+ User user = findOrThrow(userId);
+ return new PublicUserProfile(user.id(), user.displayName());
+ }
+
+ @Override
+ public PagedResult listAll(int page, int size) {
+ PagedResult users = userRepository.findAll(page, size);
+ return new PagedResult<>(users.content().stream().map(UserUseCaseImpl::toProfile).toList(),
+ users.totalElements(), users.page(), users.size());
+ }
+
+ @Override
+ public void updateRole(int userId, Role role) {
+ findOrThrow(userId);
+ userRepository.updateRole(userId, role);
+ }
+
+ @Override
+ public void deleteAccount(int userId) {
+ findOrThrow(userId);
+ userRepository.softDelete(userId);
+ }
+
+ private User findOrThrow(int userId) {
+ return userRepository.findById(userId).orElseThrow(() -> new NotFoundException("No user with id " + userId));
+ }
+
+ private static UserProfile toProfile(User user) {
+ return new UserProfile(user.id(), user.email(), user.displayName(), user.bio(), user.role(), user.version());
+ }
+}
diff --git a/imdb/src/main/java/com/ludovictemgoua/imdb/application/WatchlistUseCaseImpl.java b/imdb/src/main/java/com/ludovictemgoua/imdb/application/WatchlistUseCaseImpl.java
new file mode 100644
index 0000000..5f5a4f0
--- /dev/null
+++ b/imdb/src/main/java/com/ludovictemgoua/imdb/application/WatchlistUseCaseImpl.java
@@ -0,0 +1,60 @@
+package com.ludovictemgoua.imdb.application;
+
+import com.ludovictemgoua.imdb.application.contracts.WatchlistUseCase;
+import com.ludovictemgoua.imdb.domain.exception.ConflictException;
+import com.ludovictemgoua.imdb.domain.exception.NotFoundException;
+import com.ludovictemgoua.imdb.domain.model.Visibility;
+import com.ludovictemgoua.imdb.domain.model.WatchlistView;
+import com.ludovictemgoua.imdb.domain.repository.WatchlistRepository;
+import com.ludovictemgoua.imdb.domain.repository.WriteResult;
+import com.ludovictemgoua.imdb.utils.ImdbIds;
+import org.springframework.stereotype.Service;
+
+import java.util.Optional;
+
+@Service
+public class WatchlistUseCaseImpl implements WatchlistUseCase {
+
+ private final WatchlistRepository watchlistRepository;
+
+ public WatchlistUseCaseImpl(WatchlistRepository watchlistRepository) {
+ this.watchlistRepository = watchlistRepository;
+ }
+
+ @Override
+ public WatchlistView getOwn(int userId) {
+ return watchlistRepository.findOrCreateByUserId(userId);
+ }
+
+ @Override
+ public WatchlistView getForUser(Optional viewerUserId, int targetUserId) {
+ WatchlistView view = watchlistRepository.findByUserId(targetUserId)
+ .orElseThrow(() -> new NotFoundException("No watchlist for that user"));
+ boolean isOwner = viewerUserId.isPresent() && viewerUserId.get() == targetUserId;
+ if (view.visibility() == Visibility.PRIVATE && !isOwner) {
+ throw new NotFoundException("No watchlist for that user");
+ }
+ return view;
+ }
+
+ @Override
+ public void addItem(int userId, String titleId) {
+ var watchlist = watchlistRepository.findOrCreateByUserId(userId);
+ watchlistRepository.addItem(watchlist.id(), ImdbIds.parseTitleId(titleId));
+ }
+
+ @Override
+ public void removeItem(int userId, String titleId) {
+ var watchlist = watchlistRepository.findOrCreateByUserId(userId);
+ watchlistRepository.removeItem(watchlist.id(), ImdbIds.parseTitleId(titleId));
+ }
+
+ @Override
+ public void updateVisibility(int userId, Visibility visibility) {
+ var watchlist = watchlistRepository.findOrCreateByUserId(userId);
+ var result = watchlistRepository.updateVisibility(watchlist.id(), visibility, watchlist.version());
+ if (result == WriteResult.VERSION_CONFLICT) {
+ throw new ConflictException("Watchlist was modified concurrently - retry");
+ }
+ }
+}
diff --git a/imdb/src/main/java/com/ludovictemgoua/imdb/application/contracts/AuthUseCase.java b/imdb/src/main/java/com/ludovictemgoua/imdb/application/contracts/AuthUseCase.java
new file mode 100644
index 0000000..395cdcc
--- /dev/null
+++ b/imdb/src/main/java/com/ludovictemgoua/imdb/application/contracts/AuthUseCase.java
@@ -0,0 +1,14 @@
+package com.ludovictemgoua.imdb.application.contracts;
+
+import com.ludovictemgoua.imdb.application.rest.LoginRequest;
+import com.ludovictemgoua.imdb.application.rest.RegisterRequest;
+import com.ludovictemgoua.imdb.application.rest.TokenPair;
+
+public interface AuthUseCase {
+
+ TokenPair register(RegisterRequest request);
+
+ TokenPair login(LoginRequest request);
+
+ TokenPair refresh(String refreshToken);
+}
diff --git a/imdb/src/main/java/com/ludovictemgoua/imdb/application/contracts/ListUseCase.java b/imdb/src/main/java/com/ludovictemgoua/imdb/application/contracts/ListUseCase.java
new file mode 100644
index 0000000..89b2a2f
--- /dev/null
+++ b/imdb/src/main/java/com/ludovictemgoua/imdb/application/contracts/ListUseCase.java
@@ -0,0 +1,28 @@
+package com.ludovictemgoua.imdb.application.contracts;
+
+import com.ludovictemgoua.imdb.application.rest.CreateListRequest;
+import com.ludovictemgoua.imdb.application.rest.UpdateListRequest;
+import com.ludovictemgoua.imdb.domain.model.CustomList;
+import com.ludovictemgoua.imdb.domain.model.CustomListView;
+import com.ludovictemgoua.imdb.domain.model.PagedResult;
+
+import java.util.Optional;
+
+public interface ListUseCase {
+
+ CustomList create(int userId, CreateListRequest request);
+
+ CustomListView getById(int listId, Optional viewerUserId);
+
+ PagedResult getMine(int userId, int page, int size);
+
+ PagedResult getPublic(int page, int size);
+
+ void update(int listId, int userId, UpdateListRequest request);
+
+ void delete(int listId, int userId, int expectedVersion);
+
+ void addItem(int listId, int userId, String titleId);
+
+ void removeItem(int listId, int userId, String titleId);
+}
diff --git a/imdb/src/main/java/com/ludovictemgoua/imdb/application/contracts/PersonAdminUseCase.java b/imdb/src/main/java/com/ludovictemgoua/imdb/application/contracts/PersonAdminUseCase.java
new file mode 100644
index 0000000..b6a0163
--- /dev/null
+++ b/imdb/src/main/java/com/ludovictemgoua/imdb/application/contracts/PersonAdminUseCase.java
@@ -0,0 +1,17 @@
+package com.ludovictemgoua.imdb.application.contracts;
+
+import com.ludovictemgoua.imdb.application.rest.CreatePersonRequest;
+import com.ludovictemgoua.imdb.application.rest.PatchPersonRequest;
+import com.ludovictemgoua.imdb.application.rest.UpdatePersonRequest;
+import com.ludovictemgoua.imdb.domain.model.PersonCore;
+
+public interface PersonAdminUseCase {
+
+ PersonCore create(CreatePersonRequest request);
+
+ PersonCore update(String personId, UpdatePersonRequest request);
+
+ PersonCore patch(String personId, PatchPersonRequest request);
+
+ void delete(String personId);
+}
diff --git a/imdb/src/main/java/com/ludovictemgoua/imdb/application/contracts/ReviewUseCase.java b/imdb/src/main/java/com/ludovictemgoua/imdb/application/contracts/ReviewUseCase.java
new file mode 100644
index 0000000..0415052
--- /dev/null
+++ b/imdb/src/main/java/com/ludovictemgoua/imdb/application/contracts/ReviewUseCase.java
@@ -0,0 +1,20 @@
+package com.ludovictemgoua.imdb.application.contracts;
+
+import com.ludovictemgoua.imdb.application.rest.ReviewRequest;
+import com.ludovictemgoua.imdb.domain.model.PagedResult;
+import com.ludovictemgoua.imdb.domain.model.Review;
+
+public interface ReviewUseCase {
+
+ Review create(int userId, String titleId, ReviewRequest request);
+
+ Review getMine(int userId, String titleId);
+
+ Review update(int userId, String titleId, ReviewRequest request);
+
+ void delete(int userId, String titleId, int expectedVersion);
+
+ PagedResult listForTitle(String titleId, int page, int size);
+
+ PagedResult listForUser(int userId, int page, int size);
+}
diff --git a/imdb/src/main/java/com/ludovictemgoua/imdb/application/contracts/SixDegreesOutcome.java b/imdb/src/main/java/com/ludovictemgoua/imdb/application/contracts/SixDegreesOutcome.java
new file mode 100644
index 0000000..57e0da2
--- /dev/null
+++ b/imdb/src/main/java/com/ludovictemgoua/imdb/application/contracts/SixDegreesOutcome.java
@@ -0,0 +1,15 @@
+package com.ludovictemgoua.imdb.application.contracts;
+
+import com.ludovictemgoua.imdb.application.rest.SixDegreesResult;
+import com.ludovictemgoua.imdb.domain.model.PersonCandidate;
+
+import java.util.List;
+
+public sealed interface SixDegreesOutcome {
+ record Found(SixDegreesResult result) implements SixDegreesOutcome {
+ }
+ record Ambiguous(String query, List candidates) implements SixDegreesOutcome {
+ }
+ record PersonNotFound(String query) implements SixDegreesOutcome {
+ }
+}
diff --git a/imdb/src/main/java/com/ludovictemgoua/imdb/application/contracts/SixDegreesUseCase.java b/imdb/src/main/java/com/ludovictemgoua/imdb/application/contracts/SixDegreesUseCase.java
new file mode 100644
index 0000000..3f3ed17
--- /dev/null
+++ b/imdb/src/main/java/com/ludovictemgoua/imdb/application/contracts/SixDegreesUseCase.java
@@ -0,0 +1,6 @@
+package com.ludovictemgoua.imdb.application.contracts;
+
+public interface SixDegreesUseCase {
+
+ SixDegreesOutcome compute(String queryA, String queryB, int maxDegree);
+}
diff --git a/imdb/src/main/java/com/ludovictemgoua/imdb/application/contracts/TitleAdminUseCase.java b/imdb/src/main/java/com/ludovictemgoua/imdb/application/contracts/TitleAdminUseCase.java
new file mode 100644
index 0000000..465c53a
--- /dev/null
+++ b/imdb/src/main/java/com/ludovictemgoua/imdb/application/contracts/TitleAdminUseCase.java
@@ -0,0 +1,37 @@
+package com.ludovictemgoua.imdb.application.contracts;
+
+import com.ludovictemgoua.imdb.application.rest.CreateTitleRequest;
+import com.ludovictemgoua.imdb.application.rest.CrewRequest;
+import com.ludovictemgoua.imdb.application.rest.PatchTitleRequest;
+import com.ludovictemgoua.imdb.application.rest.PrincipalRequest;
+import com.ludovictemgoua.imdb.application.rest.RatingRequest;
+import com.ludovictemgoua.imdb.application.rest.UpdateTitleRequest;
+import com.ludovictemgoua.imdb.domain.model.PrincipalCredit;
+import com.ludovictemgoua.imdb.domain.model.TitleCore;
+
+import java.util.List;
+
+public interface TitleAdminUseCase {
+
+ TitleCore create(CreateTitleRequest request);
+
+ TitleCore update(String titleId, UpdateTitleRequest request);
+
+ TitleCore patch(String titleId, PatchTitleRequest request);
+
+ void delete(String titleId);
+
+ void upsertCrew(String titleId, CrewRequest request);
+
+ void upsertRating(String titleId, RatingRequest request);
+
+ void deleteRating(String titleId);
+
+ List getAllPrincipals(String titleId);
+
+ void addPrincipal(String titleId, PrincipalRequest request);
+
+ void updatePrincipal(String titleId, int ordering, PrincipalRequest request, int expectedVersion);
+
+ void deletePrincipal(String titleId, int ordering);
+}
diff --git a/imdb/src/main/java/com/ludovictemgoua/imdb/application/contracts/TitleDetailUseCase.java b/imdb/src/main/java/com/ludovictemgoua/imdb/application/contracts/TitleDetailUseCase.java
new file mode 100644
index 0000000..b7d5a56
--- /dev/null
+++ b/imdb/src/main/java/com/ludovictemgoua/imdb/application/contracts/TitleDetailUseCase.java
@@ -0,0 +1,8 @@
+package com.ludovictemgoua.imdb.application.contracts;
+
+import com.ludovictemgoua.imdb.domain.model.TitleDetail;
+
+public interface TitleDetailUseCase {
+
+ TitleDetail getDetail(String titleId);
+}
diff --git a/imdb/src/main/java/com/ludovictemgoua/imdb/application/contracts/TitleSearchUseCase.java b/imdb/src/main/java/com/ludovictemgoua/imdb/application/contracts/TitleSearchUseCase.java
new file mode 100644
index 0000000..230d4ca
--- /dev/null
+++ b/imdb/src/main/java/com/ludovictemgoua/imdb/application/contracts/TitleSearchUseCase.java
@@ -0,0 +1,9 @@
+package com.ludovictemgoua.imdb.application.contracts;
+
+import com.ludovictemgoua.imdb.domain.model.PagedResult;
+import com.ludovictemgoua.imdb.domain.model.TitleSummary;
+
+public interface TitleSearchUseCase {
+
+ PagedResult search(String query, int page, int size);
+}
diff --git a/imdb/src/main/java/com/ludovictemgoua/imdb/application/contracts/TopRatedUseCase.java b/imdb/src/main/java/com/ludovictemgoua/imdb/application/contracts/TopRatedUseCase.java
new file mode 100644
index 0000000..95c2159
--- /dev/null
+++ b/imdb/src/main/java/com/ludovictemgoua/imdb/application/contracts/TopRatedUseCase.java
@@ -0,0 +1,10 @@
+package com.ludovictemgoua.imdb.application.contracts;
+
+import com.ludovictemgoua.imdb.domain.model.GenreTopRatedItem;
+
+import java.util.List;
+
+public interface TopRatedUseCase {
+
+ List findTopRated(String genre, int limit, Integer minVotes);
+}
diff --git a/imdb/src/main/java/com/ludovictemgoua/imdb/application/contracts/UserUseCase.java b/imdb/src/main/java/com/ludovictemgoua/imdb/application/contracts/UserUseCase.java
new file mode 100644
index 0000000..62cd48e
--- /dev/null
+++ b/imdb/src/main/java/com/ludovictemgoua/imdb/application/contracts/UserUseCase.java
@@ -0,0 +1,24 @@
+package com.ludovictemgoua.imdb.application.contracts;
+
+import com.ludovictemgoua.imdb.application.rest.UpdateProfileRequest;
+import com.ludovictemgoua.imdb.domain.model.PagedResult;
+import com.ludovictemgoua.imdb.domain.model.PublicUserProfile;
+import com.ludovictemgoua.imdb.domain.model.Role;
+import com.ludovictemgoua.imdb.domain.model.UserProfile;
+
+public interface UserUseCase {
+
+ UserProfile getOwnProfile(int userId);
+
+ UserProfile updateOwnProfile(int userId, UpdateProfileRequest request);
+
+ void deleteOwnAccount(int userId);
+
+ PublicUserProfile getPublicProfile(int userId);
+
+ PagedResult listAll(int page, int size);
+
+ void updateRole(int userId, Role role);
+
+ void deleteAccount(int userId);
+}
diff --git a/imdb/src/main/java/com/ludovictemgoua/imdb/application/contracts/WatchlistUseCase.java b/imdb/src/main/java/com/ludovictemgoua/imdb/application/contracts/WatchlistUseCase.java
new file mode 100644
index 0000000..4846f39
--- /dev/null
+++ b/imdb/src/main/java/com/ludovictemgoua/imdb/application/contracts/WatchlistUseCase.java
@@ -0,0 +1,19 @@
+package com.ludovictemgoua.imdb.application.contracts;
+
+import com.ludovictemgoua.imdb.domain.model.Visibility;
+import com.ludovictemgoua.imdb.domain.model.WatchlistView;
+
+import java.util.Optional;
+
+public interface WatchlistUseCase {
+
+ WatchlistView getOwn(int userId);
+
+ WatchlistView getForUser(Optional viewerUserId, int targetUserId);
+
+ void addItem(int userId, String titleId);
+
+ void removeItem(int userId, String titleId);
+
+ void updateVisibility(int userId, Visibility visibility);
+}
diff --git a/imdb/src/main/java/com/ludovictemgoua/imdb/application/rest/AddListItemRequest.java b/imdb/src/main/java/com/ludovictemgoua/imdb/application/rest/AddListItemRequest.java
new file mode 100644
index 0000000..0237a43
--- /dev/null
+++ b/imdb/src/main/java/com/ludovictemgoua/imdb/application/rest/AddListItemRequest.java
@@ -0,0 +1,6 @@
+package com.ludovictemgoua.imdb.application.rest;
+
+import jakarta.validation.constraints.NotBlank;
+
+public record AddListItemRequest(@NotBlank String titleId) {
+}
diff --git a/imdb/src/main/java/com/ludovictemgoua/imdb/application/rest/AddWatchlistItemRequest.java b/imdb/src/main/java/com/ludovictemgoua/imdb/application/rest/AddWatchlistItemRequest.java
new file mode 100644
index 0000000..b1c68cc
--- /dev/null
+++ b/imdb/src/main/java/com/ludovictemgoua/imdb/application/rest/AddWatchlistItemRequest.java
@@ -0,0 +1,6 @@
+package com.ludovictemgoua.imdb.application.rest;
+
+import jakarta.validation.constraints.NotBlank;
+
+public record AddWatchlistItemRequest(@NotBlank String titleId) {
+}
diff --git a/imdb/src/main/java/com/ludovictemgoua/imdb/application/rest/CreateListRequest.java b/imdb/src/main/java/com/ludovictemgoua/imdb/application/rest/CreateListRequest.java
new file mode 100644
index 0000000..40b8b9e
--- /dev/null
+++ b/imdb/src/main/java/com/ludovictemgoua/imdb/application/rest/CreateListRequest.java
@@ -0,0 +1,8 @@
+package com.ludovictemgoua.imdb.application.rest;
+
+import com.ludovictemgoua.imdb.domain.model.Visibility;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+
+public record CreateListRequest(@NotBlank String name, @NotNull Visibility visibility) {
+}
diff --git a/imdb/src/main/java/com/ludovictemgoua/imdb/application/rest/CreatePersonRequest.java b/imdb/src/main/java/com/ludovictemgoua/imdb/application/rest/CreatePersonRequest.java
new file mode 100644
index 0000000..7cf642d
--- /dev/null
+++ b/imdb/src/main/java/com/ludovictemgoua/imdb/application/rest/CreatePersonRequest.java
@@ -0,0 +1,9 @@
+package com.ludovictemgoua.imdb.application.rest;
+
+import jakarta.validation.constraints.NotBlank;
+
+import java.util.List;
+
+public record CreatePersonRequest(@NotBlank String primaryName, Integer birthYear, Integer deathYear,
+ List primaryProfession) {
+}
diff --git a/imdb/src/main/java/com/ludovictemgoua/imdb/application/rest/CreateTitleRequest.java b/imdb/src/main/java/com/ludovictemgoua/imdb/application/rest/CreateTitleRequest.java
new file mode 100644
index 0000000..1b294b2
--- /dev/null
+++ b/imdb/src/main/java/com/ludovictemgoua/imdb/application/rest/CreateTitleRequest.java
@@ -0,0 +1,10 @@
+package com.ludovictemgoua.imdb.application.rest;
+
+import jakarta.validation.constraints.NotBlank;
+
+import java.util.List;
+
+public record CreateTitleRequest(@NotBlank String primaryTitle, @NotBlank String originalTitle,
+ @NotBlank String titleType, Integer startYear, Integer endYear,
+ Integer runtimeMinutes, List genres) {
+}
diff --git a/imdb/src/main/java/com/ludovictemgoua/imdb/application/rest/CrewRequest.java b/imdb/src/main/java/com/ludovictemgoua/imdb/application/rest/CrewRequest.java
new file mode 100644
index 0000000..2676dce
--- /dev/null
+++ b/imdb/src/main/java/com/ludovictemgoua/imdb/application/rest/CrewRequest.java
@@ -0,0 +1,6 @@
+package com.ludovictemgoua.imdb.application.rest;
+
+import java.util.List;
+
+public record CrewRequest(List directors, List writers) {
+}
diff --git a/imdb/src/main/java/com/ludovictemgoua/imdb/application/rest/LoginRequest.java b/imdb/src/main/java/com/ludovictemgoua/imdb/application/rest/LoginRequest.java
new file mode 100644
index 0000000..b837746
--- /dev/null
+++ b/imdb/src/main/java/com/ludovictemgoua/imdb/application/rest/LoginRequest.java
@@ -0,0 +1,7 @@
+package com.ludovictemgoua.imdb.application.rest;
+
+import jakarta.validation.constraints.Email;
+import jakarta.validation.constraints.NotBlank;
+
+public record LoginRequest(@NotBlank @Email String email, @NotBlank String password) {
+}
diff --git a/imdb/src/main/java/com/ludovictemgoua/imdb/application/rest/PatchPersonRequest.java b/imdb/src/main/java/com/ludovictemgoua/imdb/application/rest/PatchPersonRequest.java
new file mode 100644
index 0000000..ec69bcd
--- /dev/null
+++ b/imdb/src/main/java/com/ludovictemgoua/imdb/application/rest/PatchPersonRequest.java
@@ -0,0 +1,7 @@
+package com.ludovictemgoua.imdb.application.rest;
+
+import java.util.List;
+
+public record PatchPersonRequest(String primaryName, Integer birthYear, Integer deathYear,
+ List primaryProfession, int version) {
+}
diff --git a/imdb/src/main/java/com/ludovictemgoua/imdb/application/rest/PatchTitleRequest.java b/imdb/src/main/java/com/ludovictemgoua/imdb/application/rest/PatchTitleRequest.java
new file mode 100644
index 0000000..4a72f24
--- /dev/null
+++ b/imdb/src/main/java/com/ludovictemgoua/imdb/application/rest/PatchTitleRequest.java
@@ -0,0 +1,10 @@
+package com.ludovictemgoua.imdb.application.rest;
+
+import java.util.List;
+
+// Every field nullable/absent - merge-patch semantics (docs/crud-expansion-design.md §6.5): only
+// fields present in the JSON body are applied, everything else is left as-is on the existing row.
+public record PatchTitleRequest(String primaryTitle, String originalTitle, String titleType,
+ Integer startYear, Integer endYear, Integer runtimeMinutes,
+ List genres, int version) {
+}
diff --git a/imdb/src/main/java/com/ludovictemgoua/imdb/application/rest/PathStep.java b/imdb/src/main/java/com/ludovictemgoua/imdb/application/rest/PathStep.java
new file mode 100644
index 0000000..21b9661
--- /dev/null
+++ b/imdb/src/main/java/com/ludovictemgoua/imdb/application/rest/PathStep.java
@@ -0,0 +1,6 @@
+package com.ludovictemgoua.imdb.application.rest;
+
+import com.ludovictemgoua.imdb.domain.model.SharedTitle;
+
+public record PathStep(String id, String name, SharedTitle sharedTitle) {
+}
diff --git a/imdb/src/main/java/com/ludovictemgoua/imdb/application/rest/PersonRef.java b/imdb/src/main/java/com/ludovictemgoua/imdb/application/rest/PersonRef.java
new file mode 100644
index 0000000..375b1bf
--- /dev/null
+++ b/imdb/src/main/java/com/ludovictemgoua/imdb/application/rest/PersonRef.java
@@ -0,0 +1,4 @@
+package com.ludovictemgoua.imdb.application.rest;
+
+public record PersonRef(String id, String name) {
+}
diff --git a/imdb/src/main/java/com/ludovictemgoua/imdb/application/rest/PrincipalRequest.java b/imdb/src/main/java/com/ludovictemgoua/imdb/application/rest/PrincipalRequest.java
new file mode 100644
index 0000000..458b5c4
--- /dev/null
+++ b/imdb/src/main/java/com/ludovictemgoua/imdb/application/rest/PrincipalRequest.java
@@ -0,0 +1,9 @@
+package com.ludovictemgoua.imdb.application.rest;
+
+import jakarta.validation.constraints.NotBlank;
+
+import java.util.List;
+
+public record PrincipalRequest(@NotBlank String personId, @NotBlank String category, String job,
+ List characters, int ordering) {
+}
diff --git a/imdb/src/main/java/com/ludovictemgoua/imdb/application/rest/RatingRequest.java b/imdb/src/main/java/com/ludovictemgoua/imdb/application/rest/RatingRequest.java
new file mode 100644
index 0000000..7bf9672
--- /dev/null
+++ b/imdb/src/main/java/com/ludovictemgoua/imdb/application/rest/RatingRequest.java
@@ -0,0 +1,9 @@
+package com.ludovictemgoua.imdb.application.rest;
+
+import jakarta.validation.constraints.DecimalMax;
+import jakarta.validation.constraints.DecimalMin;
+import jakarta.validation.constraints.Min;
+
+public record RatingRequest(@DecimalMin("0.0") @DecimalMax("10.0") double averageRating,
+ @Min(0) int numVotes) {
+}
diff --git a/imdb/src/main/java/com/ludovictemgoua/imdb/application/rest/RegisterRequest.java b/imdb/src/main/java/com/ludovictemgoua/imdb/application/rest/RegisterRequest.java
new file mode 100644
index 0000000..fa2ece7
--- /dev/null
+++ b/imdb/src/main/java/com/ludovictemgoua/imdb/application/rest/RegisterRequest.java
@@ -0,0 +1,11 @@
+package com.ludovictemgoua.imdb.application.rest;
+
+import jakarta.validation.constraints.Email;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Size;
+
+public record RegisterRequest(
+ @NotBlank @Email String email,
+ @NotBlank @Size(min = 8) String password,
+ @NotBlank String displayName) {
+}
diff --git a/imdb/src/main/java/com/ludovictemgoua/imdb/application/rest/ReviewRequest.java b/imdb/src/main/java/com/ludovictemgoua/imdb/application/rest/ReviewRequest.java
new file mode 100644
index 0000000..ab58ade
--- /dev/null
+++ b/imdb/src/main/java/com/ludovictemgoua/imdb/application/rest/ReviewRequest.java
@@ -0,0 +1,7 @@
+package com.ludovictemgoua.imdb.application.rest;
+
+import jakarta.validation.constraints.Max;
+import jakarta.validation.constraints.Min;
+
+public record ReviewRequest(@Min(1) @Max(10) int rating, String body, int version) {
+}
diff --git a/imdb/src/main/java/com/ludovictemgoua/imdb/application/rest/RoleRequest.java b/imdb/src/main/java/com/ludovictemgoua/imdb/application/rest/RoleRequest.java
new file mode 100644
index 0000000..76b1554
--- /dev/null
+++ b/imdb/src/main/java/com/ludovictemgoua/imdb/application/rest/RoleRequest.java
@@ -0,0 +1,7 @@
+package com.ludovictemgoua.imdb.application.rest;
+
+import com.ludovictemgoua.imdb.domain.model.Role;
+import jakarta.validation.constraints.NotNull;
+
+public record RoleRequest(@NotNull Role role) {
+}
diff --git a/imdb/src/main/java/com/ludovictemgoua/imdb/application/rest/SixDegreesResult.java b/imdb/src/main/java/com/ludovictemgoua/imdb/application/rest/SixDegreesResult.java
new file mode 100644
index 0000000..35d8391
--- /dev/null
+++ b/imdb/src/main/java/com/ludovictemgoua/imdb/application/rest/SixDegreesResult.java
@@ -0,0 +1,7 @@
+package com.ludovictemgoua.imdb.application.rest;
+
+import java.util.List;
+
+public record SixDegreesResult(PersonRef personA, PersonRef personB, Integer degree,
+ boolean withinRequestedMax, List path) {
+}
diff --git a/imdb/src/main/java/com/ludovictemgoua/imdb/application/rest/TokenPair.java b/imdb/src/main/java/com/ludovictemgoua/imdb/application/rest/TokenPair.java
new file mode 100644
index 0000000..9353595
--- /dev/null
+++ b/imdb/src/main/java/com/ludovictemgoua/imdb/application/rest/TokenPair.java
@@ -0,0 +1,4 @@
+package com.ludovictemgoua.imdb.application.rest;
+
+public record TokenPair(String accessToken, String refreshToken) {
+}
diff --git a/imdb/src/main/java/com/ludovictemgoua/imdb/application/rest/UpdateListRequest.java b/imdb/src/main/java/com/ludovictemgoua/imdb/application/rest/UpdateListRequest.java
new file mode 100644
index 0000000..3e0bfdc
--- /dev/null
+++ b/imdb/src/main/java/com/ludovictemgoua/imdb/application/rest/UpdateListRequest.java
@@ -0,0 +1,8 @@
+package com.ludovictemgoua.imdb.application.rest;
+
+import com.ludovictemgoua.imdb.domain.model.Visibility;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+
+public record UpdateListRequest(@NotBlank String name, @NotNull Visibility visibility, int version) {
+}
diff --git a/imdb/src/main/java/com/ludovictemgoua/imdb/application/rest/UpdatePersonRequest.java b/imdb/src/main/java/com/ludovictemgoua/imdb/application/rest/UpdatePersonRequest.java
new file mode 100644
index 0000000..6c94873
--- /dev/null
+++ b/imdb/src/main/java/com/ludovictemgoua/imdb/application/rest/UpdatePersonRequest.java
@@ -0,0 +1,9 @@
+package com.ludovictemgoua.imdb.application.rest;
+
+import jakarta.validation.constraints.NotBlank;
+
+import java.util.List;
+
+public record UpdatePersonRequest(@NotBlank String primaryName, Integer birthYear, Integer deathYear,
+ List primaryProfession, int version) {
+}
diff --git a/imdb/src/main/java/com/ludovictemgoua/imdb/application/rest/UpdateProfileRequest.java b/imdb/src/main/java/com/ludovictemgoua/imdb/application/rest/UpdateProfileRequest.java
new file mode 100644
index 0000000..fcfd907
--- /dev/null
+++ b/imdb/src/main/java/com/ludovictemgoua/imdb/application/rest/UpdateProfileRequest.java
@@ -0,0 +1,6 @@
+package com.ludovictemgoua.imdb.application.rest;
+
+import jakarta.validation.constraints.NotBlank;
+
+public record UpdateProfileRequest(@NotBlank String displayName, String bio, int version) {
+}
diff --git a/imdb/src/main/java/com/ludovictemgoua/imdb/application/rest/UpdateTitleRequest.java b/imdb/src/main/java/com/ludovictemgoua/imdb/application/rest/UpdateTitleRequest.java
new file mode 100644
index 0000000..1717a34
--- /dev/null
+++ b/imdb/src/main/java/com/ludovictemgoua/imdb/application/rest/UpdateTitleRequest.java
@@ -0,0 +1,10 @@
+package com.ludovictemgoua.imdb.application.rest;
+
+import jakarta.validation.constraints.NotBlank;
+
+import java.util.List;
+
+public record UpdateTitleRequest(@NotBlank String primaryTitle, @NotBlank String originalTitle,
+ @NotBlank String titleType, Integer startYear, Integer endYear,
+ Integer runtimeMinutes, List genres, int version) {
+}
diff --git a/imdb/src/main/java/com/ludovictemgoua/imdb/application/rest/VisibilityRequest.java b/imdb/src/main/java/com/ludovictemgoua/imdb/application/rest/VisibilityRequest.java
new file mode 100644
index 0000000..8bdcf1a
--- /dev/null
+++ b/imdb/src/main/java/com/ludovictemgoua/imdb/application/rest/VisibilityRequest.java
@@ -0,0 +1,7 @@
+package com.ludovictemgoua.imdb.application.rest;
+
+import com.ludovictemgoua.imdb.domain.model.Visibility;
+import jakarta.validation.constraints.NotNull;
+
+public record VisibilityRequest(@NotNull Visibility visibility) {
+}
diff --git a/imdb/src/main/java/com/ludovictemgoua/imdb/domain/exception/ConflictException.java b/imdb/src/main/java/com/ludovictemgoua/imdb/domain/exception/ConflictException.java
new file mode 100644
index 0000000..54a64cb
--- /dev/null
+++ b/imdb/src/main/java/com/ludovictemgoua/imdb/domain/exception/ConflictException.java
@@ -0,0 +1,7 @@
+package com.ludovictemgoua.imdb.domain.exception;
+
+public class ConflictException extends RuntimeException {
+ public ConflictException(String message) {
+ super(message);
+ }
+}
diff --git a/imdb/src/main/java/com/ludovictemgoua/imdb/domain/exception/ForbiddenException.java b/imdb/src/main/java/com/ludovictemgoua/imdb/domain/exception/ForbiddenException.java
new file mode 100644
index 0000000..2515bd5
--- /dev/null
+++ b/imdb/src/main/java/com/ludovictemgoua/imdb/domain/exception/ForbiddenException.java
@@ -0,0 +1,7 @@
+package com.ludovictemgoua.imdb.domain.exception;
+
+public class ForbiddenException extends RuntimeException {
+ public ForbiddenException(String message) {
+ super(message);
+ }
+}
diff --git a/imdb/src/main/java/com/ludovictemgoua/imdb/domain/exception/NotFoundException.java b/imdb/src/main/java/com/ludovictemgoua/imdb/domain/exception/NotFoundException.java
new file mode 100644
index 0000000..f03f5cf
--- /dev/null
+++ b/imdb/src/main/java/com/ludovictemgoua/imdb/domain/exception/NotFoundException.java
@@ -0,0 +1,7 @@
+package com.ludovictemgoua.imdb.domain.exception;
+
+public class NotFoundException extends RuntimeException {
+ public NotFoundException(String message) {
+ super(message);
+ }
+}
diff --git a/imdb/src/main/java/com/ludovictemgoua/imdb/domain/model/CastMember.java b/imdb/src/main/java/com/ludovictemgoua/imdb/domain/model/CastMember.java
new file mode 100644
index 0000000..e2983d6
--- /dev/null
+++ b/imdb/src/main/java/com/ludovictemgoua/imdb/domain/model/CastMember.java
@@ -0,0 +1,6 @@
+package com.ludovictemgoua.imdb.domain.model;
+
+import java.util.List;
+
+public record CastMember(String id, String name, String category, List characters, int ordering) {
+}
diff --git a/imdb/src/main/java/com/ludovictemgoua/imdb/domain/model/CreditedPerson.java b/imdb/src/main/java/com/ludovictemgoua/imdb/domain/model/CreditedPerson.java
new file mode 100644
index 0000000..4c20e96
--- /dev/null
+++ b/imdb/src/main/java/com/ludovictemgoua/imdb/domain/model/CreditedPerson.java
@@ -0,0 +1,4 @@
+package com.ludovictemgoua.imdb.domain.model;
+
+public record CreditedPerson(String id, String name) {
+}
diff --git a/imdb/src/main/java/com/ludovictemgoua/imdb/domain/model/CustomList.java b/imdb/src/main/java/com/ludovictemgoua/imdb/domain/model/CustomList.java
new file mode 100644
index 0000000..6b0b183
--- /dev/null
+++ b/imdb/src/main/java/com/ludovictemgoua/imdb/domain/model/CustomList.java
@@ -0,0 +1,4 @@
+package com.ludovictemgoua.imdb.domain.model;
+
+public record CustomList(int id, int userId, String name, Visibility visibility, int version) {
+}
diff --git a/imdb/src/main/java/com/ludovictemgoua/imdb/domain/model/CustomListView.java b/imdb/src/main/java/com/ludovictemgoua/imdb/domain/model/CustomListView.java
new file mode 100644
index 0000000..11b94ca
--- /dev/null
+++ b/imdb/src/main/java/com/ludovictemgoua/imdb/domain/model/CustomListView.java
@@ -0,0 +1,7 @@
+package com.ludovictemgoua.imdb.domain.model;
+
+import java.util.List;
+
+public record CustomListView(int id, int userId, String name, Visibility visibility, int version,
+ List items) {
+}
diff --git a/imdb/src/main/java/com/ludovictemgoua/imdb/domain/model/GenreTopRatedItem.java b/imdb/src/main/java/com/ludovictemgoua/imdb/domain/model/GenreTopRatedItem.java
new file mode 100644
index 0000000..bb00081
--- /dev/null
+++ b/imdb/src/main/java/com/ludovictemgoua/imdb/domain/model/GenreTopRatedItem.java
@@ -0,0 +1,5 @@
+package com.ludovictemgoua.imdb.domain.model;
+
+public record GenreTopRatedItem(String id, String primaryTitle, Integer startYear, double averageRating,
+ int numVotes, double weightedRating) {
+}
diff --git a/imdb/src/main/java/com/ludovictemgoua/imdb/domain/model/GraphPath.java b/imdb/src/main/java/com/ludovictemgoua/imdb/domain/model/GraphPath.java
new file mode 100644
index 0000000..db2d678
--- /dev/null
+++ b/imdb/src/main/java/com/ludovictemgoua/imdb/domain/model/GraphPath.java
@@ -0,0 +1,10 @@
+package com.ludovictemgoua.imdb.domain.model;
+
+import java.util.List;
+
+// Deliberately algorithm-agnostic: just a degree and the ordered chain of person ids connecting the
+// two endpoints. Nothing here leaks that today's implementation happens to compute this by meeting
+// two searches in the middle - a future CoStarGraphRepository implementation (precomputed BFS, a
+// graph database) returns the exact same shape without this type ever changing.
+public record GraphPath(int degree, List personIds) {
+}
diff --git a/imdb/src/main/java/com/ludovictemgoua/imdb/domain/model/ListItemView.java b/imdb/src/main/java/com/ludovictemgoua/imdb/domain/model/ListItemView.java
new file mode 100644
index 0000000..3fb3870
--- /dev/null
+++ b/imdb/src/main/java/com/ludovictemgoua/imdb/domain/model/ListItemView.java
@@ -0,0 +1,6 @@
+package com.ludovictemgoua.imdb.domain.model;
+
+import java.time.Instant;
+
+public record ListItemView(String titleId, String primaryTitle, Instant addedAt) {
+}
diff --git a/imdb/src/main/java/com/ludovictemgoua/imdb/domain/model/PagedResult.java b/imdb/src/main/java/com/ludovictemgoua/imdb/domain/model/PagedResult.java
new file mode 100644
index 0000000..6af6b52
--- /dev/null
+++ b/imdb/src/main/java/com/ludovictemgoua/imdb/domain/model/PagedResult.java
@@ -0,0 +1,6 @@
+package com.ludovictemgoua.imdb.domain.model;
+
+import java.util.List;
+
+public record PagedResult(List content, long totalElements, int page, int size) {
+}
diff --git a/imdb/src/main/java/com/ludovictemgoua/imdb/domain/model/PersonCandidate.java b/imdb/src/main/java/com/ludovictemgoua/imdb/domain/model/PersonCandidate.java
new file mode 100644
index 0000000..720f3ab
--- /dev/null
+++ b/imdb/src/main/java/com/ludovictemgoua/imdb/domain/model/PersonCandidate.java
@@ -0,0 +1,6 @@
+package com.ludovictemgoua.imdb.domain.model;
+
+import java.util.List;
+
+public record PersonCandidate(String id, String name, Integer birthYear, List knownFor) {
+}
diff --git a/imdb/src/main/java/com/ludovictemgoua/imdb/domain/model/PersonCore.java b/imdb/src/main/java/com/ludovictemgoua/imdb/domain/model/PersonCore.java
new file mode 100644
index 0000000..9854bcf
--- /dev/null
+++ b/imdb/src/main/java/com/ludovictemgoua/imdb/domain/model/PersonCore.java
@@ -0,0 +1,7 @@
+package com.ludovictemgoua.imdb.domain.model;
+
+import java.util.List;
+
+public record PersonCore(String id, String primaryName, Integer birthYear, Integer deathYear,
+ List primaryProfession, int version) {
+}
diff --git a/imdb/src/main/java/com/ludovictemgoua/imdb/domain/model/PersonResolution.java b/imdb/src/main/java/com/ludovictemgoua/imdb/domain/model/PersonResolution.java
new file mode 100644
index 0000000..f5b1b73
--- /dev/null
+++ b/imdb/src/main/java/com/ludovictemgoua/imdb/domain/model/PersonResolution.java
@@ -0,0 +1,12 @@
+package com.ludovictemgoua.imdb.domain.model;
+
+import java.util.List;
+
+public sealed interface PersonResolution {
+ record Resolved(int nconst, String name) implements PersonResolution {
+ }
+ record Ambiguous(List candidates) implements PersonResolution {
+ }
+ record NotFound() implements PersonResolution {
+ }
+}
diff --git a/imdb/src/main/java/com/ludovictemgoua/imdb/domain/model/PrincipalCredit.java b/imdb/src/main/java/com/ludovictemgoua/imdb/domain/model/PrincipalCredit.java
new file mode 100644
index 0000000..bd79d7f
--- /dev/null
+++ b/imdb/src/main/java/com/ludovictemgoua/imdb/domain/model/PrincipalCredit.java
@@ -0,0 +1,7 @@
+package com.ludovictemgoua.imdb.domain.model;
+
+import java.util.List;
+
+public record PrincipalCredit(String personId, String personName, String category, String job,
+ List characters, int ordering, int version) {
+}
diff --git a/imdb/src/main/java/com/ludovictemgoua/imdb/domain/model/PublicUserProfile.java b/imdb/src/main/java/com/ludovictemgoua/imdb/domain/model/PublicUserProfile.java
new file mode 100644
index 0000000..e4662e0
--- /dev/null
+++ b/imdb/src/main/java/com/ludovictemgoua/imdb/domain/model/PublicUserProfile.java
@@ -0,0 +1,4 @@
+package com.ludovictemgoua.imdb.domain.model;
+
+public record PublicUserProfile(int id, String displayName) {
+}
diff --git a/imdb/src/main/java/com/ludovictemgoua/imdb/domain/model/RatingAggregate.java b/imdb/src/main/java/com/ludovictemgoua/imdb/domain/model/RatingAggregate.java
new file mode 100644
index 0000000..906703a
--- /dev/null
+++ b/imdb/src/main/java/com/ludovictemgoua/imdb/domain/model/RatingAggregate.java
@@ -0,0 +1,4 @@
+package com.ludovictemgoua.imdb.domain.model;
+
+public record RatingAggregate(double average, int count) {
+}
diff --git a/imdb/src/main/java/com/ludovictemgoua/imdb/domain/model/RatingView.java b/imdb/src/main/java/com/ludovictemgoua/imdb/domain/model/RatingView.java
new file mode 100644
index 0000000..fd3ce10
--- /dev/null
+++ b/imdb/src/main/java/com/ludovictemgoua/imdb/domain/model/RatingView.java
@@ -0,0 +1,4 @@
+package com.ludovictemgoua.imdb.domain.model;
+
+public record RatingView(double average, int numVotes) {
+}
diff --git a/imdb/src/main/java/com/ludovictemgoua/imdb/domain/model/Review.java b/imdb/src/main/java/com/ludovictemgoua/imdb/domain/model/Review.java
new file mode 100644
index 0000000..05d5499
--- /dev/null
+++ b/imdb/src/main/java/com/ludovictemgoua/imdb/domain/model/Review.java
@@ -0,0 +1,7 @@
+package com.ludovictemgoua.imdb.domain.model;
+
+import java.time.Instant;
+
+public record Review(int id, int userId, int titleId, int rating, String body, int version,
+ Instant createdAt, Instant updatedAt) {
+}
diff --git a/imdb/src/main/java/com/ludovictemgoua/imdb/domain/model/Role.java b/imdb/src/main/java/com/ludovictemgoua/imdb/domain/model/Role.java
new file mode 100644
index 0000000..a02328e
--- /dev/null
+++ b/imdb/src/main/java/com/ludovictemgoua/imdb/domain/model/Role.java
@@ -0,0 +1,3 @@
+package com.ludovictemgoua.imdb.domain.model;
+
+public enum Role { USER, ADMIN }
diff --git a/imdb/src/main/java/com/ludovictemgoua/imdb/domain/model/SharedTitle.java b/imdb/src/main/java/com/ludovictemgoua/imdb/domain/model/SharedTitle.java
new file mode 100644
index 0000000..3b24608
--- /dev/null
+++ b/imdb/src/main/java/com/ludovictemgoua/imdb/domain/model/SharedTitle.java
@@ -0,0 +1,4 @@
+package com.ludovictemgoua.imdb.domain.model;
+
+public record SharedTitle(String id, String primaryTitle) {
+}
diff --git a/imdb/src/main/java/com/ludovictemgoua/imdb/domain/model/TitleCore.java b/imdb/src/main/java/com/ludovictemgoua/imdb/domain/model/TitleCore.java
new file mode 100644
index 0000000..1e3e273
--- /dev/null
+++ b/imdb/src/main/java/com/ludovictemgoua/imdb/domain/model/TitleCore.java
@@ -0,0 +1,8 @@
+package com.ludovictemgoua.imdb.domain.model;
+
+import java.util.List;
+
+public record TitleCore(String id, String primaryTitle, String originalTitle, String titleType,
+ Integer startYear, Integer endYear, Integer runtimeMinutes,
+ List genres, Double averageRating, Integer numVotes, int version) {
+}
diff --git a/imdb/src/main/java/com/ludovictemgoua/imdb/domain/model/TitleDetail.java b/imdb/src/main/java/com/ludovictemgoua/imdb/domain/model/TitleDetail.java
new file mode 100644
index 0000000..0e15084
--- /dev/null
+++ b/imdb/src/main/java/com/ludovictemgoua/imdb/domain/model/TitleDetail.java
@@ -0,0 +1,11 @@
+package com.ludovictemgoua.imdb.domain.model;
+
+import java.util.List;
+
+public record TitleDetail(String id, String primaryTitle, String originalTitle, String titleType,
+ Integer startYear, Integer endYear, Integer runtimeMinutes,
+ List genres, RatingView rating,
+ List