💎 Módulo 03: API REST, Busca e Alerta de Estoque Mínimo

Com a regra de negócio pronta (Módulo 02), este módulo expõe tudo como endpoints REST: registrar movimentação, consultar histórico, pesquisar produtos (RF09) e listar os que estão com estoque baixo (RF11).


### Aula 3.1: MovimentacaoEstoqueController

Ação: o endpoint de registrar (POST) exige ADMIN ou ESTOQUISTA. Os de leitura (GET) ficam abertos a qualquer usuário autenticado — mesmo padrão de _01 (leitura ampla, escrita restrita).

Código: controller/MovimentacaoEstoqueController.java

@RestController
@RequestMapping("/api/movimentacoes")
@Tag(name = "Movimentações de Estoque", description = "Endpoints para registrar entradas/saídas e consultar o histórico")
@SecurityRequirement(name = "bearerAuth")
public class MovimentacaoEstoqueController {

    private final MovimentacaoEstoqueService service;

    public MovimentacaoEstoqueController(MovimentacaoEstoqueService service) {
        this.service = service;
    }

    @GetMapping
    public ResponseEntity<List<MovimentacaoEstoqueDTO>> findAll() {
        return ResponseEntity.ok(service.findAll());
    }

    @GetMapping("/produto/{produtoId}")
    public ResponseEntity<List<MovimentacaoEstoqueDTO>> findByProdutoId(@PathVariable Integer produtoId) {
        return ResponseEntity.ok(service.findByProdutoId(produtoId));
    }

    @PostMapping
    @PreAuthorize("hasAnyRole('ADMIN', 'ESTOQUISTA')")
    public ResponseEntity<MovimentacaoEstoqueDTO> registrar(@Valid @RequestBody MovimentacaoEstoqueFormDTO formDTO) {
        MovimentacaoEstoqueDTO dto = service.registrar(formDTO);
        return ResponseEntity.status(HttpStatus.CREATED).body(dto);
    }
}

💡 hasAnyRole('ADMIN', 'ESTOQUISTA') em vez de dois @PreAuthorize separados? O Spring Security avalia a expressão SpEL uma vez só — não existe “empilhar” @PreAuthorize no mesmo método. Quando mais de um papel deveria ter acesso ao mesmo endpoint, a expressão é sempre hasAnyRole(...) com todos eles.


### Aula 3.2: Busca e Alerta em ProdutoController

Ação: dois GETs novos, ambos leitura (sem @PreAuthorize extra — herdam a regra “qualquer autenticado” do restante do controller).

Código: controller/ProdutoController.java (endpoints novos)

@GetMapping("/busca")
public ResponseEntity<List<ProdutoDTO>> buscarPorNome(@RequestParam String nome) {
    return ResponseEntity.ok(service.buscarPorNome(nome));
}

@GetMapping("/estoque-baixo")
public ResponseEntity<List<ProdutoDTO>> findComEstoqueBaixo() {
    return ResponseEntity.ok(service.findComEstoqueBaixo());
}

⚠️ Ordem de rotas importa? /api/produtos/busca e /api/produtos/{id} parecem poder colidir (busca cairia em {id}?), mas o Spring MVC resolve isso corretamente: um segmento estático (/busca, /estoque-baixo) sempre vence um @PathVariable (/{id}) na hora de escolher o handler, independente da ordem de declaração no arquivo. Ainda assim, é boa prática declarar as rotas estáticas antes das variáveis no código-fonte, por legibilidade.

ProdutoServiceImpl ganhou buscarPorNome/findComEstoqueBaixo, reaproveitando o mesmo helper privado de busca em lote (mapToProdutoDTOs) que findAll() já usava — extraído numa função só para não duplicar a lógica de N+1 três vezes.


### Aula 3.3: Testando a Autorização — o Gotcha do @WebMvcTest

Conceito-Chave: testar @PreAuthorize isoladamente (sem subir o Spring Boot inteiro) exige atenção: @WebMvcTest não carrega automaticamente a @Configuration da aplicação (SecurityConfig) — ele aplica a configuração de segurança padrão do Spring Boot (CSRF ligado, sem @EnableMethodSecurity), o que faz o @PreAuthorize simplesmente não ser avaliado.

Código: MovimentacaoEstoqueControllerTest.java (configuração do slice)

@WebMvcTest(MovimentacaoEstoqueController.class)
@Import(SecurityConfig.class)              // traz @EnableMethodSecurity + o filtro real (CSRF desligado)
@AutoConfigureMockMvc(addFilters = false)  // @PreAuthorize é AOP, não depende de Filters
class MovimentacaoEstoqueControllerTest {

    @MockBean private MovimentacaoEstoqueService movimentacaoEstoqueService;
    @MockBean private JwtAuthenticationFilter jwtAuthenticationFilter; // dependência do SecurityConfig
    @MockBean private UserDetailsService userDetailsService;            // idem

    @Test
    @WithMockUser(roles = "ESTOQUISTA")
    void registrar_shouldReturn201Created_whenUserIsEstoquista() throws Exception {
        // ... given(...).willReturn(...) ...
        mockMvc.perform(post("/api/movimentacoes")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(objectMapper.writeValueAsString(form)))
                .andExpect(status().isCreated());
    }

    @Test
    @WithMockUser(roles = "USER")
    void registrar_shouldReturn403Forbidden_whenUserIsCommonUser() throws Exception {
        mockMvc.perform(post("/api/movimentacoes")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(objectMapper.writeValueAsString(form)))
                .andExpect(status().isForbidden());
    }
}

Por que cada peça é necessária:

  1. @Import(SecurityConfig.class) — sem isso, @EnableMethodSecurity(prePostEnabled = true) (declarado em SecurityConfig) nunca é processado no contexto do slice, e o AOP que intercepta @PreAuthorize não existe. O primeiro sintoma foi surpreendente: os testes passavam com status 200 tanto para ESTOQUISTA quanto para USER — a autorização estava sendo silenciosamente ignorada, não bloqueada.
  2. @AutoConfigureMockMvc(addFilters = false)@PreAuthorize é um interceptor em torno do bean do controller (Spring AOP), não depende da cadeia de Servlet Filters. Desligar os filtros evita dois problemas de uma vez: (a) o CsrfFilter padrão bloqueando POST/DELETE sem token, e (b) o bean mockado de JwtAuthenticationFilter (@MockBean, sem nenhum doFilter() stubado) sendo registrado como um Filter real e silenciosamente engolindo a requisição (um mock sem stub não chama filterChain.doFilter(), então a cadeia simplesmente para ali).
  3. @MockBean UserDetailsServiceSecurityConfig.authenticationProvider() precisa desse bean para ser construído; sem ele, o contexto do teste falha ao subir (mesmo com os filtros desligados, o @Bean securityFilterChain(...) ainda precisa ser instanciado).

Isso revelou também que GlobalExceptionHandler não tratava AccessDeniedException (ver Módulo 02, Aula 2.1) — sem o ExceptionTranslationFilter da cadeia real presente no teste, a exceção do @PreAuthorize negado caía direto no handler genérico e virava 500 em vez de 403. O handler dedicado corrige isso tanto no teste quanto como rede de segurança em produção.


Conclusão do Módulo 03

A API está completa: movimentações com autorização por papel, busca e alerta de estoque mínimo. No próximo e último módulo, fechamos com os relatórios (RF12) e as telas novas da SPA.