1
0
Fork 0
mirror of https://github.com/s-frick/effigenix.git synced 2026-03-28 08:29:36 +01:00

fix(inventory): Review-Fixes für US-6.1 InventoryCount

- GlobalExceptionHandler und ErrorResponse nach infrastructure.shared
  extrahieren (war fälschlich in usermanagement)
- CountItem.deviation() prüft UOM-Kompatibilität
- InvalidInventoryCountId Error-Typ für null/blank ID (400 statt 404)
- saveChildren() auf UPSERT (UPDATE→INSERT) mit Orphan-Cleanup umstellen
- logger.trace → logger.warn bei DB-Fehlern
- Stocks ohne Batches in CreateInventoryCount überspringen
- AuthorizationPort Defense in Depth in alle 3 InventoryCount Use Cases
- Kombinierter DB-Index auf (storage_location_id, status)
This commit is contained in:
Sebastian Frick 2026-02-26 19:14:55 +01:00
parent c047ca93de
commit a214002fab
19 changed files with 205 additions and 130 deletions

View file

@ -4,22 +4,31 @@ import de.effigenix.application.inventory.command.CreateInventoryCountCommand;
import de.effigenix.domain.inventory.*; import de.effigenix.domain.inventory.*;
import de.effigenix.shared.common.Result; import de.effigenix.shared.common.Result;
import de.effigenix.shared.persistence.UnitOfWork; import de.effigenix.shared.persistence.UnitOfWork;
import de.effigenix.shared.security.ActorId;
import de.effigenix.shared.security.AuthorizationPort;
public class CreateInventoryCount { public class CreateInventoryCount {
private final InventoryCountRepository inventoryCountRepository; private final InventoryCountRepository inventoryCountRepository;
private final StockRepository stockRepository; private final StockRepository stockRepository;
private final UnitOfWork unitOfWork; private final UnitOfWork unitOfWork;
private final AuthorizationPort authPort;
public CreateInventoryCount(InventoryCountRepository inventoryCountRepository, public CreateInventoryCount(InventoryCountRepository inventoryCountRepository,
StockRepository stockRepository, StockRepository stockRepository,
UnitOfWork unitOfWork) { UnitOfWork unitOfWork,
AuthorizationPort authPort) {
this.inventoryCountRepository = inventoryCountRepository; this.inventoryCountRepository = inventoryCountRepository;
this.stockRepository = stockRepository; this.stockRepository = stockRepository;
this.unitOfWork = unitOfWork; this.unitOfWork = unitOfWork;
this.authPort = authPort;
} }
public Result<InventoryCountError, InventoryCount> execute(CreateInventoryCountCommand cmd) { public Result<InventoryCountError, InventoryCount> execute(CreateInventoryCountCommand cmd, ActorId actorId) {
if (!authPort.can(actorId, InventoryAction.INVENTORY_COUNT_WRITE)) {
return Result.failure(new InventoryCountError.Unauthorized("Not authorized to create inventory counts"));
}
// 1. Draft aus Command bauen // 1. Draft aus Command bauen
var draft = new InventoryCountDraft(cmd.storageLocationId(), cmd.countDate(), cmd.initiatedBy()); var draft = new InventoryCountDraft(cmd.storageLocationId(), cmd.countDate(), cmd.initiatedBy());
@ -48,14 +57,16 @@ public class CreateInventoryCount {
{ return Result.failure(new InventoryCountError.RepositoryFailure(err.message())); } { return Result.failure(new InventoryCountError.RepositoryFailure(err.message())); }
case Result.Success(var stocks) -> { case Result.Success(var stocks) -> {
for (Stock stock : stocks) { for (Stock stock : stocks) {
// Gesamtmenge aus Batches berechnen // Skip stocks without batches nothing to count
if (stock.batches().isEmpty()) {
continue;
}
var totalAmount = stock.batches().stream() var totalAmount = stock.batches().stream()
.map(b -> b.quantity().amount()) .map(b -> b.quantity().amount())
.reduce(java.math.BigDecimal.ZERO, java.math.BigDecimal::add); .reduce(java.math.BigDecimal.ZERO, java.math.BigDecimal::add);
String unit = stock.batches().isEmpty() String unit = stock.batches().getFirst().quantity().uom().name();
? "KILOGRAM"
: stock.batches().getFirst().quantity().uom().name();
var itemDraft = new CountItemDraft( var itemDraft = new CountItemDraft(
stock.articleId().value(), stock.articleId().value(),

View file

@ -1,22 +1,31 @@
package de.effigenix.application.inventory; package de.effigenix.application.inventory;
import de.effigenix.domain.inventory.InventoryAction;
import de.effigenix.domain.inventory.InventoryCount; import de.effigenix.domain.inventory.InventoryCount;
import de.effigenix.domain.inventory.InventoryCountError; import de.effigenix.domain.inventory.InventoryCountError;
import de.effigenix.domain.inventory.InventoryCountId; import de.effigenix.domain.inventory.InventoryCountId;
import de.effigenix.domain.inventory.InventoryCountRepository; import de.effigenix.domain.inventory.InventoryCountRepository;
import de.effigenix.shared.common.Result; import de.effigenix.shared.common.Result;
import de.effigenix.shared.security.ActorId;
import de.effigenix.shared.security.AuthorizationPort;
public class GetInventoryCount { public class GetInventoryCount {
private final InventoryCountRepository inventoryCountRepository; private final InventoryCountRepository inventoryCountRepository;
private final AuthorizationPort authPort;
public GetInventoryCount(InventoryCountRepository inventoryCountRepository) { public GetInventoryCount(InventoryCountRepository inventoryCountRepository, AuthorizationPort authPort) {
this.inventoryCountRepository = inventoryCountRepository; this.inventoryCountRepository = inventoryCountRepository;
this.authPort = authPort;
} }
public Result<InventoryCountError, InventoryCount> execute(String inventoryCountId) { public Result<InventoryCountError, InventoryCount> execute(String inventoryCountId, ActorId actorId) {
if (!authPort.can(actorId, InventoryAction.INVENTORY_COUNT_READ)) {
return Result.failure(new InventoryCountError.Unauthorized("Not authorized to view inventory counts"));
}
if (inventoryCountId == null || inventoryCountId.isBlank()) { if (inventoryCountId == null || inventoryCountId.isBlank()) {
return Result.failure(new InventoryCountError.InventoryCountNotFound(inventoryCountId)); return Result.failure(new InventoryCountError.InvalidInventoryCountId("must not be blank"));
} }
return switch (inventoryCountRepository.findById(InventoryCountId.of(inventoryCountId))) { return switch (inventoryCountRepository.findById(InventoryCountId.of(inventoryCountId))) {

View file

@ -3,18 +3,26 @@ package de.effigenix.application.inventory;
import de.effigenix.domain.inventory.*; import de.effigenix.domain.inventory.*;
import de.effigenix.shared.common.RepositoryError; import de.effigenix.shared.common.RepositoryError;
import de.effigenix.shared.common.Result; import de.effigenix.shared.common.Result;
import de.effigenix.shared.security.ActorId;
import de.effigenix.shared.security.AuthorizationPort;
import java.util.List; import java.util.List;
public class ListInventoryCounts { public class ListInventoryCounts {
private final InventoryCountRepository inventoryCountRepository; private final InventoryCountRepository inventoryCountRepository;
private final AuthorizationPort authPort;
public ListInventoryCounts(InventoryCountRepository inventoryCountRepository) { public ListInventoryCounts(InventoryCountRepository inventoryCountRepository, AuthorizationPort authPort) {
this.inventoryCountRepository = inventoryCountRepository; this.inventoryCountRepository = inventoryCountRepository;
this.authPort = authPort;
} }
public Result<InventoryCountError, List<InventoryCount>> execute(String storageLocationId) { public Result<InventoryCountError, List<InventoryCount>> execute(String storageLocationId, ActorId actorId) {
if (!authPort.can(actorId, InventoryAction.INVENTORY_COUNT_READ)) {
return Result.failure(new InventoryCountError.Unauthorized("Not authorized to view inventory counts"));
}
if (storageLocationId != null) { if (storageLocationId != null) {
StorageLocationId locId; StorageLocationId locId;
try { try {

View file

@ -74,12 +74,18 @@ public class CountItem {
/** /**
* Computed deviation: actualQuantity - expectedQuantity. * Computed deviation: actualQuantity - expectedQuantity.
* Returns null if actualQuantity has not been set yet. * Returns null if actualQuantity has not been set yet or if UOMs are incompatible.
*
* Invariant: actualQuantity and expectedQuantity should always share the same UOM.
* A UOM mismatch indicates a data integrity issue.
*/ */
public BigDecimal deviation() { public BigDecimal deviation() {
if (actualQuantity == null) { if (actualQuantity == null) {
return null; return null;
} }
if (actualQuantity.uom() != expectedQuantity.uom()) {
return null;
}
return actualQuantity.amount().subtract(expectedQuantity.amount()); return actualQuantity.amount().subtract(expectedQuantity.amount());
} }

View file

@ -65,6 +65,11 @@ public sealed interface InventoryCountError {
@Override public String message() { return "Counter must not be the same person who initiated the count"; } @Override public String message() { return "Counter must not be the same person who initiated the count"; }
} }
record InvalidInventoryCountId(String reason) implements InventoryCountError {
@Override public String code() { return "INVALID_INVENTORY_COUNT_ID"; }
@Override public String message() { return "Invalid inventory count ID: " + reason; }
}
record InventoryCountNotFound(String id) implements InventoryCountError { record InventoryCountNotFound(String id) implements InventoryCountError {
@Override public String code() { return "INVENTORY_COUNT_NOT_FOUND"; } @Override public String code() { return "INVENTORY_COUNT_NOT_FOUND"; }
@Override public String message() { return "Inventory count not found: " + id; } @Override public String message() { return "Inventory count not found: " + id; }

View file

@ -159,17 +159,17 @@ public class InventoryUseCaseConfiguration {
// ==================== InventoryCount Use Cases ==================== // ==================== InventoryCount Use Cases ====================
@Bean @Bean
public CreateInventoryCount createInventoryCount(InventoryCountRepository inventoryCountRepository, StockRepository stockRepository, UnitOfWork unitOfWork) { public CreateInventoryCount createInventoryCount(InventoryCountRepository inventoryCountRepository, StockRepository stockRepository, UnitOfWork unitOfWork, AuthorizationPort authorizationPort) {
return new CreateInventoryCount(inventoryCountRepository, stockRepository, unitOfWork); return new CreateInventoryCount(inventoryCountRepository, stockRepository, unitOfWork, authorizationPort);
} }
@Bean @Bean
public GetInventoryCount getInventoryCount(InventoryCountRepository inventoryCountRepository) { public GetInventoryCount getInventoryCount(InventoryCountRepository inventoryCountRepository, AuthorizationPort authorizationPort) {
return new GetInventoryCount(inventoryCountRepository); return new GetInventoryCount(inventoryCountRepository, authorizationPort);
} }
@Bean @Bean
public ListInventoryCounts listInventoryCounts(InventoryCountRepository inventoryCountRepository) { public ListInventoryCounts listInventoryCounts(InventoryCountRepository inventoryCountRepository, AuthorizationPort authorizationPort) {
return new ListInventoryCounts(inventoryCountRepository); return new ListInventoryCounts(inventoryCountRepository, authorizationPort);
} }
} }

View file

@ -45,7 +45,7 @@ public class JdbcInventoryCountRepository implements InventoryCountRepository {
} }
return Result.success(Optional.of(loadChildren(countOpt.get(), id.value()))); return Result.success(Optional.of(loadChildren(countOpt.get(), id.value())));
} catch (Exception e) { } catch (Exception e) {
logger.trace("Database error in findById", e); logger.warn("Database error in findById", e);
return Result.failure(new RepositoryError.DatabaseError(e.getMessage())); return Result.failure(new RepositoryError.DatabaseError(e.getMessage()));
} }
} }
@ -58,7 +58,7 @@ public class JdbcInventoryCountRepository implements InventoryCountRepository {
.list(); .list();
return Result.success(loadChildrenForAll(counts)); return Result.success(loadChildrenForAll(counts));
} catch (Exception e) { } catch (Exception e) {
logger.trace("Database error in findAll", e); logger.warn("Database error in findAll", e);
return Result.failure(new RepositoryError.DatabaseError(e.getMessage())); return Result.failure(new RepositoryError.DatabaseError(e.getMessage()));
} }
} }
@ -72,7 +72,7 @@ public class JdbcInventoryCountRepository implements InventoryCountRepository {
.list(); .list();
return Result.success(loadChildrenForAll(counts)); return Result.success(loadChildrenForAll(counts));
} catch (Exception e) { } catch (Exception e) {
logger.trace("Database error in findByStorageLocationId", e); logger.warn("Database error in findByStorageLocationId", e);
return Result.failure(new RepositoryError.DatabaseError(e.getMessage())); return Result.failure(new RepositoryError.DatabaseError(e.getMessage()));
} }
} }
@ -90,7 +90,7 @@ public class JdbcInventoryCountRepository implements InventoryCountRepository {
.single(); .single();
return Result.success(count > 0); return Result.success(count > 0);
} catch (Exception e) { } catch (Exception e) {
logger.trace("Database error in existsActiveByStorageLocationId", e); logger.warn("Database error in existsActiveByStorageLocationId", e);
return Result.failure(new RepositoryError.DatabaseError(e.getMessage())); return Result.failure(new RepositoryError.DatabaseError(e.getMessage()));
} }
} }
@ -122,7 +122,7 @@ public class JdbcInventoryCountRepository implements InventoryCountRepository {
return Result.success(null); return Result.success(null);
} catch (Exception e) { } catch (Exception e) {
logger.trace("Database error in save", e); logger.warn("Database error in save", e);
return Result.failure(new RepositoryError.DatabaseError(e.getMessage())); return Result.failure(new RepositoryError.DatabaseError(e.getMessage()));
} }
} }
@ -142,29 +142,59 @@ public class JdbcInventoryCountRepository implements InventoryCountRepository {
private void saveChildren(InventoryCount count) { private void saveChildren(InventoryCount count) {
String countId = count.id().value(); String countId = count.id().value();
// Delete + re-insert count items // Remove orphaned items no longer in the aggregate
jdbc.sql("DELETE FROM count_items WHERE inventory_count_id = :countId") List<String> currentIds = count.countItems().stream()
.param("countId", countId) .map(item -> item.id().value())
.update(); .toList();
if (currentIds.isEmpty()) {
jdbc.sql("DELETE FROM count_items WHERE inventory_count_id = :countId")
.param("countId", countId)
.update();
} else {
jdbc.sql("DELETE FROM count_items WHERE inventory_count_id = :countId AND id NOT IN (:ids)")
.param("countId", countId)
.param("ids", currentIds)
.update();
}
// Upsert each item (UPDATE INSERT)
for (CountItem item : count.countItems()) { for (CountItem item : count.countItems()) {
jdbc.sql(""" int rows = jdbc.sql("""
INSERT INTO count_items UPDATE count_items
(id, inventory_count_id, article_id, SET article_id = :articleId,
expected_quantity_amount, expected_quantity_unit, expected_quantity_amount = :expectedQuantityAmount,
actual_quantity_amount, actual_quantity_unit) expected_quantity_unit = :expectedQuantityUnit,
VALUES (:id, :countId, :articleId, actual_quantity_amount = :actualQuantityAmount,
:expectedQuantityAmount, :expectedQuantityUnit, actual_quantity_unit = :actualQuantityUnit
:actualQuantityAmount, :actualQuantityUnit) WHERE id = :id
""") """)
.param("id", item.id().value()) .param("id", item.id().value())
.param("countId", countId)
.param("articleId", item.articleId().value()) .param("articleId", item.articleId().value())
.param("expectedQuantityAmount", item.expectedQuantity().amount()) .param("expectedQuantityAmount", item.expectedQuantity().amount())
.param("expectedQuantityUnit", item.expectedQuantity().uom().name()) .param("expectedQuantityUnit", item.expectedQuantity().uom().name())
.param("actualQuantityAmount", item.actualQuantity() != null ? item.actualQuantity().amount() : null) .param("actualQuantityAmount", item.actualQuantity() != null ? item.actualQuantity().amount() : null)
.param("actualQuantityUnit", item.actualQuantity() != null ? item.actualQuantity().uom().name() : null) .param("actualQuantityUnit", item.actualQuantity() != null ? item.actualQuantity().uom().name() : null)
.update(); .update();
if (rows == 0) {
jdbc.sql("""
INSERT INTO count_items
(id, inventory_count_id, article_id,
expected_quantity_amount, expected_quantity_unit,
actual_quantity_amount, actual_quantity_unit)
VALUES (:id, :countId, :articleId,
:expectedQuantityAmount, :expectedQuantityUnit,
:actualQuantityAmount, :actualQuantityUnit)
""")
.param("id", item.id().value())
.param("countId", countId)
.param("articleId", item.articleId().value())
.param("expectedQuantityAmount", item.expectedQuantity().amount())
.param("expectedQuantityUnit", item.expectedQuantity().uom().name())
.param("actualQuantityAmount", item.actualQuantity() != null ? item.actualQuantity().amount() : null)
.param("actualQuantityUnit", item.actualQuantity() != null ? item.actualQuantity().uom().name() : null)
.update();
}
} }
} }

View file

@ -7,6 +7,7 @@ import de.effigenix.application.inventory.command.CreateInventoryCountCommand;
import de.effigenix.domain.inventory.InventoryCountError; import de.effigenix.domain.inventory.InventoryCountError;
import de.effigenix.infrastructure.inventory.web.dto.CreateInventoryCountRequest; import de.effigenix.infrastructure.inventory.web.dto.CreateInventoryCountRequest;
import de.effigenix.infrastructure.inventory.web.dto.InventoryCountResponse; import de.effigenix.infrastructure.inventory.web.dto.InventoryCountResponse;
import de.effigenix.shared.security.ActorId;
import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid; import jakarta.validation.Valid;
@ -48,7 +49,7 @@ public class InventoryCountController {
authentication.getName() authentication.getName()
); );
var result = createInventoryCount.execute(cmd); var result = createInventoryCount.execute(cmd, ActorId.of(authentication.getName()));
if (result.isFailure()) { if (result.isFailure()) {
throw new InventoryCountDomainErrorException(result.unsafeGetError()); throw new InventoryCountDomainErrorException(result.unsafeGetError());
@ -60,8 +61,11 @@ public class InventoryCountController {
@GetMapping("/{id}") @GetMapping("/{id}")
@PreAuthorize("hasAuthority('INVENTORY_COUNT_READ')") @PreAuthorize("hasAuthority('INVENTORY_COUNT_READ')")
public ResponseEntity<InventoryCountResponse> getInventoryCount(@PathVariable String id) { public ResponseEntity<InventoryCountResponse> getInventoryCount(
var result = getInventoryCount.execute(id); @PathVariable String id,
Authentication authentication
) {
var result = getInventoryCount.execute(id, ActorId.of(authentication.getName()));
if (result.isFailure()) { if (result.isFailure()) {
throw new InventoryCountDomainErrorException(result.unsafeGetError()); throw new InventoryCountDomainErrorException(result.unsafeGetError());
@ -73,9 +77,10 @@ public class InventoryCountController {
@GetMapping @GetMapping
@PreAuthorize("hasAuthority('INVENTORY_COUNT_READ')") @PreAuthorize("hasAuthority('INVENTORY_COUNT_READ')")
public ResponseEntity<List<InventoryCountResponse>> listInventoryCounts( public ResponseEntity<List<InventoryCountResponse>> listInventoryCounts(
@RequestParam(required = false) String storageLocationId @RequestParam(required = false) String storageLocationId,
Authentication authentication
) { ) {
var result = listInventoryCounts.execute(storageLocationId); var result = listInventoryCounts.execute(storageLocationId, ActorId.of(authentication.getName()));
if (result.isFailure()) { if (result.isFailure()) {
throw new InventoryCountDomainErrorException(result.unsafeGetError()); throw new InventoryCountDomainErrorException(result.unsafeGetError());

View file

@ -69,6 +69,7 @@ public final class InventoryErrorHttpStatusMapper {
case InventoryCountError.InvalidInitiatedBy e -> 400; case InventoryCountError.InvalidInitiatedBy e -> 400;
case InventoryCountError.InvalidArticleId e -> 400; case InventoryCountError.InvalidArticleId e -> 400;
case InventoryCountError.InvalidQuantity e -> 400; case InventoryCountError.InvalidQuantity e -> 400;
case InventoryCountError.InvalidInventoryCountId e -> 400;
case InventoryCountError.Unauthorized e -> 403; case InventoryCountError.Unauthorized e -> 403;
case InventoryCountError.RepositoryFailure e -> 500; case InventoryCountError.RepositoryFailure e -> 500;
}; };

View file

@ -1,7 +1,7 @@
package de.effigenix.infrastructure.security; package de.effigenix.infrastructure.security;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import de.effigenix.infrastructure.usermanagement.web.dto.ErrorResponse; import de.effigenix.infrastructure.shared.web.exception.ErrorResponse;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.Logger; import org.slf4j.Logger;

View file

@ -1,7 +1,7 @@
package de.effigenix.infrastructure.security; package de.effigenix.infrastructure.security;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import de.effigenix.infrastructure.usermanagement.web.dto.ErrorResponse; import de.effigenix.infrastructure.shared.web.exception.ErrorResponse;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.Logger; import org.slf4j.Logger;

View file

@ -1,4 +1,4 @@
package de.effigenix.infrastructure.usermanagement.web.dto; package de.effigenix.infrastructure.shared.web.exception;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;

View file

@ -1,4 +1,4 @@
package de.effigenix.infrastructure.usermanagement.web.exception; package de.effigenix.infrastructure.shared.web.exception;
import de.effigenix.domain.inventory.InventoryCountError; import de.effigenix.domain.inventory.InventoryCountError;
import de.effigenix.domain.inventory.StockMovementError; import de.effigenix.domain.inventory.StockMovementError;
@ -30,7 +30,7 @@ import de.effigenix.infrastructure.masterdata.web.exception.MasterDataErrorHttpS
import de.effigenix.infrastructure.usermanagement.web.controller.AuthController; import de.effigenix.infrastructure.usermanagement.web.controller.AuthController;
import de.effigenix.infrastructure.usermanagement.web.controller.RoleController; import de.effigenix.infrastructure.usermanagement.web.controller.RoleController;
import de.effigenix.infrastructure.usermanagement.web.controller.UserController; import de.effigenix.infrastructure.usermanagement.web.controller.UserController;
import de.effigenix.infrastructure.usermanagement.web.dto.ErrorResponse; import de.effigenix.infrastructure.usermanagement.web.exception.UserErrorHttpStatusMapper;
import io.sentry.Sentry; import io.sentry.Sentry;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
@ -372,10 +372,6 @@ public class GlobalExceptionHandler {
* Handles validation errors from @Valid annotations. * Handles validation errors from @Valid annotations.
* *
* Returns 400 Bad Request with list of validation errors. * Returns 400 Bad Request with list of validation errors.
* Example:
* - Username is required
* - Email must be valid
* - Password must be at least 8 characters
* *
* @param ex Validation exception * @param ex Validation exception
* @param request HTTP request * @param request HTTP request
@ -412,17 +408,6 @@ public class GlobalExceptionHandler {
.body(errorResponse); .body(errorResponse);
} }
/**
* Handles authentication errors (e.g., invalid JWT token).
*
* Returns 401 Unauthorized.
* This is typically caught by SecurityConfig's authenticationEntryPoint,
* but included here for completeness.
*
* @param ex Authentication exception
* @param request HTTP request
* @return Error response with 401 status
*/
@ExceptionHandler(AuthenticationException.class) @ExceptionHandler(AuthenticationException.class)
public ResponseEntity<ErrorResponse> handleAuthenticationError( public ResponseEntity<ErrorResponse> handleAuthenticationError(
AuthenticationException ex, AuthenticationException ex,
@ -442,16 +427,6 @@ public class GlobalExceptionHandler {
.body(errorResponse); .body(errorResponse);
} }
/**
* Handles authorization errors (missing permissions).
*
* Returns 403 Forbidden.
* Triggered when user lacks required permission for an action.
*
* @param ex Access denied exception
* @param request HTTP request
* @return Error response with 403 status
*/
@ExceptionHandler(AccessDeniedException.class) @ExceptionHandler(AccessDeniedException.class)
public ResponseEntity<ErrorResponse> handleAccessDeniedError( public ResponseEntity<ErrorResponse> handleAccessDeniedError(
AccessDeniedException ex, AccessDeniedException ex,
@ -471,15 +446,6 @@ public class GlobalExceptionHandler {
.body(errorResponse); .body(errorResponse);
} }
/**
* Handles illegal arguments (e.g., invalid UUID format).
*
* Returns 400 Bad Request.
*
* @param ex Illegal argument exception
* @param request HTTP request
* @return Error response with 400 status
*/
@ExceptionHandler(IllegalArgumentException.class) @ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<ErrorResponse> handleIllegalArgumentError( public ResponseEntity<ErrorResponse> handleIllegalArgumentError(
IllegalArgumentException ex, IllegalArgumentException ex,
@ -499,18 +465,6 @@ public class GlobalExceptionHandler {
.body(errorResponse); .body(errorResponse);
} }
/**
* Handles unexpected runtime errors.
*
* Returns 500 Internal Server Error.
* Logs full stack trace for debugging.
*
* IMPORTANT: Do not expose internal error details to clients in production!
*
* @param ex Runtime exception
* @param request HTTP request
* @return Error response with 500 status
*/
@ExceptionHandler(RuntimeException.class) @ExceptionHandler(RuntimeException.class)
public ResponseEntity<ErrorResponse> handleRuntimeError( public ResponseEntity<ErrorResponse> handleRuntimeError(
RuntimeException ex, RuntimeException ex,

View file

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd">
<changeSet id="036-add-inventory-counts-composite-index" author="effigenix">
<createIndex indexName="idx_inventory_counts_location_status"
tableName="inventory_counts">
<column name="storage_location_id"/>
<column name="status"/>
</createIndex>
</changeSet>
</databaseChangeLog>

View file

@ -41,5 +41,6 @@
<include file="db/changelog/changes/034-create-inventory-counts-schema.xml"/> <include file="db/changelog/changes/034-create-inventory-counts-schema.xml"/>
<include file="db/changelog/changes/035-seed-inventory-count-permissions.xml"/> <include file="db/changelog/changes/035-seed-inventory-count-permissions.xml"/>
<include file="db/changelog/changes/036-add-inventory-counts-composite-index.xml"/>
</databaseChangeLog> </databaseChangeLog>

View file

@ -8,6 +8,8 @@ import de.effigenix.shared.common.RepositoryError;
import de.effigenix.shared.common.Result; import de.effigenix.shared.common.Result;
import de.effigenix.shared.common.UnitOfMeasure; import de.effigenix.shared.common.UnitOfMeasure;
import de.effigenix.shared.persistence.UnitOfWork; import de.effigenix.shared.persistence.UnitOfWork;
import de.effigenix.shared.security.ActorId;
import de.effigenix.shared.security.AuthorizationPort;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@ -31,12 +33,15 @@ class CreateInventoryCountTest {
@Mock private InventoryCountRepository inventoryCountRepository; @Mock private InventoryCountRepository inventoryCountRepository;
@Mock private StockRepository stockRepository; @Mock private StockRepository stockRepository;
@Mock private UnitOfWork unitOfWork; @Mock private UnitOfWork unitOfWork;
@Mock private AuthorizationPort authPort;
private CreateInventoryCount createInventoryCount; private CreateInventoryCount createInventoryCount;
private final ActorId actorId = ActorId.of("user-1");
@BeforeEach @BeforeEach
void setUp() { void setUp() {
createInventoryCount = new CreateInventoryCount(inventoryCountRepository, stockRepository, unitOfWork); createInventoryCount = new CreateInventoryCount(inventoryCountRepository, stockRepository, unitOfWork, authPort);
when(authPort.can(any(ActorId.class), any())).thenReturn(true);
} }
private void stubUnitOfWork() { private void stubUnitOfWork() {
@ -86,7 +91,7 @@ class CreateInventoryCountTest {
when(inventoryCountRepository.save(any(InventoryCount.class))) when(inventoryCountRepository.save(any(InventoryCount.class)))
.thenReturn(Result.success(null)); .thenReturn(Result.success(null));
var result = createInventoryCount.execute(cmd); var result = createInventoryCount.execute(cmd, actorId);
assertThat(result.isSuccess()).isTrue(); assertThat(result.isSuccess()).isTrue();
var count = result.unsafeGetValue(); var count = result.unsafeGetValue();
@ -111,7 +116,7 @@ class CreateInventoryCountTest {
when(inventoryCountRepository.save(any(InventoryCount.class))) when(inventoryCountRepository.save(any(InventoryCount.class)))
.thenReturn(Result.success(null)); .thenReturn(Result.success(null));
var result = createInventoryCount.execute(cmd); var result = createInventoryCount.execute(cmd, actorId);
assertThat(result.isSuccess()).isTrue(); assertThat(result.isSuccess()).isTrue();
assertThat(result.unsafeGetValue().countItems()).isEmpty(); assertThat(result.unsafeGetValue().countItems()).isEmpty();
@ -125,7 +130,7 @@ class CreateInventoryCountTest {
when(inventoryCountRepository.existsActiveByStorageLocationId(StorageLocationId.of("location-1"))) when(inventoryCountRepository.existsActiveByStorageLocationId(StorageLocationId.of("location-1")))
.thenReturn(Result.success(true)); .thenReturn(Result.success(true));
var result = createInventoryCount.execute(cmd); var result = createInventoryCount.execute(cmd, actorId);
assertThat(result.isFailure()).isTrue(); assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.ActiveCountExists.class); assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.ActiveCountExists.class);
@ -136,7 +141,7 @@ class CreateInventoryCountTest {
void shouldFailWhenStorageLocationIdInvalid() { void shouldFailWhenStorageLocationIdInvalid() {
var cmd = new CreateInventoryCountCommand("", LocalDate.now().toString(), "user-1"); var cmd = new CreateInventoryCountCommand("", LocalDate.now().toString(), "user-1");
var result = createInventoryCount.execute(cmd); var result = createInventoryCount.execute(cmd, actorId);
assertThat(result.isFailure()).isTrue(); assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InvalidStorageLocationId.class); assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InvalidStorageLocationId.class);
@ -147,7 +152,7 @@ class CreateInventoryCountTest {
void shouldFailWhenCountDateInFuture() { void shouldFailWhenCountDateInFuture() {
var cmd = new CreateInventoryCountCommand("location-1", LocalDate.now().plusDays(1).toString(), "user-1"); var cmd = new CreateInventoryCountCommand("location-1", LocalDate.now().plusDays(1).toString(), "user-1");
var result = createInventoryCount.execute(cmd); var result = createInventoryCount.execute(cmd, actorId);
assertThat(result.isFailure()).isTrue(); assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.CountDateInFuture.class); assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.CountDateInFuture.class);
@ -161,7 +166,7 @@ class CreateInventoryCountTest {
when(inventoryCountRepository.existsActiveByStorageLocationId(StorageLocationId.of("location-1"))) when(inventoryCountRepository.existsActiveByStorageLocationId(StorageLocationId.of("location-1")))
.thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost"))); .thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost")));
var result = createInventoryCount.execute(cmd); var result = createInventoryCount.execute(cmd, actorId);
assertThat(result.isFailure()).isTrue(); assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.RepositoryFailure.class); assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.RepositoryFailure.class);
@ -177,7 +182,7 @@ class CreateInventoryCountTest {
when(stockRepository.findAllByStorageLocationId(StorageLocationId.of("location-1"))) when(stockRepository.findAllByStorageLocationId(StorageLocationId.of("location-1")))
.thenReturn(Result.failure(new RepositoryError.DatabaseError("stock db down"))); .thenReturn(Result.failure(new RepositoryError.DatabaseError("stock db down")));
var result = createInventoryCount.execute(cmd); var result = createInventoryCount.execute(cmd, actorId);
assertThat(result.isFailure()).isTrue(); assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.RepositoryFailure.class); assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.RepositoryFailure.class);
@ -196,15 +201,15 @@ class CreateInventoryCountTest {
when(inventoryCountRepository.save(any(InventoryCount.class))) when(inventoryCountRepository.save(any(InventoryCount.class)))
.thenReturn(Result.failure(new RepositoryError.DatabaseError("save failed"))); .thenReturn(Result.failure(new RepositoryError.DatabaseError("save failed")));
var result = createInventoryCount.execute(cmd); var result = createInventoryCount.execute(cmd, actorId);
assertThat(result.isFailure()).isTrue(); assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.RepositoryFailure.class); assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.RepositoryFailure.class);
} }
@Test @Test
@DisplayName("should handle stock without batches (zero quantity)") @DisplayName("should skip stocks without batches")
void shouldHandleStockWithoutBatches() { void shouldSkipStocksWithoutBatches() {
stubUnitOfWork(); stubUnitOfWork();
var cmd = new CreateInventoryCountCommand("location-1", LocalDate.now().toString(), "user-1"); var cmd = new CreateInventoryCountCommand("location-1", LocalDate.now().toString(), "user-1");
@ -221,12 +226,10 @@ class CreateInventoryCountTest {
when(inventoryCountRepository.save(any(InventoryCount.class))) when(inventoryCountRepository.save(any(InventoryCount.class)))
.thenReturn(Result.success(null)); .thenReturn(Result.success(null));
var result = createInventoryCount.execute(cmd); var result = createInventoryCount.execute(cmd, actorId);
assertThat(result.isSuccess()).isTrue(); assertThat(result.isSuccess()).isTrue();
assertThat(result.unsafeGetValue().countItems()).hasSize(1); assertThat(result.unsafeGetValue().countItems()).isEmpty();
assertThat(result.unsafeGetValue().countItems().getFirst().expectedQuantity().amount())
.isEqualByComparingTo(BigDecimal.ZERO);
} }
@Test @Test
@ -262,7 +265,7 @@ class CreateInventoryCountTest {
when(inventoryCountRepository.save(any(InventoryCount.class))) when(inventoryCountRepository.save(any(InventoryCount.class)))
.thenReturn(Result.success(null)); .thenReturn(Result.success(null));
var result = createInventoryCount.execute(cmd); var result = createInventoryCount.execute(cmd, actorId);
assertThat(result.isSuccess()).isTrue(); assertThat(result.isSuccess()).isTrue();
assertThat(result.unsafeGetValue().countItems()).hasSize(2); assertThat(result.unsafeGetValue().countItems()).hasSize(2);
@ -273,7 +276,7 @@ class CreateInventoryCountTest {
void shouldFailWhenInitiatedByNull() { void shouldFailWhenInitiatedByNull() {
var cmd = new CreateInventoryCountCommand("location-1", LocalDate.now().toString(), null); var cmd = new CreateInventoryCountCommand("location-1", LocalDate.now().toString(), null);
var result = createInventoryCount.execute(cmd); var result = createInventoryCount.execute(cmd, actorId);
assertThat(result.isFailure()).isTrue(); assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InvalidInitiatedBy.class); assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InvalidInitiatedBy.class);
@ -284,7 +287,7 @@ class CreateInventoryCountTest {
void shouldFailWhenCountDateUnparseable() { void shouldFailWhenCountDateUnparseable() {
var cmd = new CreateInventoryCountCommand("location-1", "not-a-date", "user-1"); var cmd = new CreateInventoryCountCommand("location-1", "not-a-date", "user-1");
var result = createInventoryCount.execute(cmd); var result = createInventoryCount.execute(cmd, actorId);
assertThat(result.isFailure()).isTrue(); assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InvalidCountDate.class); assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InvalidCountDate.class);

View file

@ -3,6 +3,8 @@ package de.effigenix.application.inventory;
import de.effigenix.domain.inventory.*; import de.effigenix.domain.inventory.*;
import de.effigenix.shared.common.RepositoryError; import de.effigenix.shared.common.RepositoryError;
import de.effigenix.shared.common.Result; import de.effigenix.shared.common.Result;
import de.effigenix.shared.security.ActorId;
import de.effigenix.shared.security.AuthorizationPort;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@ -16,6 +18,7 @@ import java.util.List;
import java.util.Optional; import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class) @ExtendWith(MockitoExtension.class)
@ -23,13 +26,16 @@ import static org.mockito.Mockito.when;
class GetInventoryCountTest { class GetInventoryCountTest {
@Mock private InventoryCountRepository inventoryCountRepository; @Mock private InventoryCountRepository inventoryCountRepository;
@Mock private AuthorizationPort authPort;
private GetInventoryCount getInventoryCount; private GetInventoryCount getInventoryCount;
private InventoryCount existingCount; private InventoryCount existingCount;
private final ActorId actorId = ActorId.of("user-1");
@BeforeEach @BeforeEach
void setUp() { void setUp() {
getInventoryCount = new GetInventoryCount(inventoryCountRepository); getInventoryCount = new GetInventoryCount(inventoryCountRepository, authPort);
when(authPort.can(any(ActorId.class), any())).thenReturn(true);
existingCount = InventoryCount.reconstitute( existingCount = InventoryCount.reconstitute(
InventoryCountId.of("count-1"), InventoryCountId.of("count-1"),
@ -48,7 +54,7 @@ class GetInventoryCountTest {
when(inventoryCountRepository.findById(InventoryCountId.of("count-1"))) when(inventoryCountRepository.findById(InventoryCountId.of("count-1")))
.thenReturn(Result.success(Optional.of(existingCount))); .thenReturn(Result.success(Optional.of(existingCount)));
var result = getInventoryCount.execute("count-1"); var result = getInventoryCount.execute("count-1", actorId);
assertThat(result.isSuccess()).isTrue(); assertThat(result.isSuccess()).isTrue();
assertThat(result.unsafeGetValue().id().value()).isEqualTo("count-1"); assertThat(result.unsafeGetValue().id().value()).isEqualTo("count-1");
@ -60,7 +66,7 @@ class GetInventoryCountTest {
when(inventoryCountRepository.findById(InventoryCountId.of("count-1"))) when(inventoryCountRepository.findById(InventoryCountId.of("count-1")))
.thenReturn(Result.success(Optional.empty())); .thenReturn(Result.success(Optional.empty()));
var result = getInventoryCount.execute("count-1"); var result = getInventoryCount.execute("count-1", actorId);
assertThat(result.isFailure()).isTrue(); assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InventoryCountNotFound.class); assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InventoryCountNotFound.class);
@ -72,36 +78,36 @@ class GetInventoryCountTest {
when(inventoryCountRepository.findById(InventoryCountId.of("count-1"))) when(inventoryCountRepository.findById(InventoryCountId.of("count-1")))
.thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost"))); .thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost")));
var result = getInventoryCount.execute("count-1"); var result = getInventoryCount.execute("count-1", actorId);
assertThat(result.isFailure()).isTrue(); assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.RepositoryFailure.class); assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.RepositoryFailure.class);
} }
@Test @Test
@DisplayName("should fail with InventoryCountNotFound when id is null") @DisplayName("should fail with InvalidInventoryCountId when id is null")
void shouldFailWhenIdIsNull() { void shouldFailWhenIdIsNull() {
var result = getInventoryCount.execute(null); var result = getInventoryCount.execute(null, actorId);
assertThat(result.isFailure()).isTrue(); assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InventoryCountNotFound.class); assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InvalidInventoryCountId.class);
} }
@Test @Test
@DisplayName("should fail with InventoryCountNotFound when id is blank") @DisplayName("should fail with InvalidInventoryCountId when id is blank")
void shouldFailWhenIdIsBlank() { void shouldFailWhenIdIsBlank() {
var result = getInventoryCount.execute(" "); var result = getInventoryCount.execute(" ", actorId);
assertThat(result.isFailure()).isTrue(); assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InventoryCountNotFound.class); assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InvalidInventoryCountId.class);
} }
@Test @Test
@DisplayName("should fail with InventoryCountNotFound when id is empty string") @DisplayName("should fail with InvalidInventoryCountId when id is empty string")
void shouldFailWhenIdIsEmpty() { void shouldFailWhenIdIsEmpty() {
var result = getInventoryCount.execute(""); var result = getInventoryCount.execute("", actorId);
assertThat(result.isFailure()).isTrue(); assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InventoryCountNotFound.class); assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InvalidInventoryCountId.class);
} }
} }

View file

@ -3,6 +3,8 @@ package de.effigenix.application.inventory;
import de.effigenix.domain.inventory.*; import de.effigenix.domain.inventory.*;
import de.effigenix.shared.common.RepositoryError; import de.effigenix.shared.common.RepositoryError;
import de.effigenix.shared.common.Result; import de.effigenix.shared.common.Result;
import de.effigenix.shared.security.ActorId;
import de.effigenix.shared.security.AuthorizationPort;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@ -15,6 +17,7 @@ import java.time.LocalDate;
import java.util.List; import java.util.List;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*; import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class) @ExtendWith(MockitoExtension.class)
@ -22,14 +25,17 @@ import static org.mockito.Mockito.*;
class ListInventoryCountsTest { class ListInventoryCountsTest {
@Mock private InventoryCountRepository inventoryCountRepository; @Mock private InventoryCountRepository inventoryCountRepository;
@Mock private AuthorizationPort authPort;
private ListInventoryCounts listInventoryCounts; private ListInventoryCounts listInventoryCounts;
private InventoryCount count1; private InventoryCount count1;
private InventoryCount count2; private InventoryCount count2;
private final ActorId actorId = ActorId.of("user-1");
@BeforeEach @BeforeEach
void setUp() { void setUp() {
listInventoryCounts = new ListInventoryCounts(inventoryCountRepository); listInventoryCounts = new ListInventoryCounts(inventoryCountRepository, authPort);
when(authPort.can(any(ActorId.class), any())).thenReturn(true);
count1 = InventoryCount.reconstitute( count1 = InventoryCount.reconstitute(
InventoryCountId.of("count-1"), InventoryCountId.of("count-1"),
@ -57,7 +63,7 @@ class ListInventoryCountsTest {
void shouldReturnAllCountsWhenNoFilter() { void shouldReturnAllCountsWhenNoFilter() {
when(inventoryCountRepository.findAll()).thenReturn(Result.success(List.of(count1, count2))); when(inventoryCountRepository.findAll()).thenReturn(Result.success(List.of(count1, count2)));
var result = listInventoryCounts.execute(null); var result = listInventoryCounts.execute(null, actorId);
assertThat(result.isSuccess()).isTrue(); assertThat(result.isSuccess()).isTrue();
assertThat(result.unsafeGetValue()).hasSize(2); assertThat(result.unsafeGetValue()).hasSize(2);
@ -70,7 +76,7 @@ class ListInventoryCountsTest {
when(inventoryCountRepository.findByStorageLocationId(StorageLocationId.of("location-1"))) when(inventoryCountRepository.findByStorageLocationId(StorageLocationId.of("location-1")))
.thenReturn(Result.success(List.of(count1))); .thenReturn(Result.success(List.of(count1)));
var result = listInventoryCounts.execute("location-1"); var result = listInventoryCounts.execute("location-1", actorId);
assertThat(result.isSuccess()).isTrue(); assertThat(result.isSuccess()).isTrue();
assertThat(result.unsafeGetValue()).hasSize(1); assertThat(result.unsafeGetValue()).hasSize(1);
@ -84,7 +90,7 @@ class ListInventoryCountsTest {
when(inventoryCountRepository.findAll()) when(inventoryCountRepository.findAll())
.thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost"))); .thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost")));
var result = listInventoryCounts.execute(null); var result = listInventoryCounts.execute(null, actorId);
assertThat(result.isFailure()).isTrue(); assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.RepositoryFailure.class); assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.RepositoryFailure.class);
@ -96,7 +102,7 @@ class ListInventoryCountsTest {
when(inventoryCountRepository.findByStorageLocationId(StorageLocationId.of("location-1"))) when(inventoryCountRepository.findByStorageLocationId(StorageLocationId.of("location-1")))
.thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost"))); .thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost")));
var result = listInventoryCounts.execute("location-1"); var result = listInventoryCounts.execute("location-1", actorId);
assertThat(result.isFailure()).isTrue(); assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.RepositoryFailure.class); assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.RepositoryFailure.class);
@ -108,7 +114,7 @@ class ListInventoryCountsTest {
when(inventoryCountRepository.findByStorageLocationId(StorageLocationId.of("unknown"))) when(inventoryCountRepository.findByStorageLocationId(StorageLocationId.of("unknown")))
.thenReturn(Result.success(List.of())); .thenReturn(Result.success(List.of()));
var result = listInventoryCounts.execute("unknown"); var result = listInventoryCounts.execute("unknown", actorId);
assertThat(result.isSuccess()).isTrue(); assertThat(result.isSuccess()).isTrue();
assertThat(result.unsafeGetValue()).isEmpty(); assertThat(result.unsafeGetValue()).isEmpty();
@ -117,7 +123,7 @@ class ListInventoryCountsTest {
@Test @Test
@DisplayName("should fail with InvalidStorageLocationId for blank storageLocationId") @DisplayName("should fail with InvalidStorageLocationId for blank storageLocationId")
void shouldFailWhenBlankStorageLocationId() { void shouldFailWhenBlankStorageLocationId() {
var result = listInventoryCounts.execute(" "); var result = listInventoryCounts.execute(" ", actorId);
assertThat(result.isFailure()).isTrue(); assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InvalidStorageLocationId.class); assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InvalidStorageLocationId.class);

View file

@ -429,6 +429,20 @@ class InventoryCountTest {
assertThat(item.deviation()).isEqualByComparingTo(new BigDecimal("2.5")); assertThat(item.deviation()).isEqualByComparingTo(new BigDecimal("2.5"));
} }
@Test
@DisplayName("should return null when UOMs differ")
void shouldReturnNullWhenUomsDiffer() {
var item = CountItem.reconstitute(
CountItemId.of("item-1"),
ArticleId.of("article-1"),
Quantity.reconstitute(new BigDecimal("10.0"), UnitOfMeasure.KILOGRAM),
Quantity.reconstitute(new BigDecimal("8.0"), UnitOfMeasure.LITER)
);
assertThat(item.isCounted()).isTrue();
assertThat(item.deviation()).isNull();
}
@Test @Test
@DisplayName("should compute zero deviation (actual == expected)") @DisplayName("should compute zero deviation (actual == expected)")
void shouldComputeZeroDeviation() { void shouldComputeZeroDeviation() {