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. + +Swagger UI showing grouped, fully-documented imdb endpoints + +Grafana dashboard breaking down Six Degrees latency against the other three endpoints + +A real distributed trace in Tempo, opened directly from Grafana + +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. + +[![imdb CI](https://github.com/icemc/java-backend-playground/actions/workflows/imdb-ci.yml/badge.svg)](https://github.com/icemc/java-backend-playground/actions/workflows/imdb-ci.yml) +![Java](https://img.shields.io/badge/Java-21-orange) +![Spring Boot](https://img.shields.io/badge/Spring%20Boot-4.1-brightgreen) +![Build](https://img.shields.io/badge/build-Maven-blue) +![License](https://img.shields.io/badge/license-Apache--2.0-green) + +## 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`. + +Swagger UI showing grouped, fully-documented endpoints + +*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. + +Grafana data sources: Loki, Prometheus, Tempo, all provisioned + +*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 | + +Grafana dashboard list: HTTP Overview, Six Degrees Latency Breakdown, k6 Load Test Results, Cache Hit Ratio + +HTTP Overview dashboard mid-load-test: request rate, p95 latency, 5xx rate, and JVM heap all climbing together + +*Mid-load-test, not a static demo: request rate and p95 latency climbing together across every endpoint, JVM heap tracking along with it.* + +Six Degrees Latency Breakdown dashboard: p50/p95/p99 for six-degrees versus the other three endpoints + +*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 Hit Ratio dashboard: hit ratio by region climbing from a cold cache to 80-100% + +*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).* + +k6 Load Test Results dashboard showing a real failure-rate spike during a load test run + +*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." + +A real distributed trace in Tempo: nested spans for the security filter chain and a Redis command, each with real timing + +*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.* + +Raw PromQL query against cache_gets_total, broken down by region and hit/miss + +*The metric backing the Cache Hit Ratio dashboard above, queried directly - a hand-rolled `FunctionCounter` (`CacheConfig.cacheStatisticsMeterBinder`), not a framework default.* + +Loki log explorer showing structured JSON logs with requestId and other correlation fields + +*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 directors, List writers, + List cast, int castTotalCount, + double userRatingAverage, int userRatingCount) { +} diff --git a/imdb/src/main/java/com/ludovictemgoua/imdb/domain/model/TitleSummary.java b/imdb/src/main/java/com/ludovictemgoua/imdb/domain/model/TitleSummary.java new file mode 100644 index 0000000..4abf0ec --- /dev/null +++ b/imdb/src/main/java/com/ludovictemgoua/imdb/domain/model/TitleSummary.java @@ -0,0 +1,5 @@ +package com.ludovictemgoua.imdb.domain.model; + +public record TitleSummary(String id, String primaryTitle, String originalTitle, String titleType, + Integer startYear, Integer endYear) { +} diff --git a/imdb/src/main/java/com/ludovictemgoua/imdb/domain/model/User.java b/imdb/src/main/java/com/ludovictemgoua/imdb/domain/model/User.java new file mode 100644 index 0000000..50d2d8c --- /dev/null +++ b/imdb/src/main/java/com/ludovictemgoua/imdb/domain/model/User.java @@ -0,0 +1,5 @@ +package com.ludovictemgoua.imdb.domain.model; + +public record User(int id, String email, String passwordHash, String displayName, String bio, + Role role, int version) { +} diff --git a/imdb/src/main/java/com/ludovictemgoua/imdb/domain/model/UserProfile.java b/imdb/src/main/java/com/ludovictemgoua/imdb/domain/model/UserProfile.java new file mode 100644 index 0000000..2be4704 --- /dev/null +++ b/imdb/src/main/java/com/ludovictemgoua/imdb/domain/model/UserProfile.java @@ -0,0 +1,4 @@ +package com.ludovictemgoua.imdb.domain.model; + +public record UserProfile(int id, String email, String displayName, String bio, Role role, int version) { +} diff --git a/imdb/src/main/java/com/ludovictemgoua/imdb/domain/model/Visibility.java b/imdb/src/main/java/com/ludovictemgoua/imdb/domain/model/Visibility.java new file mode 100644 index 0000000..0e10126 --- /dev/null +++ b/imdb/src/main/java/com/ludovictemgoua/imdb/domain/model/Visibility.java @@ -0,0 +1,3 @@ +package com.ludovictemgoua.imdb.domain.model; + +public enum Visibility { PUBLIC, PRIVATE } diff --git a/imdb/src/main/java/com/ludovictemgoua/imdb/domain/model/WatchlistItemView.java b/imdb/src/main/java/com/ludovictemgoua/imdb/domain/model/WatchlistItemView.java new file mode 100644 index 0000000..4efed7f --- /dev/null +++ b/imdb/src/main/java/com/ludovictemgoua/imdb/domain/model/WatchlistItemView.java @@ -0,0 +1,6 @@ +package com.ludovictemgoua.imdb.domain.model; + +import java.time.Instant; + +public record WatchlistItemView(String titleId, String primaryTitle, Instant addedAt) { +} diff --git a/imdb/src/main/java/com/ludovictemgoua/imdb/domain/model/WatchlistView.java b/imdb/src/main/java/com/ludovictemgoua/imdb/domain/model/WatchlistView.java new file mode 100644 index 0000000..a7384c0 --- /dev/null +++ b/imdb/src/main/java/com/ludovictemgoua/imdb/domain/model/WatchlistView.java @@ -0,0 +1,6 @@ +package com.ludovictemgoua.imdb.domain.model; + +import java.util.List; + +public record WatchlistView(int id, int userId, Visibility visibility, int version, List items) { +} diff --git a/imdb/src/main/java/com/ludovictemgoua/imdb/domain/repository/CoStarGraphRepository.java b/imdb/src/main/java/com/ludovictemgoua/imdb/domain/repository/CoStarGraphRepository.java new file mode 100644 index 0000000..26cfd35 --- /dev/null +++ b/imdb/src/main/java/com/ludovictemgoua/imdb/domain/repository/CoStarGraphRepository.java @@ -0,0 +1,10 @@ +package com.ludovictemgoua.imdb.domain.repository; + +import com.ludovictemgoua.imdb.domain.model.GraphPath; + +import java.util.Optional; + +public interface CoStarGraphRepository { + + Optional findShortestPath(int personA, int personB); +} diff --git a/imdb/src/main/java/com/ludovictemgoua/imdb/domain/repository/CustomListRepository.java b/imdb/src/main/java/com/ludovictemgoua/imdb/domain/repository/CustomListRepository.java new file mode 100644 index 0000000..e717123 --- /dev/null +++ b/imdb/src/main/java/com/ludovictemgoua/imdb/domain/repository/CustomListRepository.java @@ -0,0 +1,27 @@ +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); +} diff --git a/imdb/src/main/java/com/ludovictemgoua/imdb/domain/repository/PersonRepository.java b/imdb/src/main/java/com/ludovictemgoua/imdb/domain/repository/PersonRepository.java new file mode 100644 index 0000000..97b14d5 --- /dev/null +++ b/imdb/src/main/java/com/ludovictemgoua/imdb/domain/repository/PersonRepository.java @@ -0,0 +1,27 @@ +package com.ludovictemgoua.imdb.domain.repository; + +import com.ludovictemgoua.imdb.domain.model.PersonCandidate; +import com.ludovictemgoua.imdb.domain.model.PersonCore; + +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +public interface PersonRepository { + + List findByName(String name); + + Optional findNameById(int nconst); + + Map findNamesByIds(Collection nconsts); + + PersonCore insertPerson(String primaryName, Integer birthYear, Integer deathYear, List primaryProfession); + + Optional findCore(int nconst); + + WriteResult updatePerson(int nconst, String primaryName, Integer birthYear, Integer deathYear, + List primaryProfession, int expectedVersion); + + WriteResult softDeletePerson(int nconst); +} diff --git a/imdb/src/main/java/com/ludovictemgoua/imdb/domain/repository/ReviewRepository.java b/imdb/src/main/java/com/ludovictemgoua/imdb/domain/repository/ReviewRepository.java new file mode 100644 index 0000000..0e1d3dd --- /dev/null +++ b/imdb/src/main/java/com/ludovictemgoua/imdb/domain/repository/ReviewRepository.java @@ -0,0 +1,24 @@ +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); +} diff --git a/imdb/src/main/java/com/ludovictemgoua/imdb/domain/repository/TitleRepository.java b/imdb/src/main/java/com/ludovictemgoua/imdb/domain/repository/TitleRepository.java new file mode 100644 index 0000000..37f8f95 --- /dev/null +++ b/imdb/src/main/java/com/ludovictemgoua/imdb/domain/repository/TitleRepository.java @@ -0,0 +1,57 @@ +package com.ludovictemgoua.imdb.domain.repository; + +import com.ludovictemgoua.imdb.domain.model.CastMember; +import com.ludovictemgoua.imdb.domain.model.CreditedPerson; +import com.ludovictemgoua.imdb.domain.model.GenreTopRatedItem; +import com.ludovictemgoua.imdb.domain.model.PagedResult; +import com.ludovictemgoua.imdb.domain.model.PrincipalCredit; +import com.ludovictemgoua.imdb.domain.model.SharedTitle; +import com.ludovictemgoua.imdb.domain.model.TitleCore; +import com.ludovictemgoua.imdb.domain.model.TitleSummary; + +import java.util.List; +import java.util.Optional; + +public interface TitleRepository { + + PagedResult search(String query, int page, int size); + + Optional findCore(int tconst); + + List findDirectors(int tconst); + + List findWriters(int tconst); + + List findTopCast(int tconst, int limit); + + int countCast(int tconst); + + List findTopRated(String genre, int limit, int minVotes); + + Optional findAnyCommonTitle(int personA, int personB); + + 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); + + 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); +} diff --git a/imdb/src/main/java/com/ludovictemgoua/imdb/domain/repository/UserRepository.java b/imdb/src/main/java/com/ludovictemgoua/imdb/domain/repository/UserRepository.java new file mode 100644 index 0000000..72f0f0b --- /dev/null +++ b/imdb/src/main/java/com/ludovictemgoua/imdb/domain/repository/UserRepository.java @@ -0,0 +1,26 @@ +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); +} diff --git a/imdb/src/main/java/com/ludovictemgoua/imdb/domain/repository/WatchlistRepository.java b/imdb/src/main/java/com/ludovictemgoua/imdb/domain/repository/WatchlistRepository.java new file mode 100644 index 0000000..4e9ee86 --- /dev/null +++ b/imdb/src/main/java/com/ludovictemgoua/imdb/domain/repository/WatchlistRepository.java @@ -0,0 +1,19 @@ +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); +} diff --git a/imdb/src/main/java/com/ludovictemgoua/imdb/domain/repository/WriteResult.java b/imdb/src/main/java/com/ludovictemgoua/imdb/domain/repository/WriteResult.java new file mode 100644 index 0000000..6799421 --- /dev/null +++ b/imdb/src/main/java/com/ludovictemgoua/imdb/domain/repository/WriteResult.java @@ -0,0 +1,8 @@ +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 } diff --git a/imdb/src/main/java/com/ludovictemgoua/imdb/infrastructure/cache/CacheConfig.java b/imdb/src/main/java/com/ludovictemgoua/imdb/infrastructure/cache/CacheConfig.java new file mode 100644 index 0000000..36ac9ab --- /dev/null +++ b/imdb/src/main/java/com/ludovictemgoua/imdb/infrastructure/cache/CacheConfig.java @@ -0,0 +1,113 @@ +package com.ludovictemgoua.imdb.infrastructure.cache; + +import io.micrometer.core.instrument.FunctionCounter; +import io.micrometer.core.instrument.binder.MeterBinder; +import io.micrometer.observation.ObservationRegistry; +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.cache.RedisCacheWriter; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.serializer.GenericJacksonJsonRedisSerializer; +import org.springframework.data.redis.serializer.RedisSerializationContext; +import tools.jackson.databind.jsontype.BasicPolymorphicTypeValidator; +import tools.jackson.databind.jsontype.PolymorphicTypeValidator; + +import java.time.Duration; +import java.util.Set; + +@Configuration +@EnableCaching +public class CacheConfig { + + // The four @Cacheable cacheNames (LLD §6). Passed to initialCacheNames below so each region + // exists in the manager from startup rather than being created lazily on its first @Cacheable + // call - required for cacheStatisticsMeterBinder below to have something to read immediately, + // and (independently) for RedisCacheManager.getCacheNames() to ever report these regions at all. + private static final Set CACHE_NAMES = + Set.of("title-search", "title-detail", "top-rated", "six-degrees"); + + @Bean + public RedisCacheWriter redisCacheWriter(RedisConnectionFactory connectionFactory, + ObservationRegistry observationRegistry) { + // .collectStatistics() turns on Spring Data Redis's own CacheStatisticsCollector (gets/hits/ + // misses/puts per cache name) - the same native source Spring Boot's now-removed + // CacheMetricsRegistrar/RedisCacheMeterBinderProvider used to read automatically pre-Boot-4.1 + // (confirmed absent by decompiling spring-boot-actuator-autoconfigure-4.1.0.jar: no "cache" + // package exists in it at all anymore). cacheStatisticsMeterBinder below is the manual + // replacement for that removed auto-binding, reading from this same writer. + RedisCacheWriter writer = RedisCacheWriter.create(connectionFactory, + RedisCacheWriter.RedisCacheWriterConfigurer::collectStatistics); + // Wrapped so every cache lookup also tags the current trace span with cache.result=hit/miss + // (tracing-design.md) - the same hit/miss signal the Counters above already track in + // aggregate, attached to a single request's trace instead. + return new ObservingRedisCacheWriter(writer, observationRegistry); + } + + @Bean + public MeterBinder cacheStatisticsMeterBinder(RedisCacheWriter redisCacheWriter) { + return registry -> CACHE_NAMES.forEach(name -> { + FunctionCounter.builder("cache.gets", redisCacheWriter, + writer -> writer.getCacheStatistics(name).getHits()) + .tag("cache", name).tag("result", "hit") + .register(registry); + FunctionCounter.builder("cache.gets", redisCacheWriter, + writer -> writer.getCacheStatistics(name).getMisses()) + .tag("cache", name).tag("result", "miss") + .register(registry); + FunctionCounter.builder("cache.puts", redisCacheWriter, + writer -> writer.getCacheStatistics(name).getPuts()) + .tag("cache", name) + .register(registry); + }); + } + + @Bean + public RedisCacheManager cacheManager(RedisCacheWriter redisCacheWriter) { + // GenericJacksonJsonRedisSerializer, not GenericJackson2JsonRedisSerializer: Boot 4.1's default + // Jackson is Jackson 3 (tools.jackson.databind.*, a different Maven groupId/package than the + // Jackson 2.x com.fasterxml.jackson.databind.* the "2"-suffixed serializer needs - the latter + // fails at runtime with a ClassNotFoundException since Jackson 2 isn't on the classpath at all + // by default anymore). enableSpringCacheNullValueSupport() is opt-in here (it was automatic on + // the old serializer's default constructor) - needed since caching a "no path found" result as + // null is deliberate (LLD §6). + // + // enableDefaultTyping is also opt-in here (automatic on the old serializer) - without it, the + // serializer writes plain JSON with no type metadata at all, so every cache HIT (not miss) + // deserializes to a raw LinkedHashMap instead of the original record and throws + // ClassCastException - only surfaced once a real cache entry was actually read back on a second + // request, since writing a cache entry never exercises the read path. Scoped to our own + // packages (not enableUnsafeDefaultTyping's wide-open Object.class) since Redis is a trust + // boundary in principle even though this deployment doesn't expose it externally. + PolymorphicTypeValidator typeValidator = BasicPolymorphicTypeValidator.builder() + .allowIfSubType("com.ludovictemgoua.imdb.") + .allowIfSubType("java.util.") + .build(); + + RedisCacheConfiguration defaults = RedisCacheConfiguration.defaultCacheConfig() + .entryTtl(Duration.ofHours(24)) + .serializeValuesWith(RedisSerializationContext.SerializationPair + .fromSerializer(GenericJacksonJsonRedisSerializer.builder() + .enableSpringCacheNullValueSupport() + .enableDefaultTyping(typeValidator) + .build())); + + // 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 write would defeat the + // cache almost entirely. Accept a bounded staleness window instead (design doc §6.2). + // + // withCacheConfiguration must come after initialCacheNames: initialCacheNames registers every + // name in CACHE_NAMES against cacheDefaults, so calling it afterward would silently overwrite + // this override back to the 24h default. + RedisCacheConfiguration searchCacheConfig = defaults.entryTtl(Duration.ofMinutes(15)); + + return RedisCacheManager.builder(redisCacheWriter) + .cacheDefaults(defaults) + .initialCacheNames(CACHE_NAMES) + .withCacheConfiguration("title-search", searchCacheConfig) + .build(); + } +} diff --git a/imdb/src/main/java/com/ludovictemgoua/imdb/infrastructure/cache/CachingCoStarGraphRepository.java b/imdb/src/main/java/com/ludovictemgoua/imdb/infrastructure/cache/CachingCoStarGraphRepository.java new file mode 100644 index 0000000..84b7fc2 --- /dev/null +++ b/imdb/src/main/java/com/ludovictemgoua/imdb/infrastructure/cache/CachingCoStarGraphRepository.java @@ -0,0 +1,36 @@ +package com.ludovictemgoua.imdb.infrastructure.cache; + +import com.ludovictemgoua.imdb.domain.model.GraphPath; +import com.ludovictemgoua.imdb.domain.repository.CoStarGraphRepository; +import com.ludovictemgoua.imdb.infrastructure.persistence.JdbcCoStarGraphRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.context.annotation.Primary; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +// Caching lives at the repository level here, not the use-case level like the three Title* use +// cases above - the true shortest distance between two person ids is a clean, unambiguous cache key +// independent of anything SixDegreesUseCase does with names/disambiguation/maxDegree (LLD §6). +@Repository +@Primary +public class CachingCoStarGraphRepository implements CoStarGraphRepository { + + private static final Logger log = LoggerFactory.getLogger(CachingCoStarGraphRepository.class); + + private final JdbcCoStarGraphRepository delegate; + + public CachingCoStarGraphRepository(JdbcCoStarGraphRepository delegate) { + this.delegate = delegate; + } + + @Override + @Cacheable(cacheNames = "six-degrees", + key = "T(java.lang.Math).min(#personA, #personB) + '-' + T(java.lang.Math).max(#personA, #personB)") + public Optional findShortestPath(int personA, int personB) { + log.debug("cache miss, computing: cache=six-degrees personA={} personB={}", personA, personB); + return delegate.findShortestPath(personA, personB); + } +} diff --git a/imdb/src/main/java/com/ludovictemgoua/imdb/infrastructure/cache/CachingPersonAdminUseCase.java b/imdb/src/main/java/com/ludovictemgoua/imdb/infrastructure/cache/CachingPersonAdminUseCase.java new file mode 100644 index 0000000..8696062 --- /dev/null +++ b/imdb/src/main/java/com/ludovictemgoua/imdb/infrastructure/cache/CachingPersonAdminUseCase.java @@ -0,0 +1,49 @@ +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); + } +} diff --git a/imdb/src/main/java/com/ludovictemgoua/imdb/infrastructure/cache/CachingReviewUseCase.java b/imdb/src/main/java/com/ludovictemgoua/imdb/infrastructure/cache/CachingReviewUseCase.java new file mode 100644 index 0000000..74d109f --- /dev/null +++ b/imdb/src/main/java/com/ludovictemgoua/imdb/infrastructure/cache/CachingReviewUseCase.java @@ -0,0 +1,56 @@ +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 - 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); + } +} diff --git a/imdb/src/main/java/com/ludovictemgoua/imdb/infrastructure/cache/CachingTitleAdminUseCase.java b/imdb/src/main/java/com/ludovictemgoua/imdb/infrastructure/cache/CachingTitleAdminUseCase.java new file mode 100644 index 0000000..b2fc9f3 --- /dev/null +++ b/imdb/src/main/java/com/ludovictemgoua/imdb/infrastructure/cache/CachingTitleAdminUseCase.java @@ -0,0 +1,113 @@ +package com.ludovictemgoua.imdb.infrastructure.cache; + +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.TitleAdminUseCaseImpl; +import com.ludovictemgoua.imdb.application.rest.UpdateTitleRequest; +import com.ludovictemgoua.imdb.application.contracts.TitleAdminUseCase; +import com.ludovictemgoua.imdb.domain.model.PrincipalCredit; +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; + +import java.util.List; + +// 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, 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, 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); + } + + @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); + } +} diff --git a/imdb/src/main/java/com/ludovictemgoua/imdb/infrastructure/cache/CachingTitleDetailUseCase.java b/imdb/src/main/java/com/ludovictemgoua/imdb/infrastructure/cache/CachingTitleDetailUseCase.java new file mode 100644 index 0000000..f3705eb --- /dev/null +++ b/imdb/src/main/java/com/ludovictemgoua/imdb/infrastructure/cache/CachingTitleDetailUseCase.java @@ -0,0 +1,30 @@ +package com.ludovictemgoua.imdb.infrastructure.cache; + +import com.ludovictemgoua.imdb.application.contracts.TitleDetailUseCase; +import com.ludovictemgoua.imdb.application.TitleDetailUseCaseImpl; +import com.ludovictemgoua.imdb.domain.model.TitleDetail; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.context.annotation.Primary; +import org.springframework.stereotype.Service; + +@Service +@Primary +public class CachingTitleDetailUseCase implements TitleDetailUseCase { + + private static final Logger log = LoggerFactory.getLogger(CachingTitleDetailUseCase.class); + + private final TitleDetailUseCaseImpl delegate; + + public CachingTitleDetailUseCase(TitleDetailUseCaseImpl delegate) { + this.delegate = delegate; + } + + @Override + @Cacheable(cacheNames = "title-detail", key = "#titleId") + public TitleDetail getDetail(String titleId) { + log.debug("cache miss, computing: cache=title-detail titleId={}", titleId); + return delegate.getDetail(titleId); + } +} diff --git a/imdb/src/main/java/com/ludovictemgoua/imdb/infrastructure/cache/CachingTitleSearchUseCase.java b/imdb/src/main/java/com/ludovictemgoua/imdb/infrastructure/cache/CachingTitleSearchUseCase.java new file mode 100644 index 0000000..76c07f5 --- /dev/null +++ b/imdb/src/main/java/com/ludovictemgoua/imdb/infrastructure/cache/CachingTitleSearchUseCase.java @@ -0,0 +1,39 @@ +package com.ludovictemgoua.imdb.infrastructure.cache; + +import com.ludovictemgoua.imdb.application.contracts.TitleSearchUseCase; +import com.ludovictemgoua.imdb.application.TitleSearchUseCaseImpl; +import com.ludovictemgoua.imdb.domain.model.PagedResult; +import com.ludovictemgoua.imdb.domain.model.TitleSummary; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.context.annotation.Primary; +import org.springframework.stereotype.Service; + +// @Primary: whichever bean wins here is what every consumer of TitleSearchUseCase (controllers) gets +// by constructor injection, with zero awareness that caching exists. Swap caching technology, or drop +// it entirely, by changing only this class - TitleSearchUseCaseImpl and every controller are untouched. +@Service +@Primary +public class CachingTitleSearchUseCase implements TitleSearchUseCase { + + private static final Logger log = LoggerFactory.getLogger(CachingTitleSearchUseCase.class); + + private final TitleSearchUseCaseImpl delegate; + + public CachingTitleSearchUseCase(TitleSearchUseCaseImpl delegate) { + this.delegate = delegate; + } + + @Override + @Cacheable(cacheNames = "title-search", key = "#query + ':' + #page + ':' + #size") + public PagedResult search(String query, int page, int size) { + // @Cacheable only calls the annotated method on a miss (Spring's caching proxy intercepts + // hits before this body ever runs) - so this line is inherently a cache-miss signal, not + // something that needs its own hit/miss branching. Aggregate hit ratio is already tracked via + // Micrometer's auto-bound cache metrics (LLD §6/§7, the Cache Hit Ratio dashboard); this is + // the per-request complement for tracing a specific slow request back to "it missed cache". + log.debug("cache miss, computing: cache=title-search query={} page={} size={}", query, page, size); + return delegate.search(query, page, size); + } +} diff --git a/imdb/src/main/java/com/ludovictemgoua/imdb/infrastructure/cache/CachingTopRatedUseCase.java b/imdb/src/main/java/com/ludovictemgoua/imdb/infrastructure/cache/CachingTopRatedUseCase.java new file mode 100644 index 0000000..7dc936c --- /dev/null +++ b/imdb/src/main/java/com/ludovictemgoua/imdb/infrastructure/cache/CachingTopRatedUseCase.java @@ -0,0 +1,32 @@ +package com.ludovictemgoua.imdb.infrastructure.cache; + +import com.ludovictemgoua.imdb.application.contracts.TopRatedUseCase; +import com.ludovictemgoua.imdb.application.TopRatedUseCaseImpl; +import com.ludovictemgoua.imdb.domain.model.GenreTopRatedItem; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.context.annotation.Primary; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +@Primary +public class CachingTopRatedUseCase implements TopRatedUseCase { + + private static final Logger log = LoggerFactory.getLogger(CachingTopRatedUseCase.class); + + private final TopRatedUseCaseImpl delegate; + + public CachingTopRatedUseCase(TopRatedUseCaseImpl delegate) { + this.delegate = delegate; + } + + @Override + @Cacheable(cacheNames = "top-rated", key = "#genre + ':' + #limit + ':' + #minVotes") + public List findTopRated(String genre, int limit, Integer minVotes) { + log.debug("cache miss, computing: cache=top-rated genre={} limit={} minVotes={}", genre, limit, minVotes); + return delegate.findTopRated(genre, limit, minVotes); + } +} diff --git a/imdb/src/main/java/com/ludovictemgoua/imdb/infrastructure/cache/ObservingRedisCacheWriter.java b/imdb/src/main/java/com/ludovictemgoua/imdb/infrastructure/cache/ObservingRedisCacheWriter.java new file mode 100644 index 0000000..a023706 --- /dev/null +++ b/imdb/src/main/java/com/ludovictemgoua/imdb/infrastructure/cache/ObservingRedisCacheWriter.java @@ -0,0 +1,139 @@ +package com.ludovictemgoua.imdb.infrastructure.cache; + +import io.micrometer.observation.Observation; +import io.micrometer.observation.ObservationRegistry; +import org.springframework.data.redis.cache.CacheStatistics; +import org.springframework.data.redis.cache.RedisCacheWriter; +import org.springframework.data.redis.connection.RedisConnection; + +import java.time.Duration; +import java.util.concurrent.CompletableFuture; +import java.util.function.Function; +import java.util.function.Supplier; + +// Every method here delegates straight through to the real writer unchanged - the only reason this +// class exists is to tag the currently active Observation with cache.result=hit/miss right where +// get() finds out which one happened, information the cache.gets Counter (CacheConfig) already +// tracks in aggregate but doesn't attach to a trace. In practice the "currently active" observation +// at that point is the enclosing controller span (WebMvcTracingConfig), not the specific Redis GET +// span underneath it - Lettuce's own span (LettuceObservationAutoConfiguration, Boot 4.1's built-in +// wiring, confirmed active via a live trace with no extra config needed) closes synchronously inside +// the delegate.get(...) call above, before this method gets a chance to tag anything. Still useful: +// a trace shows the hit/miss outcome and the Redis/DB timing breakdown together, just not literally +// on the same span. +final class ObservingRedisCacheWriter implements RedisCacheWriter { + + private static final String CACHE_RESULT_TAG = "cache.result"; + + private final RedisCacheWriter delegate; + private final ObservationRegistry observationRegistry; + + ObservingRedisCacheWriter(RedisCacheWriter delegate, ObservationRegistry observationRegistry) { + this.delegate = delegate; + this.observationRegistry = observationRegistry; + } + + @Override + public byte[] get(String name, byte[] key) { + byte[] value = delegate.get(name, key); + tagCacheResult(value != null); + return value; + } + + private void tagCacheResult(boolean hit) { + Observation current = observationRegistry.getCurrentObservation(); + if (current != null) { + current.highCardinalityKeyValue(CACHE_RESULT_TAG, hit ? "hit" : "miss"); + } + } + + @Override + public byte[] get(String name, byte[] key, Duration ttlFunction) { + return delegate.get(name, key, ttlFunction); + } + + @Override + public byte[] get(String name, byte[] key, Supplier valueLoader, Duration ttl, boolean allowNullValues) { + return delegate.get(name, key, valueLoader, ttl, allowNullValues); + } + + @Override + public boolean supportsAsyncRetrieve() { + return delegate.supportsAsyncRetrieve(); + } + + @Override + public CompletableFuture retrieve(String name, byte[] key) { + return delegate.retrieve(name, key); + } + + @Override + public CompletableFuture retrieve(String name, byte[] key, Duration ttl) { + return delegate.retrieve(name, key, ttl); + } + + @Override + public void put(String name, byte[] key, byte[] value, Duration ttl) { + delegate.put(name, key, value, ttl); + } + + @Override + public CompletableFuture store(String name, byte[] key, byte[] value, Duration ttl) { + return delegate.store(name, key, value, ttl); + } + + @Override + public byte[] putIfAbsent(String name, byte[] key, byte[] value, Duration ttl) { + return delegate.putIfAbsent(name, key, value, ttl); + } + + @Override + public void remove(String name, byte[] key) { + delegate.remove(name, key); + } + + @Override + public void evict(String name, byte[] key) { + delegate.evict(name, key); + } + + @Override + public boolean evictIfPresent(String name, byte[] key) { + return delegate.evictIfPresent(name, key); + } + + @Override + public void clean(String name, byte[] pattern) { + delegate.clean(name, pattern); + } + + @Override + public void clear(String name, byte[] pattern) { + delegate.clear(name, pattern); + } + + @Override + public boolean invalidate(String name, byte[] key) { + return delegate.invalidate(name, key); + } + + @Override + public void clearStatistics(String name) { + delegate.clearStatistics(name); + } + + @Override + public T execute(Function callback) { + return delegate.execute(callback); + } + + @Override + public RedisCacheWriter withStatisticsCollector(org.springframework.data.redis.cache.CacheStatisticsCollector cacheStatisticsCollector) { + return delegate.withStatisticsCollector(cacheStatisticsCollector); + } + + @Override + public CacheStatistics getCacheStatistics(String cacheName) { + return delegate.getCacheStatistics(cacheName); + } +} diff --git a/imdb/src/main/java/com/ludovictemgoua/imdb/infrastructure/observability/ControllerObservationInterceptor.java b/imdb/src/main/java/com/ludovictemgoua/imdb/infrastructure/observability/ControllerObservationInterceptor.java new file mode 100644 index 0000000..3cfbb74 --- /dev/null +++ b/imdb/src/main/java/com/ludovictemgoua/imdb/infrastructure/observability/ControllerObservationInterceptor.java @@ -0,0 +1,67 @@ +package com.ludovictemgoua.imdb.infrastructure.observability; + +import io.micrometer.observation.Observation; +import io.micrometer.observation.ObservationRegistry; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.web.method.HandlerMethod; +import org.springframework.web.servlet.HandlerInterceptor; + +// Wraps just the controller method invocation in its own span (tracing-design.md §4) - nested inside +// Boot's own auto-instrumented HTTP span and the Spring Security filter-chain spans (auth has already +// happened by the time a handler is resolved), and around the DB/cache spans that happen during the +// method's execution, so a trace clearly separates "framework and auth overhead" from "this endpoint's +// own logic". preHandle/afterCompletion, not a single around-advice, since HandlerInterceptor splits +// "before the handler" and "after the response is fully written" into two separate callbacks on a +// singleton bean - the Observation and its Scope are threaded between them via a request attribute +// rather than an instance field, since one interceptor instance serves every concurrent request. +public class ControllerObservationInterceptor implements HandlerInterceptor { + + private static final String OBSERVATION_ATTRIBUTE = ControllerObservationInterceptor.class.getName() + ".observation"; + private static final String SCOPE_ATTRIBUTE = ControllerObservationInterceptor.class.getName() + ".scope"; + + private final ObservationRegistry observationRegistry; + + public ControllerObservationInterceptor(ObservationRegistry observationRegistry) { + this.observationRegistry = observationRegistry; + } + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { + if (!(handler instanceof HandlerMethod handlerMethod)) { + // Static resources (swagger-ui assets) and the default error handler go through this + // interceptor too but aren't a HandlerMethod - nothing worth a span there. + return true; + } + + Observation observation = Observation + .createNotStarted(spanName(handlerMethod), observationRegistry) + .start(); + Observation.Scope scope = observation.openScope(); + + request.setAttribute(OBSERVATION_ATTRIBUTE, observation); + request.setAttribute(SCOPE_ATTRIBUTE, scope); + return true; + } + + @Override + public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, + Exception ex) { + Object scope = request.getAttribute(SCOPE_ATTRIBUTE); + Object observation = request.getAttribute(OBSERVATION_ATTRIBUTE); + if (scope == null || observation == null) { + return; + } + + ((Observation.Scope) scope).close(); + Observation obs = (Observation) observation; + if (ex != null) { + obs.error(ex); + } + obs.stop(); + } + + private static String spanName(HandlerMethod handlerMethod) { + return handlerMethod.getBeanType().getSimpleName() + "#" + handlerMethod.getMethod().getName(); + } +} diff --git a/imdb/src/main/java/com/ludovictemgoua/imdb/infrastructure/observability/ObservabilityWebMvcConfig.java b/imdb/src/main/java/com/ludovictemgoua/imdb/infrastructure/observability/ObservabilityWebMvcConfig.java new file mode 100644 index 0000000..3fa4c7d --- /dev/null +++ b/imdb/src/main/java/com/ludovictemgoua/imdb/infrastructure/observability/ObservabilityWebMvcConfig.java @@ -0,0 +1,28 @@ +package com.ludovictemgoua.imdb.infrastructure.observability; + +import io.micrometer.observation.ObservationRegistry; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +public class ObservabilityWebMvcConfig implements WebMvcConfigurer { + + private final ObservationRegistry observationRegistry; + + // WebMvcConfigurer beans are pulled into @WebMvcTest slices regardless of what else that slice + // excludes, but @WebMvcTest also explicitly disables tracing autoconfiguration - so no + // ObservationRegistry bean exists there (confirmed empirically: every @WebMvcTest controller + // test failed context startup with a constructor-injection UnsatisfiedDependencyException before + // this fallback). ObservationRegistry.NOOP makes the interceptor harmless in that case instead of + // failing the whole slice. + public ObservabilityWebMvcConfig(ObjectProvider observationRegistry) { + this.observationRegistry = observationRegistry.getIfAvailable(() -> ObservationRegistry.NOOP); + } + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(new ControllerObservationInterceptor(observationRegistry)); + } +} diff --git a/imdb/src/main/java/com/ludovictemgoua/imdb/infrastructure/openapi/OpenApiConfig.java b/imdb/src/main/java/com/ludovictemgoua/imdb/infrastructure/openapi/OpenApiConfig.java new file mode 100644 index 0000000..3a99e6b --- /dev/null +++ b/imdb/src/main/java/com/ludovictemgoua/imdb/infrastructure/openapi/OpenApiConfig.java @@ -0,0 +1,95 @@ +package com.ludovictemgoua.imdb.infrastructure.openapi; + +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.media.Content; +import io.swagger.v3.oas.models.media.MediaType; +import io.swagger.v3.oas.models.media.Schema; +import io.swagger.v3.oas.models.security.SecurityScheme; +import org.springdoc.core.customizers.GlobalOpenApiCustomizer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.util.Set; + +@Configuration +public class OpenApiConfig { + + public static final String BEARER_AUTH = "bearerAuth"; + private static final String PROBLEM_DETAIL_SCHEMA = "ProblemDetail"; + + @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")) + .components(new Components() + .addSecuritySchemes(BEARER_AUTH, new SecurityScheme() + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT") + .description("Paste the access token returned by /api/v1/auth/login or " + + "/api/v1/auth/register - click Authorize once and every " + + "\"try it out\" call below will include it automatically."))); + } + + // Every @ApiResponse in this codebase's controllers only declares a responseCode/description - it + // never repeats a schema, so springdoc falls back to the method's own return type for every + // response it lists, success or not. That's wrong for anything 4xx/5xx: ApiExceptionHandler (and + // Spring Security's entry point/access-denied handler) always return RFC 7807 ProblemDetail there, + // never the success payload. This customizer runs once over the fully-generated document and + // rewrites every non-2xx response to point at the one shared ProblemDetail schema, instead of + // hand-repeating a content/schema override on every @ApiResponse across every controller. + // + // The schema itself is registered here too, not on the imdbOpenApi() bean above - confirmed + // empirically that a schema added to that bean's own Components gets silently dropped by + // springdoc's own component resolution before the document is served, since nothing in the + // codebase actually returns a ProblemDetail-typed value for springdoc's scanner to notice it's + // used. GlobalOpenApiCustomizers run as the last step in building the document, so registering it + // here - after our own $refs already exist - is what makes it survive. + @Bean + public GlobalOpenApiCustomizer problemDetailResponseCustomizer() { + return openApi -> { + if (openApi.getComponents() == null) { + openApi.setComponents(new Components()); + } + openApi.getComponents().addSchemas(PROBLEM_DETAIL_SCHEMA, problemDetailSchema()); + + openApi.getPaths().values().forEach(pathItem -> + pathItem.readOperations().forEach(operation -> { + if (operation.getResponses() == null) { + return; + } + operation.getResponses().forEach((code, response) -> { + if (isErrorStatusCode(code)) { + response.setContent(new Content().addMediaType("application/problem+json", + new MediaType().schema(new Schema<>().$ref("#/components/schemas/" + PROBLEM_DETAIL_SCHEMA)))); + } + }); + })); + }; + } + + private static boolean isErrorStatusCode(String code) { + return code.length() == 3 && (code.charAt(0) == '4' || code.charAt(0) == '5'); + } + + // OpenAPI 3.1 (what springdoc 3.x generates by default) moved "type" from a single string to a + // "types" set to align with JSON Schema - the legacy .type(String) setter alone is silently + // dropped by the 3.1 serializer, confirmed empirically (the generated document had every property + // missing its type entirely). .types(Set.of(...)) is what actually survives serialization here. + private static Schema problemDetailSchema() { + return new Schema<>() + .types(Set.of("object")) + .description("RFC 7807 problem details, returned for every non-2xx response on this API.") + .addProperty("type", new Schema<>().types(Set.of("string")).example("about:blank")) + .addProperty("title", new Schema<>().types(Set.of("string")).example("Not Found")) + .addProperty("status", new Schema<>().types(Set.of("integer")).example(404)) + .addProperty("detail", new Schema<>().types(Set.of("string")).example("No title with id tt9999999")) + .addProperty("instance", new Schema<>().types(Set.of("string")).example("/api/v1/titles/tt9999999")); + } +} diff --git a/imdb/src/main/java/com/ludovictemgoua/imdb/infrastructure/persistence/JdbcCoStarGraphRepository.java b/imdb/src/main/java/com/ludovictemgoua/imdb/infrastructure/persistence/JdbcCoStarGraphRepository.java new file mode 100644 index 0000000..4fa11df --- /dev/null +++ b/imdb/src/main/java/com/ludovictemgoua/imdb/infrastructure/persistence/JdbcCoStarGraphRepository.java @@ -0,0 +1,93 @@ +package com.ludovictemgoua.imdb.infrastructure.persistence; + +import com.ludovictemgoua.imdb.domain.model.GraphPath; +import com.ludovictemgoua.imdb.domain.repository.CoStarGraphRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +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 javax.sql.DataSource; +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 JdbcCoStarGraphRepository implements CoStarGraphRepository { + + private static final Logger log = LoggerFactory.getLogger(JdbcCoStarGraphRepository.class); + + // A load test found a persistent tail of pairs taking multiple seconds even after fixing the + // two real bugs (LLD §5.2) and a missing index (V4) - likely the search reaching a large hub + // partway through expansion with no intersection yet. This threshold flags that population in + // logs (distinct from the use case's own per-call INFO line) without needing a trace lookup. + private static final long SLOW_QUERY_THRESHOLD_MILLIS = 1000; + + private final NamedParameterJdbcTemplate jdbc; + private final int sideCap; + private final int absoluteMaxDegree; + + public JdbcCoStarGraphRepository( + DataSource dataSource, + @Value("${six-degrees.side-cap}") int sideCap, + @Value("${six-degrees.absolute-max-degree}") int absoluteMaxDegree, + @Value("${six-degrees.query-timeout-seconds}") int queryTimeoutSeconds) { + // A dedicated JdbcTemplate over the same DataSource/connection pool, not the app's shared + // auto-configured NamedParameterJdbcTemplate bean - setQueryTimeout mutates the JdbcTemplate + // instance itself, and Spring only creates one shared instance of that bean by default, so + // setting the timeout there was leaking this query's tight timeout onto every other + // repository's queries (discovered when a plain title search got cancelled by this six-degrees- + // specific timeout). 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 (Boot 4.1 doesn't have + // one), so it's set directly on this repository's own JdbcTemplate instead. + JdbcTemplate template = new JdbcTemplate(dataSource); + template.setQueryTimeout(queryTimeoutSeconds); + this.jdbc = new NamedParameterJdbcTemplate(template); + this.sideCap = sideCap; + this.absoluteMaxDegree = absoluteMaxDegree; + } + + @Override + public Optional findShortestPath(int personA, int personB) { + // The actual bidirectional-BFS logic lives entirely in the find_shortest_co_star_path SQL + // function (V3 migration) - a real level-synchronized BFS with a genuine per-side visited + // set, replacing an earlier single-statement recursive CTE that had no visited set (only + // per-path cycle checks) and capped fan-out with an arbitrary ORDER BY that could silently + // drop the true shortest path. See the V3 migration for the full rationale. + String sql = "SELECT * FROM find_shortest_co_star_path(:personA, :personB, :sideCap, :absoluteMaxDegree)"; + var params = new MapSqlParameterSource() + .addValue("personA", personA).addValue("personB", personB) + .addValue("sideCap", sideCap).addValue("absoluteMaxDegree", absoluteMaxDegree); + + log.debug("executing find_shortest_co_star_path: personA={} personB={} sideCap={} absoluteMaxDegree={}", + personA, personB, sideCap, absoluteMaxDegree); + long startMillis = System.currentTimeMillis(); + Optional result = + jdbc.query(sql, params, JdbcCoStarGraphRepository::mapGraphPath).stream().findFirst(); + long durationMs = System.currentTimeMillis() - startMillis; + + if (durationMs > SLOW_QUERY_THRESHOLD_MILLIS) { + log.warn("slow find_shortest_co_star_path: personA={} personB={} durationMs={}", + personA, personB, durationMs); + } else { + log.debug("find_shortest_co_star_path completed: personA={} personB={} durationMs={} found={}", + personA, personB, durationMs, result.isPresent()); + } + return result; + } + + private static GraphPath mapGraphPath(ResultSet rs, int rowNum) throws SQLException { + return new GraphPath(rs.getInt("result_degree"), toIntList(rs.getArray("result_path"))); + } + + private static List toIntList(Array sqlArray) throws SQLException { + return Arrays.asList((Integer[]) sqlArray.getArray()); + } +} diff --git a/imdb/src/main/java/com/ludovictemgoua/imdb/infrastructure/persistence/JdbcCustomListRepository.java b/imdb/src/main/java/com/ludovictemgoua/imdb/infrastructure/persistence/JdbcCustomListRepository.java new file mode 100644 index 0000000..076242e --- /dev/null +++ b/imdb/src/main/java/com/ludovictemgoua/imdb/infrastructure/persistence/JdbcCustomListRepository.java @@ -0,0 +1,131 @@ +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) { + return 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")); + } +} diff --git a/imdb/src/main/java/com/ludovictemgoua/imdb/infrastructure/persistence/JdbcPersonRepository.java b/imdb/src/main/java/com/ludovictemgoua/imdb/infrastructure/persistence/JdbcPersonRepository.java new file mode 100644 index 0000000..4d20db1 --- /dev/null +++ b/imdb/src/main/java/com/ludovictemgoua/imdb/infrastructure/persistence/JdbcPersonRepository.java @@ -0,0 +1,140 @@ +package com.ludovictemgoua.imdb.infrastructure.persistence; + +import com.ludovictemgoua.imdb.domain.model.PersonCandidate; +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.slf4j.Logger; +import org.slf4j.LoggerFactory; +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.Collection; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.stream.Collectors; + +@Repository +public class JdbcPersonRepository implements PersonRepository { + + private static final Logger log = LoggerFactory.getLogger(JdbcPersonRepository.class); + + private final NamedParameterJdbcTemplate jdbc; + + public JdbcPersonRepository(NamedParameterJdbcTemplate jdbc) { + this.jdbc = jdbc; + } + + @Override + public List findByName(String name) { + 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 + """; + List candidates = jdbc.query(sql, Map.of("name", name), JdbcPersonRepository::mapCandidate); + log.debug("person lookup: name={} candidateCount={}", name, candidates.size()); + return candidates; + } + + @Override + 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(); + } + + @Override + 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)); + } + + @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 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); + } + + 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(Array sqlArray) throws SQLException { + if (sqlArray == null) return List.of(); + return List.of((String[]) sqlArray.getArray()); + } +} diff --git a/imdb/src/main/java/com/ludovictemgoua/imdb/infrastructure/persistence/JdbcReviewRepository.java b/imdb/src/main/java/com/ludovictemgoua/imdb/infrastructure/persistence/JdbcReviewRepository.java new file mode 100644 index 0000000..f312d5f --- /dev/null +++ b/imdb/src/main/java/com/ludovictemgoua/imdb/infrastructure/persistence/JdbcReviewRepository.java @@ -0,0 +1,115 @@ +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()); + } +} diff --git a/imdb/src/main/java/com/ludovictemgoua/imdb/infrastructure/persistence/JdbcTitleRepository.java b/imdb/src/main/java/com/ludovictemgoua/imdb/infrastructure/persistence/JdbcTitleRepository.java new file mode 100644 index 0000000..a899954 --- /dev/null +++ b/imdb/src/main/java/com/ludovictemgoua/imdb/infrastructure/persistence/JdbcTitleRepository.java @@ -0,0 +1,382 @@ +package com.ludovictemgoua.imdb.infrastructure.persistence; + +import com.ludovictemgoua.imdb.domain.model.CastMember; +import com.ludovictemgoua.imdb.domain.model.CreditedPerson; +import com.ludovictemgoua.imdb.domain.model.GenreTopRatedItem; +import com.ludovictemgoua.imdb.domain.model.PagedResult; +import com.ludovictemgoua.imdb.domain.model.PrincipalCredit; +import com.ludovictemgoua.imdb.domain.model.SharedTitle; +import com.ludovictemgoua.imdb.domain.model.TitleCore; +import com.ludovictemgoua.imdb.domain.model.TitleSummary; +import com.ludovictemgoua.imdb.domain.repository.TitleRepository; +import com.ludovictemgoua.imdb.domain.repository.WriteResult; +import com.ludovictemgoua.imdb.utils.ImdbIds; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +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 JdbcTitleRepository implements TitleRepository { + + private static final Logger log = LoggerFactory.getLogger(JdbcTitleRepository.class); + + private final NamedParameterJdbcTemplate jdbc; + + public JdbcTitleRepository(NamedParameterJdbcTemplate jdbc) { + this.jdbc = jdbc; + } + + @Override + 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) 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 + """; + var params = new MapSqlParameterSource() + .addValue("query", query) + .addValue("limit", size) + .addValue("offset", (long) page * size); + + List content = jdbc.query(dataSql, params, JdbcTitleRepository::mapSummary); + Long total = jdbc.queryForObject(countSql, params, Long.class); + // total is an under-count for very common query terms (gin_fuzzy_search_limit, V1 migration) + // - worth having at DEBUG to notice if that trade-off ever looks wrong for a specific term. + log.debug("title search: query={} page={} size={} returned={} total={}", + query, page, size, content.size(), total); + return new PagedResult<>(content, total == null ? 0 : total, page, size); + } + + @Override + 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, tb.version, + tr.average_rating, tr.num_votes + FROM title_basics tb + LEFT JOIN title_ratings tr ON tr.tconst = tb.tconst AND tr.deleted_at IS NULL + WHERE tb.tconst = :tconst AND tb.deleted_at IS NULL + """; + return jdbc.query(sql, Map.of("tconst", tconst), JdbcTitleRepository::mapCore).stream().findFirst(); + } + + @Override + public List findDirectors(int tconst) { + return findCrew(tconst, "directors"); + } + + @Override + public List findWriters(int tconst) { + return findCrew(tconst, "writers"); + } + + @Override + 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, JdbcTitleRepository::mapCastMember); + } + + @Override + 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; + } + + @Override + 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 genres_as_text(tb.genres) @> ARRAY[:genre]::text[] + AND tr.num_votes >= :minVotes + AND tb.deleted_at IS NULL AND tr.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 + """; + var params = new MapSqlParameterSource() + .addValue("genre", genre).addValue("minVotes", minVotes).addValue("limit", limit); + List results = jdbc.query(sql, params, JdbcTitleRepository::mapTopRated); + log.debug("top rated: genre={} limit={} minVotes={} returned={}", genre, limit, minVotes, results.size()); + return results; + } + + @Override + 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); + // findAnyCommonTitle was the site of a real bug (V4 migration) - title_principals had no + // usable index on nconst, so this fell back to a 1s+ sequential scan of a 100M-row table on + // every call. DEBUG timing here would have caught a regression of that fix immediately. + long startMillis = System.currentTimeMillis(); + Optional result = jdbc.query(sql, params, (rs, rowNum) -> + new SharedTitle(ImdbIds.formatTitleId(rs.getInt("tconst")), rs.getString("primary_title"))) + .stream().findFirst(); + log.debug("find any common title: personA={} personB={} durationMs={} found={}", + personA, personB, System.currentTimeMillis() - startMillis, result.isPresent()); + return result; + } + + @Override + public TitleCore insertTitle(String primaryTitle, String originalTitle, String titleType, + Integer startYear, Integer endYear, Integer runtimeMinutes, List genres) { + // title_type and genres are Postgres enum / enum[] columns, not text - a bare varchar(-array) + // bind parameter isn't implicitly cast to either (confirmed via a real k6 load test: every + // create failed, first on title_type, then again on genres, each with the same "column ... + // is of type ... but expression is of type character varying(...)"), so both placeholders + // need an explicit cast. Same fix needed in updateTitle below. + // is_adult is NOT NULL with no default on the real imdblib-imported schema (unlike our own + // V0 fallback, which does default it) and isn't part of this API's create/update model at + // all - explicitly false for every admin-created title, same as every imported one that + // isn't flagged adult. + String sql = """ + INSERT INTO title_basics (tconst, primary_title, original_title, title_type, + start_year, end_year, runtime_minutes, genres, is_adult) + VALUES (nextval('title_id_seq'), :primaryTitle, :originalTitle, :titleType::title_type, + :startYear, :endYear, :runtimeMinutes, :genres::genre[], false) + 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::title_type, + start_year = :startYear, end_year = :endYear, runtime_minutes = :runtimeMinutes, + genres = :genres::genre[], 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, deleted_at = NULL + """; + 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; + } + + @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; + } + // category is a Postgres enum, same reasoning as title_type::title_type above - a bare + // varchar bind parameter isn't implicitly cast to it. + String sql = """ + INSERT INTO title_principals (tconst, ordering, nconst, category, job, characters) + VALUES (:tconst, :ordering, :nconst, :category::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::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 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"), rs.getInt("version")); + } + + 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()); + } + + 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")); + } +} diff --git a/imdb/src/main/java/com/ludovictemgoua/imdb/infrastructure/persistence/JdbcUserRepository.java b/imdb/src/main/java/com/ludovictemgoua/imdb/infrastructure/persistence/JdbcUserRepository.java new file mode 100644 index 0000000..52aa686 --- /dev/null +++ b/imdb/src/main/java/com/ludovictemgoua/imdb/infrastructure/persistence/JdbcUserRepository.java @@ -0,0 +1,106 @@ +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")); + } +} diff --git a/imdb/src/main/java/com/ludovictemgoua/imdb/infrastructure/persistence/JdbcWatchlistRepository.java b/imdb/src/main/java/com/ludovictemgoua/imdb/infrastructure/persistence/JdbcWatchlistRepository.java new file mode 100644 index 0000000..d36c7de --- /dev/null +++ b/imdb/src/main/java/com/ludovictemgoua/imdb/infrastructure/persistence/JdbcWatchlistRepository.java @@ -0,0 +1,92 @@ +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.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); + } +} diff --git a/imdb/src/main/java/com/ludovictemgoua/imdb/infrastructure/security/BootstrapAdminRunner.java b/imdb/src/main/java/com/ludovictemgoua/imdb/infrastructure/security/BootstrapAdminRunner.java new file mode 100644 index 0000000..911ecc3 --- /dev/null +++ b/imdb/src/main/java/com/ludovictemgoua/imdb/infrastructure/security/BootstrapAdminRunner.java @@ -0,0 +1,47 @@ +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); + } +} diff --git a/imdb/src/main/java/com/ludovictemgoua/imdb/infrastructure/security/CurrentUser.java b/imdb/src/main/java/com/ludovictemgoua/imdb/infrastructure/security/CurrentUser.java new file mode 100644 index 0000000..9b1d762 --- /dev/null +++ b/imdb/src/main/java/com/ludovictemgoua/imdb/infrastructure/security/CurrentUser.java @@ -0,0 +1,27 @@ +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")); + } +} diff --git a/imdb/src/main/java/com/ludovictemgoua/imdb/infrastructure/security/JwtAuthenticationFilter.java b/imdb/src/main/java/com/ludovictemgoua/imdb/infrastructure/security/JwtAuthenticationFilter.java new file mode 100644 index 0000000..6483d2c --- /dev/null +++ b/imdb/src/main/java/com/ludovictemgoua/imdb/infrastructure/security/JwtAuthenticationFilter.java @@ -0,0 +1,59 @@ +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.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.List; +import java.util.Set; + +// Not a @Component - @WebMvcTest slices auto-detect and try to construct any Filter-typed bean they +// find via component scanning, regardless of whether that slice's test cares about security at all +// (confirmed empirically: a plain @Component here broke every existing controller test, including +// ones that were never going to touch auth, since Spring tried to build this filter and failed on its +// JwtService dependency not being in that slice). Registered as a @Bean inside SecurityConfig instead, +// so it only exists in a context that explicitly imports SecurityConfig. +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()); + // A refresh token is a valid, correctly-signed JWT too - excluding it here is what stops + // it from working as a general-purpose bearer credential on any endpoint that only checks + // isAuthenticated() (its own roles claim is always empty, but nothing here was previously + // checking that). Found by Copilot code review; JwtService.Parsed.refreshToken() is the + // real signal (see JwtService.parse()), not roles().isEmpty() - that happened to also + // work today only because this codebase never issues a role-less access token. + jwtService.parse(token) + .filter(parsed -> !parsed.refreshToken()) + .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); + } +} diff --git a/imdb/src/main/java/com/ludovictemgoua/imdb/infrastructure/security/JwtService.java b/imdb/src/main/java/com/ludovictemgoua/imdb/infrastructure/security/JwtService.java new file mode 100644 index 0000000..f6c7725 --- /dev/null +++ b/imdb/src/main/java/com/ludovictemgoua/imdb/infrastructure/security/JwtService.java @@ -0,0 +1,91 @@ +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())) + .claim("type", "access") + .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()); + // "refresh" is the only value that ever means anything here - a bare/missing type claim + // is treated as an access token (not just refresh tokens predating this claim, but also + // any already-issued, not-yet-expired access token from before this check existed, so a + // rolling deploy doesn't force every logged-in session to re-authenticate). + boolean refreshToken = "refresh".equals(claims.get("type", String.class)); + return Optional.of(new Parsed(userId, roles, refreshToken)); + } catch (JwtException | IllegalArgumentException e) { + return Optional.empty(); + } + } + + // refreshToken distinguishes a redeemable-once-for-new-tokens credential from a bearer-auth + // credential - without checking it, a refresh token satisfies JwtAuthenticationFilter just like + // an access token would (it parses and signs the same way, and simply carries no roles), and an + // access token satisfies AuthUseCaseImpl.refresh() just like a refresh token would (findById + // succeeds for its userId regardless of which token type produced it). Found by Copilot code + // review, verified against this exact codebase before fixing: JwtAuthenticationFilter's roles + // check alone happened to be a correct-by-coincidence fix given every user always has exactly + // one role, but didn't close the second, symmetric hole at the refresh endpoint - this claim is + // the actual signal both call sites should have been checking. + public record Parsed(int userId, Set roles, boolean refreshToken) { + } +} diff --git a/imdb/src/main/java/com/ludovictemgoua/imdb/infrastructure/security/ProblemDetailAccessDeniedHandler.java b/imdb/src/main/java/com/ludovictemgoua/imdb/infrastructure/security/ProblemDetailAccessDeniedHandler.java new file mode 100644 index 0000000..a14b0ba --- /dev/null +++ b/imdb/src/main/java/com/ludovictemgoua/imdb/infrastructure/security/ProblemDetailAccessDeniedHandler.java @@ -0,0 +1,33 @@ +package com.ludovictemgoua.imdb.infrastructure.security; + +import tools.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); + } +} diff --git a/imdb/src/main/java/com/ludovictemgoua/imdb/infrastructure/security/ProblemDetailAuthenticationEntryPoint.java b/imdb/src/main/java/com/ludovictemgoua/imdb/infrastructure/security/ProblemDetailAuthenticationEntryPoint.java new file mode 100644 index 0000000..c989949 --- /dev/null +++ b/imdb/src/main/java/com/ludovictemgoua/imdb/infrastructure/security/ProblemDetailAuthenticationEntryPoint.java @@ -0,0 +1,36 @@ +package com.ludovictemgoua.imdb.infrastructure.security; + +import tools.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); + } +} diff --git a/imdb/src/main/java/com/ludovictemgoua/imdb/infrastructure/security/SecurityConfig.java b/imdb/src/main/java/com/ludovictemgoua/imdb/infrastructure/security/SecurityConfig.java new file mode 100644 index 0000000..871d387 --- /dev/null +++ b/imdb/src/main/java/com/ludovictemgoua/imdb/infrastructure/security/SecurityConfig.java @@ -0,0 +1,61 @@ +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(); + } + + // Not @Component-scanned (see that class's own header comment) - built here so it only exists in + // a context that explicitly imports SecurityConfig, not in every @WebMvcTest slice. + @Bean + public JwtAuthenticationFilter jwtAuthenticationFilter(JwtService jwtService) { + return new JwtAuthenticationFilter(jwtService); + } + + @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/**", + "/v3/api-docs/**", "/swagger-ui.html", "/swagger-ui/**").permitAll() + // /lists/me, /users/me, and /titles/*/reviews/me must be declared before the + // broader /api/v1/lists/*, /api/v1/users/*, and /api/v1/titles/** permits below - + // Spring Security evaluates matchers in declaration order and the first match + // wins, so without this ordering those broader wildcards/prefixes would + // incorrectly treat "me" as a public resource id. + .requestMatchers(HttpMethod.GET, "/api/v1/lists/me").authenticated() + .requestMatchers(HttpMethod.GET, "/api/v1/users/me").authenticated() + .requestMatchers(HttpMethod.GET, "/api/v1/titles/*/reviews/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() + .anyRequest().authenticated()) + .exceptionHandling(handling -> handling + .authenticationEntryPoint(authenticationEntryPoint) + .accessDeniedHandler(accessDeniedHandler)) + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) + .build(); + } +} diff --git a/imdb/src/main/java/com/ludovictemgoua/imdb/presentation/ApiExceptionHandler.java b/imdb/src/main/java/com/ludovictemgoua/imdb/presentation/ApiExceptionHandler.java new file mode 100644 index 0000000..36a3587 --- /dev/null +++ b/imdb/src/main/java/com/ludovictemgoua/imdb/presentation/ApiExceptionHandler.java @@ -0,0 +1,108 @@ +package com.ludovictemgoua.imdb.presentation; + +import com.ludovictemgoua.imdb.domain.exception.ConflictException; +import com.ludovictemgoua.imdb.domain.exception.ForbiddenException; +import com.ludovictemgoua.imdb.domain.exception.NotFoundException; +import jakarta.validation.ConstraintViolationException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.dao.QueryTimeoutException; +import org.springframework.http.HttpStatus; +import org.springframework.http.ProblemDetail; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; + +// Extends ResponseEntityExceptionHandler rather than starting from a bare @RestControllerAdvice - it +// already correctly handles the whole family of standard Spring MVC exceptions (missing/malformed +// request params, wrong HTTP method, unsupported media type, no static resource, etc.) with the +// right status codes. Discovered why this matters the hard way: a bare-bones catch-all +// @ExceptionHandler(Exception.class) below is broad enough to shadow ALL of that built-in handling +// (Spring resolves to the most specific declared exception type, and plain Exception was the only +// thing registered for any of them) - a browser's routine /favicon.ico request, a missing query +// param, and a wrong HTTP verb all turned into a 500 "unexpected error" instead of their real 404/ +// 400/405. Extending the base class restores that handling for free; our own handlers below only +// need to cover what it doesn't already know about (this API's own domain exceptions) plus the +// generic catch-all for anything genuinely unexpected. +@RestControllerAdvice +public class ApiExceptionHandler extends ResponseEntityExceptionHandler { + + private static final Logger log = LoggerFactory.getLogger(ApiExceptionHandler.class); + + @ExceptionHandler(NotFoundException.class) + public ProblemDetail handleNotFound(NotFoundException ex) { + // 404 is an expected, routine outcome for this API (a title/person id that doesn't exist) - + // not an application fault, so DEBUG rather than WARN/ERROR. + log.debug("not found: {}", ex.getMessage()); + return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage()); + } + + @ExceptionHandler(IllegalArgumentException.class) + public ProblemDetail handleBadId(IllegalArgumentException ex) { + log.debug("bad request: {}", ex.getMessage()); + return ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, ex.getMessage()); + } + + // @Validated on a @RestController triggers Spring's older AOP-based MethodValidationInterceptor, + // which throws a plain ConstraintViolationException - not the newer HandlerMethodValidationException + // that Spring MVC handles automatically. Confirmed empirically (a first pass omitted this handler on + // the assumption the newer auto-handling applied here; it doesn't for @Validated-triggered validation). + @ExceptionHandler(ConstraintViolationException.class) + public ProblemDetail handleConstraintViolation(ConstraintViolationException ex) { + log.debug("bad request: {}", ex.getMessage()); + return ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, ex.getMessage()); + } + + @ExceptionHandler(ConflictException.class) + public ProblemDetail handleConflict(ConflictException ex) { + log.debug("conflict: {}", ex.getMessage()); + return ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, ex.getMessage()); + } + + @ExceptionHandler(ForbiddenException.class) + public ProblemDetail handleForbidden(ForbiddenException ex) { + log.debug("forbidden: {}", ex.getMessage()); + return ProblemDetail.forStatusAndDetail(HttpStatus.FORBIDDEN, ex.getMessage()); + } + + // @PreAuthorize denials (AuthorizationDeniedException extends this) are thrown from inside the + // controller method invocation itself - AOP method-security interception happens well past the + // security filter chain's ExceptionTranslationFilter, deep inside DispatcherServlet's own + // handler dispatch. That means they flow through Spring MVC's normal @ExceptionHandler + // resolution, same as any other controller exception - without this handler, the bare + // Exception.class catch-all below caught them first and turned a correct 403 into a spurious 500 + // (confirmed empirically: AuthorizationDeniedException logged as "unexpected error"). This is a + // distinct code path from ProblemDetailAccessDeniedHandler, which only ever fires for + // filter-level denials (the plain .anyRequest().authenticated() rule) - both are needed. + @ExceptionHandler(AccessDeniedException.class) + public ProblemDetail handleAccessDenied(AccessDeniedException ex) { + log.debug("access denied: {}", ex.getMessage()); + return ProblemDetail.forStatusAndDetail(HttpStatus.FORBIDDEN, "You do not have permission to perform this action"); + } + + // The six-degrees bidirectional BFS (find_shortest_co_star_path) has a hard query timeout + // (six-degrees.query-timeout-seconds) - a real, expected outcome for genuinely hard person pairs + // (large hub actors reached mid-expansion with no intersection yet), not a bug each time it fires. + // Spring wraps the driver's statement-canceled error in this exception; without a dedicated + // handler it fell through to the generic Exception.class catch-all below and reported a plain 500 + // "unexpected error", which is misleading for something the system anticipated and cut short on + // purpose. 504 plus an explicit message is the honest status for "the server gave up waiting on a + // downstream operation," matching what actually happened. + @ExceptionHandler(QueryTimeoutException.class) + public ProblemDetail handleQueryTimeout(QueryTimeoutException ex) { + log.warn("query timed out: {}", ex.getMessage()); + return ProblemDetail.forStatusAndDetail(HttpStatus.GATEWAY_TIMEOUT, + "This query took too long to compute and was canceled - try a lower maxDegree or a different pair"); + } + + // Catch-all for anything not already handled above - without this, an unexpected failure (a bug, + // a downstream outage) would only surface as a generic 500 from Spring Boot's default error + // controller, with the real cause visible only if you already knew to go dig through a stack + // trace dump. Logging it at ERROR with the full exception here means it's never silently lost. + @ExceptionHandler(Exception.class) + public ProblemDetail handleUnexpected(Exception ex) { + log.error("unexpected error", ex); + return ProblemDetail.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR, "An unexpected error occurred"); + } +} diff --git a/imdb/src/main/java/com/ludovictemgoua/imdb/presentation/AuthController.java b/imdb/src/main/java/com/ludovictemgoua/imdb/presentation/AuthController.java new file mode 100644 index 0000000..d103e82 --- /dev/null +++ b/imdb/src/main/java/com/ludovictemgoua/imdb/presentation/AuthController.java @@ -0,0 +1,76 @@ +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 io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +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") +@Tag(name = "Authentication", description = "Registration, login, and JWT token refresh") +public class AuthController { + + private final AuthUseCase authUseCase; + + public AuthController(AuthUseCase authUseCase) { + this.authUseCase = authUseCase; + } + + @PostMapping("/register") + @ResponseStatus(HttpStatus.CREATED) + @Operation(operationId = "registerUser", summary = "Register a new user account", + description = "Creates a USER-role account and immediately issues an access/refresh token " + + "pair - no separate login call is needed after registering.") + @ApiResponses({ + @ApiResponse(responseCode = "201", + description = "Account created; response body contains the access and refresh tokens"), + @ApiResponse(responseCode = "400", + description = "Request failed validation (e.g. malformed email, blank password)"), + @ApiResponse(responseCode = "409", description = "An account with this email already exists") + }) + public TokenPair register(@Valid @RequestBody RegisterRequest request) { + return authUseCase.register(request); + } + + @PostMapping("/login") + @Operation(operationId = "login", summary = "Log in with email and password", + description = "Exchanges valid credentials for a new access/refresh token pair.") + @ApiResponses({ + @ApiResponse(responseCode = "200", + description = "Credentials valid; response body contains the access and refresh tokens"), + @ApiResponse(responseCode = "400", description = "Request failed validation"), + @ApiResponse(responseCode = "403", description = "Email not found or password incorrect") + }) + public TokenPair login(@Valid @RequestBody LoginRequest request) { + return authUseCase.login(request); + } + + @PostMapping("/refresh") + @Operation(operationId = "refreshAccessToken", summary = "Exchange a refresh token for a new token pair", + description = "Issues a fresh access/refresh token pair from a still-valid refresh token, so a " + + "user can stay signed in without logging in again.") + @ApiResponses({ + @ApiResponse(responseCode = "200", + description = "Refresh token valid; response body contains a new access and refresh token"), + @ApiResponse(responseCode = "403", + description = "Refresh token is invalid, expired, or its user no longer exists") + }) + public TokenPair refresh(@RequestBody RefreshRequest request) { + return authUseCase.refresh(request.refreshToken()); + } + + public record RefreshRequest(@NotBlank String refreshToken) { + } +} diff --git a/imdb/src/main/java/com/ludovictemgoua/imdb/presentation/GenreController.java b/imdb/src/main/java/com/ludovictemgoua/imdb/presentation/GenreController.java new file mode 100644 index 0000000..f071b2f --- /dev/null +++ b/imdb/src/main/java/com/ludovictemgoua/imdb/presentation/GenreController.java @@ -0,0 +1,53 @@ +package com.ludovictemgoua.imdb.presentation; + +import com.ludovictemgoua.imdb.application.contracts.TopRatedUseCase; +import com.ludovictemgoua.imdb.domain.model.GenreTopRatedItem; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RestController +@RequestMapping("/api/v1/genres") +@Validated +@Tag(name = "Genres", description = "Genre-scoped top-rated title rankings") +public class GenreController { + + private final TopRatedUseCase topRatedUseCase; + + public GenreController(TopRatedUseCase topRatedUseCase) { + this.topRatedUseCase = topRatedUseCase; + } + + @GetMapping("/{genre}/top-rated") + @Operation(operationId = "getTopRatedByGenre", summary = "List the top-rated movies in a genre", + description = "Ranks movies in the given genre by an IMDb-style Bayesian weighted rating, not " + + "raw average, so a handful of perfect votes can't outrank a title with broad support.") + @ApiResponses({ + @ApiResponse(responseCode = "200", + description = "Ranked list of top-rated movies for the genre (empty if none qualify)"), + @ApiResponse(responseCode = "400", description = "limit is out of range (must be 1-100)") + }) + public List topRated( + @Parameter(description = "Genre name, e.g. \"Action\", \"Drama\" (case-sensitive, matches IMDb's genre list)") + @PathVariable String genre, + @Parameter(description = "Maximum number of results to return") + @RequestParam(defaultValue = "10") @Min(1) @Max(100) int limit, + @Parameter(description = "Minimum vote count a title needs to be considered; lower values let " + + "small-sample titles compete but shrink harder toward the pool mean. Defaults to a " + + "server-configured value if omitted.") + @RequestParam(required = false) Integer minVotes) { + return topRatedUseCase.findTopRated(genre, limit, minVotes); + } +} diff --git a/imdb/src/main/java/com/ludovictemgoua/imdb/presentation/ListController.java b/imdb/src/main/java/com/ludovictemgoua/imdb/presentation/ListController.java new file mode 100644 index 0000000..241d79b --- /dev/null +++ b/imdb/src/main/java/com/ludovictemgoua/imdb/presentation/ListController.java @@ -0,0 +1,169 @@ +package com.ludovictemgoua.imdb.presentation; + +import com.ludovictemgoua.imdb.application.rest.AddListItemRequest; +import com.ludovictemgoua.imdb.application.rest.CreateListRequest; +import com.ludovictemgoua.imdb.application.rest.UpdateListRequest; +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 io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +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.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +import static com.ludovictemgoua.imdb.infrastructure.openapi.OpenApiConfig.BEARER_AUTH; + +@RestController +@RequestMapping("/api/v1/lists") +@Validated +@Tag(name = "Custom Lists", description = "User-curated, named lists of titles with public/private visibility") +public class ListController { + + private final ListUseCase listUseCase; + + public ListController(ListUseCase listUseCase) { + this.listUseCase = listUseCase; + } + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + @SecurityRequirement(name = BEARER_AUTH) + @Operation(operationId = "createList", summary = "Create a new custom list", + description = "New lists default to PRIVATE unless the request specifies PUBLIC.") + @ApiResponses({ + @ApiResponse(responseCode = "201", description = "List created"), + @ApiResponse(responseCode = "400", description = "Request failed validation"), + @ApiResponse(responseCode = "401", description = "No valid Bearer token supplied") + }) + public CustomList create(Authentication authentication, @Valid @RequestBody CreateListRequest request) { + return listUseCase.create(CurrentUser.requireId(authentication), request); + } + + @GetMapping("/me") + @SecurityRequirement(name = BEARER_AUTH) + @Operation(operationId = "getMyLists", summary = "List the authenticated user's own custom lists", + description = "Paged; includes both PUBLIC and PRIVATE lists the caller owns.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Paged list of the caller's lists"), + @ApiResponse(responseCode = "401", description = "No valid Bearer token supplied") + }) + public PagedResult getMine( + Authentication authentication, + @Parameter(description = "Zero-based page number") @RequestParam(defaultValue = "0") @Min(0) int page, + @Parameter(description = "Results per page") @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size) { + return listUseCase.getMine(CurrentUser.requireId(authentication), page, size); + } + + @GetMapping("/public") + @Operation(operationId = "getPublicLists", summary = "List every PUBLIC custom list across all users", + description = "Publicly accessible; paged.") + @ApiResponse(responseCode = "200", description = "Paged list of PUBLIC lists") + public PagedResult getPublic( + @Parameter(description = "Zero-based page number") @RequestParam(defaultValue = "0") @Min(0) int page, + @Parameter(description = "Results per page") @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size) { + return listUseCase.getPublic(page, size); + } + + @GetMapping("/{listId}") + @Operation(operationId = "getListById", summary = "Get a custom list and its items", + description = "Publicly accessible for PUBLIC lists. Returns 404 for a PRIVATE list unless the " + + "caller is its owner - existence is hidden, not just access-denied.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "The list and its items"), + @ApiResponse(responseCode = "404", description = "No list with that id, or it is PRIVATE and the caller isn't its owner") + }) + public CustomListView getById(Authentication authentication, + @Parameter(description = "Numeric list id") @PathVariable int listId) { + return listUseCase.getById(listId, CurrentUser.idOf(authentication)); + } + + @PutMapping("/{listId}") + @SecurityRequirement(name = BEARER_AUTH) + @Operation(operationId = "updateList", summary = "Rename or change the visibility of a list", + description = "Owner-only. A non-owner gets 403 if the list is PUBLIC (existence already " + + "visible) or 404 if it's PRIVATE (existence stays hidden). Optimistic locking via " + + "the request's version field.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "List updated"), + @ApiResponse(responseCode = "400", description = "Request failed validation"), + @ApiResponse(responseCode = "401", description = "No valid Bearer token supplied"), + @ApiResponse(responseCode = "403", description = "Caller does not own this PUBLIC list"), + @ApiResponse(responseCode = "404", description = "No list with that id, or it is PRIVATE and the caller isn't its owner"), + @ApiResponse(responseCode = "409", description = "version does not match the current row - refresh and retry") + }) + public void update(Authentication authentication, @Parameter(description = "Numeric list id") @PathVariable int listId, + @Valid @RequestBody UpdateListRequest request) { + listUseCase.update(listId, CurrentUser.requireId(authentication), request); + } + + @DeleteMapping("/{listId}") + @ResponseStatus(HttpStatus.NO_CONTENT) + @SecurityRequirement(name = BEARER_AUTH) + @Operation(operationId = "deleteList", summary = "Delete a custom list", + description = "Owner-only; same 403-vs-404 visibility rule as updateList.") + @ApiResponses({ + @ApiResponse(responseCode = "204", description = "List deleted"), + @ApiResponse(responseCode = "401", description = "No valid Bearer token supplied"), + @ApiResponse(responseCode = "403", description = "Caller does not own this PUBLIC list"), + @ApiResponse(responseCode = "404", description = "No list with that id, or it is PRIVATE and the caller isn't its owner"), + @ApiResponse(responseCode = "409", description = "expectedVersion does not match the current row - refresh and retry") + }) + public void delete(Authentication authentication, @Parameter(description = "Numeric list id") @PathVariable int listId, + @Parameter(description = "Version read from the list being deleted, for optimistic locking") + @RequestParam int expectedVersion) { + listUseCase.delete(listId, CurrentUser.requireId(authentication), expectedVersion); + } + + @PostMapping("/{listId}/items") + @ResponseStatus(HttpStatus.CREATED) + @SecurityRequirement(name = BEARER_AUTH) + @Operation(operationId = "addListItem", summary = "Add a title to a custom list", + description = "Owner-only; same 403-vs-404 visibility rule as updateList.") + @ApiResponses({ + @ApiResponse(responseCode = "201", description = "Title added"), + @ApiResponse(responseCode = "400", description = "Request failed validation"), + @ApiResponse(responseCode = "401", description = "No valid Bearer token supplied"), + @ApiResponse(responseCode = "403", description = "Caller does not own this PUBLIC list"), + @ApiResponse(responseCode = "404", description = "No list with that id, or it is PRIVATE and the caller isn't its owner") + }) + public void addItem(Authentication authentication, @Parameter(description = "Numeric list id") @PathVariable int listId, + @Valid @RequestBody AddListItemRequest request) { + listUseCase.addItem(listId, CurrentUser.requireId(authentication), request.titleId()); + } + + @DeleteMapping("/{listId}/items/{titleId}") + @ResponseStatus(HttpStatus.NO_CONTENT) + @SecurityRequirement(name = BEARER_AUTH) + @Operation(operationId = "removeListItem", summary = "Remove a title from a custom list", + description = "Owner-only; same 403-vs-404 visibility rule as updateList.") + @ApiResponses({ + @ApiResponse(responseCode = "204", description = "Title removed"), + @ApiResponse(responseCode = "401", description = "No valid Bearer token supplied"), + @ApiResponse(responseCode = "403", description = "Caller does not own this PUBLIC list"), + @ApiResponse(responseCode = "404", description = "No list with that id, or it is PRIVATE and the caller isn't its owner") + }) + public void removeItem(Authentication authentication, @Parameter(description = "Numeric list id") @PathVariable int listId, + @Parameter(description = "IMDb-style title id, e.g. tt0111161") @PathVariable String titleId) { + listUseCase.removeItem(listId, CurrentUser.requireId(authentication), titleId); + } +} diff --git a/imdb/src/main/java/com/ludovictemgoua/imdb/presentation/PersonController.java b/imdb/src/main/java/com/ludovictemgoua/imdb/presentation/PersonController.java new file mode 100644 index 0000000..9106f94 --- /dev/null +++ b/imdb/src/main/java/com/ludovictemgoua/imdb/presentation/PersonController.java @@ -0,0 +1,161 @@ +package com.ludovictemgoua.imdb.presentation; + +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.application.contracts.PersonAdminUseCase; +import com.ludovictemgoua.imdb.application.contracts.SixDegreesOutcome; +import com.ludovictemgoua.imdb.application.contracts.SixDegreesUseCase; +import com.ludovictemgoua.imdb.domain.model.PersonCore; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +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.security.access.prepost.PreAuthorize; +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.PatchMapping; +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.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Map; + +import static com.ludovictemgoua.imdb.infrastructure.openapi.OpenApiConfig.BEARER_AUTH; + +@RestController +@RequestMapping("/api/v1/people") +@Validated +@Tag(name = "People", description = "Six-degrees graph queries and admin CRUD over people") +public class PersonController { + + private final SixDegreesUseCase sixDegreesUseCase; + private final PersonAdminUseCase personAdminUseCase; + + public PersonController(SixDegreesUseCase sixDegreesUseCase, PersonAdminUseCase personAdminUseCase) { + this.sixDegreesUseCase = sixDegreesUseCase; + this.personAdminUseCase = personAdminUseCase; + } + + @GetMapping("/six-degrees") + @Operation(operationId = "computeSixDegrees", summary = "Find the degree of separation between two people", + description = "Generalized \"six degrees of Kevin Bacon\": bidirectional BFS over shared-title " + + "co-star edges. personA/personB accept either an exact nm-id or a free-text name; a " + + "name matching more than one person returns a disambiguation payload (still HTTP 200, " + + "with requiresDisambiguation=true and a list of candidates) instead of an error.") + @ApiResponses({ + @ApiResponse(responseCode = "200", + description = "Either a result (with a degree, or degree=null if no path exists within " + + "maxDegree) or a disambiguation payload"), + @ApiResponse(responseCode = "400", description = "maxDegree is out of range (must be 1-7)"), + @ApiResponse(responseCode = "404", description = "personA or personB matches no known person"), + @ApiResponse(responseCode = "504", + description = "The search took too long to compute against a genuinely hard pair " + + "(e.g. two well-connected hub actors with no shared title) and was canceled " + + "server-side - try a lower maxDegree or a different pair") + }) + public ResponseEntity sixDegrees( + @Parameter(description = "First person: an nm-id (e.g. nm0000102) or a free-text name") + @RequestParam String personA, + @Parameter(description = "Second person: an nm-id (e.g. nm0000102) or a free-text name") + @RequestParam String personB, + @Parameter(description = "Upper bound on search depth per side of the bidirectional search") + @RequestParam(defaultValue = "7") @Min(1) @Max(7) int maxDegree) { + + SixDegreesOutcome outcome = sixDegreesUseCase.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())); + }; + } + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + @PreAuthorize("hasRole('ADMIN')") + @SecurityRequirement(name = BEARER_AUTH) + @Operation(operationId = "createPerson", summary = "Create a new person", + description = "Admin-only. Inserts a new person using an id from the admin id sequence, " + + "separate from the imported IMDb id space so it can never collide with one.") + @ApiResponses({ + @ApiResponse(responseCode = "201", description = "Person created"), + @ApiResponse(responseCode = "400", description = "Request failed validation"), + @ApiResponse(responseCode = "401", description = "No valid Bearer token supplied"), + @ApiResponse(responseCode = "403", description = "Caller is authenticated but not an admin") + }) + public PersonCore create(@Valid @RequestBody CreatePersonRequest request) { + return personAdminUseCase.create(request); + } + + @PutMapping("/{personId}") + @PreAuthorize("hasRole('ADMIN')") + @SecurityRequirement(name = BEARER_AUTH) + @Operation(operationId = "updatePerson", summary = "Replace a person's core fields", + description = "Admin-only. Full replace with optimistic locking - the request's version field " + + "must match the row's current version or the update is rejected with 409.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Person updated; response body is the new state"), + @ApiResponse(responseCode = "400", description = "Request failed validation"), + @ApiResponse(responseCode = "401", description = "No valid Bearer token supplied"), + @ApiResponse(responseCode = "403", description = "Caller is authenticated but not an admin"), + @ApiResponse(responseCode = "404", description = "No person with that id"), + @ApiResponse(responseCode = "409", description = "version does not match the current row - refresh and retry") + }) + public PersonCore update(@Parameter(description = "IMDb-style person id, e.g. nm0000209") @PathVariable String personId, + @Valid @RequestBody UpdatePersonRequest request) { + return personAdminUseCase.update(personId, request); + } + + @PatchMapping("/{personId}") + @PreAuthorize("hasRole('ADMIN')") + @SecurityRequirement(name = BEARER_AUTH) + @Operation(operationId = "patchPerson", summary = "Partially update a person", + description = "Admin-only. Merge-patch semantics - only fields present in the request body are " + + "changed, everything else keeps its current value. Still requires the current version.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Person updated; response body is the new state"), + @ApiResponse(responseCode = "400", description = "Request failed validation"), + @ApiResponse(responseCode = "401", description = "No valid Bearer token supplied"), + @ApiResponse(responseCode = "403", description = "Caller is authenticated but not an admin"), + @ApiResponse(responseCode = "404", description = "No person with that id"), + @ApiResponse(responseCode = "409", description = "version does not match the current row - refresh and retry") + }) + public PersonCore patch(@Parameter(description = "IMDb-style person id, e.g. nm0000209") @PathVariable String personId, + @RequestBody PatchPersonRequest request) { + return personAdminUseCase.patch(personId, request); + } + + @DeleteMapping("/{personId}") + @ResponseStatus(HttpStatus.NO_CONTENT) + @PreAuthorize("hasRole('ADMIN')") + @SecurityRequirement(name = BEARER_AUTH) + @Operation(operationId = "deletePerson", summary = "Soft-delete a person", + description = "Admin-only. Marks the person as deleted; existing cast/crew credits that " + + "reference them are left intact.") + @ApiResponses({ + @ApiResponse(responseCode = "204", description = "Person deleted"), + @ApiResponse(responseCode = "401", description = "No valid Bearer token supplied"), + @ApiResponse(responseCode = "403", description = "Caller is authenticated but not an admin"), + @ApiResponse(responseCode = "404", description = "No person with that id") + }) + public void delete(@Parameter(description = "IMDb-style person id, e.g. nm0000209") @PathVariable String personId) { + personAdminUseCase.delete(personId); + } +} diff --git a/imdb/src/main/java/com/ludovictemgoua/imdb/presentation/RequestLoggingFilter.java b/imdb/src/main/java/com/ludovictemgoua/imdb/presentation/RequestLoggingFilter.java new file mode 100644 index 0000000..b5df80d --- /dev/null +++ b/imdb/src/main/java/com/ludovictemgoua/imdb/presentation/RequestLoggingFilter.java @@ -0,0 +1,90 @@ +package com.ludovictemgoua.imdb.presentation; + +import com.ludovictemgoua.imdb.utils.HeaderSanitizer; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.slf4j.MDC; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.UUID; + +// Ordered.HIGHEST_PRECEDENCE + 2, not HIGHEST_PRECEDENCE itself: Spring's own +// ServerHttpObservationFilter (WebMvcObservationAutoConfiguration) registers at exactly +// HIGHEST_PRECEDENCE + 1 (confirmed by decompiling spring-boot-webmvc-4.1.0.jar, not assumed) - that +// filter is what opens the span whose scope triggers trace/span-id MDC population +// (micrometer-tracing-bridge-otel's Slf4JEventListener, also auto-registered). Running at +// HIGHEST_PRECEDENCE itself would wrap *around* that filter instead of nesting inside it, so +// "request started"/"request completed" below would log before the span exists and after it's +// already closed - exactly the bug this order was chosen to avoid (confirmed empirically: those two +// log lines were the only ones in a request's entire lifecycle missing traceId/spanId, despite the +// MDC population mechanism itself working correctly for everything logged in between). A plain +// @Component is enough for Spring Boot to auto-register any jakarta.servlet.Filter bean; no separate +// FilterRegistrationBean needed. +@Component +@Order(Ordered.HIGHEST_PRECEDENCE + 2) +public class RequestLoggingFilter extends OncePerRequestFilter { + + private static final Logger log = LoggerFactory.getLogger(RequestLoggingFilter.class); + private static final String REQUEST_ID_HEADER = "X-Request-Id"; + private static final String MDC_KEY = "requestId"; + + // Prometheus scrapes /actuator/prometheus every ~15s and Docker/orchestrator health checks poll + // /actuator/health continuously - neither is real application traffic, and logging them at INFO + // would drown out the requests that actually matter. shouldNotFilter skips the whole filter (no + // requestId, no log lines) for these rather than just suppressing the log statements, so actuator + // traffic doesn't pay even the MDC/timing overhead. + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + return request.getRequestURI().startsWith("/actuator"); + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + String requestId = resolveRequestId(request); + MDC.put(MDC_KEY, requestId); + response.setHeader(REQUEST_ID_HEADER, requestId); + + long startMillis = System.currentTimeMillis(); + log.info("request started: method={} path={} headers={}", + request.getMethod(), request.getRequestURI(), HeaderSanitizer.sanitize(headersOf(request))); + try { + filterChain.doFilter(request, response); + } finally { + log.info("request completed: method={} path={} status={} durationMs={}", + request.getMethod(), request.getRequestURI(), response.getStatus(), + System.currentTimeMillis() - startMillis); + // Threads are pooled and reused across requests - leaving this set would leak the + // previous request's id into the next request handled by the same thread. + MDC.remove(MDC_KEY); + } + } + + // Honors an id the caller already generated (e.g. an upstream gateway) so a single request's + // logs stay correlated end-to-end across services, rather than getting a new id at each hop. + private static String resolveRequestId(HttpServletRequest request) { + String incoming = request.getHeader(REQUEST_ID_HEADER); + return (incoming == null || incoming.isBlank()) ? UUID.randomUUID().toString() : incoming; + } + + private static Map headersOf(HttpServletRequest request) { + Map headers = new LinkedHashMap<>(); + var names = request.getHeaderNames(); + if (names == null) { + return headers; + } + Collections.list(names).forEach(name -> headers.put(name, request.getHeader(name))); + return headers; + } +} diff --git a/imdb/src/main/java/com/ludovictemgoua/imdb/presentation/ReviewController.java b/imdb/src/main/java/com/ludovictemgoua/imdb/presentation/ReviewController.java new file mode 100644 index 0000000..572955b --- /dev/null +++ b/imdb/src/main/java/com/ludovictemgoua/imdb/presentation/ReviewController.java @@ -0,0 +1,138 @@ +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 io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +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; + +import static com.ludovictemgoua.imdb.infrastructure.openapi.OpenApiConfig.BEARER_AUTH; + +@RestController +@Validated +@Tag(name = "Reviews", description = "One rating-and-text review per user per title") +public class ReviewController { + + private final ReviewUseCase reviewUseCase; + + public ReviewController(ReviewUseCase reviewUseCase) { + this.reviewUseCase = reviewUseCase; + } + + @PostMapping("/api/v1/titles/{titleId}/reviews") + @ResponseStatus(HttpStatus.CREATED) + @SecurityRequirement(name = BEARER_AUTH) + @Operation(operationId = "createReview", summary = "Post a review for a title", + description = "One review per (user, title) - fails with 409 if the caller already reviewed " + + "this title (use updateMyReviewForTitle instead).") + @ApiResponses({ + @ApiResponse(responseCode = "201", description = "Review created"), + @ApiResponse(responseCode = "400", description = "Request failed validation (rating out of 1-10)"), + @ApiResponse(responseCode = "401", description = "No valid Bearer token supplied"), + @ApiResponse(responseCode = "409", description = "Caller already reviewed this title") + }) + public Review create(Authentication authentication, + @Parameter(description = "IMDb-style title id, e.g. tt0111161") @PathVariable String titleId, + @Valid @RequestBody ReviewRequest request) { + return reviewUseCase.create(CurrentUser.requireId(authentication), titleId, request); + } + + @GetMapping("/api/v1/titles/{titleId}/reviews") + @Operation(operationId = "listReviewsForTitle", summary = "List all reviews for a title", + description = "Publicly accessible; paged, newest first.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Paged list of reviews"), + @ApiResponse(responseCode = "400", description = "titleId is not a valid tt-prefixed id, or page/size is out of range") + }) + public PagedResult listForTitle( + @Parameter(description = "IMDb-style title id, e.g. tt0111161") @PathVariable String titleId, + @Parameter(description = "Zero-based page number") @RequestParam(defaultValue = "0") @Min(0) int page, + @Parameter(description = "Results per page") @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size) { + return reviewUseCase.listForTitle(titleId, page, size); + } + + @GetMapping("/api/v1/titles/{titleId}/reviews/me") + @SecurityRequirement(name = BEARER_AUTH) + @Operation(operationId = "getMyReviewForTitle", summary = "Get the authenticated user's own review for a title", + description = "Returns 404 if the caller hasn't reviewed this title yet.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Caller's review"), + @ApiResponse(responseCode = "401", description = "No valid Bearer token supplied"), + @ApiResponse(responseCode = "404", description = "Caller has not reviewed this title") + }) + public Review getMine(Authentication authentication, + @Parameter(description = "IMDb-style title id, e.g. tt0111161") @PathVariable String titleId) { + return reviewUseCase.getMine(CurrentUser.requireId(authentication), titleId); + } + + @PutMapping("/api/v1/titles/{titleId}/reviews/me") + @SecurityRequirement(name = BEARER_AUTH) + @Operation(operationId = "updateMyReviewForTitle", summary = "Update the authenticated user's review for a title", + description = "Optimistic locking - the request's version field must match the row's current " + + "version or the update is rejected with 409.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Review updated; response body is the new state"), + @ApiResponse(responseCode = "400", description = "Request failed validation"), + @ApiResponse(responseCode = "401", description = "No valid Bearer token supplied"), + @ApiResponse(responseCode = "404", description = "Caller has not reviewed this title"), + @ApiResponse(responseCode = "409", description = "version does not match the current row - refresh and retry") + }) + public Review update(Authentication authentication, + @Parameter(description = "IMDb-style title id, e.g. tt0111161") @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) + @SecurityRequirement(name = BEARER_AUTH) + @Operation(operationId = "deleteMyReviewForTitle", summary = "Delete the authenticated user's review for a title", + description = "Optimistic locking via the expectedVersion query parameter.") + @ApiResponses({ + @ApiResponse(responseCode = "204", description = "Review deleted"), + @ApiResponse(responseCode = "401", description = "No valid Bearer token supplied"), + @ApiResponse(responseCode = "404", description = "Caller has not reviewed this title"), + @ApiResponse(responseCode = "409", description = "expectedVersion does not match the current row - refresh and retry") + }) + public void delete(Authentication authentication, + @Parameter(description = "IMDb-style title id, e.g. tt0111161") @PathVariable String titleId, + @Parameter(description = "Version read from the review being deleted, for optimistic locking") + @RequestParam int expectedVersion) { + reviewUseCase.delete(CurrentUser.requireId(authentication), titleId, expectedVersion); + } + + @GetMapping("/api/v1/users/{userId}/reviews") + @Operation(operationId = "listReviewsByUser", summary = "List every review a user has written", + description = "Publicly accessible; paged, newest first.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Paged list of reviews"), + @ApiResponse(responseCode = "400", description = "page/size is out of range") + }) + public PagedResult listForUser( + @Parameter(description = "Numeric user id") @PathVariable int userId, + @Parameter(description = "Zero-based page number") @RequestParam(defaultValue = "0") @Min(0) int page, + @Parameter(description = "Results per page") @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size) { + return reviewUseCase.listForUser(userId, page, size); + } +} diff --git a/imdb/src/main/java/com/ludovictemgoua/imdb/presentation/TitleController.java b/imdb/src/main/java/com/ludovictemgoua/imdb/presentation/TitleController.java new file mode 100644 index 0000000..f25f597 --- /dev/null +++ b/imdb/src/main/java/com/ludovictemgoua/imdb/presentation/TitleController.java @@ -0,0 +1,292 @@ +package com.ludovictemgoua.imdb.presentation; + +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.application.contracts.TitleAdminUseCase; +import com.ludovictemgoua.imdb.application.contracts.TitleDetailUseCase; +import com.ludovictemgoua.imdb.application.contracts.TitleSearchUseCase; +import com.ludovictemgoua.imdb.domain.model.PagedResult; +import com.ludovictemgoua.imdb.domain.model.PrincipalCredit; +import com.ludovictemgoua.imdb.domain.model.TitleCore; +import com.ludovictemgoua.imdb.domain.model.TitleDetail; +import com.ludovictemgoua.imdb.domain.model.TitleSummary; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import org.springframework.http.HttpStatus; +import org.springframework.security.access.prepost.PreAuthorize; +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.PatchMapping; +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.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +import static com.ludovictemgoua.imdb.infrastructure.openapi.OpenApiConfig.BEARER_AUTH; + +@RestController +@RequestMapping("/api/v1/titles") +@Validated +@Tag(name = "Titles", description = "Search, detail, and admin CRUD over titles, crew, cast, and ratings") +public class TitleController { + + private final TitleSearchUseCase searchUseCase; + private final TitleDetailUseCase detailUseCase; + private final TitleAdminUseCase titleAdminUseCase; + + public TitleController(TitleSearchUseCase searchUseCase, TitleDetailUseCase detailUseCase, + TitleAdminUseCase titleAdminUseCase) { + this.searchUseCase = searchUseCase; + this.detailUseCase = detailUseCase; + this.titleAdminUseCase = titleAdminUseCase; + } + + @GetMapping("/search") + @Operation(operationId = "searchTitles", summary = "Fuzzy search titles by name", + description = "Trigram similarity search over primary and original title, tolerant of typos " + + "and partial matches. Publicly accessible.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Paged list of matching titles"), + @ApiResponse(responseCode = "400", description = "title is blank, or page/size is out of range") + }) + public PagedResult search( + @Parameter(description = "Search text matched fuzzily against the title") @RequestParam @NotBlank String title, + @Parameter(description = "Zero-based page number") @RequestParam(defaultValue = "0") @Min(0) int page, + @Parameter(description = "Results per page") @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size) { + return searchUseCase.search(title, page, size); + } + + @GetMapping("/{titleId}") + @Operation(operationId = "getTitleById", summary = "Get full detail for a title", + description = "Returns metadata, the original IMDb rating, the aggregate user rating from " + + "reviews, directors/writers, and top-billed cast. Publicly accessible.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Title detail"), + @ApiResponse(responseCode = "400", description = "titleId is not a valid tt-prefixed id"), + @ApiResponse(responseCode = "404", description = "No title with that id") + }) + public TitleDetail get( + @Parameter(description = "IMDb-style title id, e.g. tt0111161") @PathVariable String titleId) { + return detailUseCase.getDetail(titleId); + } + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + @PreAuthorize("hasRole('ADMIN')") + @SecurityRequirement(name = BEARER_AUTH) + @Operation(operationId = "createTitle", summary = "Create a new title", + description = "Admin-only. Inserts a new title using an id from the admin id sequence, " + + "separate from the imported IMDb id space so it can never collide with one.") + @ApiResponses({ + @ApiResponse(responseCode = "201", description = "Title created"), + @ApiResponse(responseCode = "400", description = "Request failed validation"), + @ApiResponse(responseCode = "401", description = "No valid Bearer token supplied"), + @ApiResponse(responseCode = "403", description = "Caller is authenticated but not an admin") + }) + public TitleCore create(@Valid @RequestBody CreateTitleRequest request) { + return titleAdminUseCase.create(request); + } + + @PutMapping("/{titleId}") + @PreAuthorize("hasRole('ADMIN')") + @SecurityRequirement(name = BEARER_AUTH) + @Operation(operationId = "updateTitle", summary = "Replace a title's core fields", + description = "Admin-only. Full replace with optimistic locking - the request's version field " + + "must match the row's current version or the update is rejected with 409.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Title updated; response body is the new state"), + @ApiResponse(responseCode = "400", description = "Request failed validation"), + @ApiResponse(responseCode = "401", description = "No valid Bearer token supplied"), + @ApiResponse(responseCode = "403", description = "Caller is authenticated but not an admin"), + @ApiResponse(responseCode = "404", description = "No title with that id"), + @ApiResponse(responseCode = "409", description = "version does not match the current row - refresh and retry") + }) + public TitleCore update(@Parameter(description = "IMDb-style title id, e.g. tt0111161") @PathVariable String titleId, + @Valid @RequestBody UpdateTitleRequest request) { + return titleAdminUseCase.update(titleId, request); + } + + @PatchMapping("/{titleId}") + @PreAuthorize("hasRole('ADMIN')") + @SecurityRequirement(name = BEARER_AUTH) + @Operation(operationId = "patchTitle", summary = "Partially update a title", + description = "Admin-only. Merge-patch semantics - only fields present in the request body are " + + "changed, everything else keeps its current value. Still requires the current version.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Title updated; response body is the new state"), + @ApiResponse(responseCode = "400", description = "Request failed validation"), + @ApiResponse(responseCode = "401", description = "No valid Bearer token supplied"), + @ApiResponse(responseCode = "403", description = "Caller is authenticated but not an admin"), + @ApiResponse(responseCode = "404", description = "No title with that id"), + @ApiResponse(responseCode = "409", description = "version does not match the current row - refresh and retry") + }) + public TitleCore patch(@Parameter(description = "IMDb-style title id, e.g. tt0111161") @PathVariable String titleId, + @RequestBody PatchTitleRequest request) { + return titleAdminUseCase.patch(titleId, request); + } + + @DeleteMapping("/{titleId}") + @ResponseStatus(HttpStatus.NO_CONTENT) + @PreAuthorize("hasRole('ADMIN')") + @SecurityRequirement(name = BEARER_AUTH) + @Operation(operationId = "deleteTitle", summary = "Soft-delete a title", + description = "Admin-only. Marks the title as deleted - it stops appearing in search, detail, " + + "and top-rated results, but existing cast/crew credit records that reference it are " + + "left intact.") + @ApiResponses({ + @ApiResponse(responseCode = "204", description = "Title deleted"), + @ApiResponse(responseCode = "401", description = "No valid Bearer token supplied"), + @ApiResponse(responseCode = "403", description = "Caller is authenticated but not an admin"), + @ApiResponse(responseCode = "404", description = "No title with that id") + }) + public void delete(@Parameter(description = "IMDb-style title id, e.g. tt0111161") @PathVariable String titleId) { + titleAdminUseCase.delete(titleId); + } + + @PutMapping("/{titleId}/crew") + @PreAuthorize("hasRole('ADMIN')") + @SecurityRequirement(name = BEARER_AUTH) + @Operation(operationId = "upsertTitleCrew", summary = "Set a title's directors and writers", + description = "Admin-only. Replaces the full directors/writers list for the title in one call " + + "- this is a full replace, not a partial add.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Crew updated"), + @ApiResponse(responseCode = "400", description = "Request references a malformed person id"), + @ApiResponse(responseCode = "401", description = "No valid Bearer token supplied"), + @ApiResponse(responseCode = "403", description = "Caller is authenticated but not an admin"), + @ApiResponse(responseCode = "404", description = "No title with that id") + }) + public void upsertCrew(@Parameter(description = "IMDb-style title id, e.g. tt0111161") @PathVariable String titleId, + @RequestBody CrewRequest request) { + titleAdminUseCase.upsertCrew(titleId, request); + } + + @PutMapping("/{titleId}/rating") + @PreAuthorize("hasRole('ADMIN')") + @SecurityRequirement(name = BEARER_AUTH) + @Operation(operationId = "upsertTitleRating", summary = "Set a title's official rating", + description = "Admin-only. Creates or overwrites the title's average rating and vote count; " + + "also revives a previously soft-deleted rating row if one existed.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Rating set"), + @ApiResponse(responseCode = "400", description = "Request failed validation (rating range, vote count)"), + @ApiResponse(responseCode = "401", description = "No valid Bearer token supplied"), + @ApiResponse(responseCode = "403", description = "Caller is authenticated but not an admin"), + @ApiResponse(responseCode = "404", description = "No title with that id") + }) + public void upsertRating(@Parameter(description = "IMDb-style title id, e.g. tt0111161") @PathVariable String titleId, + @Valid @RequestBody RatingRequest request) { + titleAdminUseCase.upsertRating(titleId, request); + } + + @DeleteMapping("/{titleId}/rating") + @ResponseStatus(HttpStatus.NO_CONTENT) + @PreAuthorize("hasRole('ADMIN')") + @SecurityRequirement(name = BEARER_AUTH) + @Operation(operationId = "deleteTitleRating", summary = "Remove a title's official rating", + description = "Admin-only. Soft-deletes the rating row; the title's IMDb rating then reads as " + + "absent until a new rating is set.") + @ApiResponses({ + @ApiResponse(responseCode = "204", description = "Rating removed"), + @ApiResponse(responseCode = "401", description = "No valid Bearer token supplied"), + @ApiResponse(responseCode = "403", description = "Caller is authenticated but not an admin"), + @ApiResponse(responseCode = "404", description = "No title with that id, or it has no rating to remove") + }) + public void deleteRating(@Parameter(description = "IMDb-style title id, e.g. tt0111161") @PathVariable String titleId) { + titleAdminUseCase.deleteRating(titleId); + } + + @GetMapping("/{titleId}/principals") + @Operation(operationId = "getTitlePrincipals", summary = "List every cast/crew credit for a title", + description = "Returns the full, uncapped list of principal credits (unlike title detail's " + + "top-N cast), ordered by billing order. Publicly accessible; returns an empty list, " + + "not 404, for an unknown title id.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "List of credits (possibly empty)"), + @ApiResponse(responseCode = "400", description = "titleId is not a valid tt-prefixed id") + }) + public List getAllPrincipals( + @Parameter(description = "IMDb-style title id, e.g. tt0111161") @PathVariable String titleId) { + return titleAdminUseCase.getAllPrincipals(titleId); + } + + @PostMapping("/{titleId}/principals") + @ResponseStatus(HttpStatus.CREATED) + @PreAuthorize("hasRole('ADMIN')") + @SecurityRequirement(name = BEARER_AUTH) + @Operation(operationId = "addTitlePrincipal", summary = "Add a cast/crew credit to a title", + description = "Admin-only. Inserts one principal credit at the given billing order.") + @ApiResponses({ + @ApiResponse(responseCode = "201", description = "Credit added"), + @ApiResponse(responseCode = "400", description = "Request failed validation"), + @ApiResponse(responseCode = "401", description = "No valid Bearer token supplied"), + @ApiResponse(responseCode = "403", description = "Caller is authenticated but not an admin"), + @ApiResponse(responseCode = "404", description = "No title with that id") + }) + public void addPrincipal(@Parameter(description = "IMDb-style title id, e.g. tt0111161") @PathVariable String titleId, + @Valid @RequestBody PrincipalRequest request) { + titleAdminUseCase.addPrincipal(titleId, request); + } + + @PutMapping("/{titleId}/principals/{ordering}") + @PreAuthorize("hasRole('ADMIN')") + @SecurityRequirement(name = BEARER_AUTH) + @Operation(operationId = "updateTitlePrincipal", summary = "Update a cast/crew credit", + description = "Admin-only. Updates the category/job/characters for the credit at the given " + + "billing order. Note: unlike other admin writes on this API, a credit that doesn't " + + "exist is not distinguished from a stale version - both report 409, since the " + + "composite (titleId, ordering) key is something the caller must already know.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Credit updated"), + @ApiResponse(responseCode = "400", description = "Request failed validation"), + @ApiResponse(responseCode = "401", description = "No valid Bearer token supplied"), + @ApiResponse(responseCode = "403", description = "Caller is authenticated but not an admin"), + @ApiResponse(responseCode = "409", + description = "expectedVersion does not match the current row, or no credit exists at that ordering") + }) + public void updatePrincipal(@Parameter(description = "IMDb-style title id, e.g. tt0111161") @PathVariable String titleId, + @Parameter(description = "Billing order of the credit to update, from getTitlePrincipals") + @PathVariable int ordering, + @Valid @RequestBody PrincipalRequest request, + @Parameter(description = "Version read from the credit being updated, for optimistic locking") + @RequestParam int expectedVersion) { + titleAdminUseCase.updatePrincipal(titleId, ordering, request, expectedVersion); + } + + @DeleteMapping("/{titleId}/principals/{ordering}") + @ResponseStatus(HttpStatus.NO_CONTENT) + @PreAuthorize("hasRole('ADMIN')") + @SecurityRequirement(name = BEARER_AUTH) + @Operation(operationId = "deleteTitlePrincipal", summary = "Remove a cast/crew credit", + description = "Admin-only. Soft-deletes the credit at the given billing order.") + @ApiResponses({ + @ApiResponse(responseCode = "204", description = "Credit removed"), + @ApiResponse(responseCode = "401", description = "No valid Bearer token supplied"), + @ApiResponse(responseCode = "403", description = "Caller is authenticated but not an admin"), + @ApiResponse(responseCode = "404", description = "No credit exists at that ordering for that title") + }) + public void deletePrincipal(@Parameter(description = "IMDb-style title id, e.g. tt0111161") @PathVariable String titleId, + @Parameter(description = "Billing order of the credit to remove, from getTitlePrincipals") + @PathVariable int ordering) { + titleAdminUseCase.deletePrincipal(titleId, ordering); + } +} diff --git a/imdb/src/main/java/com/ludovictemgoua/imdb/presentation/UserController.java b/imdb/src/main/java/com/ludovictemgoua/imdb/presentation/UserController.java new file mode 100644 index 0000000..d777d1c --- /dev/null +++ b/imdb/src/main/java/com/ludovictemgoua/imdb/presentation/UserController.java @@ -0,0 +1,147 @@ +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 io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +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; + +import static com.ludovictemgoua.imdb.infrastructure.openapi.OpenApiConfig.BEARER_AUTH; + +@RestController +@Validated +@Tag(name = "Users", description = "Account profile management and admin user administration") +public class UserController { + + private final UserUseCase userUseCase; + + public UserController(UserUseCase userUseCase) { + this.userUseCase = userUseCase; + } + + @GetMapping("/api/v1/users/me") + @SecurityRequirement(name = BEARER_AUTH) + @Operation(operationId = "getOwnProfile", summary = "Get the authenticated user's full profile", + description = "Returns the caller's own profile, including fields not exposed on the public " + + "profile view (email, role, version).") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Caller's profile"), + @ApiResponse(responseCode = "401", description = "No valid Bearer token supplied") + }) + public UserProfile getOwn(Authentication authentication) { + return userUseCase.getOwnProfile(CurrentUser.requireId(authentication)); + } + + @PutMapping("/api/v1/users/me") + @SecurityRequirement(name = BEARER_AUTH) + @Operation(operationId = "updateOwnProfile", summary = "Update the authenticated user's profile", + description = "Updates display name and bio with optimistic locking - the request's version " + + "field must match the row's current version or the update is rejected with 409.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Profile updated; response body is the new state"), + @ApiResponse(responseCode = "400", description = "Request failed validation"), + @ApiResponse(responseCode = "401", description = "No valid Bearer token supplied"), + @ApiResponse(responseCode = "409", description = "version does not match the current row - refresh and retry") + }) + 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) + @SecurityRequirement(name = BEARER_AUTH) + @Operation(operationId = "deleteOwnAccount", summary = "Soft-delete the authenticated user's account", + description = "Self-service account deletion.") + @ApiResponses({ + @ApiResponse(responseCode = "204", description = "Account deleted"), + @ApiResponse(responseCode = "401", description = "No valid Bearer token supplied") + }) + public void deleteOwn(Authentication authentication) { + userUseCase.deleteOwnAccount(CurrentUser.requireId(authentication)); + } + + @GetMapping("/api/v1/users/{userId}") + @Operation(operationId = "getPublicUserProfile", summary = "Get a user's public profile", + description = "Returns only the fields safe to expose to anyone (id, display name) - no " + + "email, role, or other private data. Publicly accessible.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Public profile"), + @ApiResponse(responseCode = "404", description = "No user with that id") + }) + public PublicUserProfile getPublicProfile( + @Parameter(description = "Numeric user id") @PathVariable int userId) { + return userUseCase.getPublicProfile(userId); + } + + @GetMapping("/api/v1/users") + @PreAuthorize("hasRole('ADMIN')") + @SecurityRequirement(name = BEARER_AUTH) + @Operation(operationId = "listAllUsers", summary = "List all user accounts", + description = "Admin-only. Paged list of every account's full profile.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Paged list of user profiles"), + @ApiResponse(responseCode = "401", description = "No valid Bearer token supplied"), + @ApiResponse(responseCode = "403", description = "Caller is authenticated but not an admin") + }) + public PagedResult listAll( + @Parameter(description = "Zero-based page number") @RequestParam(defaultValue = "0") @Min(0) int page, + @Parameter(description = "Results per page") @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size) { + return userUseCase.listAll(page, size); + } + + @PutMapping("/api/v1/users/{userId}/role") + @PreAuthorize("hasRole('ADMIN')") + @SecurityRequirement(name = BEARER_AUTH) + @Operation(operationId = "updateUserRole", summary = "Change a user's role", + description = "Admin-only. Promotes or demotes a user between USER and ADMIN.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Role updated"), + @ApiResponse(responseCode = "400", description = "Request failed validation"), + @ApiResponse(responseCode = "401", description = "No valid Bearer token supplied"), + @ApiResponse(responseCode = "403", description = "Caller is authenticated but not an admin"), + @ApiResponse(responseCode = "404", description = "No user with that id") + }) + public void updateRole(@Parameter(description = "Numeric user id") @PathVariable int userId, + @Valid @RequestBody RoleRequest request) { + userUseCase.updateRole(userId, request.role()); + } + + @DeleteMapping("/api/v1/users/{userId}") + @ResponseStatus(HttpStatus.NO_CONTENT) + @PreAuthorize("hasRole('ADMIN')") + @SecurityRequirement(name = BEARER_AUTH) + @Operation(operationId = "deleteUserAccount", summary = "Soft-delete any user's account", + description = "Admin-only. Same effect as self-service deletion, callable against any account.") + @ApiResponses({ + @ApiResponse(responseCode = "204", description = "Account deleted"), + @ApiResponse(responseCode = "401", description = "No valid Bearer token supplied"), + @ApiResponse(responseCode = "403", description = "Caller is authenticated but not an admin"), + @ApiResponse(responseCode = "404", description = "No user with that id") + }) + public void deleteAccount(@Parameter(description = "Numeric user id") @PathVariable int userId) { + userUseCase.deleteAccount(userId); + } +} diff --git a/imdb/src/main/java/com/ludovictemgoua/imdb/presentation/WatchlistController.java b/imdb/src/main/java/com/ludovictemgoua/imdb/presentation/WatchlistController.java new file mode 100644 index 0000000..647b09a --- /dev/null +++ b/imdb/src/main/java/com/ludovictemgoua/imdb/presentation/WatchlistController.java @@ -0,0 +1,106 @@ +package com.ludovictemgoua.imdb.presentation; + +import com.ludovictemgoua.imdb.application.rest.AddWatchlistItemRequest; +import com.ludovictemgoua.imdb.application.rest.VisibilityRequest; +import com.ludovictemgoua.imdb.application.contracts.WatchlistUseCase; +import com.ludovictemgoua.imdb.domain.model.WatchlistView; +import com.ludovictemgoua.imdb.infrastructure.security.CurrentUser; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +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; + +import static com.ludovictemgoua.imdb.infrastructure.openapi.OpenApiConfig.BEARER_AUTH; + +@RestController +@Tag(name = "Watchlist", description = "Per-user watchlist of titles to watch later") +public class WatchlistController { + + private final WatchlistUseCase watchlistUseCase; + + public WatchlistController(WatchlistUseCase watchlistUseCase) { + this.watchlistUseCase = watchlistUseCase; + } + + @GetMapping("/api/v1/watchlist") + @SecurityRequirement(name = BEARER_AUTH) + @Operation(operationId = "getOwnWatchlist", summary = "Get the authenticated user's watchlist", + description = "Creates an empty PRIVATE watchlist on first access if the user doesn't have one yet.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Caller's watchlist and its items"), + @ApiResponse(responseCode = "401", description = "No valid Bearer token supplied") + }) + public WatchlistView getOwn(Authentication authentication) { + return watchlistUseCase.getOwn(CurrentUser.requireId(authentication)); + } + + @PostMapping("/api/v1/watchlist/items") + @ResponseStatus(HttpStatus.CREATED) + @SecurityRequirement(name = BEARER_AUTH) + @Operation(operationId = "addWatchlistItem", summary = "Add a title to the authenticated user's watchlist", + description = "No-op if the title is already on the watchlist.") + @ApiResponses({ + @ApiResponse(responseCode = "201", description = "Title added (or already present)"), + @ApiResponse(responseCode = "400", description = "titleId is not a valid tt-prefixed id"), + @ApiResponse(responseCode = "401", description = "No valid Bearer token supplied") + }) + 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) + @SecurityRequirement(name = BEARER_AUTH) + @Operation(operationId = "removeWatchlistItem", summary = "Remove a title from the authenticated user's watchlist", + description = "No-op if the title isn't on the watchlist.") + @ApiResponses({ + @ApiResponse(responseCode = "204", description = "Title removed (or was never present)"), + @ApiResponse(responseCode = "400", description = "titleId is not a valid tt-prefixed id"), + @ApiResponse(responseCode = "401", description = "No valid Bearer token supplied") + }) + public void removeItem(Authentication authentication, + @Parameter(description = "IMDb-style title id, e.g. tt0111161") @PathVariable String titleId) { + watchlistUseCase.removeItem(CurrentUser.requireId(authentication), titleId); + } + + @PutMapping("/api/v1/watchlist/visibility") + @SecurityRequirement(name = BEARER_AUTH) + @Operation(operationId = "updateWatchlistVisibility", summary = "Change the authenticated user's watchlist visibility", + description = "PRIVATE (the default) or PUBLIC; a PUBLIC watchlist becomes viewable by anyone " + + "via getUserWatchlist.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Visibility updated"), + @ApiResponse(responseCode = "400", description = "Request failed validation"), + @ApiResponse(responseCode = "401", description = "No valid Bearer token supplied") + }) + public void updateVisibility(Authentication authentication, @Valid @RequestBody VisibilityRequest request) { + watchlistUseCase.updateVisibility(CurrentUser.requireId(authentication), request.visibility()); + } + + @GetMapping("/api/v1/users/{userId}/watchlist") + @Operation(operationId = "getUserWatchlist", summary = "View another user's watchlist", + description = "Publicly accessible. Returns 404 if the watchlist is PRIVATE and the caller " + + "isn't its owner - existence is hidden, not just access-denied. Passing a valid " + + "Bearer token for the owner also reveals a PRIVATE watchlist.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "The watchlist and its items"), + @ApiResponse(responseCode = "404", description = "No user with that id, or their watchlist is PRIVATE") + }) + public WatchlistView getForUser(Authentication authentication, + @Parameter(description = "Numeric user id") @PathVariable int userId) { + return watchlistUseCase.getForUser(CurrentUser.idOf(authentication), userId); + } +} diff --git a/imdb/src/main/java/com/ludovictemgoua/imdb/utils/HeaderSanitizer.java b/imdb/src/main/java/com/ludovictemgoua/imdb/utils/HeaderSanitizer.java new file mode 100644 index 0000000..b19d76b --- /dev/null +++ b/imdb/src/main/java/com/ludovictemgoua/imdb/utils/HeaderSanitizer.java @@ -0,0 +1,29 @@ +package com.ludovictemgoua.imdb.utils; + +import java.util.LinkedHashMap; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +public final class HeaderSanitizer { + + private static final String REDACTED = "***REDACTED***"; + + // This API has no auth today, but request headers get logged wholesale by RequestLoggingFilter - + // redacting known-sensitive ones here means that logging is already safe the moment auth (or any + // header carrying a secret) is added, rather than something to remember to retrofit later. + private static final Set SENSITIVE_HEADERS = + Set.of("authorization", "cookie", "set-cookie", "x-api-key"); + + private HeaderSanitizer() { + } + + public static Map sanitize(Map headers) { + Map sanitized = new LinkedHashMap<>(); + for (Map.Entry entry : headers.entrySet()) { + boolean sensitive = SENSITIVE_HEADERS.contains(entry.getKey().toLowerCase(Locale.ROOT)); + sanitized.put(entry.getKey(), sensitive ? REDACTED : entry.getValue()); + } + return sanitized; + } +} diff --git a/imdb/src/main/java/com/ludovictemgoua/imdb/utils/ImdbIds.java b/imdb/src/main/java/com/ludovictemgoua/imdb/utils/ImdbIds.java new file mode 100644 index 0000000..480ec9c --- /dev/null +++ b/imdb/src/main/java/com/ludovictemgoua/imdb/utils/ImdbIds.java @@ -0,0 +1,34 @@ +package com.ludovictemgoua.imdb.utils; + +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); + } +} diff --git a/imdb/src/main/resources/application.yaml b/imdb/src/main/resources/application.yaml new file mode 100644 index 0000000..d10d28e --- /dev/null +++ b/imdb/src/main/resources/application.yaml @@ -0,0 +1,119 @@ +spring: + application: + name: imdb + datasource: + url: jdbc:postgresql://localhost:5432/imdb + username: imdb + password: password + hikari: + # Default (10) was completely swamped by k6's 100-VU search load test - HikariPool logs showed + # total=10, active=10, idle=0, waiting=46, cascading into 30s connection-acquisition timeouts. + # 30 gave headroom for that isolated single-endpoint load, but k6/all-endpoints.js (which runs + # browsing/userJourney/adminWrites scenarios simultaneously, peaking around 105 combined VUs) + # exhausted it again - CannotGetJdbcConnectionException on nearly every repository, not just one + # endpoint, confirming pool-wide starvation rather than a bug in any single code path. 60 still + # leaves comfortable headroom under Postgres's max_connections=100 (pg_stat_activity showed only + # ~4 non-pool connections in use - exporter plus incidental admin sessions). + maximum-pool-size: 60 + data: + redis: + host: localhost + port: 6379 + flyway: + enabled: true + # abanda/imdb-postgresql keeps bouncing its own listener throughout the (up to 30 minute) + # background data import, well after the container's healthcheck first reports healthy - + # discovered empirically while bringing this stack up. Flyway's own retry loop rides out those + # gaps directly instead of the whole application failing to start on an unlucky first attempt. + connect-retries: 180 + connect-retries-interval: 10s + # abanda/imdb-postgresql's own init.sh populates the `public` schema (name_basics, title_basics, + # etc.) via raw psql/imdblib, entirely outside Flyway - so on a freshly imported database Flyway + # sees a non-empty schema with no history table and refuses to proceed. baseline-version: 0 tells + # it to treat V0 (the CREATE TABLE IF NOT EXISTS base schema, already satisfied by imdblib's + # tables) as the starting point, and only apply V1/V2 on top. Has no effect on the empty schema + # Testcontainers spins up for integration tests - Flyway just runs V0-V2 there as normal. + baseline-on-migrate: true + baseline-version: 0 + +management: + endpoints: + web: + exposure: + include: health, prometheus + tracing: + sampling: + probability: 1.0 + opentelemetry: + tracing: + export: + otlp: + endpoint: http://localhost:4318/v1/traces + # Adding the "OpenTelemetry" Initializr module also auto-configures a push-based OTLP metrics + # exporter (defaulting to localhost:4318/v1/metrics, which inside a container doesn't resolve to + # anything useful) - discovered from a live ConnectException in the logs. Metrics were always meant + # to go via Prometheus pull-scrape only (LLD dependency table, `/actuator/prometheus`), not push, so + # this second unwanted exporter is disabled outright rather than pointed anywhere. Note the + # inconsistent Boot 4.1 property namespace: metrics kept "management.otlp.*" while tracing moved to + # "management.opentelemetry.tracing.*" above - confirmed against OtlpMetricsProperties directly, not + # guessed, since the two don't mirror each other. + otlp: + metrics: + export: + enabled: false + metrics: + distribution: + percentiles-histogram: + http.server.requests: true + +six-degrees: + side-cap: 4 + absolute-max-degree: 7 + # fan-out-cap removed: the V3 find_shortest_co_star_path function does a real BFS with a genuine + # visited set, so it never needs to arbitrarily drop neighbors to bound the search - it expands + # every unvisited neighbor of the current frontier, which is what fixed a real bug (the old + # capped-and-ordered fan-out could silently miss the true shortest path). + # Tried raising this to 5 after fixing the missing title_principals.nconst index (V4) - did not + # help: the ~25-30% failure rate under k6 load stayed essentially the same, it just made each + # failing request wait 5s instead of 2s before erroring. That rules out "just needs a bit more + # time" - there's a persistent population of genuinely hard pairs (likely: search reaches a large + # hub partway through expansion with no intersection yet, and processing that hub's full neighbor + # set for one level is itself expensive) that needs its own investigation, tracked as a follow-up + # rather than solved by this knob. Reverted to 2 - failing fast is better than failing slow when + # the timeout isn't fixing the underlying issue either way. + query-timeout-seconds: 2 + +top-rated: + default-min-votes: 1000 + +imdb: + jwt: + # No default - must be set via the JWT_SECRET env var (>= 32 bytes for HS256). Deliberately + # absent here rather than defaulted to something committed, since this signs every access/ + # refresh token. Maven Surefire/Failsafe set a fixed test-only value for all test runs (pom.xml). + secret: ${JWT_SECRET} + 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:} + +logging: + structured: + format: + # Boot 4.1's native structured logging (org.springframework.boot.logging.structured, verified + # directly in the spring-boot-4.1.0 jar) - no logstash-logback-encoder or other dependency + # needed. "logstash" is a plain, schema-agnostic flat JSON shape (timestamp/level/logger/ + # message + all MDC entries) - the right choice for Loki, which doesn't care about a specific + # schema, unlike ECS (Elastic-specific) or GELF (Graylog-specific). MDC entries - traceId/ + # spanId (already populated by Micrometer Tracing) and requestId (RequestLoggingFilter, + # presentation layer) - are included in every line automatically, no per-log-statement wiring. + console: logstash + level: + root: INFO + # Overridable per-environment via the standard Spring Boot env var + # (LOGGING_LEVEL_COM_LUDOVICTEMGOUA_IMDB=DEBUG) with no code change - e.g. to get per-query + # detail from infrastructure.persistence/cache while debugging locally, without turning up noise + # from the framework itself (Spring/Hikari/Tomcat stay at root's INFO). + com.ludovictemgoua.imdb: INFO diff --git a/imdb/src/main/resources/db/migration/V0__base_schema.sql b/imdb/src/main/resources/db/migration/V0__base_schema.sql new file mode 100644 index 0000000..72ea3f1 --- /dev/null +++ b/imdb/src/main/resources/db/migration/V0__base_schema.sql @@ -0,0 +1,78 @@ +-- Both enums mirror what abanda/imdb-postgresql's own imdblib import actually creates in the real +-- dev database (title_basics.title_type, title_principals.category) - confirmed via +-- information_schema against the live container, not assumed. This migration only ever executes +-- against a genuinely empty schema (Testcontainers; the real dev DB is baselined at V0 and never +-- runs this file), so matching production here is what keeps the two environments' schemas from +-- silently drifting apart. Guarded with a DO block since CREATE TYPE has no IF NOT EXISTS clause. +DO $$ BEGIN + CREATE TYPE title_type AS ENUM ( + 'movie', 'short', 'tvEpisode', 'tvMiniSeries', 'tvMovie', 'tvPilot', + 'tvSeries', 'tvShort', 'tvSpecial', 'video', 'videoGame' + ); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +DO $$ BEGIN + CREATE TYPE category AS ENUM ( + 'actor', 'actress', 'self', 'writer', 'director', 'producer', 'editor', + 'cinematographer', 'composer', 'production_designer', 'casting_director', + 'archive_footage', 'archive_sound' + ); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +DO $$ BEGIN + CREATE TYPE genre AS ENUM ( + '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' + ); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +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 title_type 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 genre[] +); + +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 category NOT NULL, + job TEXT, + characters TEXT[], + PRIMARY KEY (tconst, ordering) +); diff --git a/imdb/src/main/resources/db/migration/V10__lists.sql b/imdb/src/main/resources/db/migration/V10__lists.sql new file mode 100644 index 0000000..8288334 --- /dev/null +++ b/imdb/src/main/resources/db/migration/V10__lists.sql @@ -0,0 +1,16 @@ +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) +); diff --git a/imdb/src/main/resources/db/migration/V11__fix_admin_id_sequence_baselines.sql b/imdb/src/main/resources/db/migration/V11__fix_admin_id_sequence_baselines.sql new file mode 100644 index 0000000..a92217d --- /dev/null +++ b/imdb/src/main/resources/db/migration/V11__fix_admin_id_sequence_baselines.sql @@ -0,0 +1,36 @@ +-- V6 seeded title_id_seq/person_id_seq from each id's own table only (max(tconst) FROM title_basics, +-- max(nconst) FROM name_basics) - discovered under k6 admin-write load testing that title_principals +-- (and, in principle, title_ratings/title_crew/name_basics.known_for_titles) can reference a tconst +-- higher than title_basics' own max: raw IMDb exports are dumped as a set of independently-snapshotted +-- files, so title.principals.tsv can reference a title that title.basics.tsv's own snapshot doesn't +-- have a row for (confirmed live: 6830 such orphaned title_principals rows on this dataset). Once +-- title_id_seq's nextval() reached one of those orphaned tconsts, POST /titles/{id}/principals started +-- failing with a title_principals_pkey duplicate-key error - a real, already-imported credit sitting at +-- the exact (tconst, ordering) an admin-created title was about to reuse. Recomputing the baseline as +-- the greatest tconst/nconst referenced ANYWHERE in the schema, not just each id's own table, closes +-- the gap. person_id_seq isn't currently colliding (admin person creation during this session's testing +-- already pushed it past every orphaned nconst), but has the identical latent exposure on a fresh +-- import, so it's corrected here too rather than waiting for its own load-test failure to prove it. +DO $$ +DECLARE + next_title_id BIGINT; + next_person_id BIGINT; +BEGIN + SELECT GREATEST( + COALESCE((SELECT max(tconst) FROM title_basics), 0), + COALESCE((SELECT max(tconst) FROM title_ratings), 0), + COALESCE((SELECT max(tconst) FROM title_crew), 0), + COALESCE((SELECT max(tconst) FROM title_principals), 0), + COALESCE((SELECT max(t) FROM (SELECT unnest(known_for_titles) AS t FROM name_basics) k), 0) + ) + 1 INTO next_title_id; + + SELECT GREATEST( + COALESCE((SELECT max(nconst) FROM name_basics), 0), + COALESCE((SELECT max(nconst) FROM title_principals), 0), + COALESCE((SELECT max(n) FROM (SELECT unnest(directors) AS n FROM title_crew) d), 0), + COALESCE((SELECT max(n) FROM (SELECT unnest(writers) AS n FROM title_crew) w), 0) + ) + 1 INTO next_person_id; + + PERFORM setval('title_id_seq', next_title_id, false); + PERFORM setval('person_id_seq', next_person_id, false); +END $$; diff --git a/imdb/src/main/resources/db/migration/V1__extensions_and_search_indexes.sql b/imdb/src/main/resources/db/migration/V1__extensions_and_search_indexes.sql new file mode 100644 index 0000000..248aa84 --- /dev/null +++ b/imdb/src/main/resources/db/migration/V1__extensions_and_search_indexes.sql @@ -0,0 +1,53 @@ +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); + +-- title_basics.genres is GENRE[] (a custom enum array, V0) - genres::text[] works as a query-time +-- cast, but Postgres won't accept a bare cast in an index expression since it can't prove the +-- underlying enum-array-to-text-array cast function is IMMUTABLE. Wrapping it in our own SQL +-- function marked IMMUTABLE (accepting anyarray, so it still works if this column is ever plain +-- text[] too) sidesteps that - discovered when the plain ::text[] version failed at migration time +-- against the real, freshly-imported dataset with "functions in index expression must be marked +-- IMMUTABLE". JdbcTitleRepository's genre filter query must use this same function, not a raw cast, +-- so the planner recognizes the expression as matching the index. +CREATE OR REPLACE FUNCTION genres_as_text(anyarray) RETURNS text[] + LANGUAGE sql IMMUTABLE PARALLEL SAFE AS $$ SELECT $1::text[] $$; + +CREATE INDEX idx_title_basics_genres + ON title_basics USING gin (genres_as_text(genres)); + +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); + +-- Discovered under k6 load testing: short/common search terms (e.g. "man", "war", "day" - the bulk +-- of realistic single-word title searches) make pg_trgm's `%` operator match an enormous fraction of +-- the table, since a 3-4 letter string has very few distinct trigrams. Raising +-- pg_trgm.similarity_threshold does NOT help - EXPLAIN ANALYZE showed the GIN bitmap index scan +-- returns the exact same huge candidate set (832K+ rows for "man") regardless of threshold, because +-- the threshold is only applied as a post-fetch recheck, not at the index scan itself. The actual +-- fix is gin_fuzzy_search_limit, which caps how many rows a GIN index scan will return for a +-- low-selectivity query term: took the "man" query from 39.6s / 176,300 buffer reads down to ~120ms +-- / ~3,000 buffer reads, and turned a 100%-failure k6 run into a 100%-pass, p95=4.45ms run. +-- Trade-off (accepted deliberately): `totalElements` in the search response becomes an +-- under-count for very common terms, since it's counting a sampled candidate set rather than the +-- true total (Postgres's own docs describe gin_fuzzy_search_limit results as "likely to be +-- incomplete" - expected and fine for a fuzzy-search results page, not fine for anything relying on +-- an exact count). Set at the database level so every pooled HikariCP connection picks it up +-- automatically, without a per-query SET statement or Java code change. Uses current_database() +-- rather than hardcoding "imdb" - Testcontainers' PostgreSQLContainer defaults to a database named +-- "test", and this migration runs there too. +DO $$ +BEGIN + EXECUTE format('ALTER DATABASE %I SET gin_fuzzy_search_limit = 5000', current_database()); +END $$; diff --git a/imdb/src/main/resources/db/migration/V2__co_star_edges_materialized_view.sql b/imdb/src/main/resources/db/migration/V2__co_star_edges_materialized_view.sql new file mode 100644 index 0000000..59e006c --- /dev/null +++ b/imdb/src/main/resources/db/migration/V2__co_star_edges_materialized_view.sql @@ -0,0 +1,10 @@ +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); diff --git a/imdb/src/main/resources/db/migration/V3__shortest_co_star_path_function.sql b/imdb/src/main/resources/db/migration/V3__shortest_co_star_path_function.sql new file mode 100644 index 0000000..b406e70 --- /dev/null +++ b/imdb/src/main/resources/db/migration/V3__shortest_co_star_path_function.sql @@ -0,0 +1,149 @@ +-- Replaces an earlier single-statement bidirectional recursive CTE that had two real bugs, both +-- found under real load/data (not review): +-- +-- 1. Correctness: the old query capped each node's fan-out 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 would report "no path found" (or a longer path) even though a real, +-- shorter path existed. +-- 2. Performance: cycle prevention only checked that a single path didn't revisit its own +-- history (`NOT nbr.person_b = ANY(path)`) - it had 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, causing genuine combinatorial blowup. 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. +-- +-- This function is a proper level-synchronized bidirectional BFS: real visited-set temp tables +-- per side (so every node is expanded at most once, ever - no redundant re-expansion, no +-- arbitrary cap needed to drop real neighbors), expanding whichever side currently has the +-- smaller frontier (the standard bidirectional-BFS optimization - keeps total work minimal), and +-- stopping the instant the two frontiers intersect (true shortest path, found as early as +-- possible - most real pairs resolve within 1-2 hops). Verified against the exact pathological +-- pair that broke the old query (two ~8,000-co-star hub nodes, no direct edge): 44ms, correct +-- degree-2 result, versus 3+ minutes before. +CREATE OR REPLACE FUNCTION find_shortest_co_star_path( + p_person_a INTEGER, + p_person_b INTEGER, + p_side_cap INTEGER, + p_absolute_max_degree INTEGER +) RETURNS TABLE(result_degree INTEGER, result_path INTEGER[]) AS $$ +DECLARE + v_new_forward INTEGER[]; + v_new_backward INTEGER[]; + v_meeting INTEGER; + v_fwd_depth INTEGER := 0; + v_bwd_depth INTEGER := 0; + v_frontier_forward INTEGER[] := ARRAY[p_person_a]; + v_frontier_backward INTEGER[] := ARRAY[p_person_b]; + -- Tie-break flag for when both frontiers are the same size (see WHILE loop below) - without + -- this, a plain "<=" comparison always favors forward on ties, which completely starves the + -- backward side for any non-branching chain (frontier size stays 1 = 1 forever), since forward + -- would keep winning every tie until it alone hits side_cap. Flipped after every tie-driven + -- choice so both sides make progress. Found by a failing integration test on a 5-edge linear + -- chain (1-2-3-4-5-6), not by review. + v_prefer_forward BOOLEAN := true; +BEGIN + IF p_person_a = p_person_b THEN + RETURN QUERY SELECT 0, ARRAY[p_person_a]; + RETURN; + END IF; + + -- Session-scoped temp tables, reused across calls on the same pooled connection rather than + -- ON COMMIT DROP - a call wrapped in a caller-managed transaction that runs this function more + -- than once before committing (e.g. a @Transactional test calling findShortestPath twice) would + -- hit "relation already exists" on the second call, since ON COMMIT DROP only cleans up at + -- actual commit, not between statements in an open transaction. TRUNCATE instead, which is + -- correct regardless of transaction/session boundaries. + CREATE TEMP TABLE IF NOT EXISTS visited_forward (person INTEGER PRIMARY KEY, parent INTEGER); + CREATE TEMP TABLE IF NOT EXISTS visited_backward (person INTEGER PRIMARY KEY, parent INTEGER); + TRUNCATE visited_forward, visited_backward; + INSERT INTO visited_forward VALUES (p_person_a, NULL); + INSERT INTO visited_backward VALUES (p_person_b, NULL); + + WHILE v_fwd_depth + v_bwd_depth < p_absolute_max_degree + AND v_fwd_depth < p_side_cap AND v_bwd_depth < p_side_cap + AND (array_length(v_frontier_forward, 1) IS NOT NULL OR array_length(v_frontier_backward, 1) IS NOT NULL) + LOOP + IF array_length(v_frontier_forward, 1) IS NOT NULL + AND (array_length(v_frontier_backward, 1) IS NULL + OR array_length(v_frontier_forward, 1) < array_length(v_frontier_backward, 1) + OR (array_length(v_frontier_forward, 1) = array_length(v_frontier_backward, 1) AND v_prefer_forward)) THEN + + WITH new_nodes AS ( + INSERT INTO visited_forward (person, parent) + SELECT x.person_b, x.parent FROM ( + SELECT DISTINCT ON (e.person_b) e.person_b, e.person_a AS parent + FROM co_star_edges e + WHERE e.person_a = ANY(v_frontier_forward) + AND NOT EXISTS (SELECT 1 FROM visited_forward vf WHERE vf.person = e.person_b) + ORDER BY e.person_b + ) x + RETURNING person + ) + SELECT array_agg(person) INTO v_new_forward FROM new_nodes; + + v_frontier_forward := v_new_forward; + v_fwd_depth := v_fwd_depth + 1; + v_prefer_forward := false; + + IF v_new_forward IS NOT NULL THEN + SELECT vb.person INTO v_meeting + FROM visited_backward vb + WHERE vb.person = ANY(v_new_forward) + LIMIT 1; + EXIT WHEN v_meeting IS NOT NULL; + END IF; + ELSE + WITH new_nodes AS ( + INSERT INTO visited_backward (person, parent) + SELECT x.person_b, x.parent FROM ( + SELECT DISTINCT ON (e.person_b) e.person_b, e.person_a AS parent + FROM co_star_edges e + WHERE e.person_a = ANY(v_frontier_backward) + AND NOT EXISTS (SELECT 1 FROM visited_backward vb WHERE vb.person = e.person_b) + ORDER BY e.person_b + ) x + RETURNING person + ) + SELECT array_agg(person) INTO v_new_backward FROM new_nodes; + + v_frontier_backward := v_new_backward; + v_bwd_depth := v_bwd_depth + 1; + v_prefer_forward := true; + + IF v_new_backward IS NOT NULL THEN + SELECT vf.person INTO v_meeting + FROM visited_forward vf + WHERE vf.person = ANY(v_new_backward) + LIMIT 1; + EXIT WHEN v_meeting IS NOT NULL; + END IF; + END IF; + END LOOP; + + IF v_meeting IS NULL THEN + RETURN; + END IF; + + -- Reconstruct the path by walking 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 + -- recursive CTE is the right (and cheap) tool here. + RETURN QUERY + WITH RECURSIVE forward_chain AS ( + SELECT person, parent, 1 AS ord FROM visited_forward WHERE person = v_meeting + UNION ALL + SELECT vf.person, vf.parent, fc.ord + 1 + FROM visited_forward vf JOIN forward_chain fc ON vf.person = fc.parent + ), + backward_chain AS ( + SELECT person, parent, 1 AS ord FROM visited_backward WHERE person = v_meeting + UNION ALL + SELECT vb.person, vb.parent, bc.ord + 1 + FROM visited_backward vb JOIN backward_chain bc ON vb.person = bc.parent + ) + SELECT + (SELECT max(ord) FROM forward_chain) - 1 + (SELECT max(ord) FROM backward_chain) - 1, + (SELECT array_agg(person ORDER BY ord DESC) FROM forward_chain) + || (SELECT array_agg(person ORDER BY ord ASC) FROM backward_chain WHERE ord > 1); +END; +$$ LANGUAGE plpgsql; diff --git a/imdb/src/main/resources/db/migration/V4__title_principals_nconst_index.sql b/imdb/src/main/resources/db/migration/V4__title_principals_nconst_index.sql new file mode 100644 index 0000000..300b8d7 --- /dev/null +++ b/imdb/src/main/resources/db/migration/V4__title_principals_nconst_index.sql @@ -0,0 +1,11 @@ +-- findAnyCommonTitle (JdbcTitleRepository - used once per hop to enrich a six-degrees path with the +-- shared title connecting each pair) filters title_principals by nconst with no category +-- restriction, but the only existing nconst index (idx_title_principals_nconst_acting, V1) is +-- partial - WHERE category IN ('actor', 'actress', 'self') - so it can't be used here. Confirmed via +-- EXPLAIN ANALYZE: without this index, the planner falls back to a Parallel Seq Scan over the +-- 100M-row table, over 1 second per call even with parallel workers helping. Under real k6 load +-- (50 concurrent six-degrees requests, each needing up to 7 of these calls to build a full path), +-- this alone drove Postgres CPU past 1500% and produced an 83% failure rate against the endpoint's +-- 2-second query timeout - not the bidirectional BFS function, which tested fast in isolation the +-- whole time. +CREATE INDEX idx_title_principals_nconst ON title_principals (nconst); diff --git a/imdb/src/main/resources/db/migration/V5__users_table.sql b/imdb/src/main/resources/db/migration/V5__users_table.sql new file mode 100644 index 0000000..e5b4b49 --- /dev/null +++ b/imdb/src/main/resources/db/migration/V5__users_table.sql @@ -0,0 +1,13 @@ +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; diff --git a/imdb/src/main/resources/db/migration/V6__admin_id_sequences.sql b/imdb/src/main/resources/db/migration/V6__admin_id_sequences.sql new file mode 100644 index 0000000..b810d1e --- /dev/null +++ b/imdb/src/main/resources/db/migration/V6__admin_id_sequences.sql @@ -0,0 +1,16 @@ +-- Admin-created titles/people (admin CRUD) 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 $$; diff --git a/imdb/src/main/resources/db/migration/V7__core_entity_version_and_soft_delete.sql b/imdb/src/main/resources/db/migration/V7__core_entity_version_and_soft_delete.sql new file mode 100644 index 0000000..eb01654 --- /dev/null +++ b/imdb/src/main/resources/db/migration/V7__core_entity_version_and_soft_delete.sql @@ -0,0 +1,5 @@ +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; diff --git a/imdb/src/main/resources/db/migration/V8__watchlists.sql b/imdb/src/main/resources/db/migration/V8__watchlists.sql new file mode 100644 index 0000000..6e88a14 --- /dev/null +++ b/imdb/src/main/resources/db/migration/V8__watchlists.sql @@ -0,0 +1,16 @@ +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) +); diff --git a/imdb/src/main/resources/db/migration/V9__reviews.sql b/imdb/src/main/resources/db/migration/V9__reviews.sql new file mode 100644 index 0000000..2139a10 --- /dev/null +++ b/imdb/src/main/resources/db/migration/V9__reviews.sql @@ -0,0 +1,13 @@ +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; diff --git a/imdb/src/test/java/com/ludovictemgoua/imdb/ImdbApplicationTests.java b/imdb/src/test/java/com/ludovictemgoua/imdb/ImdbApplicationTests.java new file mode 100644 index 0000000..b5d750f --- /dev/null +++ b/imdb/src/test/java/com/ludovictemgoua/imdb/ImdbApplicationTests.java @@ -0,0 +1,15 @@ +package com.ludovictemgoua.imdb; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; + +@Import(TestcontainersConfiguration.class) +@SpringBootTest +class ImdbApplicationTests { + + @Test + void contextLoads() { + } + +} diff --git a/imdb/src/test/java/com/ludovictemgoua/imdb/TestImdbApplication.java b/imdb/src/test/java/com/ludovictemgoua/imdb/TestImdbApplication.java new file mode 100644 index 0000000..91718f0 --- /dev/null +++ b/imdb/src/test/java/com/ludovictemgoua/imdb/TestImdbApplication.java @@ -0,0 +1,11 @@ +package com.ludovictemgoua.imdb; + +import org.springframework.boot.SpringApplication; + +public class TestImdbApplication { + + public static void main(String[] args) { + SpringApplication.from(ImdbApplication::main).with(TestcontainersConfiguration.class).run(args); + } + +} diff --git a/imdb/src/test/java/com/ludovictemgoua/imdb/TestcontainersConfiguration.java b/imdb/src/test/java/com/ludovictemgoua/imdb/TestcontainersConfiguration.java new file mode 100644 index 0000000..db28b64 --- /dev/null +++ b/imdb/src/test/java/com/ludovictemgoua/imdb/TestcontainersConfiguration.java @@ -0,0 +1,38 @@ +package com.ludovictemgoua.imdb; + +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.context.annotation.Bean; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.grafana.LgtmStackContainer; +import org.testcontainers.postgresql.PostgreSQLContainer; +import org.testcontainers.utility.DockerImageName; + +@TestConfiguration(proxyBeanMethods = false) +public class TestcontainersConfiguration { + + @Bean + @ServiceConnection + LgtmStackContainer grafanaLgtmContainer() { + // Pinned, not :latest - the floating tag makes a passing build today no guarantee of a + // passing build tomorrow if upstream ships a breaking change. 0.29.0 confirmed current via + // the Docker Hub API (its digest matches :latest's at the time of pinning), not guessed. + return new LgtmStackContainer(DockerImageName.parse("grafana/otel-lgtm:0.29.0")); + } + + @Bean + @ServiceConnection + PostgreSQLContainer postgresContainer() { + return new PostgreSQLContainer(DockerImageName.parse("postgres:17")); + } + + @Bean + @ServiceConnection(name = "redis") + GenericContainer redisContainer() { + // redis:7-alpine, not :latest - also matches the version docker-compose.yaml actually runs in + // dev/prod (README's External Services table), so the test double and the real deployment + // target are the same major version instead of silently drifting apart. + return new GenericContainer<>(DockerImageName.parse("redis:7-alpine")).withExposedPorts(6379); + } + +} diff --git a/imdb/src/test/java/com/ludovictemgoua/imdb/application/AuthUseCaseImplTest.java b/imdb/src/test/java/com/ludovictemgoua/imdb/application/AuthUseCaseImplTest.java new file mode 100644 index 0000000..07ea00f --- /dev/null +++ b/imdb/src/test/java/com/ludovictemgoua/imdb/application/AuthUseCaseImplTest.java @@ -0,0 +1,126 @@ +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.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.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.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +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(eq("ada@example.com"), anyString(), eq("Ada"), eq(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(ForbiddenException.class); + } + + @Test + void refreshIssuesNewTokensForAValidRefreshToken() { + given(jwtService.parse("refresh-token")) + .willReturn(Optional.of(new JwtService.Parsed(1, Set.of(), true))); + given(userRepository.findById(1)) + .willReturn(Optional.of(new User(1, "ada@example.com", "hash", "Ada", null, Role.USER, 0))); + given(jwtService.issueAccessToken(1, Set.of(Role.USER))).willReturn("new-access"); + given(jwtService.issueRefreshToken(1)).willReturn("new-refresh"); + + var tokens = new AuthUseCaseImpl(userRepository, jwtService, passwordEncoder) + .refresh("refresh-token"); + + assertThat(tokens.accessToken()).isEqualTo("new-access"); + assertThat(tokens.refreshToken()).isEqualTo("new-refresh"); + } + + // Regression test for a real bug found by Copilot code review: an access token used to parse + // successfully here too (JwtService.parse doesn't care which kind of token it's given), so a + // caller could mint a fresh token pair from their own short-lived access token without ever + // holding a real refresh token, defeating the point of the access token's short TTL. + @Test + void refreshRejectsAnAccessTokenPresentedAsARefreshToken() { + given(jwtService.parse("access-token")) + .willReturn(Optional.of(new JwtService.Parsed(1, Set.of(Role.USER), false))); + var useCase = new AuthUseCaseImpl(userRepository, jwtService, passwordEncoder); + + assertThatThrownBy(() -> useCase.refresh("access-token")) + .isInstanceOf(ForbiddenException.class); + } + + @Test + void refreshThrowsForbiddenForAnUnparsableToken() { + given(jwtService.parse("garbage")).willReturn(Optional.empty()); + var useCase = new AuthUseCaseImpl(userRepository, jwtService, passwordEncoder); + + assertThatThrownBy(() -> useCase.refresh("garbage")) + .isInstanceOf(ForbiddenException.class); + } +} diff --git a/imdb/src/test/java/com/ludovictemgoua/imdb/application/ListUseCaseImplTest.java b/imdb/src/test/java/com/ludovictemgoua/imdb/application/ListUseCaseImplTest.java new file mode 100644 index 0000000..86bfb35 --- /dev/null +++ b/imdb/src/test/java/com/ludovictemgoua/imdb/application/ListUseCaseImplTest.java @@ -0,0 +1,68 @@ +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"); + } +} diff --git a/imdb/src/test/java/com/ludovictemgoua/imdb/application/PersonAdminUseCaseImplTest.java b/imdb/src/test/java/com/ludovictemgoua/imdb/application/PersonAdminUseCaseImplTest.java new file mode 100644 index 0000000..1e4fcd9 --- /dev/null +++ b/imdb/src/test/java/com/ludovictemgoua/imdb/application/PersonAdminUseCaseImplTest.java @@ -0,0 +1,56 @@ +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); + } +} diff --git a/imdb/src/test/java/com/ludovictemgoua/imdb/application/PersonResolutionUseCaseTest.java b/imdb/src/test/java/com/ludovictemgoua/imdb/application/PersonResolutionUseCaseTest.java new file mode 100644 index 0000000..5d75908 --- /dev/null +++ b/imdb/src/test/java/com/ludovictemgoua/imdb/application/PersonResolutionUseCaseTest.java @@ -0,0 +1,75 @@ +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 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.mockito.BDDMockito.given; + +@ExtendWith(MockitoExtension.class) +class PersonResolutionUseCaseTest { + + @Mock + PersonRepository personRepository; + + @Test + void resolvesDirectlyByIdWithoutSearchingByName() { + given(personRepository.findNameById(102)).willReturn(Optional.of("Kevin Bacon")); + + var resolution = new PersonResolutionUseCase(personRepository).resolve("nm0000102"); + + assertThat(resolution).isInstanceOfSatisfying(PersonResolution.Resolved.class, resolved -> { + assertThat(resolved.nconst()).isEqualTo(102); + assertThat(resolved.name()).isEqualTo("Kevin Bacon"); + }); + } + + @Test + void unknownIdIsNotFound() { + given(personRepository.findNameById(999)).willReturn(Optional.empty()); + + var resolution = new PersonResolutionUseCase(personRepository).resolve("nm0000999"); + + assertThat(resolution).isInstanceOf(PersonResolution.NotFound.class); + } + + @Test + void singleNameMatchResolves() { + given(personRepository.findByName("Kevin Bacon")) + .willReturn(List.of(new PersonCandidate("nm0000102", "Kevin Bacon", 1958, List.of()))); + + var resolution = new PersonResolutionUseCase(personRepository).resolve("Kevin Bacon"); + + assertThat(resolution).isInstanceOfSatisfying(PersonResolution.Resolved.class, + resolved -> assertThat(resolved.nconst()).isEqualTo(102)); + } + + @Test + void multipleNameMatchesAreAmbiguous() { + given(personRepository.findByName("Jamie Lee")).willReturn(List.of( + new PersonCandidate("nm0000020", "Jamie Lee", 1975, List.of()), + new PersonCandidate("nm0000021", "Jamie Lee", 1990, List.of()))); + + var resolution = new PersonResolutionUseCase(personRepository).resolve("Jamie Lee"); + + assertThat(resolution).isInstanceOfSatisfying(PersonResolution.Ambiguous.class, + amb -> assertThat(amb.candidates()).hasSize(2)); + } + + @Test + void noNameMatchesIsNotFound() { + given(personRepository.findByName("Nobody")).willReturn(List.of()); + + var resolution = new PersonResolutionUseCase(personRepository).resolve("Nobody"); + + assertThat(resolution).isInstanceOf(PersonResolution.NotFound.class); + } +} diff --git a/imdb/src/test/java/com/ludovictemgoua/imdb/application/ReviewUseCaseImplTest.java b/imdb/src/test/java/com/ludovictemgoua/imdb/application/ReviewUseCaseImplTest.java new file mode 100644 index 0000000..3bc1745 --- /dev/null +++ b/imdb/src/test/java/com/ludovictemgoua/imdb/application/ReviewUseCaseImplTest.java @@ -0,0 +1,45 @@ +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); + } +} diff --git a/imdb/src/test/java/com/ludovictemgoua/imdb/application/SixDegreesUseCaseImplTest.java b/imdb/src/test/java/com/ludovictemgoua/imdb/application/SixDegreesUseCaseImplTest.java new file mode 100644 index 0000000..7b8848a --- /dev/null +++ b/imdb/src/test/java/com/ludovictemgoua/imdb/application/SixDegreesUseCaseImplTest.java @@ -0,0 +1,137 @@ +package com.ludovictemgoua.imdb.application; + +import com.ludovictemgoua.imdb.application.contracts.SixDegreesOutcome; +import com.ludovictemgoua.imdb.domain.model.GraphPath; +import com.ludovictemgoua.imdb.domain.model.PersonCandidate; +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 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.Map; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; + +@ExtendWith(MockitoExtension.class) +class SixDegreesUseCaseImplTest { + + @Mock + PersonRepository personRepository; + @Mock + CoStarGraphRepository graphRepository; + @Mock + TitleRepository titleRepository; + + // PersonResolutionUseCase is a real collaborator here, not mocked - it's an internal, pure-logic + // helper (see its own class-level comment), not a boundary worth mocking in isolation. The real + // boundary for this test is the repositories. + private SixDegreesUseCaseImpl newUseCase() { + var personResolution = new PersonResolutionUseCase(personRepository); + return new SixDegreesUseCaseImpl(personResolution, graphRepository, personRepository, titleRepository); + } + + @Test + void sameResolvedPersonIsDegreeZeroWithoutQueryingTheGraph() { + given(personRepository.findNameById(102)).willReturn(Optional.of("Kevin Bacon")); + + var outcome = newUseCase().compute("nm0000102", "nm0000102", 7); + + assertThat(outcome).isInstanceOfSatisfying(SixDegreesOutcome.Found.class, found -> { + assertThat(found.result().degree()).isZero(); + assertThat(found.result().withinRequestedMax()).isTrue(); + }); + verifyNoInteractions(graphRepository); + } + + @Test + void ambiguousNameOnFirstSideShortCircuitsBeforeResolvingSecond() { + var candidates = List.of( + new PersonCandidate("nm0000020", "Jamie Lee", 1975, List.of()), + new PersonCandidate("nm0000021", "Jamie Lee", 1990, List.of())); + given(personRepository.findByName("Jamie Lee")).willReturn(candidates); + + var outcome = newUseCase().compute("Jamie Lee", "nm0000158", 7); + + assertThat(outcome).isInstanceOfSatisfying(SixDegreesOutcome.Ambiguous.class, + amb -> assertThat(amb.candidates()).hasSize(2)); + verifyNoInteractions(graphRepository); + verify(personRepository, never()).findNameById(anyInt()); + } + + @Test + void personNotFoundOnFirstSideShortCircuits() { + given(personRepository.findByName("Nobody")).willReturn(List.of()); + + var outcome = newUseCase().compute("Nobody", "nm0000158", 7); + + assertThat(outcome).isInstanceOfSatisfying(SixDegreesOutcome.PersonNotFound.class, + notFound -> assertThat(notFound.query()).isEqualTo("Nobody")); + verifyNoInteractions(graphRepository); + } + + @Test + void resolvesFullAnnotatedPathWhenDegreeIsWithinRequestedMax() { + given(personRepository.findNameById(102)).willReturn(Optional.of("Kevin Bacon")); + given(personRepository.findNameById(129)).willReturn(Optional.of("Tom Cruise")); + given(graphRepository.findShortestPath(102, 129)) + .willReturn(Optional.of(new GraphPath(1, List.of(102, 129)))); + given(personRepository.findNamesByIds(List.of(102, 129))) + .willReturn(Map.of(102, "Kevin Bacon", 129, "Tom Cruise")); + given(titleRepository.findAnyCommonTitle(102, 129)) + .willReturn(Optional.of(new SharedTitle("tt0100405", "A Few Good Men"))); + + var outcome = newUseCase().compute("nm0000102", "nm0000129", 7); + + assertThat(outcome).isInstanceOfSatisfying(SixDegreesOutcome.Found.class, found -> { + var result = found.result(); + assertThat(result.degree()).isEqualTo(1); + assertThat(result.withinRequestedMax()).isTrue(); + assertThat(result.path()).hasSize(2); + assertThat(result.path().get(1).sharedTitle().primaryTitle()).isEqualTo("A Few Good Men"); + }); + } + + @Test + void reportsTrueDegreeButNoPathWhenBeyondRequestedMax() { + given(personRepository.findNameById(102)).willReturn(Optional.of("Kevin Bacon")); + given(personRepository.findNameById(158)).willReturn(Optional.of("Tom Hanks")); + given(graphRepository.findShortestPath(102, 158)) + .willReturn(Optional.of(new GraphPath(5, List.of(102, 2, 3, 4, 5, 158)))); + + var outcome = newUseCase().compute("nm0000102", "nm0000158", 3); + + assertThat(outcome).isInstanceOfSatisfying(SixDegreesOutcome.Found.class, found -> { + var result = found.result(); + assertThat(result.degree()).isEqualTo(5); + assertThat(result.withinRequestedMax()).isFalse(); + assertThat(result.path()).isEmpty(); + }); + verifyNoInteractions(titleRepository); + } + + @Test + void reportsNullDegreeWhenNoConnectionExistsWithinTheAbsoluteCap() { + given(personRepository.findNameById(102)).willReturn(Optional.of("Kevin Bacon")); + given(personRepository.findNameById(999)).willReturn(Optional.of("Nobody Connected")); + given(graphRepository.findShortestPath(102, 999)).willReturn(Optional.empty()); + + var outcome = newUseCase().compute("nm0000102", "nm0000999", 7); + + assertThat(outcome).isInstanceOfSatisfying(SixDegreesOutcome.Found.class, found -> { + assertThat(found.result().degree()).isNull(); + assertThat(found.result().withinRequestedMax()).isFalse(); + assertThat(found.result().path()).isEmpty(); + }); + } +} diff --git a/imdb/src/test/java/com/ludovictemgoua/imdb/application/TitleAdminUseCaseImplTest.java b/imdb/src/test/java/com/ludovictemgoua/imdb/application/TitleAdminUseCaseImplTest.java new file mode 100644 index 0000000..953d3d3 --- /dev/null +++ b/imdb/src/test/java/com/ludovictemgoua/imdb/application/TitleAdminUseCaseImplTest.java @@ -0,0 +1,86 @@ +package com.ludovictemgoua.imdb.application; + +import com.ludovictemgoua.imdb.application.rest.CreateTitleRequest; +import com.ludovictemgoua.imdb.application.rest.PrincipalRequest; +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); + } + + @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); + } +} diff --git a/imdb/src/test/java/com/ludovictemgoua/imdb/application/TitleDetailUseCaseImplTest.java b/imdb/src/test/java/com/ludovictemgoua/imdb/application/TitleDetailUseCaseImplTest.java new file mode 100644 index 0000000..6cf52d0 --- /dev/null +++ b/imdb/src/test/java/com/ludovictemgoua/imdb/application/TitleDetailUseCaseImplTest.java @@ -0,0 +1,81 @@ +package com.ludovictemgoua.imdb.application; + +import com.ludovictemgoua.imdb.domain.exception.NotFoundException; +import com.ludovictemgoua.imdb.domain.model.CastMember; +import com.ludovictemgoua.imdb.domain.model.CreditedPerson; +import com.ludovictemgoua.imdb.domain.model.RatingAggregate; +import com.ludovictemgoua.imdb.domain.model.TitleCore; +import com.ludovictemgoua.imdb.domain.repository.ReviewRepository; +import com.ludovictemgoua.imdb.domain.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 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 TitleDetailUseCaseImplTest { + + @Mock + TitleRepository titleRepository; + @Mock + ReviewRepository reviewRepository; + + @Test + void assemblesTitleDetailFromFiveRepositoryCalls() { + var core = new TitleCore("tt0111161", "The Shawshank Redemption", "The Shawshank Redemption", + "movie", 1994, null, 142, List.of("Drama"), 9.3, 2900000, 0); + given(titleRepository.findCore(111161)).willReturn(Optional.of(core)); + given(titleRepository.findDirectors(111161)) + .willReturn(List.of(new CreditedPerson("nm0001104", "Frank Darabont"))); + given(titleRepository.findWriters(111161)) + .willReturn(List.of(new CreditedPerson("nm0001104", "Frank Darabont"))); + given(titleRepository.findTopCast(111161, 20)) + .willReturn(List.of(new CastMember("nm0000209", "Tim Robbins", "actor", + List.of("Andy Dufresne"), 1))); + given(titleRepository.countCast(111161)).willReturn(20); + given(reviewRepository.aggregateForTitle(111161)).willReturn(new RatingAggregate(8.5, 42)); + + var detail = new TitleDetailUseCaseImpl(titleRepository, reviewRepository).getDetail("tt0111161"); + + assertThat(detail.id()).isEqualTo("tt0111161"); + assertThat(detail.rating().average()).isEqualTo(9.3); + assertThat(detail.rating().numVotes()).isEqualTo(2900000); + assertThat(detail.directors()).hasSize(1); + assertThat(detail.cast()).hasSize(1); + assertThat(detail.castTotalCount()).isEqualTo(20); + assertThat(detail.userRatingAverage()).isEqualTo(8.5); + assertThat(detail.userRatingCount()).isEqualTo(42); + } + + @Test + void missingCoreThrowsNotFound() { + given(titleRepository.findCore(111161)).willReturn(Optional.empty()); + + assertThatThrownBy(() -> new TitleDetailUseCaseImpl(titleRepository, reviewRepository).getDetail("tt0111161")) + .isInstanceOf(NotFoundException.class); + } + + @Test + void nullRatingFieldsDefaultToZeroRatherThanNull() { + var core = new TitleCore("tt9999999", "Unrated Title", "Unrated Title", + "movie", null, null, null, List.of(), null, null, 0); + given(titleRepository.findCore(9999999)).willReturn(Optional.of(core)); + given(titleRepository.findDirectors(9999999)).willReturn(List.of()); + given(titleRepository.findWriters(9999999)).willReturn(List.of()); + given(titleRepository.findTopCast(9999999, 20)).willReturn(List.of()); + given(titleRepository.countCast(9999999)).willReturn(0); + given(reviewRepository.aggregateForTitle(9999999)).willReturn(new RatingAggregate(0, 0)); + + var detail = new TitleDetailUseCaseImpl(titleRepository, reviewRepository).getDetail("tt9999999"); + + assertThat(detail.rating().average()).isZero(); + assertThat(detail.rating().numVotes()).isZero(); + } +} diff --git a/imdb/src/test/java/com/ludovictemgoua/imdb/application/TitleSearchUseCaseImplTest.java b/imdb/src/test/java/com/ludovictemgoua/imdb/application/TitleSearchUseCaseImplTest.java new file mode 100644 index 0000000..6b37ae0 --- /dev/null +++ b/imdb/src/test/java/com/ludovictemgoua/imdb/application/TitleSearchUseCaseImplTest.java @@ -0,0 +1,34 @@ +package com.ludovictemgoua.imdb.application; + +import com.ludovictemgoua.imdb.domain.model.PagedResult; +import com.ludovictemgoua.imdb.domain.model.TitleSummary; +import com.ludovictemgoua.imdb.domain.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 java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; + +@ExtendWith(MockitoExtension.class) +class TitleSearchUseCaseImplTest { + + @Mock + TitleRepository titleRepository; + + @Test + void delegatesSearchToTheRepositoryUnchanged() { + var expected = new PagedResult<>( + List.of(new TitleSummary("tt0111161", "The Shawshank Redemption", + "The Shawshank Redemption", "movie", 1994, null)), + 1, 0, 20); + given(titleRepository.search("shawshank", 0, 20)).willReturn(expected); + + var result = new TitleSearchUseCaseImpl(titleRepository).search("shawshank", 0, 20); + + assertThat(result).isSameAs(expected); + } +} diff --git a/imdb/src/test/java/com/ludovictemgoua/imdb/application/TopRatedUseCaseImplTest.java b/imdb/src/test/java/com/ludovictemgoua/imdb/application/TopRatedUseCaseImplTest.java new file mode 100644 index 0000000..004318b --- /dev/null +++ b/imdb/src/test/java/com/ludovictemgoua/imdb/application/TopRatedUseCaseImplTest.java @@ -0,0 +1,41 @@ +package com.ludovictemgoua.imdb.application; + +import com.ludovictemgoua.imdb.domain.model.GenreTopRatedItem; +import com.ludovictemgoua.imdb.domain.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 java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; + +@ExtendWith(MockitoExtension.class) +class TopRatedUseCaseImplTest { + + @Mock + TitleRepository titleRepository; + + @Test + void fallsBackToTheConfiguredDefaultWhenMinVotesIsNotProvided() { + var expected = List.of(new GenreTopRatedItem("tt0111161", "The Shawshank Redemption", + 1994, 9.3, 2900000, 9.2)); + given(titleRepository.findTopRated("Drama", 10, 1000)).willReturn(expected); + + var result = new TopRatedUseCaseImpl(titleRepository, 1000).findTopRated("Drama", 10, null); + + assertThat(result).isSameAs(expected); + } + + @Test + void usesTheCallerSuppliedMinVotesWhenPresent() { + var expected = List.of(); + given(titleRepository.findTopRated("Drama", 10, 50000)).willReturn(expected); + + var result = new TopRatedUseCaseImpl(titleRepository, 1000).findTopRated("Drama", 10, 50000); + + assertThat(result).isSameAs(expected); + } +} diff --git a/imdb/src/test/java/com/ludovictemgoua/imdb/application/UserUseCaseImplTest.java b/imdb/src/test/java/com/ludovictemgoua/imdb/application/UserUseCaseImplTest.java new file mode 100644 index 0000000..b364c67 --- /dev/null +++ b/imdb/src/test/java/com/ludovictemgoua/imdb/application/UserUseCaseImplTest.java @@ -0,0 +1,62 @@ +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); + } +} diff --git a/imdb/src/test/java/com/ludovictemgoua/imdb/application/WatchlistUseCaseImplTest.java b/imdb/src/test/java/com/ludovictemgoua/imdb/application/WatchlistUseCaseImplTest.java new file mode 100644 index 0000000..199549d --- /dev/null +++ b/imdb/src/test/java/com/ludovictemgoua/imdb/application/WatchlistUseCaseImplTest.java @@ -0,0 +1,62 @@ +package com.ludovictemgoua.imdb.application; + +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 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(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); + } +} diff --git a/imdb/src/test/java/com/ludovictemgoua/imdb/infrastructure/cache/CacheConfigIntegrationTest.java b/imdb/src/test/java/com/ludovictemgoua/imdb/infrastructure/cache/CacheConfigIntegrationTest.java new file mode 100644 index 0000000..7cc145e --- /dev/null +++ b/imdb/src/test/java/com/ludovictemgoua/imdb/infrastructure/cache/CacheConfigIntegrationTest.java @@ -0,0 +1,35 @@ +package com.ludovictemgoua.imdb.infrastructure.cache; + +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.data.redis.cache.RedisCache; +import org.springframework.data.redis.cache.RedisCacheManager; + +import java.time.Duration; + +import static org.assertj.core.api.Assertions.assertThat; + +@Import(TestcontainersConfiguration.class) +@SpringBootTest +class CacheConfigIntegrationTest { + + @Autowired + RedisCacheManager cacheManager; + + @Test + void titleSearchCacheHasAFifteenMinuteTtlNotTheDefaultTwentyFourHours() { + var config = ((RedisCache) cacheManager.getCache("title-search")).getCacheConfiguration(); + + assertThat(config.getTtlFunction().getTimeToLive("key", "value")).isEqualTo(Duration.ofMinutes(15)); + } + + @Test + void titleDetailCacheKeepsTheDefaultTwentyFourHourTtl() { + var config = ((RedisCache) cacheManager.getCache("title-detail")).getCacheConfiguration(); + + assertThat(config.getTtlFunction().getTimeToLive("key", "value")).isEqualTo(Duration.ofHours(24)); + } +} diff --git a/imdb/src/test/java/com/ludovictemgoua/imdb/infrastructure/cache/CacheEvictionIntegrationTest.java b/imdb/src/test/java/com/ludovictemgoua/imdb/infrastructure/cache/CacheEvictionIntegrationTest.java new file mode 100644 index 0000000..765aede --- /dev/null +++ b/imdb/src/test/java/com/ludovictemgoua/imdb/infrastructure/cache/CacheEvictionIntegrationTest.java @@ -0,0 +1,112 @@ +package com.ludovictemgoua.imdb.infrastructure.cache; + +import com.ludovictemgoua.imdb.TestcontainersConfiguration; +import com.ludovictemgoua.imdb.application.rest.PatchPersonRequest; +import com.ludovictemgoua.imdb.application.rest.RatingRequest; +import com.ludovictemgoua.imdb.application.rest.UpdateTitleRequest; +import com.ludovictemgoua.imdb.application.contracts.PersonAdminUseCase; +import com.ludovictemgoua.imdb.application.contracts.SixDegreesUseCase; +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.BeforeEach; +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; + +// See CachingTitleDetailUseCaseIntegrationTest et al. for why this exercises real Redis instead of +// a mocked CacheManager - a mock can prove a @CacheEvict-annotated method was called, but not that +// the eviction actually reached Redis. clearCaches() below matches those tests' established +// @BeforeEach pattern (each region is cleared, not just the ones this test primes) so no stale entry +// from a previous test can be mistaken for one this test wrote itself. +// +// awaitCacheState polls instead of asserting immediately: this Testcontainers Redis, running over +// Docker Desktop's Windows npipe/WSL2 network path, occasionally serves a read a few milliseconds +// before a just-issued write/eviction on the same connection is visible - a local dev-environment +// networking artifact, not an application bug (the same eviction succeeds every time once given a +// short window to land). +@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 + SixDegreesUseCase sixDegreesUseCase; + @Autowired + CacheManager cacheManager; + + @BeforeEach + void clearCaches() { + cacheManager.getCache("title-detail").clear(); + cacheManager.getCache("top-rated").clear(); + cacheManager.getCache("six-degrees").clear(); + } + + @Test + void updatingATitleEvictsItsTitleDetailCacheEntry() { + titleDetailUseCase.getDetail("tt0000100"); + assertThat(awaitCacheState("title-detail", "tt0000100", true)).isNotNull(); + + var current = titleDetailUseCase.getDetail("tt0000100"); + titleAdminUseCase.update("tt0000100", new UpdateTitleRequest( + current.primaryTitle(), current.originalTitle(), current.titleType(), + current.startYear(), current.endYear(), current.runtimeMinutes(), current.genres(), 0)); + + assertThat(awaitCacheState("title-detail", "tt0000100", false)).isNull(); + } + + @Test + void writingARatingEvictsTheEntireTopRatedRegion() { + topRatedUseCase.findTopRated("Action", 10, 100); + assertThat(awaitCacheState("top-rated", "Action:10:100", true)).isNotNull(); + + titleAdminUseCase.upsertRating("tt0000200", new RatingRequest(9.0, 200000)); + + assertThat(awaitCacheState("top-rated", "Action:10:100", false)).isNull(); + } + + @Test + void updatingAPersonEvictsTheEntireSixDegreesRegion() { + sixDegreesUseCase.compute("nm0000001", "nm0000002", 7); + assertThat(awaitCacheState("six-degrees", "1-2", true)).isNotNull(); + + personAdminUseCase.patch("nm0000001", new PatchPersonRequest("Kevin Bacon Jr.", null, null, List.of(), 0)); + + assertThat(awaitCacheState("six-degrees", "1-2", false)).isNull(); + } + + private Object awaitCacheState(String cacheName, String key, boolean expectPresent) { + Object value = null; + for (int attempt = 0; attempt < 20; attempt++) { + var wrapper = cacheManager.getCache(cacheName).get(key); + value = wrapper == null ? null : wrapper.get(); + if ((wrapper != null) == expectPresent) { + return value; + } + try { + Thread.sleep(50); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return value; + } + } + return value; + } +} diff --git a/imdb/src/test/java/com/ludovictemgoua/imdb/infrastructure/cache/CachingCoStarGraphRepositoryIntegrationTest.java b/imdb/src/test/java/com/ludovictemgoua/imdb/infrastructure/cache/CachingCoStarGraphRepositoryIntegrationTest.java new file mode 100644 index 0000000..03925b6 --- /dev/null +++ b/imdb/src/test/java/com/ludovictemgoua/imdb/infrastructure/cache/CachingCoStarGraphRepositoryIntegrationTest.java @@ -0,0 +1,46 @@ +package com.ludovictemgoua.imdb.infrastructure.cache; + +import com.ludovictemgoua.imdb.TestcontainersConfiguration; +import com.ludovictemgoua.imdb.domain.repository.CoStarGraphRepository; +import org.junit.jupiter.api.BeforeEach; +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 static org.assertj.core.api.Assertions.assertThat; + +// See CachingTitleSearchUseCaseIntegrationTest for why this exists alongside the unit-level +// CachingCoStarGraphRepositoryTest - this exercises the real Redis serialization round trip that a +// ConcurrentMapCacheManager-backed unit test structurally cannot. +@Import(TestcontainersConfiguration.class) +@SpringBootTest +@Transactional +@Sql("/fixtures/fixture-data.sql") +class CachingCoStarGraphRepositoryIntegrationTest { + + @Autowired + CoStarGraphRepository coStarGraphRepository; + + @Autowired + CacheManager cacheManager; + + @BeforeEach + void clearCache() { + cacheManager.getCache("six-degrees").clear(); + } + + @Test + void secondCallIsServedFromRealRedisAndDeserializesToAnEqualResult() { + var first = coStarGraphRepository.findShortestPath(1, 2).orElseThrow(); + assertThat(first.degree()).isEqualTo(1); + assertThat(cacheManager.getCache("six-degrees").get("1-2")).isNotNull(); + + var second = coStarGraphRepository.findShortestPath(1, 2).orElseThrow(); + + assertThat(second).isEqualTo(first); + } +} diff --git a/imdb/src/test/java/com/ludovictemgoua/imdb/infrastructure/cache/CachingCoStarGraphRepositoryTest.java b/imdb/src/test/java/com/ludovictemgoua/imdb/infrastructure/cache/CachingCoStarGraphRepositoryTest.java new file mode 100644 index 0000000..101aa70 --- /dev/null +++ b/imdb/src/test/java/com/ludovictemgoua/imdb/infrastructure/cache/CachingCoStarGraphRepositoryTest.java @@ -0,0 +1,69 @@ +package com.ludovictemgoua.imdb.infrastructure.cache; + +import com.ludovictemgoua.imdb.domain.model.GraphPath; +import com.ludovictemgoua.imdb.domain.repository.CoStarGraphRepository; +import com.ludovictemgoua.imdb.infrastructure.persistence.JdbcCoStarGraphRepository; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.cache.CacheManager; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.cache.concurrent.ConcurrentMapCacheManager; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +@ExtendWith(SpringExtension.class) +@ContextConfiguration(classes = CachingCoStarGraphRepositoryTest.CacheTestConfig.class) +class CachingCoStarGraphRepositoryTest { + + @Configuration + @EnableCaching + static class CacheTestConfig { + @Bean + CacheManager cacheManager() { + return new ConcurrentMapCacheManager("six-degrees"); + } + + @Bean + JdbcCoStarGraphRepository delegate() { + return mock(JdbcCoStarGraphRepository.class); + } + + // @Primary here (not just on the class) - see CachingTitleSearchUseCaseTest for why. + @Bean + @org.springframework.context.annotation.Primary + CachingCoStarGraphRepository cachingCoStarGraphRepository(JdbcCoStarGraphRepository delegate) { + return new CachingCoStarGraphRepository(delegate); + } + } + + // Typed by interface - see CachingTitleSearchUseCaseTest for why the concrete class doesn't work. + @Autowired + CoStarGraphRepository target; + @Autowired + JdbcCoStarGraphRepository delegate; + + @Test + void sameUnorderedPairSharesOneCacheEntryRegardlessOfArgumentOrder() { + given(delegate.findShortestPath(1, 2)).willReturn(Optional.of(new GraphPath(1, List.of(1, 2)))); + + var first = target.findShortestPath(1, 2); + var second = target.findShortestPath(2, 1); + + assertThat(first).isEqualTo(second); + verify(delegate, times(1)).findShortestPath(1, 2); + verify(delegate, never()).findShortestPath(2, 1); + } +} diff --git a/imdb/src/test/java/com/ludovictemgoua/imdb/infrastructure/cache/CachingTitleDetailUseCaseIntegrationTest.java b/imdb/src/test/java/com/ludovictemgoua/imdb/infrastructure/cache/CachingTitleDetailUseCaseIntegrationTest.java new file mode 100644 index 0000000..4c6a2df --- /dev/null +++ b/imdb/src/test/java/com/ludovictemgoua/imdb/infrastructure/cache/CachingTitleDetailUseCaseIntegrationTest.java @@ -0,0 +1,46 @@ +package com.ludovictemgoua.imdb.infrastructure.cache; + +import com.ludovictemgoua.imdb.TestcontainersConfiguration; +import com.ludovictemgoua.imdb.application.contracts.TitleDetailUseCase; +import org.junit.jupiter.api.BeforeEach; +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 static org.assertj.core.api.Assertions.assertThat; + +// See CachingTitleSearchUseCaseIntegrationTest for why this exists alongside the unit-level +// CachingTitleDetailUseCaseTest - this exercises the real Redis serialization round trip that a +// ConcurrentMapCacheManager-backed unit test structurally cannot. +@Import(TestcontainersConfiguration.class) +@SpringBootTest +@Transactional +@Sql("/fixtures/fixture-data.sql") +class CachingTitleDetailUseCaseIntegrationTest { + + @Autowired + TitleDetailUseCase titleDetailUseCase; + + @Autowired + CacheManager cacheManager; + + @BeforeEach + void clearCache() { + cacheManager.getCache("title-detail").clear(); + } + + @Test + void secondCallIsServedFromRealRedisAndDeserializesToAnEqualResult() { + var first = titleDetailUseCase.getDetail("tt0000100"); + assertThat(first.primaryTitle()).isEqualTo("A Few Good Men"); + assertThat(cacheManager.getCache("title-detail").get("tt0000100")).isNotNull(); + + var second = titleDetailUseCase.getDetail("tt0000100"); + + assertThat(second).isEqualTo(first); + } +} diff --git a/imdb/src/test/java/com/ludovictemgoua/imdb/infrastructure/cache/CachingTitleDetailUseCaseTest.java b/imdb/src/test/java/com/ludovictemgoua/imdb/infrastructure/cache/CachingTitleDetailUseCaseTest.java new file mode 100644 index 0000000..624a803 --- /dev/null +++ b/imdb/src/test/java/com/ludovictemgoua/imdb/infrastructure/cache/CachingTitleDetailUseCaseTest.java @@ -0,0 +1,68 @@ +package com.ludovictemgoua.imdb.infrastructure.cache; + +import com.ludovictemgoua.imdb.application.TitleDetailUseCaseImpl; +import com.ludovictemgoua.imdb.application.contracts.TitleDetailUseCase; +import com.ludovictemgoua.imdb.domain.model.RatingView; +import com.ludovictemgoua.imdb.domain.model.TitleDetail; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.cache.CacheManager; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.cache.concurrent.ConcurrentMapCacheManager; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +import java.util.List; + +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +@ExtendWith(SpringExtension.class) +@ContextConfiguration(classes = CachingTitleDetailUseCaseTest.CacheTestConfig.class) +class CachingTitleDetailUseCaseTest { + + @Configuration + @EnableCaching + static class CacheTestConfig { + @Bean + CacheManager cacheManager() { + return new ConcurrentMapCacheManager("title-detail"); + } + + @Bean + TitleDetailUseCaseImpl delegate() { + return mock(TitleDetailUseCaseImpl.class); + } + + // @Primary here (not just on the class) - see CachingTitleSearchUseCaseTest for why. + @Bean + @org.springframework.context.annotation.Primary + CachingTitleDetailUseCase cachingTitleDetailUseCase(TitleDetailUseCaseImpl delegate) { + return new CachingTitleDetailUseCase(delegate); + } + } + + // Typed by interface - see CachingTitleSearchUseCaseTest for why the concrete class doesn't work. + @Autowired + TitleDetailUseCase target; + @Autowired + TitleDetailUseCaseImpl delegate; + + @Test + void secondCallWithTheSameTitleIdIsServedFromCache() { + var detail = new TitleDetail("tt0111161", "The Shawshank Redemption", "The Shawshank Redemption", + "movie", 1994, null, 142, List.of("Drama"), new RatingView(9.3, 2900000), + List.of(), List.of(), List.of(), 0, 0.0, 0); + given(delegate.getDetail("tt0111161")).willReturn(detail); + + target.getDetail("tt0111161"); + target.getDetail("tt0111161"); + + verify(delegate, times(1)).getDetail("tt0111161"); + } +} diff --git a/imdb/src/test/java/com/ludovictemgoua/imdb/infrastructure/cache/CachingTitleSearchUseCaseIntegrationTest.java b/imdb/src/test/java/com/ludovictemgoua/imdb/infrastructure/cache/CachingTitleSearchUseCaseIntegrationTest.java new file mode 100644 index 0000000..7c7cfee --- /dev/null +++ b/imdb/src/test/java/com/ludovictemgoua/imdb/infrastructure/cache/CachingTitleSearchUseCaseIntegrationTest.java @@ -0,0 +1,51 @@ +package com.ludovictemgoua.imdb.infrastructure.cache; + +import com.ludovictemgoua.imdb.TestcontainersConfiguration; +import com.ludovictemgoua.imdb.application.contracts.TitleSearchUseCase; +import org.junit.jupiter.api.BeforeEach; +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 static org.assertj.core.api.Assertions.assertThat; + +// Complements the unit-level CachingTitleSearchUseCaseTest (ConcurrentMapCacheManager, mocked +// delegate) - that one proves the @Cacheable wiring is correct, but an in-memory map never +// serializes anything, so it structurally cannot catch a bug in the real CacheConfig/ +// RedisCacheManager path. This runs the real stack end to end (real repository, real Postgres +// fixture data, real Redis via Testcontainers) so the second call is a genuine deserialize-from- +// Redis read, not a Java reference handed back from a map - exactly the class of bug that surfaced +// live earlier (GenericJacksonJsonRedisSerializer missing type metadata -> ClassCastException on a +// cache hit) and that the unit test alone could never have caught. +@Import(TestcontainersConfiguration.class) +@SpringBootTest +@Transactional +@Sql("/fixtures/fixture-data.sql") +class CachingTitleSearchUseCaseIntegrationTest { + + @Autowired + TitleSearchUseCase titleSearchUseCase; + + @Autowired + CacheManager cacheManager; + + @BeforeEach + void clearCache() { + cacheManager.getCache("title-search").clear(); + } + + @Test + void secondCallIsServedFromRealRedisAndDeserializesToAnEqualResult() { + var first = titleSearchUseCase.search("Few Good Men", 0, 20); + assertThat(first.content()).extracting("id").contains("tt0000100"); + assertThat(cacheManager.getCache("title-search").get("Few Good Men:0:20")).isNotNull(); + + var second = titleSearchUseCase.search("Few Good Men", 0, 20); + + assertThat(second).isEqualTo(first); + } +} diff --git a/imdb/src/test/java/com/ludovictemgoua/imdb/infrastructure/cache/CachingTitleSearchUseCaseTest.java b/imdb/src/test/java/com/ludovictemgoua/imdb/infrastructure/cache/CachingTitleSearchUseCaseTest.java new file mode 100644 index 0000000..3d42031 --- /dev/null +++ b/imdb/src/test/java/com/ludovictemgoua/imdb/infrastructure/cache/CachingTitleSearchUseCaseTest.java @@ -0,0 +1,85 @@ +package com.ludovictemgoua.imdb.infrastructure.cache; + +import com.ludovictemgoua.imdb.application.TitleSearchUseCaseImpl; +import com.ludovictemgoua.imdb.application.contracts.TitleSearchUseCase; +import com.ludovictemgoua.imdb.domain.model.PagedResult; +import com.ludovictemgoua.imdb.domain.model.TitleSummary; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.cache.CacheManager; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.cache.concurrent.ConcurrentMapCacheManager; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +import java.util.List; + +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +// A slice test, not a full @SpringBootTest: only caching infrastructure + a mock delegate, no +// database or Testcontainers. This is the mechanical check the old DistanceCache design (LLD §2.1) +// needed reasoning about carefully to get right; now it's just "does @Cacheable actually cache." +@ExtendWith(SpringExtension.class) +@ContextConfiguration(classes = CachingTitleSearchUseCaseTest.CacheTestConfig.class) +class CachingTitleSearchUseCaseTest { + + @Configuration + @EnableCaching + static class CacheTestConfig { + @Bean + CacheManager cacheManager() { + return new ConcurrentMapCacheManager("title-search"); + } + + @Bean + TitleSearchUseCaseImpl delegate() { + return mock(TitleSearchUseCaseImpl.class); + } + + // @Primary here (not just on the class): a @Bean factory method doesn't inherit @Primary from + // the returned object's runtime class the way component-scanning a @Service does in production - + // it must be declared on the method itself to disambiguate against the delegate bean below. + @Bean + @org.springframework.context.annotation.Primary + CachingTitleSearchUseCase cachingTitleSearchUseCase(TitleSearchUseCaseImpl delegate) { + return new CachingTitleSearchUseCase(delegate); + } + } + + // Typed by interface, not the concrete CachingTitleSearchUseCase: @EnableCaching proxies any bean + // with @Cacheable methods, and since this bean implements an interface, Spring defaults to a JDK + // dynamic proxy - which satisfies the interface type but is not assignable to the concrete class. + @Autowired + TitleSearchUseCase target; + @Autowired + TitleSearchUseCaseImpl delegate; + + @Test + void secondCallWithIdenticalArgumentsIsServedFromCacheNotTheDelegate() { + var result = new PagedResult(List.of(), 0, 0, 20); + given(delegate.search("matrix", 0, 20)).willReturn(result); + + target.search("matrix", 0, 20); + target.search("matrix", 0, 20); + + verify(delegate, times(1)).search("matrix", 0, 20); + } + + @Test + void differentArgumentsAreNotConflated() { + given(delegate.search("matrix", 0, 20)).willReturn(new PagedResult<>(List.of(), 0, 0, 20)); + given(delegate.search("inception", 0, 20)).willReturn(new PagedResult<>(List.of(), 0, 0, 20)); + + target.search("matrix", 0, 20); + target.search("inception", 0, 20); + + verify(delegate, times(1)).search("matrix", 0, 20); + verify(delegate, times(1)).search("inception", 0, 20); + } +} diff --git a/imdb/src/test/java/com/ludovictemgoua/imdb/infrastructure/cache/CachingTopRatedUseCaseIntegrationTest.java b/imdb/src/test/java/com/ludovictemgoua/imdb/infrastructure/cache/CachingTopRatedUseCaseIntegrationTest.java new file mode 100644 index 0000000..e531b0f --- /dev/null +++ b/imdb/src/test/java/com/ludovictemgoua/imdb/infrastructure/cache/CachingTopRatedUseCaseIntegrationTest.java @@ -0,0 +1,46 @@ +package com.ludovictemgoua.imdb.infrastructure.cache; + +import com.ludovictemgoua.imdb.TestcontainersConfiguration; +import com.ludovictemgoua.imdb.application.contracts.TopRatedUseCase; +import org.junit.jupiter.api.BeforeEach; +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 static org.assertj.core.api.Assertions.assertThat; + +// See CachingTitleSearchUseCaseIntegrationTest for why this exists alongside the unit-level +// CachingTopRatedUseCaseTest - this exercises the real Redis serialization round trip that a +// ConcurrentMapCacheManager-backed unit test structurally cannot. +@Import(TestcontainersConfiguration.class) +@SpringBootTest +@Transactional +@Sql("/fixtures/fixture-data.sql") +class CachingTopRatedUseCaseIntegrationTest { + + @Autowired + TopRatedUseCase topRatedUseCase; + + @Autowired + CacheManager cacheManager; + + @BeforeEach + void clearCache() { + cacheManager.getCache("top-rated").clear(); + } + + @Test + void secondCallIsServedFromRealRedisAndDeserializesToAnEqualResult() { + var first = topRatedUseCase.findTopRated("Action", 10, 100); + assertThat(first).extracting("id").startsWith("tt0000200", "tt0000201"); + assertThat(cacheManager.getCache("top-rated").get("Action:10:100")).isNotNull(); + + var second = topRatedUseCase.findTopRated("Action", 10, 100); + + assertThat(second).isEqualTo(first); + } +} diff --git a/imdb/src/test/java/com/ludovictemgoua/imdb/infrastructure/cache/CachingTopRatedUseCaseTest.java b/imdb/src/test/java/com/ludovictemgoua/imdb/infrastructure/cache/CachingTopRatedUseCaseTest.java new file mode 100644 index 0000000..1b32aec --- /dev/null +++ b/imdb/src/test/java/com/ludovictemgoua/imdb/infrastructure/cache/CachingTopRatedUseCaseTest.java @@ -0,0 +1,65 @@ +package com.ludovictemgoua.imdb.infrastructure.cache; + +import com.ludovictemgoua.imdb.application.TopRatedUseCaseImpl; +import com.ludovictemgoua.imdb.application.contracts.TopRatedUseCase; +import com.ludovictemgoua.imdb.domain.model.GenreTopRatedItem; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.cache.CacheManager; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.cache.concurrent.ConcurrentMapCacheManager; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +import java.util.List; + +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +@ExtendWith(SpringExtension.class) +@ContextConfiguration(classes = CachingTopRatedUseCaseTest.CacheTestConfig.class) +class CachingTopRatedUseCaseTest { + + @Configuration + @EnableCaching + static class CacheTestConfig { + @Bean + CacheManager cacheManager() { + return new ConcurrentMapCacheManager("top-rated"); + } + + @Bean + TopRatedUseCaseImpl delegate() { + return mock(TopRatedUseCaseImpl.class); + } + + // @Primary here (not just on the class) - see CachingTitleSearchUseCaseTest for why. + @Bean + @org.springframework.context.annotation.Primary + CachingTopRatedUseCase cachingTopRatedUseCase(TopRatedUseCaseImpl delegate) { + return new CachingTopRatedUseCase(delegate); + } + } + + // Typed by interface - see CachingTitleSearchUseCaseTest for why the concrete class doesn't work. + @Autowired + TopRatedUseCase target; + @Autowired + TopRatedUseCaseImpl delegate; + + @Test + void secondCallWithTheSameArgumentsIsServedFromCache() { + List result = List.of(); + given(delegate.findTopRated("Drama", 10, 1000)).willReturn(result); + + target.findTopRated("Drama", 10, 1000); + target.findTopRated("Drama", 10, 1000); + + verify(delegate, times(1)).findTopRated("Drama", 10, 1000); + } +} diff --git a/imdb/src/test/java/com/ludovictemgoua/imdb/infrastructure/openapi/OpenApiIntegrationTest.java b/imdb/src/test/java/com/ludovictemgoua/imdb/infrastructure/openapi/OpenApiIntegrationTest.java new file mode 100644 index 0000000..76fa1d4 --- /dev/null +++ b/imdb/src/test/java/com/ludovictemgoua/imdb/infrastructure/openapi/OpenApiIntegrationTest.java @@ -0,0 +1,37 @@ +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()); + } +} diff --git a/imdb/src/test/java/com/ludovictemgoua/imdb/infrastructure/persistence/AdminIdSequencesIntegrationTest.java b/imdb/src/test/java/com/ludovictemgoua/imdb/infrastructure/persistence/AdminIdSequencesIntegrationTest.java new file mode 100644 index 0000000..159c6de --- /dev/null +++ b/imdb/src/test/java/com/ludovictemgoua/imdb/infrastructure/persistence/AdminIdSequencesIntegrationTest.java @@ -0,0 +1,67 @@ +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.transaction.annotation.Transactional; + +import static org.assertj.core.api.Assertions.assertThat; + +@Import(TestcontainersConfiguration.class) +@SpringBootTest +@Transactional +class AdminIdSequencesIntegrationTest { + + @Autowired + JdbcTemplate jdbc; + + // No @Sql fixture load here, deliberately: V6 runs during Flyway migration, which - in this + // Testcontainers environment - happens against a genuinely empty schema (fixture data loads + // afterward, once the full Spring context, and therefore Flyway, is already up). The "never + // collides with existing rows" guarantee V6 actually provides only holds when Flyway runs AFTER + // the seed data already exists, which is the real production ordering (abanda/imdb-postgresql's + // own import completes before imdb-service - and its Flyway migrations - ever start). Asserting + // "greater than the fixture's max id" here would be asserting something this test's own ordering + // can't guarantee; what's true in every environment is that the sequence exists and is usable. + + @Test + void titleIdSequenceProducesIncreasingUsableValues() { + Integer first = jdbc.queryForObject("SELECT nextval('title_id_seq')", Integer.class); + Integer second = jdbc.queryForObject("SELECT nextval('title_id_seq')", Integer.class); + + assertThat(second).isGreaterThan(first); + } + + @Test + void personIdSequenceProducesIncreasingUsableValues() { + Integer first = jdbc.queryForObject("SELECT nextval('person_id_seq')", Integer.class); + Integer second = jdbc.queryForObject("SELECT nextval('person_id_seq')", Integer.class); + + assertThat(second).isGreaterThan(first); + } + + @Test + void titleIdSequenceStartsAboveExistingRowsWhenMigratedAgainstAlreadySeededData() { + // Simulates the real production ordering directly: insert a row with a high id (as if the + // seed image's import had already run), drop and recreate the sequence the way V6 itself + // does, and confirm nextval now correctly starts above it. + jdbc.update("INSERT INTO title_basics (tconst, title_type, primary_title, original_title) " + + "VALUES (999999, 'movie', 'Simulated Seeded Row', 'Simulated Seeded Row')"); + jdbc.update("DROP SEQUENCE title_id_seq"); + jdbc.execute(""" + DO $$ + DECLARE next_id BIGINT; + BEGIN + SELECT COALESCE(max(tconst), 0) + 1 INTO next_id FROM title_basics; + EXECUTE format('CREATE SEQUENCE title_id_seq START WITH %s', next_id); + END $$; + """); + + Integer nextVal = jdbc.queryForObject("SELECT nextval('title_id_seq')", Integer.class); + + assertThat(nextVal).isGreaterThan(999999); + } +} diff --git a/imdb/src/test/java/com/ludovictemgoua/imdb/infrastructure/persistence/JdbcCoStarGraphRepositoryIntegrationTest.java b/imdb/src/test/java/com/ludovictemgoua/imdb/infrastructure/persistence/JdbcCoStarGraphRepositoryIntegrationTest.java new file mode 100644 index 0000000..c433cb0 --- /dev/null +++ b/imdb/src/test/java/com/ludovictemgoua/imdb/infrastructure/persistence/JdbcCoStarGraphRepositoryIntegrationTest.java @@ -0,0 +1,54 @@ +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.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 JdbcCoStarGraphRepositoryIntegrationTest { + + @Autowired + JdbcCoStarGraphRepository repository; + + @Test + void directCoStarsAreOneDegreeApart() { + var path = repository.findShortestPath(1, 2).orElseThrow(); + + assertThat(path.degree()).isEqualTo(1); + assertThat(path.personIds()).containsExactly(1, 2); + } + + @Test + void findsTheShortestPathAcrossMultipleHopsOnBothSidesOfTheBidirectionalSearch() { + // 1-2-3-4-5-6: five edges, so this exercises the bidirectional CTE meeting in the middle + // (sideCap=4 per side, LLD §5.1/§5.2) rather than a single-hop lookup. + var path = repository.findShortestPath(1, 6).orElseThrow(); + + assertThat(path.degree()).isEqualTo(5); + assertThat(path.personIds()).containsExactly(1, 2, 3, 4, 5, 6); + } + + @Test + void isOrderIndependent() { + var forward = repository.findShortestPath(1, 6).orElseThrow(); + var backward = repository.findShortestPath(6, 1).orElseThrow(); + + assertThat(forward.degree()).isEqualTo(backward.degree()); + } + + @Test + void returnsEmptyWhenThePersonHasNoCoStars() { + // person 7 is the only credited principal on their one title - co_star_edges has no row for + // them at all. + assertThat(repository.findShortestPath(1, 7)).isEmpty(); + } +} diff --git a/imdb/src/test/java/com/ludovictemgoua/imdb/infrastructure/persistence/JdbcCustomListRepositoryIntegrationTest.java b/imdb/src/test/java/com/ludovictemgoua/imdb/infrastructure/persistence/JdbcCustomListRepositoryIntegrationTest.java new file mode 100644 index 0000000..f196a05 --- /dev/null +++ b/imdb/src/test/java/com/ludovictemgoua/imdb/infrastructure/persistence/JdbcCustomListRepositoryIntegrationTest.java @@ -0,0 +1,94 @@ +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"); + } +} diff --git a/imdb/src/test/java/com/ludovictemgoua/imdb/infrastructure/persistence/JdbcPersonRepositoryIntegrationTest.java b/imdb/src/test/java/com/ludovictemgoua/imdb/infrastructure/persistence/JdbcPersonRepositoryIntegrationTest.java new file mode 100644 index 0000000..08fde3b --- /dev/null +++ b/imdb/src/test/java/com/ludovictemgoua/imdb/infrastructure/persistence/JdbcPersonRepositoryIntegrationTest.java @@ -0,0 +1,115 @@ +package com.ludovictemgoua.imdb.infrastructure.persistence; + +import com.ludovictemgoua.imdb.TestcontainersConfiguration; +import com.ludovictemgoua.imdb.domain.repository.WriteResult; +import com.ludovictemgoua.imdb.utils.ImdbIds; +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 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 JdbcPersonRepositoryIntegrationTest { + + @Autowired + JdbcPersonRepository repository; + @Autowired + JdbcTemplate jdbc; + + @Test + void findByNameReturnsBothPeopleSharingAName() { + var candidates = repository.findByName("Jamie Lee"); + + assertThat(candidates).extracting("id").containsExactlyInAnyOrder("nm0000020", "nm0000021"); + } + + @Test + void findByNameReturnsExactlyOneMatchForAUniqueName() { + var candidates = repository.findByName("Kevin Bacon"); + + assertThat(candidates).extracting("id").containsExactly("nm0000001"); + } + + @Test + void findNameByIdResolvesAKnownPerson() { + assertThat(repository.findNameById(1)).contains("Kevin Bacon"); + } + + @Test + void findNameByIdIsEmptyForAnUnknownPerson() { + assertThat(repository.findNameById(987654)).isEmpty(); + } + + @Test + void findNamesByIdsBatchResolvesMultiplePeople() { + var names = repository.findNamesByIds(List.of(1, 6, 987654)); + + assertThat(names).containsEntry(1, "Kevin Bacon").containsEntry(6, "Tom Hanks"); + assertThat(names).doesNotContainKey(987654); + } + + @Test + void findByNameExcludesASoftDeletedPerson() { + jdbc.update("UPDATE name_basics SET deleted_at = now() WHERE nconst = 1"); + + assertThat(repository.findByName("Kevin Bacon")).isEmpty(); + } + + @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(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(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(); + } +} diff --git a/imdb/src/test/java/com/ludovictemgoua/imdb/infrastructure/persistence/JdbcReviewRepositoryIntegrationTest.java b/imdb/src/test/java/com/ludovictemgoua/imdb/infrastructure/persistence/JdbcReviewRepositoryIntegrationTest.java new file mode 100644 index 0000000..b9cecbe --- /dev/null +++ b/imdb/src/test/java/com/ludovictemgoua/imdb/infrastructure/persistence/JdbcReviewRepositoryIntegrationTest.java @@ -0,0 +1,83 @@ +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); + } +} diff --git a/imdb/src/test/java/com/ludovictemgoua/imdb/infrastructure/persistence/JdbcTitleRepositoryIntegrationTest.java b/imdb/src/test/java/com/ludovictemgoua/imdb/infrastructure/persistence/JdbcTitleRepositoryIntegrationTest.java new file mode 100644 index 0000000..8ea379a --- /dev/null +++ b/imdb/src/test/java/com/ludovictemgoua/imdb/infrastructure/persistence/JdbcTitleRepositoryIntegrationTest.java @@ -0,0 +1,193 @@ +package com.ludovictemgoua.imdb.infrastructure.persistence; + +import com.ludovictemgoua.imdb.TestcontainersConfiguration; +import com.ludovictemgoua.imdb.domain.repository.WriteResult; +import com.ludovictemgoua.imdb.utils.ImdbIds; +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 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 JdbcTitleRepositoryIntegrationTest { + + @Autowired + JdbcTitleRepository repository; + @Autowired + JdbcTemplate jdbc; + + @Test + void searchFindsATitleByFuzzyPrimaryTitleMatch() { + var results = repository.search("Few Good Men", 0, 20); + + assertThat(results.content()).extracting("id").contains("tt0000100"); + } + + @Test + void findCoreReturnsMetadataAndRating() { + var core = repository.findCore(100).orElseThrow(); + + assertThat(core.primaryTitle()).isEqualTo("A Few Good Men"); + assertThat(core.averageRating()).isEqualTo(8.0); + assertThat(core.numVotes()).isEqualTo(500000); + assertThat(core.genres()).containsExactly("Drama"); + } + + @Test + void findCoreIsEmptyForUnknownTitle() { + assertThat(repository.findCore(987654)).isEmpty(); + } + + @Test + void findDirectorsAndWritersUnnestTheCrewArrays() { + assertThat(repository.findDirectors(100)).extracting("name").containsExactly("Rob Reiner"); + assertThat(repository.findWriters(100)).extracting("name").containsExactly("Aaron Sorkin"); + } + + @Test + void findTopCastOrdersByBillingAndCountCastMatchesTotal() { + var cast = repository.findTopCast(100, 20); + + assertThat(cast).extracting("name").containsExactly("Kevin Bacon", "Tom Cruise"); + assertThat(repository.countCast(100)).isEqualTo(2); + } + + @Test + void findAnyCommonTitleFindsTheSharedCredit() { + var shared = repository.findAnyCommonTitle(1, 2).orElseThrow(); + + assertThat(shared.primaryTitle()).isEqualTo("A Few Good Men"); + } + + @Test + void findAnyCommonTitleIsEmptyWhenTheyNeverCoStarred() { + assertThat(repository.findAnyCommonTitle(1, 6)).isEmpty(); + } + + @Test + void findTopRatedRanksByWeightedRatingNotRawAverage() { + // 201's raw average (10.0) beats 200's (8.9), but at minVotes=100 the Bayesian shrinkage + // (PDD §9) pulls 201 down toward the pool mean enough that 200 - backed by 500,000 votes - + // still ranks first. See the fixture comment for the arithmetic this depends on. + var topRated = repository.findTopRated("Action", 10, 100); + + assertThat(topRated).extracting("id").startsWith("tt0000200", "tt0000201"); + } + + @Test + void findCoreExcludesASoftDeletedTitle() { + jdbc.update("UPDATE title_basics SET deleted_at = now() WHERE tconst = 100"); + + assertThat(repository.findCore(100)).isEmpty(); + } + + @Test + void searchExcludesASoftDeletedTitle() { + jdbc.update("UPDATE title_basics SET deleted_at = now() WHERE tconst = 100"); + + assertThat(repository.search("Few Good Men", 0, 20).content()).extracting("id").doesNotContain("tt0000100"); + } + + @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(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(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(); + } + + @Test + void insertPrincipalThenFindAllPrincipalsIncludesIt() { + var result = repository.insertPrincipal(100, 1, "actor", null, List.of("New Role"), 99); + + assertThat(result).isEqualTo(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(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); + } +} diff --git a/imdb/src/test/java/com/ludovictemgoua/imdb/infrastructure/persistence/JdbcUserRepositoryIntegrationTest.java b/imdb/src/test/java/com/ludovictemgoua/imdb/infrastructure/persistence/JdbcUserRepositoryIntegrationTest.java new file mode 100644 index 0000000..f1baa6e --- /dev/null +++ b/imdb/src/test/java/com/ludovictemgoua/imdb/infrastructure/persistence/JdbcUserRepositoryIntegrationTest.java @@ -0,0 +1,69 @@ +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(); + } +} diff --git a/imdb/src/test/java/com/ludovictemgoua/imdb/infrastructure/persistence/JdbcWatchlistRepositoryIntegrationTest.java b/imdb/src/test/java/com/ludovictemgoua/imdb/infrastructure/persistence/JdbcWatchlistRepositoryIntegrationTest.java new file mode 100644 index 0000000..31102f3 --- /dev/null +++ b/imdb/src/test/java/com/ludovictemgoua/imdb/infrastructure/persistence/JdbcWatchlistRepositoryIntegrationTest.java @@ -0,0 +1,80 @@ +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); + } +} diff --git a/imdb/src/test/java/com/ludovictemgoua/imdb/infrastructure/security/BootstrapAdminRunnerIntegrationTest.java b/imdb/src/test/java/com/ludovictemgoua/imdb/infrastructure/security/BootstrapAdminRunnerIntegrationTest.java new file mode 100644 index 0000000..6d8bef6 --- /dev/null +++ b/imdb/src/test/java/com/ludovictemgoua/imdb/infrastructure/security/BootstrapAdminRunnerIntegrationTest.java @@ -0,0 +1,31 @@ +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); + } +} diff --git a/imdb/src/test/java/com/ludovictemgoua/imdb/infrastructure/security/CurrentUserTest.java b/imdb/src/test/java/com/ludovictemgoua/imdb/infrastructure/security/CurrentUserTest.java new file mode 100644 index 0000000..1c5ace3 --- /dev/null +++ b/imdb/src/test/java/com/ludovictemgoua/imdb/infrastructure/security/CurrentUserTest.java @@ -0,0 +1,34 @@ +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); + } +} diff --git a/imdb/src/test/java/com/ludovictemgoua/imdb/infrastructure/security/JwtAuthenticationFilterTest.java b/imdb/src/test/java/com/ludovictemgoua/imdb/infrastructure/security/JwtAuthenticationFilterTest.java new file mode 100644 index 0000000..00a0e8c --- /dev/null +++ b/imdb/src/test/java/com/ludovictemgoua/imdb/infrastructure/security/JwtAuthenticationFilterTest.java @@ -0,0 +1,84 @@ +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), false))); + + 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 leavesSecurityContextEmptyForARefreshTokenPresentedAsBearerAuth() throws Exception { + given(request.getHeader("Authorization")).willReturn("Bearer refresh-token"); + given(jwtService.parse("refresh-token")) + .willReturn(Optional.of(new JwtService.Parsed(7, Set.of(), true))); + + new JwtAuthenticationFilter(jwtService).doFilterInternal(request, response, chain); + + assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull(); + 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); + } +} diff --git a/imdb/src/test/java/com/ludovictemgoua/imdb/infrastructure/security/JwtServiceTest.java b/imdb/src/test/java/com/ludovictemgoua/imdb/infrastructure/security/JwtServiceTest.java new file mode 100644 index 0000000..f886406 --- /dev/null +++ b/imdb/src/test/java/com/ludovictemgoua/imdb/infrastructure/security/JwtServiceTest.java @@ -0,0 +1,69 @@ +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); + assertThat(parsed.refreshToken()).isFalse(); + } + + @Test + void issuedRefreshTokenParsesBackWithNoRolesAndTheRefreshFlagSet() { + String token = jwtService.issueRefreshToken(42); + + var parsed = jwtService.parse(token).orElseThrow(); + + assertThat(parsed.userId()).isEqualTo(42); + assertThat(parsed.roles()).isEmpty(); + assertThat(parsed.refreshToken()).isTrue(); + } + + @Test + void parseReturnsEmptyForATamperedToken() { + // Flipping only the very last character isn't reliable - a base64url signature's final + // character often encodes fewer than 6 significant bits (the rest is decoder-ignored + // padding), so some single-character swaps at that exact position decode to the same bytes + // and the signature still verifies. Replacing the last 10 characters guarantees an actual + // content change deep enough into the signature to invalidate it. + String token = jwtService.issueAccessToken(1, Set.of(Role.USER)); + String tampered = token.substring(0, token.length() - 10) + "XXXXXXXXXX"; + + 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(); + } + } +} diff --git a/imdb/src/test/java/com/ludovictemgoua/imdb/presentation/AuthControllerTest.java b/imdb/src/test/java/com/ludovictemgoua/imdb/presentation/AuthControllerTest.java new file mode 100644 index 0000000..129ff48 --- /dev/null +++ b/imdb/src/test/java/com/ludovictemgoua/imdb/presentation/AuthControllerTest.java @@ -0,0 +1,72 @@ +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 com.ludovictemgoua.imdb.domain.exception.ConflictException; +import tools.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +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 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; + +// /api/v1/auth/** is always public (permitAll) and this test doesn't probe authorization +// boundaries, only the endpoints' own business logic - disabling filters avoids needing to import +// the whole security stack for a slice that will never exercise it. +@WebMvcTest(AuthController.class) +@AutoConfigureMockMvc(addFilters = false) +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 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 LoginRequest("ada@example.com", "password123"))) + .willReturn(new TokenPair("access", "refresh")); + + mockMvc.perform(post("/api/v1/auth/login") + .contentType("application/json") + .content(objectMapper.writeValueAsString( + new LoginRequest("ada@example.com", "password123")))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.refreshToken").value("refresh")); + } +} diff --git a/imdb/src/test/java/com/ludovictemgoua/imdb/presentation/GenreControllerTest.java b/imdb/src/test/java/com/ludovictemgoua/imdb/presentation/GenreControllerTest.java new file mode 100644 index 0000000..55efa1d --- /dev/null +++ b/imdb/src/test/java/com/ludovictemgoua/imdb/presentation/GenreControllerTest.java @@ -0,0 +1,49 @@ +package com.ludovictemgoua.imdb.presentation; + +import com.ludovictemgoua.imdb.application.contracts.TopRatedUseCase; +import com.ludovictemgoua.imdb.domain.model.GenreTopRatedItem; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +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.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +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; + +// This endpoint is permanently public/read-only (no admin CRUD is planned for genres), so the +// security filter chain isn't relevant here - disabling filters avoids needing to wire the whole +// security stack into a slice that will never test authorization behavior. +@WebMvcTest(GenreController.class) +@AutoConfigureMockMvc(addFilters = false) +class GenreControllerTest { + + @Autowired + MockMvc mockMvc; + @MockitoBean + TopRatedUseCase topRatedUseCase; + + @Test + void rejectsLimitAboveOneHundred() throws Exception { + mockMvc.perform(get("/api/v1/genres/Drama/top-rated").param("limit", "500")) + .andExpect(status().isBadRequest()); + } + + @Test + void returnsTheRankedListFromTheUseCase() throws Exception { + given(topRatedUseCase.findTopRated(eq("Drama"), eq(10), isNull())) + .willReturn(List.of(new GenreTopRatedItem("tt0111161", "The Shawshank Redemption", + 1994, 9.3, 2900000, 9.2))); + + mockMvc.perform(get("/api/v1/genres/Drama/top-rated")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].id").value("tt0111161")); + } +} diff --git a/imdb/src/test/java/com/ludovictemgoua/imdb/presentation/ListControllerTest.java b/imdb/src/test/java/com/ludovictemgoua/imdb/presentation/ListControllerTest.java new file mode 100644 index 0000000..6940d80 --- /dev/null +++ b/imdb/src/test/java/com/ludovictemgoua/imdb/presentation/ListControllerTest.java @@ -0,0 +1,62 @@ +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.PagedResult; +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.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(ListController.class) +@WithSecurityConfig +class ListControllerTest { + + @Autowired + MockMvc mockMvc; + @MockitoBean + ListUseCase listUseCase; + + @Test + void getPublicListsIsAccessibleAnonymously() throws Exception { + given(listUseCase.getPublic(0, 20)).willReturn(new 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(post("/api/v1/lists") + .contentType("application/json") + .content(""" + {"name":"My List","visibility":"PRIVATE"} + """)) + .andExpect(status().isUnauthorized()); + } + + @Test + void getMineRequiresAuthentication() throws Exception { + mockMvc.perform(get("/api/v1/lists/me")) + .andExpect(status().isUnauthorized()); + } +} diff --git a/imdb/src/test/java/com/ludovictemgoua/imdb/presentation/PersonControllerTest.java b/imdb/src/test/java/com/ludovictemgoua/imdb/presentation/PersonControllerTest.java new file mode 100644 index 0000000..e0000c9 --- /dev/null +++ b/imdb/src/test/java/com/ludovictemgoua/imdb/presentation/PersonControllerTest.java @@ -0,0 +1,126 @@ +package com.ludovictemgoua.imdb.presentation; + +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.application.contracts.PersonAdminUseCase; +import com.ludovictemgoua.imdb.application.contracts.SixDegreesOutcome; +import com.ludovictemgoua.imdb.application.contracts.SixDegreesUseCase; +import com.ludovictemgoua.imdb.domain.model.PersonCandidate; +import com.ludovictemgoua.imdb.domain.model.PersonCore; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; +import org.springframework.dao.QueryTimeoutException; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +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(PersonController.class) +@WithSecurityConfig +class PersonControllerTest { + + @Autowired + MockMvc mockMvc; + @MockitoBean + SixDegreesUseCase sixDegreesUseCase; + @MockitoBean + PersonAdminUseCase personAdminUseCase; + + @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()); + } + + @Test + void returnsTheResultWhenFound() throws Exception { + var personA = new PersonRef("nm0000102", "Kevin Bacon"); + var personB = new PersonRef("nm0000158", "Tom Hanks"); + var result = new SixDegreesResult(personA, personB, 2, true, + List.of(new PathStep("nm0000102", "Kevin Bacon", null))); + given(sixDegreesUseCase.compute("nm0000102", "nm0000158", 7)) + .willReturn(new SixDegreesOutcome.Found(result)); + + mockMvc.perform(get("/api/v1/people/six-degrees") + .param("personA", "nm0000102") + .param("personB", "nm0000158")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.degree").value(2)); + } + + @Test + void returnsDisambiguationPayloadWith200WhenAmbiguous() throws Exception { + given(sixDegreesUseCase.compute("Jamie Lee", "nm0000158", 7)) + .willReturn(new SixDegreesOutcome.Ambiguous("Jamie Lee", List.of( + new PersonCandidate("nm0000020", "Jamie Lee", 1975, List.of()), + new PersonCandidate("nm0000021", "Jamie Lee", 1990, List.of())))); + + mockMvc.perform(get("/api/v1/people/six-degrees") + .param("personA", "Jamie Lee") + .param("personB", "nm0000158")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.requiresDisambiguation").value(true)) + .andExpect(jsonPath("$.candidates.length()").value(2)); + } + + @Test + void returns404WhenPersonNotFound() throws Exception { + given(sixDegreesUseCase.compute("Nobody", "nm0000158", 7)) + .willReturn(new SixDegreesOutcome.PersonNotFound("Nobody")); + + mockMvc.perform(get("/api/v1/people/six-degrees") + .param("personA", "Nobody") + .param("personB", "nm0000158")) + .andExpect(status().isNotFound()); + } + + @Test + void returns504WhenTheQueryTimesOut() throws Exception { + given(sixDegreesUseCase.compute("nm0000102", "nm3937654", 7)) + .willThrow(new QueryTimeoutException("canceling statement due to user request")); + + mockMvc.perform(get("/api/v1/people/six-degrees") + .param("personA", "nm0000102") + .param("personB", "nm3937654")) + .andExpect(status().isGatewayTimeout()); + } + + @Test + void createPersonRequiresAdminRole() throws Exception { + mockMvc.perform(post("/api/v1/people") + .with(user("1").roles("USER")) + .contentType("application/json") + .content(""" + {"primaryName":"New Person","primaryProfession":[]} + """)) + .andExpect(status().isForbidden()); + } + + @Test + void createPersonSucceedsForAdmin() throws Exception { + var created = new PersonCore("nm0000011", "New Person", null, null, List.of(), 0); + given(personAdminUseCase.create(any())).willReturn(created); + + mockMvc.perform(post("/api/v1/people") + .with(user("1").roles("ADMIN")) + .contentType("application/json") + .content(""" + {"primaryName":"New Person","primaryProfession":[]} + """)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").value("nm0000011")); + } +} diff --git a/imdb/src/test/java/com/ludovictemgoua/imdb/presentation/RequestTracingIntegrationTest.java b/imdb/src/test/java/com/ludovictemgoua/imdb/presentation/RequestTracingIntegrationTest.java new file mode 100644 index 0000000..a655183 --- /dev/null +++ b/imdb/src/test/java/com/ludovictemgoua/imdb/presentation/RequestTracingIntegrationTest.java @@ -0,0 +1,65 @@ +package com.ludovictemgoua.imdb.presentation; + +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import com.ludovictemgoua.imdb.TestcontainersConfiguration; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; +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; + +// Guards the fix in tracing-design.md §1: RequestLoggingFilter's "request started"/"request +// completed" log lines are the two that bracket a request's entire lifecycle, and were the only +// ones NOT carrying a traceId (confirmed empirically against a live trace before the fix - everything +// logged from inside the request already got one via micrometer-tracing-bridge-otel's +// Slf4JEventListener, it was specifically these two that ran outside the span's scope due to filter +// ordering). A real MockMvc request through the full registered filter chain, not a mocked slice, is +// what actually exercises that ordering. +@Import(TestcontainersConfiguration.class) +@SpringBootTest +@AutoConfigureMockMvc +class RequestTracingIntegrationTest { + + @Autowired + MockMvc mockMvc; + + private ListAppender appender; + + @BeforeEach + void attachAppender() { + appender = new ListAppender<>(); + appender.start(); + logbackLogger().addAppender(appender); + } + + @AfterEach + void detachAppender() { + logbackLogger().detachAppender(appender); + } + + private static Logger logbackLogger() { + return (Logger) LoggerFactory.getLogger(RequestLoggingFilter.class); + } + + @Test + void requestStartedAndCompletedLogLinesBothCarryATraceId() throws Exception { + mockMvc.perform(get("/api/v1/genres/Action/top-rated")); + + assertThat(appender.list) + .as("RequestLoggingFilter should log both a start and a completion line") + .hasSize(2); + assertThat(appender.list).allSatisfy(event -> { + String traceId = event.getMDCPropertyMap().get("traceId"); + assertThat(traceId).as("traceId on: %s", event.getFormattedMessage()).isNotBlank(); + }); + } +} diff --git a/imdb/src/test/java/com/ludovictemgoua/imdb/presentation/ReviewControllerTest.java b/imdb/src/test/java/com/ludovictemgoua/imdb/presentation/ReviewControllerTest.java new file mode 100644 index 0000000..b4bd713 --- /dev/null +++ b/imdb/src/test/java/com/ludovictemgoua/imdb/presentation/ReviewControllerTest.java @@ -0,0 +1,39 @@ +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) +@WithSecurityConfig +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()); + } +} diff --git a/imdb/src/test/java/com/ludovictemgoua/imdb/presentation/TitleControllerTest.java b/imdb/src/test/java/com/ludovictemgoua/imdb/presentation/TitleControllerTest.java new file mode 100644 index 0000000..78239c2 --- /dev/null +++ b/imdb/src/test/java/com/ludovictemgoua/imdb/presentation/TitleControllerTest.java @@ -0,0 +1,97 @@ +package com.ludovictemgoua.imdb.presentation; + +import com.ludovictemgoua.imdb.application.contracts.TitleAdminUseCase; +import com.ludovictemgoua.imdb.application.contracts.TitleDetailUseCase; +import com.ludovictemgoua.imdb.application.contracts.TitleSearchUseCase; +import com.ludovictemgoua.imdb.domain.exception.NotFoundException; +import com.ludovictemgoua.imdb.domain.model.PagedResult; +import com.ludovictemgoua.imdb.domain.model.TitleCore; +import com.ludovictemgoua.imdb.domain.model.TitleSummary; +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.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +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(TitleController.class) +@WithSecurityConfig +class TitleControllerTest { + + @Autowired + MockMvc mockMvc; + @MockitoBean + TitleSearchUseCase titleSearchUseCase; + @MockitoBean + TitleDetailUseCase titleDetailUseCase; + @MockitoBean + TitleAdminUseCase titleAdminUseCase; + + @Test + void searchRequiresANonBlankTitleParam() throws Exception { + mockMvc.perform(get("/api/v1/titles/search").param("title", "")) + .andExpect(status().isBadRequest()); + } + + @Test + void searchRejectsPageSizeAboveOneHundred() throws Exception { + mockMvc.perform(get("/api/v1/titles/search").param("title", "matrix").param("size", "500")) + .andExpect(status().isBadRequest()); + } + + @Test + void searchReturnsThePagedResultFromTheUseCase() throws Exception { + var summary = new TitleSummary("tt0133093", "The Matrix", "The Matrix", "movie", 1999, null); + given(titleSearchUseCase.search("matrix", 0, 20)) + .willReturn(new PagedResult<>(List.of(summary), 1, 0, 20)); + + mockMvc.perform(get("/api/v1/titles/search").param("title", "matrix")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content[0].id").value("tt0133093")) + .andExpect(jsonPath("$.totalElements").value(1)); + } + + @Test + void getReturns404WhenTheUseCaseThrowsNotFound() throws Exception { + given(titleDetailUseCase.getDetail("tt9999999")) + .willThrow(new NotFoundException("No title with id tt9999999")); + + mockMvc.perform(get("/api/v1/titles/tt9999999")) + .andExpect(status().isNotFound()); + } + + @Test + void createTitleRequiresAdminRole() throws Exception { + mockMvc.perform(post("/api/v1/titles") + .with(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 TitleCore("tt0000300", "New", "New", "movie", 2024, null, 100, List.of(), null, null, 0); + given(titleAdminUseCase.create(any())).willReturn(created); + + mockMvc.perform(post("/api/v1/titles") + .with(user("1").roles("ADMIN")) + .contentType("application/json") + .content(""" + {"primaryTitle":"New","originalTitle":"New","titleType":"movie","genres":[]} + """)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").value("tt0000300")); + } +} diff --git a/imdb/src/test/java/com/ludovictemgoua/imdb/presentation/UserControllerTest.java b/imdb/src/test/java/com/ludovictemgoua/imdb/presentation/UserControllerTest.java new file mode 100644 index 0000000..c85ba9b --- /dev/null +++ b/imdb/src/test/java/com/ludovictemgoua/imdb/presentation/UserControllerTest.java @@ -0,0 +1,57 @@ +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) +@WithSecurityConfig +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()); + } +} diff --git a/imdb/src/test/java/com/ludovictemgoua/imdb/presentation/WatchlistControllerTest.java b/imdb/src/test/java/com/ludovictemgoua/imdb/presentation/WatchlistControllerTest.java new file mode 100644 index 0000000..53bdf97 --- /dev/null +++ b/imdb/src/test/java/com/ludovictemgoua/imdb/presentation/WatchlistControllerTest.java @@ -0,0 +1,53 @@ +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) +@WithSecurityConfig +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()); + } +} diff --git a/imdb/src/test/java/com/ludovictemgoua/imdb/presentation/WithSecurityConfig.java b/imdb/src/test/java/com/ludovictemgoua/imdb/presentation/WithSecurityConfig.java new file mode 100644 index 0000000..cfa79c1 --- /dev/null +++ b/imdb/src/test/java/com/ludovictemgoua/imdb/presentation/WithSecurityConfig.java @@ -0,0 +1,25 @@ +package com.ludovictemgoua.imdb.presentation; + +import com.ludovictemgoua.imdb.infrastructure.security.JwtService; +import com.ludovictemgoua.imdb.infrastructure.security.ProblemDetailAccessDeniedHandler; +import com.ludovictemgoua.imdb.infrastructure.security.ProblemDetailAuthenticationEntryPoint; +import com.ludovictemgoua.imdb.infrastructure.security.SecurityConfig; +import org.springframework.context.annotation.Import; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +// @WebMvcTest slices don't component-scan the security package, so a test asserting real 401/403 +// behavior (as opposed to disabling filters entirely) needs the whole security stack pulled in +// explicitly - this bundles that into one annotation instead of repeating the same import list on +// every controller test that needs it. JwtAuthenticationFilter is deliberately not imported here - +// SecurityConfig's own @Bean method already produces it; importing the class directly too would +// register a second, colliding bean of the same name. +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +@Import({SecurityConfig.class, JwtService.class, + ProblemDetailAuthenticationEntryPoint.class, ProblemDetailAccessDeniedHandler.class}) +public @interface WithSecurityConfig { +} diff --git a/imdb/src/test/resources/fixtures/fixture-data.sql b/imdb/src/test/resources/fixtures/fixture-data.sql new file mode 100644 index 0000000..92e02dc --- /dev/null +++ b/imdb/src/test/resources/fixtures/fixture-data.sql @@ -0,0 +1,78 @@ +-- Co-star chain: 1-2 (title 100), 2-3 (101), 3-4 (102), 4-5 (103), 5-6 (104) - a known 5-degree +-- separation between person 1 and person 6 that requires more than one hop on each side of the +-- bidirectional search (sideCap=4 per side, LLD §5.2). Person 7 is deliberately isolated (the only +-- credited principal on their one title) to exercise the "no path found" case. + +INSERT INTO name_basics (nconst, primary_name, birth_year, known_for_titles) VALUES + (1, 'Kevin Bacon', 1958, ARRAY[100]), + (2, 'Tom Cruise', 1962, ARRAY[100, 101]), + (3, 'Jack Nicholson', 1937, ARRAY[101, 102]), + (4, 'Morgan Freeman', 1937, ARRAY[102, 103]), + (5, 'Tim Robbins', 1958, ARRAY[103, 104]), + (6, 'Tom Hanks', 1956, ARRAY[104]), + (7, 'Isolated Actor', 1980, ARRAY[105]), + (10, 'Rob Reiner', 1947, ARRAY[]::integer[]), + (11, 'Aaron Sorkin', 1961, ARRAY[]::integer[]), + (20, 'Jamie Lee', 1975, ARRAY[]::integer[]), + (21, 'Jamie Lee', 1990, ARRAY[]::integer[]); + +INSERT INTO title_basics (tconst, title_type, primary_title, original_title, start_year, genres) VALUES + (100, 'movie', 'A Few Good Men', 'A Few Good Men', 1992, ARRAY['Drama']::genre[]), + (101, 'movie', 'Movie B', 'Movie B', 1995, ARRAY['Drama']::genre[]), + (102, 'movie', 'Movie C', 'Movie C', 1998, ARRAY['Drama']::genre[]), + (103, 'movie', 'Movie D', 'Movie D', 2001, ARRAY['Drama']::genre[]), + (104, 'movie', 'The Terminal', 'The Terminal', 2004, ARRAY['Drama']::genre[]), + (105, 'movie', 'Solo Film', 'Solo Film', 2010, ARRAY['Drama']::genre[]), + (200, 'movie', 'High Vote Solid Rating', 'High Vote Solid Rating', 2000, ARRAY['Action']::genre[]), + (201, 'movie', 'Low Vote Perfect Rating', 'Low Vote Perfect Rating', 2000, ARRAY['Action']::genre[]), + (202, 'movie', 'Average Movie A', 'Average Movie A', 2000, ARRAY['Action']::genre[]), + (203, 'movie', 'Average Movie B', 'Average Movie B', 2000, ARRAY['Action']::genre[]); + +-- The weighted-rating test case (queried with minVotes=100, PDD §9): 201's raw average (10.0) beats +-- 200's (8.9), and 201's vote count (100) exactly clears the minVotes floor - but against a realistic +-- pool mean (padded by 202/203 at 5.0), the Bayesian shrinkage pulls 201's weighted score down enough +-- that 200 - overwhelmingly supported by 500,000 votes - still ranks first. This is deliberately tuned +-- so the assertion actually exercises the formula: at minVotes = m, anything that just clears the +-- filter is shrunk exactly halfway to the pool mean, which only overturns a raw-rating gap this size +-- if the pool mean is pulled low enough by the other titles. +INSERT INTO title_ratings (tconst, average_rating, num_votes) VALUES + (100, 8.0, 500000), + (101, 7.5, 400000), + (102, 7.0, 300000), + (103, 7.8, 350000), + (104, 7.7, 450000), + (105, 6.0, 10), + (200, 8.9, 500000), + (201, 10.0, 100), + (202, 5.0, 200000), + (203, 5.0, 200000); + +INSERT INTO title_crew (tconst, directors, writers) VALUES + (100, ARRAY[10], ARRAY[11]); + +INSERT INTO title_principals (tconst, ordering, nconst, category) VALUES + (100, 1, 1, 'actor'), + (100, 2, 2, 'actor'), + (101, 1, 2, 'actor'), + (101, 2, 3, 'actor'), + (102, 1, 3, 'actor'), + (102, 2, 4, 'actor'), + (103, 1, 4, 'actor'), + (103, 2, 5, 'actor'), + (104, 1, 5, 'actor'), + (104, 2, 6, 'actor'), + (105, 1, 7, 'actor'); + +-- co_star_edges is a materialized view (LLD §3.3) - it does not auto-update when title_principals +-- changes underneath it, so every fixture load must explicitly refresh it. +REFRESH MATERIALIZED VIEW co_star_edges; + +-- title_id_seq/person_id_seq (V6) are created during Flyway migration, which in this Testcontainers +-- environment runs against a genuinely empty schema (this fixture loads afterward, once the full +-- Spring context - and therefore Flyway - is already up) - so both sequences start at 1 here, unlike +-- production where abanda/imdb-postgresql's import completes before imdb-service's migrations ever +-- run. Advancing them past this fixture's own max ids here simulates that real ordering, so any +-- admin-CRUD integration test that inserts a title/person doesn't collide with a fixture row (nconst +-- 1 "Kevin Bacon" would otherwise be exactly what person_id_seq's first nextval() returns). +SELECT setval('title_id_seq', (SELECT max(tconst) FROM title_basics) + 1, false); +SELECT setval('person_id_seq', (SELECT max(nconst) FROM name_basics) + 1, false);