From fc23747ef0ba667165ee2cc1cdd1d25a0f3196c8 Mon Sep 17 00:00:00 2001 From: Vitor Hugo Date: Fri, 23 Oct 2026 20:25:19 +0000 Subject: [PATCH 1/2] fix: require confirmed interview counts and expose MCP edits --- README.md | 29 ++++++ .../2026-10-23-interview-confirmation.md | 44 +++++++++ ...026-10-23-interview-confirmation-design.md | 11 +++ .../application/ApplicationPatchRequest.java | 3 + .../dashboard/DashboardSummaryResponse.java | 2 +- .../McpApplicationLifecycleRulesResource.java | 5 ++ .../mcp/tools/McpAnalyticsTools.java | 8 +- .../mcp/tools/McpApplicationTools.java | 4 + .../service/InterviewMetricsService.java | 3 +- .../integration/ApplicationControllerIT.java | 89 +++++++++++-------- .../unit/InterviewMetricsServiceTest.java | 5 +- .../unit/mcp/McpAnalyticsToolsTest.java | 21 +++++ .../unit/mcp/McpApplicationToolsTest.java | 30 ++++++- 13 files changed, 205 insertions(+), 49 deletions(-) create mode 100644 docs/superpowers/plans/2026-10-23-interview-confirmation.md create mode 100644 docs/superpowers/specs/2026-10-23-interview-confirmation-design.md diff --git a/README.md b/README.md index 7b35cdd..c7cca46 100644 --- a/README.md +++ b/README.md @@ -836,3 +836,32 @@ GitHub Actions workflow (`.github/workflows/ci.yml`) triggers on push/PR to `mai ## API Documentation Swagger UI is available at `http://localhost:8080/swagger-ui.html` when the app is running. + + +### Completed interview counts + +`interviewCount` belongs to each application and stores the confirmed number of completed interviews. +Status changes and `interviewScheduled` do not increment it. Existing totals are preserved; notes and +legacy status events are not interpreted as proof of attendance. Scheduled, cancelled or missed +interviews and asynchronous technical tests must not be included. + +Set an absolute total through the authenticated API: + +```http +PATCH /api/v1/applications/{id} +Content-Type: application/json + +{"interviewCount": 2} +``` + +The MCP `Patch-Application` tool accepts the same optional `interviewCount` parameter. It must be a +non-negative integer. Omit it to keep the total, or set it to zero to correct it. Repeating the same +total does not add another interview. Updating this number does not award interview XP; gamification +remains independent of the business count. A status can be patched together with the total without +adding an implicit increment. + +The React form asks whether an interview was actually completed when saving a change to an interview +funnel status, including moving between interview stages. Yes adds one to the form total; No saves +without adding; closing the popup cancels saving. The total can also be corrected manually in the form. +Dashboard and MCP analytics sum stored completed counts. Analytics `interviewRate` is the percentage +of applications with at least one completed interview, rather than interviews divided by applications. diff --git a/docs/superpowers/plans/2026-10-23-interview-confirmation.md b/docs/superpowers/plans/2026-10-23-interview-confirmation.md new file mode 100644 index 0000000..35b27b4 --- /dev/null +++ b/docs/superpowers/plans/2026-10-23-interview-confirmation.md @@ -0,0 +1,44 @@ +# Interview Confirmation Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [x]`) syntax for tracking. + +**Goal:** Contar apenas entrevistas confirmadas e expor ajuste da contagem no MCP. +**Architecture:** Preservar contagem absoluta por candidatura; status gera somente histórico. React confirma incremento no salvamento. Analytics usa contagem armazenada. +**Tech Stack:** Spring Boot, Java 21, React, TypeScript, JUnit, Playwright. +**Spec:** docs/superpowers/specs/2026-10-23-interview-confirmation-design.md + +## Global Constraints +Preservar dados legados, não conceder XP ao ajustar contagem, validar inteiro não negativo, manter autorização por proprietário. + +## Review Focus +- Reenvio do mesmo total não incrementa novamente. +- Status junto com total explícito não soma implicitamente. +- Fechar popup não salva; não confirma mantém contagem. +- Erro de rede e nova tentativa não somam novamente. +- Múltiplas entrevistas não fazem taxa ultrapassar 100%. + +### Task 1: Backend e MCP +Files: InterviewMetricsService.java, McpApplicationTools.java, McpAnalyticsTools.java e testes correspondentes. +Interfaces: PATCH /api/v1/applications/{id} com interviewCount; Patch-Application com Integer interviewCount opcional antes de archived. +- [x] Escrever testes de transição preservando contagem e analytics com 2+2+1 entrevistas em 3 candidaturas. +- [x] Executar e observar falhas; remover incremento implícito, expor campo e corrigir analytics. +- [x] Cobrir validação, omissão, reenvio e propriedade pelo REST; executar testes. + +### Task 2: Confirmação na interface +Files: React src/pages/applications/ApplicationForm.tsx, src/lib/interviews.ts, tests/interview-confirmation.spec.ts. +Interfaces: requiresInterviewConfirmation(previousStatus, nextStatus) => boolean. +- [x] Testar mudança para etapa, mesma etapa, rascunho e transição entre etapas. +- [x] Adicionar diálogo no envio com Sim/Não e cancelamento, usando total absoluto. +- [x] Testar fluxo via Playwright e executar build/lint. + +### Task 3: Verificação +- [x] Documentar uso REST/MCP e comportamento de confirmação. +- [x] Executar suíte backend e checks frontend; revisar diff e registrar limitações. + + +## Verification results +- Targeted final backend run: 66 tests passed, including ApplicationControllerIT and isolated McpAuthIT. +- Full backend run: 434 tests, 0 assertion failures, 4 setup errors in McpAuthIT due to WebAuthn challenge foreign keys left by prior classes. McpAuthIT passes in isolation; its existing cleanup does not remove passkey challenges. +- Browser suite: 86/87 passed initially; the failed retry test incorrectly selected the seeded current status. Corrected that scenario and verified all nine interview confirmation cases, including two added during review. The new-application case needed the exact required-field accessible label in its test selector. +- Frontend production build, ESLint, existing Node tests, and git diff whitespace checks passed. +- Static independent review found no blocking issues; review-requested count-only, create/replace, zero and negative cases were added. diff --git a/docs/superpowers/specs/2026-10-23-interview-confirmation-design.md b/docs/superpowers/specs/2026-10-23-interview-confirmation-design.md new file mode 100644 index 0000000..01e4415 --- /dev/null +++ b/docs/superpowers/specs/2026-10-23-interview-confirmation-design.md @@ -0,0 +1,11 @@ +# Contagem confirmada de entrevistas — issue #99 + +Escopo definido pelo usuário: manter a contagem por candidatura; perguntar na interface se a entrevista foi realizada antes de incrementar por mudança de status; permitir alterar a contagem via API e MCP. + +A contagem absoluta `interviewCount` existente passa a ser a fonte de verdade. Transições continuam registradas em `interview_events`, mas não alteram o total. PUT/POST/PATCH aceitam totais explícitos; omissão preserva o total. Patch-Application expõe o mesmo campo opcional, com validação de inteiro não negativo. Reenvios do mesmo total são idempotentes e não concedem XP adicional por contagem. + +A interface pergunta ao salvar uma mudança para um status do conjunto que antes incrementava automaticamente, inclusive mudanças entre etapas desse conjunto. Sim soma um ao total do formulário; não mantém o total. Fechar o popup cancela o salvamento. Repetir o salvamento após erro deve reutilizar o total absoluto. Agendamento isolado não dispara confirmação. A pergunta esclarece que testes assíncronos não contam. + +Analytics soma entrevistas realizadas, mantendo a taxa como proporção de candidaturas com ao menos uma entrevista (máximo 100%). Preservar dados legados sem migração ou inferência de notas. Não criar registros de entrevista individuais: o usuário escolheu ajuste da contagem em vez da modelagem proposta anteriormente. + +Alternativas: manter incremento automático com flag de confirmação exigiria novos contratos em todos os comandos; usar contagem absoluta já existente evita incremento duplicado e preserva o contrato REST. diff --git a/src/main/java/com/jobtracker/dto/application/ApplicationPatchRequest.java b/src/main/java/com/jobtracker/dto/application/ApplicationPatchRequest.java index 6443e11..f5e5221 100644 --- a/src/main/java/com/jobtracker/dto/application/ApplicationPatchRequest.java +++ b/src/main/java/com/jobtracker/dto/application/ApplicationPatchRequest.java @@ -189,6 +189,9 @@ public void setInterviewCount(Integer interviewCount) { if (interviewCount == null) { return; } + if (interviewCount < 0) { + throw new com.jobtracker.exception.BadRequestException("interviewCount must be zero or greater"); + } this.interviewCount = interviewCount; providedFields.add("interviewCount"); } diff --git a/src/main/java/com/jobtracker/dto/dashboard/DashboardSummaryResponse.java b/src/main/java/com/jobtracker/dto/dashboard/DashboardSummaryResponse.java index 3014652..ce8ba06 100644 --- a/src/main/java/com/jobtracker/dto/dashboard/DashboardSummaryResponse.java +++ b/src/main/java/com/jobtracker/dto/dashboard/DashboardSummaryResponse.java @@ -10,7 +10,7 @@ public record DashboardSummaryResponse( long waitingResponses, @Schema(description = "Applications with an interview scheduled", example = "3") long interviewsScheduled, - @Schema(description = "Cumulative count of interview status entries", example = "7") + @Schema(description = "Total confirmed completed interviews across active applications", example = "7") long interviewCount, @Schema(description = "Applications with overdue follow-up dates", example = "2") long overdueFollowUps, diff --git a/src/main/java/com/jobtracker/mcp/resources/McpApplicationLifecycleRulesResource.java b/src/main/java/com/jobtracker/mcp/resources/McpApplicationLifecycleRulesResource.java index 735ecbe..bf273e5 100644 --- a/src/main/java/com/jobtracker/mcp/resources/McpApplicationLifecycleRulesResource.java +++ b/src/main/java/com/jobtracker/mcp/resources/McpApplicationLifecycleRulesResource.java @@ -70,6 +70,11 @@ - the record is a duplicate, test, invalid, or obsolete entry; Restoration preserves the current status and all other data. - Use `Patch-Application` for any partial change. It updates only the fields you send, so never resend unrelated fields or boolean flags to satisfy a schema. + - Interview counts belong to each application. Changing status or scheduling an interview does not increment them. + - After confirming attendance with the user, use `Patch-Application` with `interviewCount` set to the absolute + total of completed interviews (integer >= 0). Omit it to preserve the count. Repeating a total is idempotent. + - Scheduled, cancelled or missed interviews and asynchronous technical tests are not completed interviews. + - Do not infer counts from free-text notes or use gamification events to record interviews. - Permanent deletion is allowed only when the user explicitly requests permanent deletion. - Do not delete and recreate an application to change its archive state without explicit user approval. diff --git a/src/main/java/com/jobtracker/mcp/tools/McpAnalyticsTools.java b/src/main/java/com/jobtracker/mcp/tools/McpAnalyticsTools.java index 4ccb38d..8eb57ea 100644 --- a/src/main/java/com/jobtracker/mcp/tools/McpAnalyticsTools.java +++ b/src/main/java/com/jobtracker/mcp/tools/McpAnalyticsTools.java @@ -49,7 +49,8 @@ public McpAnalyticsTools(ApplicationRepository applicationRepository, @McpTool( name = "Get-Analytics", title = "Get Analytics", - description = "Returns aggregate analytics for the user's job applications: totals, rates, status and platform breakdown, average days to response.", + description = "Returns aggregate analytics for the user's job applications: totals, rates, status and platform breakdown, average days to response. " + + "interviewCount is the sum of completed interviews; interviewRate is the percentage of applications with at least one completed interview.", annotations = @McpAnnotations( title = "Get Analytics", readOnlyHint = true, @@ -80,13 +81,14 @@ public AnalyticsResponse getAnalytics( } int total = apps.size(); - int interviewCount = (int) apps.stream().filter(JobApplication::isInterviewScheduled).count(); + int interviewCount = apps.stream().mapToInt(JobApplication::getInterviewCount).sum(); + long applicationsWithInterviews = apps.stream().filter(a -> a.getInterviewCount() > 0).count(); int rejectionCount = (int) apps.stream().filter(a -> "REJEITADO".equals(a.getStatus()) || "Rejected".equals(a.getStatus())).count(); int ghostingCount = (int) apps.stream().filter(a -> "GHOSTING".equals(a.getStatus()) || "Ghosting".equals(a.getStatus())).count(); - double interviewRate = total > 0 ? Math.round(interviewCount * 1000.0 / total) / 10.0 : 0.0; + double interviewRate = total > 0 ? Math.round(applicationsWithInterviews * 1000.0 / total) / 10.0 : 0.0; double rejectionRate = total > 0 ? Math.round(rejectionCount * 1000.0 / total) / 10.0 : 0.0; double ghostingRate = total > 0 ? Math.round(ghostingCount * 1000.0 / total) / 10.0 : 0.0; diff --git a/src/main/java/com/jobtracker/mcp/tools/McpApplicationTools.java b/src/main/java/com/jobtracker/mcp/tools/McpApplicationTools.java index f4dea75..e42ee97 100644 --- a/src/main/java/com/jobtracker/mcp/tools/McpApplicationTools.java +++ b/src/main/java/com/jobtracker/mcp/tools/McpApplicationTools.java @@ -220,6 +220,8 @@ public ApplicationResponse updateApplication( + "Prefer this tool over Update-Application: never resend unrelated fields or boolean flags just to satisfy a schema. " + "Set archived to false to restore an archived application to the active list, or to true to archive it. " + "Restoring and archiving preserve the current status, and changing status never archives or restores a record. " + + "Set interviewCount to the absolute number of completed interviews for this application (zero or greater), after user confirmation. " + + "Status changes do not increment this count. Scheduled interviews and asynchronous tests do not count. " + "IMPORTANT: Call List-Statuses first whenever you send status. Never use a status value from memory.", annotations = @McpAnnotations( title = "Patch Application", @@ -243,6 +245,7 @@ public ApplicationResponse patchApplication( @McpToolParam(required = false, description = "Whether a DM reminder to the recruiter is enabled") Boolean recruiterDmReminderEnabled, @McpToolParam(required = false, description = "Personal notes about this application") String note, @McpToolParam(required = false, description = "Platform or job board where the vacancy was found, e.g. LinkedIn, Gupy, Indeed, Catho") String platform, + @McpToolParam(required = false, description = "Absolute number of completed interviews for this application, integer >= 0. Omit to preserve. Repeating the same total is idempotent; this does not award XP.") Integer interviewCount, @McpToolParam(required = false, description = "true to archive the application, false to restore it to the active list. Independent of status.") Boolean archived) { ApplicationPatchRequest request = new ApplicationPatchRequest(); if (vacancyName != null) request.setVacancyName(vacancyName); @@ -257,6 +260,7 @@ public ApplicationResponse patchApplication( if (recruiterDmReminderEnabled != null) request.setRecruiterDmReminderEnabled(recruiterDmReminderEnabled); if (note != null) request.setNote(note); if (platform != null) request.setPlatform(platform); + if (interviewCount != null) request.setInterviewCount(interviewCount); if (archived != null) request.setArchived(archived); return applicationService.patch(UUID.fromString(id), request); } diff --git a/src/main/java/com/jobtracker/service/InterviewMetricsService.java b/src/main/java/com/jobtracker/service/InterviewMetricsService.java index ccfbb3d..d1801f9 100644 --- a/src/main/java/com/jobtracker/service/InterviewMetricsService.java +++ b/src/main/java/com/jobtracker/service/InterviewMetricsService.java @@ -52,7 +52,8 @@ public void recordStatusTransition(JobApplication application, String oldStatus, return; } - application.setInterviewCount(application.getInterviewCount() + 1); + // Status is funnel history, not evidence that an interview was completed. + // The confirmed absolute count is supplied explicitly by the caller. InterviewEvent event = new InterviewEvent(); event.setUser(application.getUser()); diff --git a/src/test/java/com/jobtracker/integration/ApplicationControllerIT.java b/src/test/java/com/jobtracker/integration/ApplicationControllerIT.java index ad951fc..4dab04e 100644 --- a/src/test/java/com/jobtracker/integration/ApplicationControllerIT.java +++ b/src/test/java/com/jobtracker/integration/ApplicationControllerIT.java @@ -163,65 +163,76 @@ void updateStatus_shouldReturn200() throws Exception { } @Test - void statusUpdates_shouldExposeCumulativeInterviewCountWithoutRepeatedSaveDoubleCounting() throws Exception { - ApplicationRequest create = buildRequest("Interview Counter"); - MvcResult createResult = mockMvc.perform(post("/api/v1/applications") + void createAndReplace_preserveExplicitInterviewTotals() throws Exception { + ApplicationRequest request = new ApplicationRequest( + "Completed interviews", null, null, null, LocalDate.now().minusDays(1), + false, false, null, "Pending HR Response", false, null, null, 2); + MvcResult created = mockMvc.perform(post("/api/v1/applications") .header("Authorization", "Bearer " + accessToken) .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(create))) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()).andExpect(jsonPath("$.interviewCount").value(2)) .andReturn(); - - String id = objectMapper.readTree(createResult.getResponse().getContentAsString()).get("id").asText(); - - mockMvc.perform(get("/api/v1/dashboard/summary") - .header("Authorization", "Bearer " + accessToken)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.interviewCount").value(0)); - - mockMvc.perform(patch("/api/v1/applications/{id}/status", id) + String id = objectMapper.readTree(created.getResponse().getContentAsString()).get("id").asText(); + ApplicationRequest replacement = new ApplicationRequest( + "Completed interviews", null, null, null, LocalDate.now().minusDays(1), + false, false, null, "Pending Hiring Manager Response", false, null, null, 3); + for (int attempt = 0; attempt < 2; attempt++) { + mockMvc.perform(put("/api/v1/applications/{id}", id) + .header("Authorization", "Bearer " + accessToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(replacement))) + .andExpect(status().isOk()).andExpect(jsonPath("$.interviewCount").value(3)); + } + mockMvc.perform(put("/api/v1/applications/{id}", id) .header("Authorization", "Bearer " + accessToken) .contentType(MediaType.APPLICATION_JSON) - .content("{\"status\": \"Technical Test\"}")) - .andExpect(status().isOk()); + .content(objectMapper.writeValueAsString(buildRequest("Preserved count")))) + .andExpect(status().isOk()).andExpect(jsonPath("$.interviewCount").value(3)); + } - mockMvc.perform(get("/api/v1/dashboard/summary") - .header("Authorization", "Bearer " + accessToken)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.interviewCount").value(1)); + @Test + void confirmedInterviewCount_isAbsoluteAndIndependentOfStatus() throws Exception { + MvcResult created = mockMvc.perform(post("/api/v1/applications") + .header("Authorization", "Bearer " + accessToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(buildRequest("Interview Counter")))) + .andExpect(status().isCreated()).andReturn(); + String id = objectMapper.readTree(created.getResponse().getContentAsString()).get("id").asText(); mockMvc.perform(patch("/api/v1/applications/{id}/status", id) .header("Authorization", "Bearer " + accessToken) .contentType(MediaType.APPLICATION_JSON) - .content("{\"status\": \"Technical Test\"}")) - .andExpect(status().isOk()); + .content("{\"status\":\"Technical Test\"}")) + .andExpect(status().isOk()).andExpect(jsonPath("$.interviewCount").value(0)); + // Explicit count and status in the same request must not add an implicit interview. + for (int attempt = 0; attempt < 2; attempt++) { + mockMvc.perform(patch("/api/v1/applications/{id}", id) + .header("Authorization", "Bearer " + accessToken) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"status\":\"Pending HR Response\",\"interviewCount\":2}")) + .andExpect(status().isOk()).andExpect(jsonPath("$.interviewCount").value(2)); + } mockMvc.perform(patch("/api/v1/applications/{id}/status", id) .header("Authorization", "Bearer " + accessToken) .contentType(MediaType.APPLICATION_JSON) - .content("{\"status\": \"Offer Negotiation\"}")) - .andExpect(status().isOk()); + .content("{\"status\":\"Pending Hiring Manager Response\"}")) + .andExpect(status().isOk()).andExpect(jsonPath("$.interviewCount").value(2)); mockMvc.perform(get("/api/v1/dashboard/summary") .header("Authorization", "Bearer " + accessToken)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.interviewCount").value(1)); + .andExpect(status().isOk()).andExpect(jsonPath("$.interviewCount").value(2)) + .andExpect(jsonPath("$.totalApplications").value(1)); - mockMvc.perform(patch("/api/v1/applications/{id}/status", id) + mockMvc.perform(patch("/api/v1/applications/{id}", id) .header("Authorization", "Bearer " + accessToken) - .contentType(MediaType.APPLICATION_JSON) - .content("{\"status\": \"Rejected\"}")) - .andExpect(status().isOk()); - - mockMvc.perform(patch("/api/v1/applications/{id}/status", id) + .contentType(MediaType.APPLICATION_JSON).content("{\"interviewCount\":-1}")) + .andExpect(status().isBadRequest()); + mockMvc.perform(patch("/api/v1/applications/{id}", id) .header("Authorization", "Bearer " + accessToken) - .contentType(MediaType.APPLICATION_JSON) - .content("{\"status\": \"Pending HR Response\"}")) - .andExpect(status().isOk()); - - mockMvc.perform(get("/api/v1/dashboard/summary") - .header("Authorization", "Bearer " + accessToken)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.interviewCount").value(2)); + .contentType(MediaType.APPLICATION_JSON).content("{\"interviewCount\":0}")) + .andExpect(status().isOk()).andExpect(jsonPath("$.interviewCount").value(0)); } @Test diff --git a/src/test/java/com/jobtracker/unit/InterviewMetricsServiceTest.java b/src/test/java/com/jobtracker/unit/InterviewMetricsServiceTest.java index 41b83a4..3db16cb 100644 --- a/src/test/java/com/jobtracker/unit/InterviewMetricsServiceTest.java +++ b/src/test/java/com/jobtracker/unit/InterviewMetricsServiceTest.java @@ -72,10 +72,11 @@ void wasInterviewTriggered_shouldOnlyDetectEntryIntoInterviewStatus() { } @Test - void recordStatusTransition_shouldIncrementCountAndLogEventWhenEnteringInterviewStatus() { + void recordStatusTransition_shouldPreserveConfirmedCountAndLogStatusHistory() { + application.setInterviewCount(2); service.recordStatusTransition(application, "RH", "Technical Test"); - assertThat(application.getInterviewCount()).isEqualTo(1); + assertThat(application.getInterviewCount()).isEqualTo(2); verify(eventRepository).save(any()); } diff --git a/src/test/java/com/jobtracker/unit/mcp/McpAnalyticsToolsTest.java b/src/test/java/com/jobtracker/unit/mcp/McpAnalyticsToolsTest.java index e20dbe9..094ecd2 100644 --- a/src/test/java/com/jobtracker/unit/mcp/McpAnalyticsToolsTest.java +++ b/src/test/java/com/jobtracker/unit/mcp/McpAnalyticsToolsTest.java @@ -69,6 +69,7 @@ void getAnalytics_emptyList_returnsZeros() { void getAnalytics_countsCorrectly() { JobApplication rh = app("RH", false, LocalDate.now().minusDays(5), null); JobApplication interview = app("Pending HR Response", true, LocalDate.now().minusDays(3), "LinkedIn"); + interview.setInterviewCount(1); JobApplication rejected = app("Rejected", false, LocalDate.now().minusDays(10), "Gupy"); JobApplication ghost = app("Ghosting", false, LocalDate.now().minusDays(20), null); JobApplication noStatus = app(null, false, null, null); @@ -91,6 +92,26 @@ void getAnalytics_countsCorrectly() { assertThat(result.platformBreakdown()).doesNotContainKey(null); } + @Test + void getAnalytics_sumsCompletedInterviewsAndKeepsApplicationRateBounded() { + JobApplication first = app("Pending HR Response", false, LocalDate.now(), null); + JobApplication second = app("Pending Hiring Manager Response", true, LocalDate.now(), null); + JobApplication third = app("RH", false, LocalDate.now(), null); + JobApplication scheduledOnly = app("RH", true, LocalDate.now(), null); + first.setInterviewCount(2); + second.setInterviewCount(2); + third.setInterviewCount(1); + when(securityUtils.getCurrentUserId()).thenReturn(USER_ID); + when(applicationRepository.findAllByUser_IdAndArchivedFalse(USER_ID)) + .thenReturn(List.of(first, second, third, scheduledOnly)); + + AnalyticsResponse result = tools.getAnalytics(null, null, null); + + assertThat(result.totalApplications()).isEqualTo(4); + assertThat(result.interviewCount()).isEqualTo(5); + assertThat(result.interviewRate()).isEqualTo(75.0); + } + @Test void getAnalytics_filtersDateRange() { LocalDate today = LocalDate.now(); diff --git a/src/test/java/com/jobtracker/unit/mcp/McpApplicationToolsTest.java b/src/test/java/com/jobtracker/unit/mcp/McpApplicationToolsTest.java index 65d31c4..c552617 100644 --- a/src/test/java/com/jobtracker/unit/mcp/McpApplicationToolsTest.java +++ b/src/test/java/com/jobtracker/unit/mcp/McpApplicationToolsTest.java @@ -189,12 +189,13 @@ void patchApplication_sendsOnlyTheProvidedFields() { when(applicationService.patch(eq(id), any())).thenReturn(applicationResponseWithId(id)); tools.patchApplication(null, id.toString(), null, null, null, null, null, null, null, - null, null, null, null, null, Boolean.FALSE); + null, null, null, null, null, null, Boolean.FALSE); verify(applicationService).patch(eq(id), captor.capture()); ApplicationPatchRequest request = captor.getValue(); assertThat(request.hasArchived()).isTrue(); assertThat(request.getArchived()).isFalse(); + assertThat(request.hasInterviewCount()).isFalse(); assertThat(request.hasStatus()).isFalse(); assertThat(request.hasVacancyName()).isFalse(); assertThat(request.hasRhAcceptedConnection()).isFalse(); @@ -202,6 +203,28 @@ void patchApplication_sendsOnlyTheProvidedFields() { assertThat(request.hasRecruiterDmReminderEnabled()).isFalse(); } + @Test + void patchApplication_forwardsExplicitZeroInterviewCount() { + UUID id = UUID.randomUUID(); + ArgumentCaptor captor = ArgumentCaptor.forClass(ApplicationPatchRequest.class); + tools.patchApplication(null, id.toString(), null, null, null, null, + null, null, null, null, null, null, null, null, 0, null); + verify(applicationService).patch(eq(id), captor.capture()); + assertThat(captor.getValue().hasInterviewCount()).isTrue(); + assertThat(captor.getValue().getInterviewCount()).isZero(); + assertThat(captor.getValue().hasStatus()).isFalse(); + } + + @Test + void patchApplication_rejectsNegativeInterviewCount() { + org.assertj.core.api.Assertions.assertThatThrownBy(() -> + tools.patchApplication(null, UUID.randomUUID().toString(), null, null, null, null, + null, null, null, null, null, null, null, null, -1, null)) + .isInstanceOf(com.jobtracker.exception.BadRequestException.class) + .hasMessageContaining("interviewCount"); + org.mockito.Mockito.verifyNoInteractions(applicationService); + } + @Test void patchApplication_mapsAllParams() { UUID id = UUID.randomUUID(); @@ -223,7 +246,7 @@ void patchApplication_mapsAllParams() { Boolean.TRUE, "Follow up Monday", "LinkedIn", - Boolean.TRUE); + 2, Boolean.TRUE); verify(applicationService).patch(eq(id), captor.capture()); ApplicationPatchRequest request = captor.getValue(); @@ -240,6 +263,7 @@ void patchApplication_mapsAllParams() { assertThat(request.getNote()).isEqualTo("Follow up Monday"); assertThat(request.getPlatform()).isEqualTo("LinkedIn"); assertThat(request.getArchived()).isTrue(); + assertThat(request.getInterviewCount()).isEqualTo(2); } @Test @@ -249,7 +273,7 @@ void patchApplication_returnsUpdatedApplication() { when(applicationService.patch(eq(id), any())).thenReturn(expected); ApplicationResponse result = tools.patchApplication(null, id.toString(), null, null, null, null, - null, null, null, null, null, null, null, null, Boolean.FALSE); + null, null, null, null, null, null, null, null, null, Boolean.FALSE); assertThat(result).isEqualTo(expected); } From f2c6a8acfd6c863f69d41db94d87e347c5e69850 Mon Sep 17 00:00:00 2001 From: Vitor Hugo Date: Fri, 23 Oct 2026 20:44:17 +0000 Subject: [PATCH 2/2] fix: resolve Qodana OAuth and Drive findings --- .../config/AuthorizationServerConfig.java | 11 ++- .../CimdRegisteredClientRepository.java | 2 +- ...AuthorizationServerMetadataController.java | 5 +- ...thProtectedResourceMetadataController.java | 5 +- .../service/GoogleDriveService.java | 19 ++--- .../service/SdkGoogleDriveApiClient.java | 30 +++---- .../unit/GoogleDriveServiceTest.java | 15 +++- .../unit/OAuthQodanaRegressionTest.java | 64 +++++++++++++++ .../unit/SdkGoogleDriveApiClientTest.java | 81 +++++++++++++++++++ 9 files changed, 198 insertions(+), 34 deletions(-) create mode 100644 src/test/java/com/jobtracker/unit/OAuthQodanaRegressionTest.java create mode 100644 src/test/java/com/jobtracker/unit/SdkGoogleDriveApiClientTest.java diff --git a/src/main/java/com/jobtracker/config/AuthorizationServerConfig.java b/src/main/java/com/jobtracker/config/AuthorizationServerConfig.java index 7dcbe97..9afea43 100644 --- a/src/main/java/com/jobtracker/config/AuthorizationServerConfig.java +++ b/src/main/java/com/jobtracker/config/AuthorizationServerConfig.java @@ -69,6 +69,7 @@ import java.time.Duration; import java.time.Instant; import java.util.LinkedHashSet; +import java.util.Objects; import java.util.UUID; @Configuration @@ -264,16 +265,22 @@ public OAuth2TokenCustomizer jwtTokenCustomizer(UserReposito return; } + var principal = context.getPrincipal(); + if (principal == null) { + throw new IllegalStateException("An access token requires an authenticated principal"); + } + LinkedHashSet roles = new LinkedHashSet<>(); roles.add("ROLE_GPT_CLIENT"); - context.getPrincipal().getAuthorities().stream() + principal.getAuthorities().stream() .map(GrantedAuthority::getAuthority) + .filter(Objects::nonNull) .filter(authority -> authority.startsWith("ROLE_")) .filter(authority -> !"ROLE_USER".equals(authority)) .forEach(roles::add); context.getClaims().claim("roles", roles); - userRepository.findByEmail(context.getPrincipal().getName()) + userRepository.findByEmail(principal.getName()) .ifPresent(user -> context.getClaims().claim("user_id", user.getId().toString())); }; } diff --git a/src/main/java/com/jobtracker/config/CimdRegisteredClientRepository.java b/src/main/java/com/jobtracker/config/CimdRegisteredClientRepository.java index f4bc298..ab9c979 100644 --- a/src/main/java/com/jobtracker/config/CimdRegisteredClientRepository.java +++ b/src/main/java/com/jobtracker/config/CimdRegisteredClientRepository.java @@ -84,7 +84,7 @@ public RegisteredClient findById(String id) { @Override public RegisteredClient findByClientId(String clientId) { - if (clientId != null && clientId.startsWith("https://")) { + if (clientId.startsWith("https://")) { return resolveCimdClient(clientId); } return delegate.findByClientId(clientId); diff --git a/src/main/java/com/jobtracker/config/OAuthAuthorizationServerMetadataController.java b/src/main/java/com/jobtracker/config/OAuthAuthorizationServerMetadataController.java index 76215fe..9f74eed 100644 --- a/src/main/java/com/jobtracker/config/OAuthAuthorizationServerMetadataController.java +++ b/src/main/java/com/jobtracker/config/OAuthAuthorizationServerMetadataController.java @@ -42,6 +42,9 @@ public OAuthAuthorizationServerMetadataController( produces = MediaType.APPLICATION_JSON_VALUE) public Map authorizationServerMetadata(HttpServletRequest request) { String issuer = settings.getIssuer(); + if (issuer == null || issuer.isBlank()) { + throw new IllegalStateException("OAuth issuer must be configured for discovery metadata"); + } Map metadata = new LinkedHashMap<>(); metadata.put("issuer", issuer); @@ -73,6 +76,6 @@ public Map authorizationServerMetadata(HttpServletRequest reques private String registrationPath() { String path = settings.getOidcClientRegistrationEndpoint(); - return (path != null && !path.isBlank()) ? path : "/connect/register"; + return (!path.isBlank()) ? path : "/connect/register"; } } diff --git a/src/main/java/com/jobtracker/config/OAuthProtectedResourceMetadataController.java b/src/main/java/com/jobtracker/config/OAuthProtectedResourceMetadataController.java index e0a98e5..f12af12 100644 --- a/src/main/java/com/jobtracker/config/OAuthProtectedResourceMetadataController.java +++ b/src/main/java/com/jobtracker/config/OAuthProtectedResourceMetadataController.java @@ -42,6 +42,9 @@ public OAuthProtectedResourceMetadataController( produces = MediaType.APPLICATION_JSON_VALUE) public Map protectedResourceMetadata(HttpServletRequest request) { String issuer = authorizationServerSettings.getIssuer(); + if (issuer == null || issuer.isBlank()) { + throw new IllegalStateException("OAuth issuer must be configured for discovery metadata"); + } String requestPath = request.getRequestURI(); // Derive the resource path suffix after the well-known prefix. @@ -80,7 +83,7 @@ public Map protectedResourceMetadata(HttpServletRequest request) // "/connect/register") rather than hardcoded, so it tracks any issuer/path reconfiguration. private String registrationEndpoint(String issuer) { String path = authorizationServerSettings.getOidcClientRegistrationEndpoint(); - if (path == null || path.isBlank()) { + if (path.isBlank()) { path = "/connect/register"; } return issuer + path; diff --git a/src/main/java/com/jobtracker/service/GoogleDriveService.java b/src/main/java/com/jobtracker/service/GoogleDriveService.java index ecdacf6..2d3f307 100644 --- a/src/main/java/com/jobtracker/service/GoogleDriveService.java +++ b/src/main/java/com/jobtracker/service/GoogleDriveService.java @@ -106,7 +106,7 @@ public GoogleDriveBaseResumeResponse addBaseResume(GoogleDriveBaseResumeRequest resume.setDocumentName(file.name()); resume.setWebViewLink(resolveDocumentLink(file)); resume.setLanguage(request.language()); - resume.setTemplate(isReadOnly ? false : request.template()); + resume.setTemplate(!isReadOnly && request.template()); resume.setReadOnly(isReadOnly); try { GoogleDriveBaseResume saved = baseResumeRepository.save(resume); @@ -286,13 +286,13 @@ private BaseResumeResponse toBaseResumeListingResponse(GoogleDriveBaseResume res private String buildVacancyFolderName(JobApplication application) { String suffix = " - APP-" + application.getId().toString(); - String rawBase = firstNonBlank(application.getVacancyName(), application.getOrganization(), "Application"); + String rawBase = applicationDisplayName(application); String truncatedBase = truncateFileName(sanitizeFileName(rawBase), 180 - suffix.length()); return truncatedBase + suffix; } private String buildCopiedDocumentName(JobApplication application, String baseResumeName) { - String vacancyName = firstNonBlank(application.getVacancyName(), application.getOrganization(), "Application"); + String vacancyName = applicationDisplayName(application); String prefix = "APP-" + application.getId() + " - " + vacancyName; return truncateFileName(sanitizeFileName(prefix + " - " + baseResumeName), 220); } @@ -305,13 +305,14 @@ private String truncateFileName(String value, int maxLength) { return value.length() <= maxLength ? value : value.substring(0, maxLength).trim(); } - private String firstNonBlank(String... values) { - for (String value : values) { - if (StringUtils.hasText(value)) { - return value.trim(); - } + private String applicationDisplayName(JobApplication application) { + if (StringUtils.hasText(application.getVacancyName())) { + return application.getVacancyName().trim(); + } + if (StringUtils.hasText(application.getOrganization())) { + return application.getOrganization().trim(); } - return null; + return "Application"; } private String resolveDocumentLink(GoogleDriveApiClient.DriveFileMetadata file) { diff --git a/src/main/java/com/jobtracker/service/SdkGoogleDriveApiClient.java b/src/main/java/com/jobtracker/service/SdkGoogleDriveApiClient.java index 3cc8709..784eeba 100644 --- a/src/main/java/com/jobtracker/service/SdkGoogleDriveApiClient.java +++ b/src/main/java/com/jobtracker/service/SdkGoogleDriveApiClient.java @@ -77,15 +77,22 @@ public SdkGoogleDriveApiClient( this.refreshTokenClient = refreshTokenClient; } + private String requiredOAuthSetting(String value, String name) { + if (value == null || value.isBlank()) { + throw new IllegalStateException("Google OAuth " + name + " must be configured"); + } + return value; + } + // ── OAuth2 operations ──────────────────────────────────────────────────── @Override public String buildAuthorizationUrl(String state) { properties.validateConfigured(); return UriComponentsBuilder - .fromUriString(clientRegistration.getProviderDetails().getAuthorizationUri()) + .fromUriString(requiredOAuthSetting(clientRegistration.getProviderDetails().getAuthorizationUri(), "authorization URI")) .queryParam("client_id", clientRegistration.getClientId()) - .queryParam("redirect_uri", clientRegistration.getRedirectUri()) + .queryParam("redirect_uri", requiredOAuthSetting(clientRegistration.getRedirectUri(), "redirect URI")) .queryParam("response_type", "code") .queryParam("scope", String.join(" ", clientRegistration.getScopes())) .queryParam("access_type", "offline") @@ -106,13 +113,13 @@ public String buildAuthorizationUrl(String state) { public OAuthTokens exchangeAuthorizationCode(String code) { OAuth2AuthorizationRequest authRequest = OAuth2AuthorizationRequest.authorizationCode() .clientId(clientRegistration.getClientId()) - .authorizationUri(clientRegistration.getProviderDetails().getAuthorizationUri()) - .redirectUri(clientRegistration.getRedirectUri()) + .authorizationUri(requiredOAuthSetting(clientRegistration.getProviderDetails().getAuthorizationUri(), "authorization URI")) + .redirectUri(requiredOAuthSetting(clientRegistration.getRedirectUri(), "redirect URI")) .scopes(clientRegistration.getScopes()) .state("_") .build(); OAuth2AuthorizationResponse authResponse = OAuth2AuthorizationResponse.success(code) - .redirectUri(clientRegistration.getRedirectUri()) + .redirectUri(requiredOAuthSetting(clientRegistration.getRedirectUri(), "redirect URI")) .state("_") .build(); try { @@ -223,20 +230,9 @@ public String readGoogleDocText(String accessToken, String documentId) { return executeDocsOp(accessToken, "read document", docs -> { com.google.api.services.docs.v1.model.Document document = docs.documents().get(documentId).execute(); - StringBuilder text = new StringBuilder(); if (document.getBody() == null || document.getBody().getContent() == null) { return ""; } - document.getBody().getContent().forEach(element -> { - if (element.getParagraph() == null || element.getParagraph().getElements() == null) { - return; - } - element.getParagraph().getElements().forEach(paragraphElement -> { - if (paragraphElement.getTextRun() != null && paragraphElement.getTextRun().getContent() != null) { - text.append(paragraphElement.getTextRun().getContent()); - } - }); - }); return extractText(document.getBody().getContent()); }); } @@ -391,7 +387,7 @@ private OAuthTokens toOAuthTokens(OAuth2AccessTokenResponse response, String ref ? LocalDateTime.ofInstant(expiresAt, ZoneOffset.UTC) : LocalDateTime.now(ZoneOffset.UTC).plusHours(1); Set scopes = accessToken.getScopes(); - String scopeStr = (scopes == null || scopes.isEmpty()) ? null : String.join(" ", scopes); + String scopeStr = scopes.isEmpty() ? null : String.join(" ", scopes); return new OAuthTokens(accessToken.getTokenValue(), refreshTokenValue, expiresAtLdt, scopeStr); } diff --git a/src/test/java/com/jobtracker/unit/GoogleDriveServiceTest.java b/src/test/java/com/jobtracker/unit/GoogleDriveServiceTest.java index cc8905d..11a1f82 100644 --- a/src/test/java/com/jobtracker/unit/GoogleDriveServiceTest.java +++ b/src/test/java/com/jobtracker/unit/GoogleDriveServiceTest.java @@ -164,8 +164,17 @@ void addBaseResume_shouldRejectUnsupportedFileTypes() { .hasMessageContaining("Only Google Docs documents and PDF files are supported"); } - @Test - void copyBaseResumeToApplication_shouldCreateFolderAndCopyDocument() { + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.CsvSource(value = { + "Backend Engineer,Acme,Backend Engineer", + "NULL,Acme,Acme", + "'',Acme,Acme", + "NULL,NULL,Application" + }, nullValues = "NULL") + void copyBaseResumeToApplication_shouldCreateFolderAndCopyDocument( + String vacancyName, String organization, String expectedName) { + application.setVacancyName(vacancyName); + application.setOrganization(organization); connection.setRootFolderId("root-folder-id"); when(securityUtils.getCurrentUserId()).thenReturn(USER_ID); when(connectionRepository.findByUserId(USER_ID)).thenReturn(Optional.of(connection)); @@ -210,6 +219,6 @@ void copyBaseResumeToApplication_shouldCreateFolderAndCopyDocument() { ArgumentCaptor folderNameCaptor = ArgumentCaptor.forClass(String.class); verify(googleDriveApiClient).createFolder(eq("access-token"), eq("root-folder-id"), folderNameCaptor.capture()); verify(applicationRepository).save(application); - assertThat(folderNameCaptor.getValue()).contains("APP-" + APPLICATION_ID); + assertThat(folderNameCaptor.getValue()).isEqualTo(expectedName + " - APP-" + APPLICATION_ID); } } \ No newline at end of file diff --git a/src/test/java/com/jobtracker/unit/OAuthQodanaRegressionTest.java b/src/test/java/com/jobtracker/unit/OAuthQodanaRegressionTest.java new file mode 100644 index 0000000..a5a2c35 --- /dev/null +++ b/src/test/java/com/jobtracker/unit/OAuthQodanaRegressionTest.java @@ -0,0 +1,64 @@ +package com.jobtracker.unit; + +import com.jobtracker.config.AuthorizationServerConfig; +import com.jobtracker.config.McpOAuthProperties; +import com.jobtracker.config.OAuthProtectedResourceMetadataController; +import com.jobtracker.repository.UserRepository; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.jwt.JwtClaimsSet; +import org.springframework.security.oauth2.server.authorization.OAuth2TokenType; +import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings; +import org.springframework.security.oauth2.server.authorization.token.JwtEncodingContext; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +class OAuthQodanaRegressionTest { + @Test + void protectedMetadata_rejectsMissingIssuerWithConfigurationError() { + var controller = new OAuthProtectedResourceMetadataController( + AuthorizationServerSettings.builder().build(), new McpOAuthProperties()); + assertThatThrownBy(() -> controller.protectedResourceMetadata(new MockHttpServletRequest())) + .isInstanceOf(IllegalStateException.class).hasMessageContaining("issuer"); + } + + @Test + void authorizationMetadata_rejectsMissingIssuerWithConfigurationError() { + var controller = new com.jobtracker.config.OAuthAuthorizationServerMetadataController( + AuthorizationServerSettings.builder().build(), new McpOAuthProperties()); + assertThatThrownBy(() -> controller.authorizationServerMetadata(new MockHttpServletRequest())) + .isInstanceOf(IllegalStateException.class).hasMessageContaining("issuer"); + } + + @Test + void tokenCustomizer_ignoresAuthoritiesWithoutStringRepresentation() { + var users = mock(UserRepository.class); + var context = mock(JwtEncodingContext.class); + var principal = UsernamePasswordAuthenticationToken.authenticated("user@example.com", "unused", + List.of(() -> null, new SimpleGrantedAuthority("ROLE_ADMIN"), new SimpleGrantedAuthority("ROLE_USER"))); + var claims = JwtClaimsSet.builder(); + when(context.getTokenType()).thenReturn(OAuth2TokenType.ACCESS_TOKEN); + doReturn(principal).when(context).getPrincipal(); + when(context.getClaims()).thenReturn(claims); + when(users.findByEmail("user@example.com")).thenReturn(Optional.empty()); + + new AuthorizationServerConfig().jwtTokenCustomizer(users).customize(context); + + assertThat(claims.build().getClaimAsStringList("roles")) + .containsExactly("ROLE_GPT_CLIENT", "ROLE_ADMIN"); + } + + @Test + void tokenCustomizer_rejectsMissingPrincipal() { + var context = mock(JwtEncodingContext.class); + when(context.getTokenType()).thenReturn(OAuth2TokenType.ACCESS_TOKEN); + assertThatThrownBy(() -> new AuthorizationServerConfig().jwtTokenCustomizer(mock(UserRepository.class)).customize(context)) + .isInstanceOf(IllegalStateException.class).hasMessageContaining("principal"); + } +} diff --git a/src/test/java/com/jobtracker/unit/SdkGoogleDriveApiClientTest.java b/src/test/java/com/jobtracker/unit/SdkGoogleDriveApiClientTest.java new file mode 100644 index 0000000..cd951b7 --- /dev/null +++ b/src/test/java/com/jobtracker/unit/SdkGoogleDriveApiClientTest.java @@ -0,0 +1,81 @@ +package com.jobtracker.unit; + +import com.google.api.services.docs.v1.Docs; +import com.google.api.services.docs.v1.model.*; +import java.util.List; + +import com.jobtracker.config.GoogleDriveProperties; +import com.jobtracker.service.DriveClientFactory; +import com.jobtracker.service.SdkGoogleDriveApiClient; +import org.junit.jupiter.api.Test; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.core.AuthorizationGrantType; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +class SdkGoogleDriveApiClientTest { + private SdkGoogleDriveApiClient client(boolean withAuthorizationUri) { + var builder = ClientRegistration.withRegistrationId("google").clientId("client-id") + .clientSecret("secret").authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS) + .tokenUri("https://oauth2.googleapis.com/token"); + if (withAuthorizationUri) builder.authorizationUri("https://accounts.google.com/o/oauth2/v2/auth"); + return new SdkGoogleDriveApiClient(mock(GoogleDriveProperties.class), builder.build(), + mock(DriveClientFactory.class), mock(), mock()); + } + + @Test + void buildAuthorizationUrl_rejectsMissingAuthorizationUriClearly() { + assertThatThrownBy(() -> client(false).buildAuthorizationUrl("state")) + .isInstanceOf(IllegalStateException.class).hasMessageContaining("authorization URI"); + } + + @Test + void exchangeAuthorizationCode_rejectsMissingRedirectUriClearly() { + assertThatThrownBy(() -> client(true).exchangeAuthorizationCode("code")) + .isInstanceOf(IllegalStateException.class).hasMessageContaining("redirect URI"); + } + + @Test + void buildAuthorizationUrl_preservesValidGoogleOAuthParameters() { + var registration = ClientRegistration.withRegistrationId("google").clientId("client-id") + .clientSecret("secret").authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) + .authorizationUri("https://accounts.google.com/o/oauth2/v2/auth") + .tokenUri("https://oauth2.googleapis.com/token").redirectUri("https://example.com/callback") + .scope("drive", "documents.readonly").build(); + var client = new SdkGoogleDriveApiClient(mock(GoogleDriveProperties.class), registration, + mock(DriveClientFactory.class), mock(), mock()); + + String url = client.buildAuthorizationUrl("state-value"); + assertThat(url).startsWith("https://accounts.google.com/o/oauth2/v2/auth?") + .contains("client_id=client-id", "response_type=code", "state=state-value", "access_type=offline", + "redirect_uri=https://example.com/callback"); + } + + @Test + void readGoogleDocText_preservesParagraphsAndNestedTables() throws Exception { + var factory = mock(DriveClientFactory.class); + var docs = mock(Docs.class, RETURNS_DEEP_STUBS); + when(factory.createDocs("token")).thenReturn(docs); + var paragraph = new StructuralElement().setParagraph( + new Paragraph().setElements(List.of( + new ParagraphElement().setTextRun( + new TextRun().setContent("Hello"))))); + var table = new StructuralElement().setTable( + new Table().setTableRows(List.of( + new TableRow().setTableCells(List.of( + new TableCell().setContent(List.of(paragraph))))))); + var document = new Document().setBody( + new Body().setContent(List.of(paragraph, table))); + when(docs.documents().get("doc").execute()).thenReturn(document); + var registration = ClientRegistration.withRegistrationId("google").clientId("id") + .clientSecret("secret").authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS) + .tokenUri("https://oauth2.googleapis.com/token").build(); + var client = new SdkGoogleDriveApiClient(mock(GoogleDriveProperties.class), registration, factory, mock(), mock()); + + assertThat(client.readGoogleDocText("token", "doc")).isEqualTo("Hello\nHello"); + when(docs.documents().get("doc").execute()).thenReturn(new Document()); + assertThat(client.readGoogleDocText("token", "doc")).isEmpty(); + } + +}