From 98bc663004204d0284d7b58239cee27a66cb2050 Mon Sep 17 00:00:00 2001 From: Rafael Sotero Rocha <29471402+soterocra@users.noreply.github.com> Date: Wed, 11 Mar 2026 16:26:53 -0300 Subject: [PATCH 1/2] adiciona artefatos de demo --- .github/skills/issue-to-brief/SKILL.md | 102 ++++++++++++++++ .github/skills/pr-summary/SKILL.md | 73 ++++++++++++ DEMO.md | 155 +++++++++++++++++++++++++ 3 files changed, 330 insertions(+) create mode 100644 .github/skills/issue-to-brief/SKILL.md create mode 100644 .github/skills/pr-summary/SKILL.md create mode 100644 DEMO.md diff --git a/.github/skills/issue-to-brief/SKILL.md b/.github/skills/issue-to-brief/SKILL.md new file mode 100644 index 0000000..307b9fa --- /dev/null +++ b/.github/skills/issue-to-brief/SKILL.md @@ -0,0 +1,102 @@ +--- +name: issue-to-brief +description: Analisa uma issue do GitHub e transforma em um briefing tecnico acionavel para este projeto Spring Boot. Use quando o pedido mencionar issue, ticket, backlog, bug, feature, requisito ou criterios de aceite vindos do GitHub, especialmente antes de implementar com custom agents. Ajuda a ler a issue via MCP, identificar impacto no codigo, preservar contrato HTTP e preparar handoff para refatoracao e testes. +--- + +# Skill issue-to-brief + +Conduza a etapa de entendimento e preparacao antes da implementacao. + +## Fluxo + +1. Ler a issue e, se existirem, comentarios, checklist, labels e criterios de aceite via GitHub MCP. +2. Resumir o objetivo funcional em linguagem direta. +3. Separar fatos confirmados de inferencias. +4. Mapear impacto provavel no codigo deste repositorio. +5. Destacar invariantes observaveis que nao devem mudar. +6. Preparar um handoff explicito para o custom agent de refatoracao. +7. Preparar um handoff explicito para o custom agent de testes. + +## Estrutura de saida obrigatoria + +Responder com estas secoes, nesta ordem: + +```text +Resumo da issue +- Objetivo: +- Contexto: +- Criterios de aceite: + +Escopo confirmado +- O que precisa mudar: +- O que nao foi pedido: + +Impacto provavel no codigo +- Controller: +- Service: +- Repository: +- Entity: +- DTO: +- Util/Exception: +- Testes existentes relacionados: + +Invariantes e restricoes +- Contrato HTTP a preservar: +- Headers relevantes: +- Estrutura de sucesso e erro: +- Restricoes tecnicas: + +Pontos em aberto +- Duvidas: +- Suposicoes adotadas: + +Handoff para refactor +- Arquivos ou classes candidatas: +- Resultado esperado: +- Cuidados obrigatorios: + +Handoff para test +- Classes a validar: +- Cenarios minimos: +- Evidencias esperadas: +``` + +## Heuristicas para este repositorio + +Ao analisar impacto provavel, considerar primeiro: + +- `src/main/java/br/com/devsuperior/dev_xp_ai/controller` +- `src/main/java/br/com/devsuperior/dev_xp_ai/service` +- `src/main/java/br/com/devsuperior/dev_xp_ai/repository` +- `src/main/java/br/com/devsuperior/dev_xp_ai/entity` +- `src/main/java/br/com/devsuperior/dev_xp_ai/dto` +- `src/main/java/br/com/devsuperior/dev_xp_ai/exception` +- `src/main/java/br/com/devsuperior/dev_xp_ai/util` +- `src/test/java/br/com/devsuperior/dev_xp_ai` + +## Regras + +1. Nao comecar implementando. +2. Nao inventar requisito ausente na issue sem marcar como suposicao. +3. Se a issue tocar endpoint existente, explicitar que path, verbo, status code, headers e JSON observavel devem ser preservados salvo pedido explicito em contrario. +4. Se a issue estiver incompleta, registrar as lacunas antes do handoff. +5. Sempre produzir handoff utilizavel pelos custom agents `refactor` e `test`. + +## Como preparar o handoff para os agents + +Para o `refactor`: + +- dizer qual comportamento deve ser criado ou alterado +- listar classes e pacotes provaveis +- destacar contrato HTTP e restricoes de persistencia +- apontar riscos de regressao + +Para o `test`: + +- listar classes alteradas ou provaveis +- informar cenarios de sucesso e erro +- cobrar validacao de status, body, headers e cobertura + +## Resultado esperado + +Um bom resultado reduz ambiguidade da issue e deixa a implementacao pronta para seguir, com contexto suficiente para o agent de refatoracao atuar primeiro e o agent de testes atuar em seguida. diff --git a/.github/skills/pr-summary/SKILL.md b/.github/skills/pr-summary/SKILL.md new file mode 100644 index 0000000..80a6c7e --- /dev/null +++ b/.github/skills/pr-summary/SKILL.md @@ -0,0 +1,73 @@ +--- +name: pr-summary +description: Gera um resumo tecnico final de implementacao pronto para demonstracao, comentario de pull request ou fechamento de tarefa. Use quando o trabalho ja tiver sido implementado e testado, especialmente apos usar custom agents de refatoracao e testes. Ajuda a consolidar mudancas, comandos executados, cobertura, riscos, relacao com a issue e proximos passos sem omitir evidencias. +--- + +# Skill pr-summary + +Conduza a etapa final de fechamento tecnico depois da implementacao e da validacao. + +## Objetivo + +Produzir um resumo final consistente, auditavel e facil de apresentar. + +## Fluxo + +1. Reunir o contexto da issue original. +2. Reunir o que foi alterado no codigo de producao. +3. Reunir o que foi alterado nos testes. +4. Consolidar comandos executados e seus resultados relevantes. +5. Consolidar cobertura, quando disponivel. +6. Registrar riscos, limitacoes e pendencias. +7. Fechar com uma relacao explicita entre issue, implementacao e validacao. + +## Estrutura de saida obrigatoria + +Responder com estas secoes, nesta ordem: + +```text +Resumo final +- Issue: +- Objetivo entregue: + +Implementacao +- O que mudou: +- Principais classes afetadas: +- Decisoes tecnicas relevantes: + +Testes e validacao +- Testes criados ou ajustados: +- Comandos executados: +- Resultado dos testes: +- Cobertura JaCoCo: + +Contrato e riscos +- Contrato HTTP preservado ou alterado: +- Riscos e limitacoes: +- Pendencias: + +Pronto para PR +- Resumo curto para PR: +- Relacao com a issue: +``` + +## Regras + +1. Nao afirmar execucao, cobertura ou sucesso de testes sem evidencia no contexto. +2. Separar claramente fato observado de inferencia. +3. Se nao houver cobertura disponivel, dizer explicitamente. +4. Se a issue tiver criterios de aceite, dizer como cada um foi atendido ou o que ficou pendente. +5. Se houve mudanca de contrato HTTP, explicitar de forma objetiva e concreta. + +## Fechamento curto para demonstracao + +Quando fizer sentido, terminar com um bloco curto de resumo executivo contendo: + +- objetivo da issue +- o que foi implementado +- como foi validado +- se esta pronto para revisao/PR + +## Resultado esperado + +Um bom resultado deixa a demo com um encerramento claro: contexto da issue, implementacao feita, testes executados, cobertura reportada e riscos remanescentes. diff --git a/DEMO.md b/DEMO.md new file mode 100644 index 0000000..08d7fcc --- /dev/null +++ b/DEMO.md @@ -0,0 +1,155 @@ +# Demo: GitHub Copilot + MCP + Skills + Custom Agents + +Este material foi escrito para quem está assistindo à demonstração. + +## O que está acontecendo nesta demo + +Nesta apresentação, uma issue do GitHub é usada como ponto de partida para uma implementação real dentro deste projeto. Em vez de depender de um único comando genérico, o fluxo foi dividido em etapas com responsabilidades claras. + +O objetivo é mostrar como diferentes mecanismos do GitHub Copilot podem trabalhar juntos: + +- `GitHub MCP` traz o contexto externo da issue +- a skill `issue-to-brief` transforma esse contexto em um briefing técnico +- o custom agent `refactor` implementa a mudança no código de produção +- o custom agent `test` valida o resultado com testes automatizados +- a skill `pr-summary` organiza o fechamento técnico da execução + +## Conceitos principais + +### MCP + +O MCP funciona como uma ponte entre o agente e sistemas externos. Nesta demo, ele é usado para acessar as informações da issue no GitHub sem depender de cópia manual para o prompt. + +### Skill + +Uma skill não é o componente que executa a mudança no código. Ela serve para orientar o raciocínio do agente em uma etapa específica do fluxo. + +Nesta demo, duas skills são usadas: + +- `issue-to-brief`: organiza a issue em um briefing técnico acionável +- `pr-summary`: consolida o resultado final em um resumo claro e rastreável + +### Custom agent + +Um custom agent é um especialista com instruções próprias para um tipo de tarefa. + +Nesta demo, há dois: + +- `refactor`: cuida da implementação no código de produção +- `test`: cuida da validação por meio de testes automatizados + +## Fluxo da demonstração + +### 1. A issue é lida e entendida + +A primeira etapa não é alterar código. Primeiro, o sistema lê a issue do GitHub e organiza o problema em termos técnicos. + +### 2. O problema vira um briefing técnico + +Depois de ler a issue, a skill `issue-to-brief` transforma o conteúdo em algo mais útil para desenvolvimento. + +Esse briefing normalmente destaca: + +- objetivo da mudança +- critérios de aceite +- impacto provável no código +- riscos de regressão +- partes do contrato HTTP que devem ser preservadas + +### 3. A implementação vai para um especialista em código + +Com o briefing pronto, o trabalho segue para o custom agent `refactor`. + +Esse agent foi configurado para atuar especificamente em refatoração e implementação de código de produção, com foco arquitetural e com menor risco de regressão. + +### 4. A validação vai para um especialista em testes + +Depois da implementação, a tarefa segue para o custom agent `test`. + +Isso mostra que testes não foram tratados como uma consequência opcional da implementação. Eles entram como uma etapa própria, com foco próprio e com critérios objetivos de validação. + +### 5. O resultado final é consolidado + +Por fim, a skill `pr-summary` organiza um fechamento técnico da execução. + +Esse fechamento conecta: + +- a issue original +- o que foi alterado no código +- o que foi validado em testes +- riscos, limitações e pendências + +## Prompts usados na apresentação + +Os prompts abaixo são os que serão usados durante a demonstração. + +### Prompt 1: Ler a issue e gerar o briefing + +```text +Leia a issue #2 do repositorio devsuperior/ia-java-spring-2026 via GitHub MCP. + +Use a skill `issue-to-brief` #file:.github/skills/issue-to-brief/SKILL.md para transformar a issue em um briefing técnico acionável para este projeto +``` + +### Prompt 2: Implementar com o agent de refatoração + +```text +Agora implemente a issue com o custom agent `refactor`, seguindo o briefing gerado. + +Preserve o contrato HTTP existente, faça mudanças incrementais e ao final entregue a lista de classes que precisam ser analisadas pelo agent de testes. +``` + +### Prompt 3: Validar com o agent de testes + +(altere o agente antes de executar o prompt) +```text +Agora use o custom agent `test` para criar ou ajustar os testes necessários com base nas classes listadas pela etapa anterior. + +Execute os testes relevantes, informe os comandos executados, o resultado e a cobertura JaCoCo quando disponível. +``` + +### Prompt 4: Gerar o fechamento final + +```text +Use a skill `pr-summary` #file:.github/skills/pr-summary/SKILL.md para gerar o fechamento técnico desta execução. + +Quero um resumo final com: +- issue atendida +- o que foi alterado +- classes principais afetadas +- testes criados ou ajustados +- comandos executados +- resultado dos testes +- cobertura JaCoCo +- riscos e limitações +- um resumo curto pronto para PR +``` + +## O que observar durante a apresentação + +Durante a demo, vale prestar atenção em cinco sinais: + +1. O sistema primeiro entende a issue antes de alterar qualquer arquivo. +2. O contexto da tarefa vem do GitHub, não de uma descrição manual resumida. +3. A responsabilidade é distribuída entre skill e custom agents. +4. O teste aparece como etapa de validação, não como detalhe opcional. +5. O fechamento final conecta problema, implementação e evidências. + +## Estrutura usada no repositório + +Os arquivos mais relevantes para esta demonstração são: + +- `.github/agents/refactor.agent.md` +- `.github/agents/test.agent.md` +- `.github/skills/issue-to-brief/SKILL.md` +- `.github/skills/pr-summary/SKILL.md` +- `ISSUE-SEPARAR-USUARIO-EXPERIENCIA.md` + +## Resultado esperado + +Ao final da apresentação, a expectativa é que fique claro que: + +- o MCP traz contexto externo de forma integrada +- skills ajudam a organizar e orientar o fluxo +- custom agents permitem especialização por responsabilidade +- implementação e validação podem ser encadeadas de forma mais disciplinada From de9aa19c53abb8dc304051e9a066846f59068cfd Mon Sep 17 00:00:00 2001 From: Rafael Sotero Rocha <29471402+soterocra@users.noreply.github.com> Date: Wed, 11 Mar 2026 16:27:57 -0300 Subject: [PATCH 2/2] implementa issue #2 --- .github/skills/issue-to-brief/SKILL.md | 51 +++++- .github/skills/pr-summary/SKILL.md | 47 ++++- DEMO.md | 18 +- ...ty.java => DeveloperExperienceEntity.java} | 42 +---- .../dev_xp_ai/entity/DeveloperUserEntity.java | 52 ++++++ .../DeveloperExperienceRepository.java | 27 +++ .../repository/DeveloperRepository.java | 21 --- .../repository/DeveloperUserRepository.java | 14 ++ .../dev_xp_ai/service/DeveloperService.java | 101 ++++++---- src/main/resources/schema.sql | 11 +- .../DeveloperExperienceRepositoryTest.java | 150 +++++++++++++++ .../DeveloperUserRepositoryTest.java | 133 ++++++++++++++ .../dev_xp_ai/util/TextNormalizerTest.java | 173 ++++++++++++++++++ 13 files changed, 725 insertions(+), 115 deletions(-) rename src/main/java/br/com/devsuperior/dev_xp_ai/entity/{DeveloperEntity.java => DeveloperExperienceEntity.java} (57%) create mode 100644 src/main/java/br/com/devsuperior/dev_xp_ai/entity/DeveloperUserEntity.java create mode 100644 src/main/java/br/com/devsuperior/dev_xp_ai/repository/DeveloperExperienceRepository.java delete mode 100644 src/main/java/br/com/devsuperior/dev_xp_ai/repository/DeveloperRepository.java create mode 100644 src/main/java/br/com/devsuperior/dev_xp_ai/repository/DeveloperUserRepository.java create mode 100644 src/test/java/br/com/devsuperior/dev_xp_ai/repository/DeveloperExperienceRepositoryTest.java create mode 100644 src/test/java/br/com/devsuperior/dev_xp_ai/repository/DeveloperUserRepositoryTest.java create mode 100644 src/test/java/br/com/devsuperior/dev_xp_ai/util/TextNormalizerTest.java diff --git a/.github/skills/issue-to-brief/SKILL.md b/.github/skills/issue-to-brief/SKILL.md index 307b9fa..69d6892 100644 --- a/.github/skills/issue-to-brief/SKILL.md +++ b/.github/skills/issue-to-brief/SKILL.md @@ -1,13 +1,29 @@ --- name: issue-to-brief -description: Analisa uma issue do GitHub e transforma em um briefing tecnico acionavel para este projeto Spring Boot. Use quando o pedido mencionar issue, ticket, backlog, bug, feature, requisito ou criterios de aceite vindos do GitHub, especialmente antes de implementar com custom agents. Ajuda a ler a issue via MCP, identificar impacto no codigo, preservar contrato HTTP e preparar handoff para refatoracao e testes. +description: Analisa uma issue do GitHub e transforma em um briefing tecnico acionavel para este projeto Spring Boot. Use antes de implementar, quando o pedido envolver issue, ticket, backlog, bug, feature, requisito ou criterios de aceite, para mapear impacto no codigo, preservar contrato HTTP e preparar handoff para refactor e testes. +argument-hint: [issue] [contexto adicional] [restricoes ou contrato a preservar] --- -# Skill issue-to-brief +# Issue To Brief -Conduza a etapa de entendimento e preparacao antes da implementacao. +Esta skill conduz a etapa de entendimento e preparacao antes da implementacao. -## Fluxo +## Quando usar esta skill + +Use esta skill quando precisar: + +- ler uma issue do GitHub e converter em briefing tecnico objetivo +- separar escopo confirmado de suposicoes antes de codar +- mapear impacto provavel no backend Spring Boot deste repositorio +- preparar handoff claro para refactor e para testes + +Nao use esta skill para resumir o trabalho ja concluido. Nesse caso, use a skill `pr-summary`. + +## Objetivo + +Reduzir ambiguidade da issue e deixar a implementacao pronta para seguir com contexto suficiente para os proximos agents. + +## Procedimento 1. Ler a issue e, se existirem, comentarios, checklist, labels e criterios de aceite via GitHub MCP. 2. Resumir o objetivo funcional em linguagem direta. @@ -17,7 +33,7 @@ Conduza a etapa de entendimento e preparacao antes da implementacao. 6. Preparar um handoff explicito para o custom agent de refatoracao. 7. Preparar um handoff explicito para o custom agent de testes. -## Estrutura de saida obrigatoria +## Saida obrigatoria Responder com estas secoes, nesta ordem: @@ -61,7 +77,7 @@ Handoff para test - Evidencias esperadas: ``` -## Heuristicas para este repositorio +## Como analisar neste repositorio Ao analisar impacto provavel, considerar primeiro: @@ -74,6 +90,15 @@ Ao analisar impacto provavel, considerar primeiro: - `src/main/java/br/com/devsuperior/dev_xp_ai/util` - `src/test/java/br/com/devsuperior/dev_xp_ai` +## Como preencher a saida + +- Em `Resumo da issue`, priorize objetivo funcional, contexto operacional e criterios de aceite explicitos. +- Em `Escopo confirmado`, diferencie pedido explicito de extrapolacao tecnica. +- Em `Impacto provavel no codigo`, cite apenas camadas plausiveis; se uma camada nao parecer afetada, diga isso. +- Em `Invariantes e restricoes`, trate contrato HTTP observavel como item critico: path, verbo, status, headers e JSON. +- Em `Pontos em aberto`, registre lacunas reais antes de sugerir implementacao. +- Nos handoffs, escreva instrucoes que possam ser usadas diretamente pelos agents `refactor` e `test`. + ## Regras 1. Nao comecar implementando. @@ -97,6 +122,20 @@ Para o `test`: - informar cenarios de sucesso e erro - cobrar validacao de status, body, headers e cobertura +## Exemplo de uso + +Entrada: + +```text +/issue-to-brief issue #456 sobre ajuste de validacao no endpoint de clientes sem mudar o contrato HTTP +``` + +Saida esperada: + +- briefing com escopo confirmado e lacunas visiveis +- impacto provavel no codigo deste projeto +- handoff claro para refactor e para test + ## Resultado esperado Um bom resultado reduz ambiguidade da issue e deixa a implementacao pronta para seguir, com contexto suficiente para o agent de refatoracao atuar primeiro e o agent de testes atuar em seguida. diff --git a/.github/skills/pr-summary/SKILL.md b/.github/skills/pr-summary/SKILL.md index 80a6c7e..33d824d 100644 --- a/.github/skills/pr-summary/SKILL.md +++ b/.github/skills/pr-summary/SKILL.md @@ -1,17 +1,28 @@ --- name: pr-summary -description: Gera um resumo tecnico final de implementacao pronto para demonstracao, comentario de pull request ou fechamento de tarefa. Use quando o trabalho ja tiver sido implementado e testado, especialmente apos usar custom agents de refatoracao e testes. Ajuda a consolidar mudancas, comandos executados, cobertura, riscos, relacao com a issue e proximos passos sem omitir evidencias. +description: Gera um resumo tecnico final de implementacao para demo, comentario de pull request ou fechamento de tarefa. Use quando a mudanca ja tiver sido implementada e validada e voce precisar consolidar evidencias, testes, cobertura, riscos e relacao com a issue sem inventar fatos. +argument-hint: [issue ou contexto] [arquivos alterados] [testes/comandos executados] --- -# Skill pr-summary +# PR Summary -Conduza a etapa final de fechamento tecnico depois da implementacao e da validacao. +Esta skill conduz o fechamento tecnico depois da implementacao e da validacao. + +## Quando usar esta skill + +Use esta skill quando precisar: + +- transformar uma implementacao concluida em um resumo final apresentavel +- preparar texto para comentario de PR, demo ou encerramento de tarefa +- consolidar mudancas, testes executados, cobertura, riscos e pendencias com base em evidencia + +Nao use esta skill para entender uma issue antes de implementar. Nesse caso, use a skill `issue-to-brief`. ## Objetivo -Produzir um resumo final consistente, auditavel e facil de apresentar. +Produzir um resumo final consistente, auditavel e facil de apresentar, preservando a separacao entre fato observado e inferencia. -## Fluxo +## Procedimento 1. Reunir o contexto da issue original. 2. Reunir o que foi alterado no codigo de producao. @@ -21,7 +32,7 @@ Produzir um resumo final consistente, auditavel e facil de apresentar. 6. Registrar riscos, limitacoes e pendencias. 7. Fechar com uma relacao explicita entre issue, implementacao e validacao. -## Estrutura de saida obrigatoria +## Saida obrigatoria Responder com estas secoes, nesta ordem: @@ -51,6 +62,14 @@ Pronto para PR - Relacao com a issue: ``` +## Como preencher a saida + +- Use apenas fatos que estejam visiveis no contexto, no diff, nos arquivos alterados ou nos resultados de comando. +- Se algum ponto relevante nao estiver disponivel, diga explicitamente que a evidencia nao foi encontrada. +- Ao mencionar cobertura, informe numeros somente quando houver relatorio ou saida concreta. +- Ao mencionar contrato HTTP, deixe claro se o contrato foi preservado ou alterado e cite o que mudou. +- Ao mencionar riscos, prefira riscos observaveis, regressivos ou de rollout, e nao frases genericas. + ## Regras 1. Nao afirmar execucao, cobertura ou sucesso de testes sem evidencia no contexto. @@ -59,9 +78,23 @@ Pronto para PR 4. Se a issue tiver criterios de aceite, dizer como cada um foi atendido ou o que ficou pendente. 5. Se houve mudanca de contrato HTTP, explicitar de forma objetiva e concreta. +## Exemplo de uso + +Entrada: + +```text +/pr-summary issue #123, alteracoes no endpoint de pedidos, testes de integracao executados +``` + +Saida esperada: + +- resumo final com secoes fixas +- relacao objetiva entre issue, implementacao e validacao +- riscos e pendencias explicitados sem exagero + ## Fechamento curto para demonstracao -Quando fizer sentido, terminar com um bloco curto de resumo executivo contendo: +Quando fizer sentido, termine com um bloco curto de resumo executivo contendo: - objetivo da issue - o que foi implementado diff --git a/DEMO.md b/DEMO.md index 08d7fcc..5d433e3 100644 --- a/DEMO.md +++ b/DEMO.md @@ -110,19 +110,15 @@ Execute os testes relevantes, informe os comandos executados, o resultado e a co ### Prompt 4: Gerar o fechamento final +Antes faça: +- altere a branch para `feature/tabela` +- git add e git commit das mudanças +- git push + ```text Use a skill `pr-summary` #file:.github/skills/pr-summary/SKILL.md para gerar o fechamento técnico desta execução. - -Quero um resumo final com: -- issue atendida -- o que foi alterado -- classes principais afetadas -- testes criados ou ajustados -- comandos executados -- resultado dos testes -- cobertura JaCoCo -- riscos e limitações -- um resumo curto pronto para PR +O commit e push já foi feito, mapeie a branch atual e seguida para próxima tarefa. +Faça o PR dessa branch para a main, usando via GitHub MCP, usando o resumo curto gerado e referenciando a issue #2. ``` ## O que observar durante a apresentação diff --git a/src/main/java/br/com/devsuperior/dev_xp_ai/entity/DeveloperEntity.java b/src/main/java/br/com/devsuperior/dev_xp_ai/entity/DeveloperExperienceEntity.java similarity index 57% rename from src/main/java/br/com/devsuperior/dev_xp_ai/entity/DeveloperEntity.java rename to src/main/java/br/com/devsuperior/dev_xp_ai/entity/DeveloperExperienceEntity.java index 2eef9b5..2f67e0f 100644 --- a/src/main/java/br/com/devsuperior/dev_xp_ai/entity/DeveloperEntity.java +++ b/src/main/java/br/com/devsuperior/dev_xp_ai/entity/DeveloperExperienceEntity.java @@ -4,23 +4,14 @@ import org.springframework.data.relational.core.mapping.Column; import org.springframework.data.relational.core.mapping.Table; -@Table("tb_developer") -public class DeveloperEntity { +@Table("tb_developer_experience") +public class DeveloperExperienceEntity { @Id private Long id; - @Column("full_name") - private String fullName; - - @Column("email") - private String email; - - @Column("nickname") - private String nickname; - - @Column("uf") - private String uf; + @Column("user_id") + private Long userId; @Column("years_of_experience") private Integer yearsOfExperience; @@ -34,17 +25,13 @@ public class DeveloperEntity { @Column("skills") private String skills; - public DeveloperEntity() { + public DeveloperExperienceEntity() { } - public DeveloperEntity(Long id, String fullName, String email, String nickname, - String uf, Integer yearsOfExperience, String primaryLanguage, - Boolean interestedInAi, String skills) { + public DeveloperExperienceEntity(Long id, Long userId, Integer yearsOfExperience, + String primaryLanguage, Boolean interestedInAi, String skills) { this.id = id; - this.fullName = fullName; - this.email = email; - this.nickname = nickname; - this.uf = uf; + this.userId = userId; this.yearsOfExperience = yearsOfExperience; this.primaryLanguage = primaryLanguage; this.interestedInAi = interestedInAi; @@ -54,17 +41,8 @@ public DeveloperEntity(Long id, String fullName, String email, String nickname, public Long getId() { return id; } public void setId(Long id) { this.id = id; } - public String getFullName() { return fullName; } - public void setFullName(String fullName) { this.fullName = fullName; } - - public String getEmail() { return email; } - public void setEmail(String email) { this.email = email; } - - public String getNickname() { return nickname; } - public void setNickname(String nickname) { this.nickname = nickname; } - - public String getUf() { return uf; } - public void setUf(String uf) { this.uf = uf; } + public Long getUserId() { return userId; } + public void setUserId(Long userId) { this.userId = userId; } public Integer getYearsOfExperience() { return yearsOfExperience; } public void setYearsOfExperience(Integer yearsOfExperience) { this.yearsOfExperience = yearsOfExperience; } diff --git a/src/main/java/br/com/devsuperior/dev_xp_ai/entity/DeveloperUserEntity.java b/src/main/java/br/com/devsuperior/dev_xp_ai/entity/DeveloperUserEntity.java new file mode 100644 index 0000000..95fd1ac --- /dev/null +++ b/src/main/java/br/com/devsuperior/dev_xp_ai/entity/DeveloperUserEntity.java @@ -0,0 +1,52 @@ +package br.com.devsuperior.dev_xp_ai.entity; + +import org.springframework.data.annotation.Id; +import org.springframework.data.relational.core.mapping.Column; +import org.springframework.data.relational.core.mapping.Table; + +@Table("tb_developer") +public class DeveloperUserEntity { + + @Id + private Long id; + + @Column("full_name") + private String fullName; + + @Column("email") + private String email; + + @Column("nickname") + private String nickname; + + @Column("uf") + private String uf; + + public DeveloperUserEntity() { + } + + public DeveloperUserEntity(Long id, String fullName, String email, String nickname, String uf) { + this.id = id; + this.fullName = fullName; + this.email = email; + this.nickname = nickname; + this.uf = uf; + } + + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + + public String getFullName() { return fullName; } + public void setFullName(String fullName) { this.fullName = fullName; } + + public String getEmail() { return email; } + public void setEmail(String email) { this.email = email; } + + public String getNickname() { return nickname; } + public void setNickname(String nickname) { this.nickname = nickname; } + + public String getUf() { return uf; } + public void setUf(String uf) { this.uf = uf; } +} + + diff --git a/src/main/java/br/com/devsuperior/dev_xp_ai/repository/DeveloperExperienceRepository.java b/src/main/java/br/com/devsuperior/dev_xp_ai/repository/DeveloperExperienceRepository.java new file mode 100644 index 0000000..0fa5cd4 --- /dev/null +++ b/src/main/java/br/com/devsuperior/dev_xp_ai/repository/DeveloperExperienceRepository.java @@ -0,0 +1,27 @@ +package br.com.devsuperior.dev_xp_ai.repository; + +import br.com.devsuperior.dev_xp_ai.entity.DeveloperExperienceEntity; +import org.springframework.data.jdbc.repository.query.Query; +import org.springframework.data.repository.CrudRepository; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface DeveloperExperienceRepository extends CrudRepository { + + Optional findByUserId(Long userId); + + @Query(""" + SELECT e.* FROM tb_developer_experience e + INNER JOIN tb_developer u ON u.id = e.user_id + WHERE (:uf IS NULL OR u.uf = :uf) + AND (:language IS NULL OR LOWER(e.primary_language) = LOWER(:language)) + ORDER BY u.id + """) + List findAllByFilters(@Param("uf") String uf, @Param("language") String language); +} + + diff --git a/src/main/java/br/com/devsuperior/dev_xp_ai/repository/DeveloperRepository.java b/src/main/java/br/com/devsuperior/dev_xp_ai/repository/DeveloperRepository.java deleted file mode 100644 index fdced4b..0000000 --- a/src/main/java/br/com/devsuperior/dev_xp_ai/repository/DeveloperRepository.java +++ /dev/null @@ -1,21 +0,0 @@ -package br.com.devsuperior.dev_xp_ai.repository; - -import br.com.devsuperior.dev_xp_ai.entity.DeveloperEntity; -import org.springframework.data.jdbc.repository.query.Query; -import org.springframework.data.repository.CrudRepository; -import org.springframework.data.repository.query.Param; -import org.springframework.stereotype.Repository; - -import java.util.List; - -@Repository -public interface DeveloperRepository extends CrudRepository { - - boolean existsByEmailIgnoreCase(String email); - - boolean existsByNicknameIgnoreCase(String nickname); - - @Query("SELECT * FROM tb_developer WHERE (:uf IS NULL OR uf = :uf) AND (:language IS NULL OR LOWER(primary_language) = LOWER(:language)) ORDER BY id") - List findAllByFilters(@Param("uf") String uf, @Param("language") String language); -} - diff --git a/src/main/java/br/com/devsuperior/dev_xp_ai/repository/DeveloperUserRepository.java b/src/main/java/br/com/devsuperior/dev_xp_ai/repository/DeveloperUserRepository.java new file mode 100644 index 0000000..42584d7 --- /dev/null +++ b/src/main/java/br/com/devsuperior/dev_xp_ai/repository/DeveloperUserRepository.java @@ -0,0 +1,14 @@ +package br.com.devsuperior.dev_xp_ai.repository; + +import br.com.devsuperior.dev_xp_ai.entity.DeveloperUserEntity; +import org.springframework.data.repository.CrudRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface DeveloperUserRepository extends CrudRepository { + + boolean existsByEmailIgnoreCase(String email); + + boolean existsByNicknameIgnoreCase(String nickname); +} + diff --git a/src/main/java/br/com/devsuperior/dev_xp_ai/service/DeveloperService.java b/src/main/java/br/com/devsuperior/dev_xp_ai/service/DeveloperService.java index d3eecd9..de21b7b 100644 --- a/src/main/java/br/com/devsuperior/dev_xp_ai/service/DeveloperService.java +++ b/src/main/java/br/com/devsuperior/dev_xp_ai/service/DeveloperService.java @@ -3,10 +3,12 @@ import br.com.devsuperior.dev_xp_ai.dto.DeveloperCreateRequest; import br.com.devsuperior.dev_xp_ai.dto.DeveloperResponse; import br.com.devsuperior.dev_xp_ai.dto.UpdateExperienceRequest; -import br.com.devsuperior.dev_xp_ai.entity.DeveloperEntity; +import br.com.devsuperior.dev_xp_ai.entity.DeveloperExperienceEntity; +import br.com.devsuperior.dev_xp_ai.entity.DeveloperUserEntity; import br.com.devsuperior.dev_xp_ai.exception.ConflictException; import br.com.devsuperior.dev_xp_ai.exception.DeveloperNotFoundException; -import br.com.devsuperior.dev_xp_ai.repository.DeveloperRepository; +import br.com.devsuperior.dev_xp_ai.repository.DeveloperExperienceRepository; +import br.com.devsuperior.dev_xp_ai.repository.DeveloperUserRepository; import br.com.devsuperior.dev_xp_ai.util.TextNormalizer; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -32,10 +34,13 @@ public class DeveloperService { "RR", "SC", "SP", "SE", "TO" ); - private final DeveloperRepository developerRepository; + private final DeveloperUserRepository developerUserRepository; + private final DeveloperExperienceRepository developerExperienceRepository; - public DeveloperService(DeveloperRepository developerRepository) { - this.developerRepository = developerRepository; + public DeveloperService(DeveloperUserRepository developerUserRepository, + DeveloperExperienceRepository developerExperienceRepository) { + this.developerUserRepository = developerUserRepository; + this.developerExperienceRepository = developerExperienceRepository; } public DeveloperResponse createDeveloper(DeveloperCreateRequest request) { @@ -56,32 +61,39 @@ public DeveloperResponse createDeveloper(DeveloperCreateRequest request) { .toList(); String skillsCsv = TextNormalizer.serializeSkills(normalizedSkillsList); - if (developerRepository.existsByEmailIgnoreCase(normalizedEmail)) { + if (developerUserRepository.existsByEmailIgnoreCase(normalizedEmail)) { throw new ConflictException("Email ja cadastrado", List.of("Ja existe um desenvolvedor com este email.")); } - if (developerRepository.existsByNicknameIgnoreCase(normalizedNickname)) { + if (developerUserRepository.existsByNicknameIgnoreCase(normalizedNickname)) { throw new ConflictException("Nickname ja cadastrado", List.of("Ja existe um desenvolvedor com este nickname.")); } - DeveloperEntity entity = new DeveloperEntity( + DeveloperUserEntity userEntity = new DeveloperUserEntity( null, normalizedName, normalizedEmail, normalizedNickname, - normalizedUf, + normalizedUf + ); + DeveloperUserEntity savedUser = developerUserRepository.save(userEntity); + log.info("[correlationId={}] Dados de usuario persistidos: id={}", MDC.get("correlationId"), savedUser.getId()); + + DeveloperExperienceEntity experienceEntity = new DeveloperExperienceEntity( + null, + savedUser.getId(), request.yearsOfExperience(), normalizedLanguage, request.interestedInAi(), skillsCsv ); + DeveloperExperienceEntity savedExperience = developerExperienceRepository.save(experienceEntity); + log.info("[correlationId={}] Dados de experiencia persistidos: userId={}", MDC.get("correlationId"), savedUser.getId()); - DeveloperEntity saved = developerRepository.save(entity); - - log.info("[correlationId={}] Developer criado com sucesso: id={}", MDC.get("correlationId"), saved.getId()); - return toResponse(saved); + log.info("[correlationId={}] Developer criado com sucesso: id={}", MDC.get("correlationId"), savedUser.getId()); + return toResponse(savedUser, savedExperience); } public List listDevelopers(String uf, String language) { @@ -97,10 +109,15 @@ public List listDevelopers(String uf, String language) { String normalizedLanguage = (language != null && !language.isBlank()) ? language.trim() : null; - List result = developerRepository - .findAllByFilters(normalizedUf, normalizedLanguage) - .stream() - .map(this::toResponse) + List experiences = developerExperienceRepository + .findAllByFilters(normalizedUf, normalizedLanguage); + + List result = experiences.stream() + .map(exp -> { + DeveloperUserEntity user = developerUserRepository.findById(exp.getUserId()) + .orElseThrow(() -> new DeveloperNotFoundException(exp.getUserId())); + return toResponse(user, exp); + }) .toList(); log.info("[correlationId={}] Listagem concluida: {} resultado(s)", MDC.get("correlationId"), result.size()); @@ -110,11 +127,14 @@ public List listDevelopers(String uf, String language) { public DeveloperResponse getDeveloperById(Long id) { log.info("[correlationId={}] Buscando developer por id={}", MDC.get("correlationId"), id); - DeveloperEntity entity = developerRepository.findById(id) + DeveloperUserEntity user = developerUserRepository.findById(id) + .orElseThrow(() -> new DeveloperNotFoundException(id)); + + DeveloperExperienceEntity experience = developerExperienceRepository.findByUserId(id) .orElseThrow(() -> new DeveloperNotFoundException(id)); log.info("[correlationId={}] Developer encontrado: id={}", MDC.get("correlationId"), id); - return toResponse(entity); + return toResponse(user, experience); } public DeveloperResponse updateExperience(Long id, UpdateExperienceRequest request) { @@ -125,16 +145,24 @@ public DeveloperResponse updateExperience(Long id, UpdateExperienceRequest reque throw new IllegalArgumentException(String.join("; ", errors)); } - DeveloperEntity entity = developerRepository.findById(id) + DeveloperUserEntity user = developerUserRepository.findById(id) + .orElseThrow(() -> new DeveloperNotFoundException(id)); + + DeveloperExperienceEntity experience = developerExperienceRepository.findByUserId(id) .orElseThrow(() -> new DeveloperNotFoundException(id)); - entity.setYearsOfExperience(request.yearsOfExperience()); - DeveloperEntity updated = developerRepository.save(entity); + experience.setYearsOfExperience(request.yearsOfExperience()); + DeveloperExperienceEntity updatedExperience = developerExperienceRepository.save(experience); - log.info("[correlationId={}] Experiencia atualizada para id={}: yearsOfExperience={}", MDC.get("correlationId"), id, updated.getYearsOfExperience()); - return toResponse(updated); + log.info("[correlationId={}] Experiencia atualizada para id={}: yearsOfExperience={}", + MDC.get("correlationId"), id, updatedExperience.getYearsOfExperience()); + return toResponse(user, updatedExperience); } + // ------------------------------------------------------------------------- + // Validações + // ------------------------------------------------------------------------- + private List validateCreateRequest(DeveloperCreateRequest request) { List errors = new ArrayList<>(); if (request == null) { @@ -190,17 +218,21 @@ private List validateExperienceUpdate(UpdateExperienceRequest request) { return errors; } - private DeveloperResponse toResponse(DeveloperEntity entity) { + // ------------------------------------------------------------------------- + // Mapeamento + // ------------------------------------------------------------------------- + + private DeveloperResponse toResponse(DeveloperUserEntity user, DeveloperExperienceEntity experience) { return new DeveloperResponse( - entity.getId(), - entity.getFullName(), - entity.getEmail(), - entity.getNickname(), - entity.getUf(), - entity.getYearsOfExperience(), - entity.getPrimaryLanguage(), - entity.getInterestedInAi(), - TextNormalizer.deserializeSkills(entity.getSkills()) + user.getId(), + user.getFullName(), + user.getEmail(), + user.getNickname(), + user.getUf(), + experience.getYearsOfExperience(), + experience.getPrimaryLanguage(), + experience.getInterestedInAi(), + TextNormalizer.deserializeSkills(experience.getSkills()) ); } @@ -208,4 +240,3 @@ private boolean hasText(String value) { return value != null && !value.trim().isEmpty(); } } - diff --git a/src/main/resources/schema.sql b/src/main/resources/schema.sql index 4bfd7cd..269e322 100644 --- a/src/main/resources/schema.sql +++ b/src/main/resources/schema.sql @@ -3,10 +3,15 @@ CREATE TABLE IF NOT EXISTS tb_developer ( full_name VARCHAR(120) NOT NULL, email VARCHAR(120) NOT NULL UNIQUE, nickname VARCHAR(30) NOT NULL UNIQUE, - uf VARCHAR(2) NOT NULL, + uf VARCHAR(2) NOT NULL +); + +CREATE TABLE IF NOT EXISTS tb_developer_experience ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + user_id BIGINT NOT NULL UNIQUE, years_of_experience INT NOT NULL, primary_language VARCHAR(50) NOT NULL, interested_in_ai BOOLEAN NOT NULL, - skills VARCHAR(300) NOT NULL + skills VARCHAR(300) NOT NULL, + CONSTRAINT fk_experience_user FOREIGN KEY (user_id) REFERENCES tb_developer(id) ON DELETE CASCADE ); - diff --git a/src/test/java/br/com/devsuperior/dev_xp_ai/repository/DeveloperExperienceRepositoryTest.java b/src/test/java/br/com/devsuperior/dev_xp_ai/repository/DeveloperExperienceRepositoryTest.java new file mode 100644 index 0000000..e11e2db --- /dev/null +++ b/src/test/java/br/com/devsuperior/dev_xp_ai/repository/DeveloperExperienceRepositoryTest.java @@ -0,0 +1,150 @@ +package br.com.devsuperior.dev_xp_ai.repository; + +import br.com.devsuperior.dev_xp_ai.entity.DeveloperExperienceEntity; +import br.com.devsuperior.dev_xp_ai.entity.DeveloperUserEntity; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.util.List; +import java.util.Optional; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.*; + +@SpringBootTest +@DisplayName("DeveloperExperienceRepository") +class DeveloperExperienceRepositoryTest { + + @Autowired + private DeveloperUserRepository userRepository; + + @Autowired + private DeveloperExperienceRepository experienceRepository; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @BeforeEach + void limpar() { + jdbcTemplate.execute("DELETE FROM tb_developer_experience"); + jdbcTemplate.execute("DELETE FROM tb_developer"); + } + + private DeveloperUserEntity salvarUsuario(String email, String nickname, String uf) { + return userRepository.save(new DeveloperUserEntity(null, "Dev Teste", email, nickname, uf)); + } + + private DeveloperExperienceEntity salvarExperiencia(Long userId, String language, int anos) { + return experienceRepository.save( + new DeveloperExperienceEntity(null, userId, anos, language, true, "Spring Boot")); + } + + @Nested + @DisplayName("findByUserId") + class FindByUserId { + + @Test + @DisplayName("Deve retornar experiência quando userId existe") + void deveRetornarExperienciaQuandoUserIdExiste() { + DeveloperUserEntity user = salvarUsuario("exp@example.com", "exp_dev", "RJ"); + salvarExperiencia(user.getId(), "Java", 4); + + Optional result = experienceRepository.findByUserId(user.getId()); + + assertThat(result.isPresent(), is(true)); + assertThat(result.get().getUserId(), is(user.getId())); + assertThat(result.get().getPrimaryLanguage(), is("Java")); + assertThat(result.get().getYearsOfExperience(), is(4)); + } + + @Test + @DisplayName("Deve retornar Optional vazio quando userId não existe") + void deveRetornarVazioQuandoUserIdNaoExiste() { + Optional result = experienceRepository.findByUserId(99999L); + assertThat(result.isPresent(), is(false)); + } + } + + @Nested + @DisplayName("findAllByFilters") + class FindAllByFilters { + + @BeforeEach + void inserirMassaDeDados() { + DeveloperUserEntity userSP = salvarUsuario("sp@example.com", "dev_sp", "SP"); + DeveloperUserEntity userRJ = salvarUsuario("rj@example.com", "dev_rj", "RJ"); + DeveloperUserEntity userMG = salvarUsuario("mg@example.com", "dev_mg", "MG"); + + salvarExperiencia(userSP.getId(), "Java", 5); + salvarExperiencia(userRJ.getId(), "Python", 3); + salvarExperiencia(userMG.getId(), "Java", 7); + } + + @Test + @DisplayName("Deve retornar todos quando filtros forem nulos") + void deveRetornarTodosQuandoFiltrosNulos() { + List result = experienceRepository.findAllByFilters(null, null); + assertThat(result, hasSize(3)); + } + + @Test + @DisplayName("Deve filtrar por UF corretamente") + void deveFiltrarPorUf() { + List result = experienceRepository.findAllByFilters("SP", null); + assertThat(result, hasSize(1)); + } + + @Test + @DisplayName("Deve filtrar por linguagem corretamente") + void deveFiltrarPorLinguagem() { + List result = experienceRepository.findAllByFilters(null, "Java"); + assertThat(result, hasSize(2)); + } + + @Test + @DisplayName("Deve filtrar por linguagem de forma case-insensitive") + void deveFiltrarPorLinguagemCaseInsensitive() { + List result = experienceRepository.findAllByFilters(null, "java"); + assertThat(result, hasSize(2)); + } + + @Test + @DisplayName("Deve filtrar por UF e linguagem combinados") + void deveFiltrarPorUfELinguagemCombinados() { + List result = experienceRepository.findAllByFilters("RJ", "Python"); + assertThat(result, hasSize(1)); + } + + @Test + @DisplayName("Deve retornar lista vazia quando filtro não encontra resultado") + void deveRetornarVazioQuandoSemResultado() { + List result = experienceRepository.findAllByFilters(null, "COBOL"); + assertThat(result, is(empty())); + } + } + + @Nested + @DisplayName("save e atualização") + class SaveEAtualizacao { + + @Test + @DisplayName("Deve atualizar yearsOfExperience sem alterar outros campos") + void deveAtualizarYearsOfExperienceSemAlterar() { + DeveloperUserEntity user = salvarUsuario("upd@example.com", "upd_dev", "PR"); + DeveloperExperienceEntity exp = salvarExperiencia(user.getId(), "Kotlin", 2); + + exp.setYearsOfExperience(10); + DeveloperExperienceEntity updated = experienceRepository.save(exp); + + assertThat(updated.getYearsOfExperience(), is(10)); + assertThat(updated.getPrimaryLanguage(), is("Kotlin")); + assertThat(updated.getUserId(), is(user.getId())); + } + } +} + diff --git a/src/test/java/br/com/devsuperior/dev_xp_ai/repository/DeveloperUserRepositoryTest.java b/src/test/java/br/com/devsuperior/dev_xp_ai/repository/DeveloperUserRepositoryTest.java new file mode 100644 index 0000000..aa25df9 --- /dev/null +++ b/src/test/java/br/com/devsuperior/dev_xp_ai/repository/DeveloperUserRepositoryTest.java @@ -0,0 +1,133 @@ +package br.com.devsuperior.dev_xp_ai.repository; + +import br.com.devsuperior.dev_xp_ai.entity.DeveloperUserEntity; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.util.Optional; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.*; + +@SpringBootTest +@DisplayName("DeveloperUserRepository") +class DeveloperUserRepositoryTest { + + @Autowired + private DeveloperUserRepository userRepository; + + @Autowired + private DeveloperExperienceRepository experienceRepository; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @BeforeEach + void limpar() { + jdbcTemplate.execute("DELETE FROM tb_developer_experience"); + jdbcTemplate.execute("DELETE FROM tb_developer"); + } + + private DeveloperUserEntity salvarUsuario(String email, String nickname) { + return userRepository.save(new DeveloperUserEntity(null, "Dev Teste", email, nickname, "SP")); + } + + @Nested + @DisplayName("existsByEmailIgnoreCase") + class ExistsByEmail { + + @Test + @DisplayName("Deve retornar true quando email já existe") + void deveRetornarTrueQuandoEmailExiste() { + salvarUsuario("dev@example.com", "dev_nick"); + assertThat(userRepository.existsByEmailIgnoreCase("dev@example.com"), is(true)); + } + + @Test + @DisplayName("Deve ser case-insensitive ao verificar email") + void deveSercaseInsensitiveAoVerificarEmail() { + salvarUsuario("dev@example.com", "dev_nick"); + assertThat(userRepository.existsByEmailIgnoreCase("DEV@EXAMPLE.COM"), is(true)); + } + + @Test + @DisplayName("Deve retornar false quando email não existe") + void deveRetornarFalseQuandoEmailNaoExiste() { + assertThat(userRepository.existsByEmailIgnoreCase("naoexiste@example.com"), is(false)); + } + } + + @Nested + @DisplayName("existsByNicknameIgnoreCase") + class ExistsByNickname { + + @Test + @DisplayName("Deve retornar true quando nickname já existe") + void deveRetornarTrueQuandoNicknameExiste() { + salvarUsuario("dev@example.com", "dev_nick"); + assertThat(userRepository.existsByNicknameIgnoreCase("dev_nick"), is(true)); + } + + @Test + @DisplayName("Deve ser case-insensitive ao verificar nickname") + void deveSerCaseInsensitiveAoVerificarNickname() { + salvarUsuario("dev@example.com", "dev_nick"); + assertThat(userRepository.existsByNicknameIgnoreCase("DEV_NICK"), is(true)); + } + + @Test + @DisplayName("Deve retornar false quando nickname não existe") + void deveRetornarFalseQuandoNicknameNaoExiste() { + assertThat(userRepository.existsByNicknameIgnoreCase("nickquenaoexiste"), is(false)); + } + } + + @Nested + @DisplayName("save e findById") + class SaveEFindById { + + @Test + @DisplayName("Deve persistir e recuperar usuário por id") + void devePersistirERecuperarUsuarioPorId() { + DeveloperUserEntity saved = salvarUsuario("maria@example.com", "maria_dev"); + + assertThat(saved.getId(), notNullValue()); + Optional found = userRepository.findById(saved.getId()); + assertThat(found.isPresent(), is(true)); + assertThat(found.get().getEmail(), is("maria@example.com")); + assertThat(found.get().getNickname(), is("maria_dev")); + assertThat(found.get().getUf(), is("SP")); + } + + @Test + @DisplayName("Deve retornar Optional vazio quando id não existe") + void deveRetornarVazioQuandoIdNaoExiste() { + Optional found = userRepository.findById(99999L); + assertThat(found.isPresent(), is(false)); + } + } + + @Nested + @DisplayName("delete em cascata") + class DeleteCascata { + + @Test + @DisplayName("Deve apagar experiência associada ao deletar usuário") + void deveApagarExperienciaAoDeletarUsuario() { + DeveloperUserEntity user = salvarUsuario("cascade@example.com", "cascade_dev"); + experienceRepository.save( + new br.com.devsuperior.dev_xp_ai.entity.DeveloperExperienceEntity( + null, user.getId(), 3, "Java", true, "Spring Boot")); + + userRepository.deleteById(user.getId()); + + assertThat(experienceRepository.findByUserId(user.getId()).isPresent(), is(false)); + } + } +} + diff --git a/src/test/java/br/com/devsuperior/dev_xp_ai/util/TextNormalizerTest.java b/src/test/java/br/com/devsuperior/dev_xp_ai/util/TextNormalizerTest.java new file mode 100644 index 0000000..c7a1175 --- /dev/null +++ b/src/test/java/br/com/devsuperior/dev_xp_ai/util/TextNormalizerTest.java @@ -0,0 +1,173 @@ +package br.com.devsuperior.dev_xp_ai.util; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.*; + +@DisplayName("TextNormalizer") +class TextNormalizerTest { + + @Nested + @DisplayName("toTitleCase") + class ToTitleCase { + + @Test + @DisplayName("Deve capitalizar primeira letra de cada palavra") + void deveCapitalizarPrimeiraLetraDeCadaPalavra() { + assertThat(TextNormalizer.toTitleCase("maria da silva"), is("Maria Da Silva")); + } + + @Test + @DisplayName("Deve converter texto todo em maiúsculo para title case") + void deveConverterMaiusculoParaTitleCase() { + assertThat(TextNormalizer.toTitleCase("JOAO CARLOS"), is("Joao Carlos")); + } + + @Test + @DisplayName("Deve normalizar espaços extras entre palavras") + void deveNormalizarEspacosExtras() { + assertThat(TextNormalizer.toTitleCase(" maria silva "), is("Maria Silva")); + } + + @Test + @DisplayName("Deve retornar string vazia quando entrada for nula") + void deveRetornarVazioQuandoNulo() { + assertThat(TextNormalizer.toTitleCase(null), is("")); + } + + @Test + @DisplayName("Deve retornar string vazia quando entrada for em branco") + void deveRetornarVazioQuandoEmBranco() { + assertThat(TextNormalizer.toTitleCase(" "), is("")); + } + + @Test + @DisplayName("Deve processar palavra única corretamente") + void deveProcessarPalavraUnica() { + assertThat(TextNormalizer.toTitleCase("java"), is("Java")); + } + } + + @Nested + @DisplayName("toLowerCaseTrimmed") + class ToLowerCaseTrimmed { + + @Test + @DisplayName("Deve converter para minúsculo e remover espaços") + void deveConverterParaMinusculoERemoverEspacos() { + assertThat(TextNormalizer.toLowerCaseTrimmed(" MARIA@EXAMPLE.COM "), is("maria@example.com")); + } + + @Test + @DisplayName("Deve retornar string vazia quando entrada for nula") + void deveRetornarVazioQuandoNulo() { + assertThat(TextNormalizer.toLowerCaseTrimmed(null), is("")); + } + + @Test + @DisplayName("Deve manter texto já em minúsculo inalterado") + void deveManterMinusculoInaletrado() { + assertThat(TextNormalizer.toLowerCaseTrimmed("abc"), is("abc")); + } + } + + @Nested + @DisplayName("toUpperCaseTrimmed") + class ToUpperCaseTrimmed { + + @Test + @DisplayName("Deve converter para maiúsculo e remover espaços") + void deveConverterParaMaiusculoERemoverEspacos() { + assertThat(TextNormalizer.toUpperCaseTrimmed(" sp "), is("SP")); + } + + @Test + @DisplayName("Deve retornar string vazia quando entrada for nula") + void deveRetornarVazioQuandoNulo() { + assertThat(TextNormalizer.toUpperCaseTrimmed(null), is("")); + } + + @Test + @DisplayName("Deve manter texto já em maiúsculo inalterado") + void deveManterMaiusculoInaletrado() { + assertThat(TextNormalizer.toUpperCaseTrimmed("RJ"), is("RJ")); + } + } + + @Nested + @DisplayName("serializeSkills") + class SerializeSkills { + + @Test + @DisplayName("Deve serializar lista de skills em CSV") + void deveSerializarListaEmCsv() { + assertThat(TextNormalizer.serializeSkills(List.of("Java", "Spring Boot", "Docker")), + is("Java,Spring Boot,Docker")); + } + + @Test + @DisplayName("Deve retornar string vazia quando lista for nula") + void deveRetornarVazioQuandoNulo() { + assertThat(TextNormalizer.serializeSkills(null), is("")); + } + + @Test + @DisplayName("Deve serializar lista com um único item") + void deveSerializarListaComUmItem() { + assertThat(TextNormalizer.serializeSkills(List.of("Kotlin")), is("Kotlin")); + } + + @Test + @DisplayName("Deve remover espaços externos de cada skill ao serializar") + void deveRemoverEspacosExternosDeSkills() { + assertThat(TextNormalizer.serializeSkills(List.of(" Java ", " Docker ")), + is("Java,Docker")); + } + } + + @Nested + @DisplayName("deserializeSkills") + class DeserializeSkills { + + @Test + @DisplayName("Deve deserializar CSV de skills em lista") + void deveDeserializarCsvEmLista() { + List result = TextNormalizer.deserializeSkills("Java,Spring Boot,Docker"); + assertThat(result, hasSize(3)); + assertThat(result, contains("Java", "Spring Boot", "Docker")); + } + + @Test + @DisplayName("Deve retornar lista vazia quando CSV for nulo") + void deveRetornarListaVaziaQuandoNulo() { + assertThat(TextNormalizer.deserializeSkills(null), is(empty())); + } + + @Test + @DisplayName("Deve retornar lista vazia quando CSV for em branco") + void deveRetornarListaVaziaQuandoEmBranco() { + assertThat(TextNormalizer.deserializeSkills(" "), is(empty())); + } + + @Test + @DisplayName("Deve remover espaços externos de cada item ao deserializar") + void deveRemoverEspacosExternosAoDeserializar() { + List result = TextNormalizer.deserializeSkills(" Java , Docker "); + assertThat(result, contains("Java", "Docker")); + } + + @Test + @DisplayName("Deve deserializar lista com um único item") + void deveDeserializarUmUnicoItem() { + List result = TextNormalizer.deserializeSkills("Kotlin"); + assertThat(result, hasSize(1)); + assertThat(result, contains("Kotlin")); + } + } +} +