Skip to content

feat(catalog): categories and products - #4

Merged
EdgarEldy merged 17 commits into
developfrom
feature/catalog
Sep 19, 2026
Merged

EdgarEldy merged 17 commits into
developfrom
feature/catalog

Conversation

@EdgarEldy

Copy link
Copy Markdown
Owner

Branch

feature/catalog

Task checklist

  • core/catalog: Category, Product entities, dto classes, RowMappers, mappers, DAOs, services, DaoConfig/ServiceConfig
  • Business rule: deleting a category that still has products is rejected (BusinessRuleException -> 422)
  • ws: CategoryController, ProductController, CategoryConverter, ProductConverter
  • WebMvcConfig imports core/catalog's config
  • Tests at all five layers: DAOs with their own fixtures, the category-deletion rejection, permission-denied cases

Commit summary

  • catalog core: entities, dto, RowMappers, mappers, JdbcTemplate DAOs, services (unique category names, price rules, race handling without an exception translator), explicit wiring
  • ws: payloads with Bean Validation, converters, controllers with declarative permissions and page/size validation, product filter by categoryId
  • tests: 91 in catalog and the catalog controller and flow tests in ws (712 tests across the reactor)
  • review fixes: price bounded before scaling, category vanishing between check and write handled

Limits documented: HTTP rejects prices such as 1.500 (at most two decimals), a product update racing a category delete can 500 on the FK only in the narrow update window handled at write time.

Test plan

  • Tests exist at every layer the branch's checklist calls for
  • Each DAO test class loads its own dedicated SQL fixture file
  • mvn clean verify is green on the whole reactor

Category and Product are table-shaped classes documented with the
jakarta.persistence annotations, and CategoryDto and ProductDto are the
business shapes the services expose. The unit price is a BigDecimal on both
sides, never a double, because money must not suffer binary rounding.

The two shapes stay separate classes even though their fields match today.
The RowMappers read one row into an entity, using rs.getBigDecimal for the
NUMERIC unit price so the exact decimal value survives the round trip. The
entity mappers convert between entity and dto in both directions.

Neither queries the database or holds any business rule.
The DAOs write the SQL of the catalog with JdbcTemplate through AbstractDao:
paginated reads ordered by id with LIMIT and OFFSET, INSERT ... RETURNING id,
updates and deletes. ProductDao chooses between two statements depending on
whether a category filter is given, which keeps every query plain and
index-friendly, and counts products per category for the delete rule.

They work in entity types only and have no stereotype annotation.
The services own the business rules: names are trimmed and bounded, category
names are unique, a category that still has products cannot be deleted, a
product must reference an existing category, and prices are non-negative
decimals with at most two digits after the point, normalised to scale two.

Races are handled without an exception translator: a DuplicateKeyException or
DataIntegrityViolationException raised by a concurrent change is turned into the
same business refusal as the pre-check, so it never leaks as a 500. The service
returns dto types and pages through PageDto.
DaoConfig and ServiceConfig declare the beans typed by interface, and
ServiceConfig imports DaoConfig so importing it alone is enough. The service
beans are proxied for @transactional through their interfaces.

The catalog module needs no configuration of its own beyond these two classes.
Requests carry Bean Validation constraints: bounded names, and for the unit
price a non-null decimal that is at least 0.00 with at most ten integer digits and
two decimals, so a malformed price is a 400 before it reaches the service.
Converters map to and from the core dto classes and build the PageResponse.

Payload classes stay distinct from core dto classes.
Both controllers follow the README table with declarative
@PreAuthorize("hasPermission('CATEGORY'|'PRODUCT', 'READ'|'WRITE')"), only
call their service and return ApiResponse for every answer. The list endpoints
validate page and size, and the product list accepts an optional categoryId
filter.

Creations return 201, and there is no business logic in either controller.
The catalog ServiceConfig is added to the WebMvcConfig import list, by fully
qualified name because every core module has a class with that simple name.

Importing the ServiceConfig alone is enough since it imports its own DaoConfig.
…odule

The catalog DAO tests run against a real PostgreSQL, so the module declares
the Testcontainers JUnit and PostgreSQL artifacts and the common test-jar
holding the shared singleton container, all with test scope, exactly like the
auth module.

They stay out of the parent pom because only modules with DAO tests need them.
Each DAO test class loads its own dedicated fixture, truncating only the
tables it touches. They check CRUD, page ordering and offsets, the optional
category filter, counts per category, a duplicate category name, and the
constraints owned by the schema: the foreign key on the category, the CHECK on a
negative price and the exact NUMERIC round trip of 12.50.

A delete refused by a foreign key surfaces as DataIntegrityViolationException,
which is what the service relies on for its race handling.
RowMapper tests use a mocked ResultSet, including the BigDecimal unit price,
and the entity mappers are checked with hand-built objects and no mocks.

They pin the mapping contracts without needing a database.
With mocked DAOs the tests cover the page bounds, the 404 cases, name trimming
and length limits, duplicate names including the DuplicateKeyException race, and
the refusal to delete a category that still has products, both from the count
and from the foreign key race.

For products they cover an unknown category, every price rule (negative,
too many decimals, too large, normalised to two decimals) and the foreign key
race on delete.
The real security configuration and a real signed JWT are used with mocked
services. Every endpoint is checked for status and JSON shape, validation errors
per field including each price case, page and size violations, a page beyond
the last that keeps content as an empty array, and the categoryId filter passed
to the service.

The 404 and 422 mappings, malformed JSON, 415 and 405 are covered too.
A parameterized test asserts, for every endpoint, that no token gives 401, that
a token holding every other permission or none gives 403, that a blacklisted
token gives 401, and that a token holding only the required permission succeeds.

It proves each route is guarded by its own CATEGORY or PRODUCT permission.
Real services and a real database prove the CRUD of categories and products,
the category filter, the refusal to delete a category with products followed by
success once the product is gone, an unknown category giving 404 with nothing
stored, duplicate names giving 422 and the exact stored price.

Users holding only CATEGORY:WRITE or only PRODUCT:WRITE, a read-only role
and a user without permission are checked for 403. Test rows are named with a
random suffix and removed afterwards, so the shared database stays clean.
The five catalog tasks are checked: the entities, dto classes, RowMappers, mappers,
DAOs, services and configuration of core/catalog, the category deletion rule, the
controllers and converters in ws, the import in WebMvcConfig, and the tests at all
five layers with a dedicated fixture per DAO test class.
…tegory

cleanPrice called setScale before comparing with the maximum, so a value such
as 1E+999999999 made BigDecimal build an enormous number before being refused.
The bound is now checked first, on the raw value.

create and update also checked that the category exists and then wrote, so a
category deleted in between made the foreign key raise a
DataIntegrityViolationException that became a 500. Both now translate it into the
same ResourceNotFoundException as the pre-check. Three unit tests pin these
cases.
@EdgarEldy
EdgarEldy merged commit 1b01988 into develop Sep 19, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant