From 4a404c29e8c44ef057783a07fccffc9e53a55372 Mon Sep 17 00:00:00 2001 From: CMaintz Date: Wed, 30 Sep 2026 14:49:16 +0200 Subject: [PATCH 1/2] test(backend): @WebMvcTest coverage for job, company, user, skill, profile-section and interview-prep controllers --- .../web/controller/CompanyControllerTest.java | 208 +++++++++++++++ .../InterviewPrepControllerTest.java | 213 +++++++++++++++ .../web/controller/JobControllerTest.java | 244 ++++++++++++++++++ .../ProfileSectionControllerTest.java | 197 ++++++++++++++ .../web/controller/SkillControllerTest.java | 216 ++++++++++++++++ .../web/controller/UserControllerTest.java | 168 ++++++++++++ 6 files changed, 1246 insertions(+) create mode 100644 backend/src/test/java/com/autoapplicant/adapter/web/controller/CompanyControllerTest.java create mode 100644 backend/src/test/java/com/autoapplicant/adapter/web/controller/InterviewPrepControllerTest.java create mode 100644 backend/src/test/java/com/autoapplicant/adapter/web/controller/JobControllerTest.java create mode 100644 backend/src/test/java/com/autoapplicant/adapter/web/controller/ProfileSectionControllerTest.java create mode 100644 backend/src/test/java/com/autoapplicant/adapter/web/controller/SkillControllerTest.java create mode 100644 backend/src/test/java/com/autoapplicant/adapter/web/controller/UserControllerTest.java diff --git a/backend/src/test/java/com/autoapplicant/adapter/web/controller/CompanyControllerTest.java b/backend/src/test/java/com/autoapplicant/adapter/web/controller/CompanyControllerTest.java new file mode 100644 index 00000000..bcb19942 --- /dev/null +++ b/backend/src/test/java/com/autoapplicant/adapter/web/controller/CompanyControllerTest.java @@ -0,0 +1,208 @@ +package com.autoapplicant.adapter.web.controller; + +import static org.hamcrest.Matchers.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import com.autoapplicant.adapter.security.SecurityContextHelper; +import com.autoapplicant.domain.company.Company; +import com.autoapplicant.domain.company.CompanyResearch; +import com.autoapplicant.domain.company.OutreachContact; +import com.autoapplicant.domain.company.OutreachReason; +import com.autoapplicant.domain.company.OutreachStatus; +import com.autoapplicant.domain.company.OutreachTarget; +import com.autoapplicant.port.in.auth.ProvisionFirebaseUserUseCase; +import com.autoapplicant.port.in.auth.ResolveLinkedInUserUseCase; +import com.autoapplicant.port.in.company.FindOutreachTargetsUseCase; +import com.autoapplicant.port.in.company.GetCompaniesUseCase; +import com.autoapplicant.port.in.company.ManageCompanyResearchUseCase; +import com.autoapplicant.port.in.company.ManageOutreachUseCase; +import com.autoapplicant.port.in.job.GetJobsByCompanyUseCase; +import com.google.firebase.auth.FirebaseAuth; +import java.time.Instant; +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest(controllers = CompanyController.class) +@AutoConfigureMockMvc(addFilters = false) +class CompanyControllerTest { + + @Autowired MockMvc mvc; + + @MockitoBean GetCompaniesUseCase companies; + @MockitoBean ManageCompanyResearchUseCase research; + @MockitoBean FindOutreachTargetsUseCase outreachTargets; + @MockitoBean ManageOutreachUseCase outreach; + @MockitoBean GetJobsByCompanyUseCase companyJobs; + @MockitoBean SecurityContextHelper secCtx; + @MockitoBean ProvisionFirebaseUserUseCase provisionUser; + @MockitoBean ResolveLinkedInUserUseCase resolveLinkedInUser; + @MockitoBean FirebaseAuth firebaseAuth; + + UUID userId = UUID.randomUUID(); + UUID companyId = UUID.randomUUID(); + UUID outreachId = UUID.randomUUID(); + + @BeforeEach + void setUp() { + when(secCtx.getCurrentUserId()).thenReturn(userId); + } + + @Test + void search_passes_query_and_paging() throws Exception { + when(companies.searchCompanies("acme", 1, 10)).thenReturn(List.of(company())); + + mvc.perform(get("/api/v1/companies").param("q", "acme").param("page", "1").param("size", "10")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(1))) + .andExpect(jsonPath("$[0].name").value("Acme")); + + verify(companies).searchCompanies("acme", 1, 10); + } + + @Test + void get_by_id_returns_company() throws Exception { + when(companies.getCompanyById(companyId)).thenReturn(Optional.of(company())); + + mvc.perform(get("/api/v1/companies/{id}", companyId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(companyId.toString())) + .andExpect(jsonPath("$.slug").value("acme")); + } + + @Test + void get_by_id_returns_404_when_missing() throws Exception { + when(companies.getCompanyById(companyId)).thenReturn(Optional.empty()); + + mvc.perform(get("/api/v1/companies/{id}", companyId)) + .andExpect(status().isNotFound()); + } + + @Test + void get_research_returns_empty_notes_when_none_saved() throws Exception { + when(research.getResearch(companyId)).thenReturn(Optional.empty()); + + mvc.perform(get("/api/v1/companies/{id}/research", companyId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.notes").value(nullValue())); + } + + @Test + void save_research_returns_saved_notes() throws Exception { + when(research.saveResearch(companyId, "They ship weekly")) + .thenReturn(Optional.of(new CompanyResearch("They ship weekly", Instant.now()))); + + mvc.perform(put("/api/v1/companies/{id}/research", companyId) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"notes":"They ship weekly"} + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.notes").value("They ship weekly")); + } + + @Test + void save_research_returns_404_for_unknown_company() throws Exception { + when(research.saveResearch(eq(companyId), any())).thenReturn(Optional.empty()); + + mvc.perform(put("/api/v1/companies/{id}/research", companyId) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"notes":"x"} + """)) + .andExpect(status().isNotFound()); + } + + @Test + void outreach_targets_pass_limit_and_hiring_flag() throws Exception { + OutreachTarget target = new OutreachTarget(companyId, "Acme", null, 72, + List.of(), null, List.of("Java"), false); + when(outreachTargets.findOutreachTargets(userId, 7, true)).thenReturn(List.of(target)); + + mvc.perform(get("/api/v1/companies/outreach-targets") + .param("limit", "7").param("includeHiringNow", "true")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].companyName").value("Acme")) + .andExpect(jsonPath("$[0].score").value(72)); + + verify(outreachTargets).findOutreachTargets(userId, 7, true); + } + + @Test + void track_outreach_passes_request_fields() throws Exception { + when(outreach.track(userId, companyId, "Acme", "Jane")) + .thenReturn(outreach(OutreachStatus.SAVED, null)); + + mvc.perform(post("/api/v1/companies/outreach") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"companyId":"%s","companyName":"Acme","contactName":"Jane"} + """.formatted(companyId))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(outreachId.toString())) + .andExpect(jsonPath("$.status").value("SAVED")); + + verify(outreach).track(userId, companyId, "Acme", "Jane"); + } + + @Test + void update_outreach_parses_status_and_follow_up_date() throws Exception { + LocalDate due = LocalDate.of(2026, 10, 15); + when(outreach.update(userId, outreachId, OutreachStatus.CONTACTED, "email", due, null)) + .thenReturn(outreach(OutreachStatus.CONTACTED, due)); + + mvc.perform(patch("/api/v1/companies/outreach/{id}", outreachId) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"status":"CONTACTED","channel":"email","followUpDue":"2026-10-15"} + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("CONTACTED")) + .andExpect(jsonPath("$.followUpDue").value("2026-10-15")); + + verify(outreach).update(userId, outreachId, OutreachStatus.CONTACTED, "email", due, null); + } + + @Test + void update_outreach_maps_not_found_to_400() throws Exception { + when(outreach.update(any(), any(), any(), any(), any(), any())) + .thenThrow(new IllegalArgumentException("Outreach not found")); + + mvc.perform(patch("/api/v1/companies/outreach/{id}", outreachId) + .contentType(MediaType.APPLICATION_JSON) + .content("{}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value("Outreach not found")); + } + + @Test + void untrack_outreach_returns_204() throws Exception { + mvc.perform(delete("/api/v1/companies/outreach/{id}", outreachId)) + .andExpect(status().isNoContent()); + + verify(outreach).untrack(userId, outreachId); + } + + private Company company() { + return new Company(companyId, "Acme", "acme", "https://acme.example", null, null, null, + null, "Software", "DK", false, false, Instant.now(), Instant.now()); + } + + private OutreachContact outreach(OutreachStatus status, LocalDate followUpDue) { + return new OutreachContact(outreachId, userId, companyId, "Acme", status, null, "Jane", + null, followUpDue, null, Instant.now(), Instant.now()); + } +} diff --git a/backend/src/test/java/com/autoapplicant/adapter/web/controller/InterviewPrepControllerTest.java b/backend/src/test/java/com/autoapplicant/adapter/web/controller/InterviewPrepControllerTest.java new file mode 100644 index 00000000..57468283 --- /dev/null +++ b/backend/src/test/java/com/autoapplicant/adapter/web/controller/InterviewPrepControllerTest.java @@ -0,0 +1,213 @@ +package com.autoapplicant.adapter.web.controller; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import com.autoapplicant.adapter.security.SecurityContextHelper; +import com.autoapplicant.domain.interview.InterviewPrepPack; +import com.autoapplicant.domain.interview.InterviewQuestion; +import com.autoapplicant.domain.interview.MockInterviewTurn; +import com.autoapplicant.port.in.auth.ProvisionFirebaseUserUseCase; +import com.autoapplicant.port.in.auth.ResolveLinkedInUserUseCase; +import com.autoapplicant.port.in.interview.GenerateInterviewPrepUseCase; +import com.autoapplicant.port.in.interview.GenerateInterviewQuestionsUseCase; +import com.autoapplicant.port.in.interview.ManageInterviewQuestionsUseCase; +import com.autoapplicant.port.in.interview.MockInterviewUseCase; +import com.google.firebase.auth.FirebaseAuth; +import java.time.Instant; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest(controllers = InterviewPrepController.class) +@AutoConfigureMockMvc(addFilters = false) +class InterviewPrepControllerTest { + + @Autowired MockMvc mvc; + + @MockitoBean ManageInterviewQuestionsUseCase manage; + @MockitoBean GenerateInterviewQuestionsUseCase generate; + @MockitoBean GenerateInterviewPrepUseCase generatePrep; + @MockitoBean MockInterviewUseCase mockInterview; + @MockitoBean SecurityContextHelper secCtx; + @MockitoBean ProvisionFirebaseUserUseCase provisionUser; + @MockitoBean ResolveLinkedInUserUseCase resolveLinkedInUser; + @MockitoBean FirebaseAuth firebaseAuth; + + UUID userId = UUID.randomUUID(); + UUID jobId = UUID.randomUUID(); + UUID questionId = UUID.randomUUID(); + + @BeforeEach + void setUp() { + when(secCtx.getCurrentUserId()).thenReturn(userId); + } + + @Test + void list_returns_questions_for_job_and_user() throws Exception { + when(manage.getQuestions(jobId, userId)).thenReturn(List.of(question(questionId, 0))); + + mvc.perform(get("/api/v1/jobs/{jobId}/interview-prep", jobId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(1))) + .andExpect(jsonPath("$[0].id").value(questionId.toString())) + .andExpect(jsonPath("$[0].question").value("Why us?")); + } + + @Test + void add_appends_question_after_existing_ones() throws Exception { + when(manage.getQuestions(jobId, userId)) + .thenReturn(List.of(question(UUID.randomUUID(), 0), question(UUID.randomUUID(), 1))); + when(manage.saveQuestion(any(InterviewQuestion.class))).thenReturn(question(questionId, 2)); + + mvc.perform(post("/api/v1/jobs/{jobId}/interview-prep", jobId) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"question":"Why us?","category":"motivation"} + """)) + .andExpect(status().isCreated()) + .andExpect(header().string("Location", + "/api/v1/jobs/" + jobId + "/interview-prep/" + questionId)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(InterviewQuestion.class); + verify(manage).saveQuestion(captor.capture()); + InterviewQuestion sent = captor.getValue(); + assertThat(sent.id()).isNull(); + assertThat(sent.jobId()).isEqualTo(jobId); + assertThat(sent.userId()).isEqualTo(userId); + assertThat(sent.displayOrder()).isEqualTo(2); + assertThat(sent.practiced()).isFalse(); + } + + @Test + void generate_defaults_count_to_ten_when_not_positive() throws Exception { + when(generate.generateQuestions(jobId, userId, "desc", 10)) + .thenReturn(List.of(question(questionId, 0))); + + mvc.perform(post("/api/v1/jobs/{jobId}/interview-prep/generate", jobId) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"jobDescription":"desc","count":0} + """)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$", hasSize(1))); + + verify(generate).generateQuestions(jobId, userId, "desc", 10); + } + + @Test + void generate_passes_explicit_count() throws Exception { + when(generate.generateQuestions(jobId, userId, null, 4)).thenReturn(List.of()); + + mvc.perform(post("/api/v1/jobs/{jobId}/interview-prep/generate", jobId) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"count":4} + """)) + .andExpect(status().isCreated()); + + verify(generate).generateQuestions(jobId, userId, null, 4); + } + + @Test + void pack_returns_generated_prep_pack() throws Exception { + when(generatePrep.generatePrepPack(userId, jobId)).thenReturn(new InterviewPrepPack( + List.of(question(questionId, 0)), List.of("Stay consistent"), List.of("Team size?"))); + + mvc.perform(post("/api/v1/jobs/{jobId}/interview-prep/pack", jobId)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.questions", hasSize(1))) + .andExpect(jsonPath("$.consistencyBrief[0]").value("Stay consistent")) + .andExpect(jsonPath("$.questionsToAsk[0]").value("Team size?")); + } + + @Test + void roleplay_defaults_missing_transcript_and_wrap_up() throws Exception { + when(mockInterview.respond(userId, jobId, List.of(), false)).thenReturn("Tell me about you"); + + mvc.perform(post("/api/v1/jobs/{jobId}/interview-prep/roleplay", jobId) + .contentType(MediaType.APPLICATION_JSON) + .content("{}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.reply").value("Tell me about you")); + + verify(mockInterview).respond(userId, jobId, List.of(), false); + } + + @Test + void roleplay_passes_transcript_and_wrap_up() throws Exception { + List transcript = List.of(new MockInterviewTurn("user", "Hi")); + when(mockInterview.respond(userId, jobId, transcript, true)).thenReturn("Feedback"); + + mvc.perform(post("/api/v1/jobs/{jobId}/interview-prep/roleplay", jobId) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"messages":[{"role":"user","content":"Hi"}],"wrapUp":true} + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.reply").value("Feedback")); + } + + @Test + void update_merges_request_over_existing_question() throws Exception { + InterviewQuestion current = question(questionId, 3); + when(manage.getQuestions(jobId, userId)).thenReturn(List.of(current)); + when(manage.saveQuestion(any(InterviewQuestion.class))).thenReturn(current); + + mvc.perform(put("/api/v1/jobs/{jobId}/interview-prep/{id}", jobId, questionId) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"starAnswer":"S/T/A/R","practiced":true} + """)) + .andExpect(status().isOk()); + + ArgumentCaptor captor = ArgumentCaptor.forClass(InterviewQuestion.class); + verify(manage).saveQuestion(captor.capture()); + InterviewQuestion sent = captor.getValue(); + assertThat(sent.id()).isEqualTo(questionId); + assertThat(sent.question()).isEqualTo("Why us?"); + assertThat(sent.category()).isEqualTo("motivation"); + assertThat(sent.starAnswer()).isEqualTo("S/T/A/R"); + assertThat(sent.practiced()).isTrue(); + assertThat(sent.displayOrder()).isEqualTo(3); + } + + @Test + void update_returns_404_when_question_not_in_users_list() throws Exception { + when(manage.getQuestions(jobId, userId)).thenReturn(List.of(question(UUID.randomUUID(), 0))); + + mvc.perform(put("/api/v1/jobs/{jobId}/interview-prep/{id}", jobId, questionId) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"practiced":true} + """)) + .andExpect(status().isNotFound()); + + verify(manage, never()).saveQuestion(any()); + } + + @Test + void delete_returns_204_and_deletes_for_current_user() throws Exception { + mvc.perform(delete("/api/v1/jobs/{jobId}/interview-prep/{id}", jobId, questionId)) + .andExpect(status().isNoContent()); + + verify(manage).deleteQuestion(questionId, userId); + } + + private InterviewQuestion question(UUID id, int order) { + return new InterviewQuestion(id, jobId, userId, "Why us?", "motivation", null, false, + order, Instant.now(), Instant.now()); + } +} diff --git a/backend/src/test/java/com/autoapplicant/adapter/web/controller/JobControllerTest.java b/backend/src/test/java/com/autoapplicant/adapter/web/controller/JobControllerTest.java new file mode 100644 index 00000000..e82a0a2f --- /dev/null +++ b/backend/src/test/java/com/autoapplicant/adapter/web/controller/JobControllerTest.java @@ -0,0 +1,244 @@ +package com.autoapplicant.adapter.web.controller; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import com.autoapplicant.adapter.security.SecurityContextHelper; +import com.autoapplicant.domain.job.EmploymentType; +import com.autoapplicant.domain.job.Job; +import com.autoapplicant.domain.job.JobSource; +import com.autoapplicant.domain.job.RemoteType; +import com.autoapplicant.domain.matching.FeedbackType; +import com.autoapplicant.domain.matching.MatchLabel; +import com.autoapplicant.domain.matching.MatchResult; +import com.autoapplicant.domain.matching.RecommendationFeedback; +import com.autoapplicant.domain.search.JobSearchQuery; +import com.autoapplicant.port.in.auth.ProvisionFirebaseUserUseCase; +import com.autoapplicant.port.in.auth.ResolveLinkedInUserUseCase; +import com.autoapplicant.port.in.document.GetDocumentsForJobUseCase; +import com.autoapplicant.port.in.job.*; +import com.autoapplicant.port.in.matching.SubmitRecommendationFeedbackUseCase; +import com.google.firebase.auth.FirebaseAuth; +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.data.domain.PageImpl; +import org.springframework.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest(controllers = JobController.class) +@AutoConfigureMockMvc(addFilters = false) +class JobControllerTest { + + @Autowired MockMvc mvc; + + @MockitoBean GetJobsUseCase getJobs; + @MockitoBean GetJobByIdUseCase getJobById; + @MockitoBean GetSavedJobsUseCase getSavedJobs; + @MockitoBean GetIgnoredJobsUseCase getIgnoredJobs; + @MockitoBean SearchJobsUseCase searchJobs; + @MockitoBean GetRecommendationsUseCase getRecommendations; + @MockitoBean SaveJobUseCase saveJob; + @MockitoBean IgnoreJobUseCase ignoreJob; + @MockitoBean CreateManualJobUseCase createManualJob; + @MockitoBean SubmitRecommendationFeedbackUseCase feedbackUseCase; + @MockitoBean GetDocumentsForJobUseCase getDocsForJob; + @MockitoBean ReportJobInactiveUseCase reportJobInactive; + @MockitoBean GetSimilarJobsUseCase getSimilarJobs; + @MockitoBean SemanticSearchJobsUseCase semanticSearch; + @MockitoBean SecurityContextHelper secCtx; + @MockitoBean ProvisionFirebaseUserUseCase provisionUser; + @MockitoBean ResolveLinkedInUserUseCase resolveLinkedInUser; + @MockitoBean FirebaseAuth firebaseAuth; + + UUID userId = UUID.randomUUID(); + UUID jobId = UUID.randomUUID(); + + @BeforeEach + void setUp() { + when(secCtx.getCurrentUserId()).thenReturn(userId); + } + + @Test + void list_builds_query_for_current_user_and_returns_page() throws Exception { + when(getJobs.getJobs(any(JobSearchQuery.class))) + .thenReturn(new PageImpl<>(List.of(job(jobId, "Backend Dev", "short")))); + + mvc.perform(get("/api/v1/jobs").param("page", "2").param("size", "5")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content", hasSize(1))) + .andExpect(jsonPath("$.content[0].id").value(jobId.toString())) + .andExpect(jsonPath("$.content[0].title").value("Backend Dev")); + + ArgumentCaptor captor = ArgumentCaptor.forClass(JobSearchQuery.class); + verify(getJobs).getJobs(captor.capture()); + JobSearchQuery query = captor.getValue(); + assertThat(query.page()).isEqualTo(2); + assertThat(query.size()).isEqualTo(5); + assertThat(query.sortBy()).isEqualTo("postedAt"); + assertThat(query.userId()).isEqualTo(userId); + } + + @Test + void list_truncates_long_descriptions_in_preview() throws Exception { + String longText = "x".repeat(2000); + when(getJobs.getJobs(any(JobSearchQuery.class))) + .thenReturn(new PageImpl<>(List.of(job(jobId, "Dev", longText)))); + + mvc.perform(get("/api/v1/jobs")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content[0].descriptionTruncated").value(true)); + } + + @Test + void get_by_id_returns_full_job() throws Exception { + String longText = "y".repeat(2000); + when(getJobById.getJobById(jobId)).thenReturn(Optional.of(job(jobId, "Dev", longText))); + + mvc.perform(get("/api/v1/jobs/{id}", jobId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(jobId.toString())) + .andExpect(jsonPath("$.source").value("MANUAL")) + .andExpect(jsonPath("$.descriptionClean").value(longText)) + .andExpect(jsonPath("$.descriptionTruncated").value(false)); + } + + @Test + void get_by_id_returns_404_when_missing() throws Exception { + when(getJobById.getJobById(jobId)).thenReturn(Optional.empty()); + + mvc.perform(get("/api/v1/jobs/{id}", jobId)) + .andExpect(status().isNotFound()); + } + + @Test + void lookup_returns_404_when_url_unknown() throws Exception { + when(getJobById.lookupByUrl("https://example.com/x")).thenReturn(Optional.empty()); + + mvc.perform(get("/api/v1/jobs/lookup").param("url", "https://example.com/x")) + .andExpect(status().isNotFound()); + } + + @Test + void recommendations_map_match_results() throws Exception { + MatchResult result = new MatchResult(jobId, userId, job(jobId, "Dev", "d"), true, + 0.8, 0.5, 87, MatchLabel.STRONG, List.of("Java")); + when(getRecommendations.getRecommendations(userId, 3)).thenReturn(List.of(result)); + + mvc.perform(get("/api/v1/jobs/recommendations").param("limit", "3")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].jobId").value(jobId.toString())) + .andExpect(jsonPath("$[0].totalScore").value(87)) + .andExpect(jsonPath("$[0].matchLabel").value("STRONG")) + .andExpect(jsonPath("$[0].job.title").value("Dev")); + } + + @Test + void save_and_ignore_pass_current_user_and_job() throws Exception { + mvc.perform(post("/api/v1/jobs/{id}/save", jobId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.saved").value(true)); + mvc.perform(post("/api/v1/jobs/{id}/ignore", jobId).param("reason", "too far")) + .andExpect(status().isNoContent()); + mvc.perform(post("/api/v1/jobs/{id}/report-inactive", jobId)) + .andExpect(status().isAccepted()); + + verify(saveJob).saveJob(userId, jobId); + verify(ignoreJob).ignoreJob(userId, jobId, "too far"); + verify(reportJobInactive).reportInactive(userId, jobId); + } + + @Test + void feedback_passes_feedback_type() throws Exception { + when(feedbackUseCase.submitFeedback(userId, jobId, FeedbackType.LIKE)) + .thenReturn(new RecommendationFeedback(UUID.randomUUID(), userId, jobId, + FeedbackType.LIKE, Instant.now())); + + mvc.perform(post("/api/v1/jobs/{id}/feedback", jobId).param("type", "LIKE")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.feedbackType").value("LIKE")); + } + + @Test + void semantic_search_and_similar_clamp_limit() throws Exception { + when(semanticSearch.semanticSearch("java", 100)).thenReturn(List.of()); + when(getSimilarJobs.getSimilarJobs(jobId, 1)).thenReturn(List.of()); + + mvc.perform(get("/api/v1/jobs/search/semantic").param("q", "java").param("limit", "500")) + .andExpect(status().isOk()); + mvc.perform(get("/api/v1/jobs/{id}/similar", jobId).param("limit", "0")) + .andExpect(status().isOk()); + + verify(semanticSearch).semanticSearch("java", 100); + verify(getSimilarJobs).getSimilarJobs(jobId, 1); + } + + @Test + void add_manually_creates_manual_job_with_defaults() throws Exception { + when(createManualJob.createManualJob(any(Job.class))).thenReturn(job(jobId, "Dev", "d")); + + mvc.perform(post("/api/v1/jobs/manual") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"title":"Dev","companyName":"Acme","remoteType":"HYBRID", + "employmentType":"FULL_TIME"} + """)) + .andExpect(status().isCreated()) + .andExpect(header().string("Location", "/api/v1/jobs/" + jobId)) + .andExpect(jsonPath("$.id").value(jobId.toString())); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Job.class); + verify(createManualJob).createManualJob(captor.capture()); + Job sent = captor.getValue(); + assertThat(sent.source()).isEqualTo(JobSource.MANUAL); + assertThat(sent.title()).isEqualTo("Dev"); + assertThat(sent.currency()).isEqualTo("DKK"); + assertThat(sent.remoteType()).isEqualTo(RemoteType.HYBRID); + assertThat(sent.employmentType()).isEqualTo(EmploymentType.FULL_TIME); + assertThat(sent.isActive()).isTrue(); + } + + @Test + void add_manually_returns_400_when_title_blank() throws Exception { + mvc.perform(post("/api/v1/jobs/manual") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"title":" "} + """)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message", containsString("title"))); + + verifyNoInteractions(createManualJob); + } + + @Test + void add_manually_returns_400_for_unknown_employment_type() throws Exception { + mvc.perform(post("/api/v1/jobs/manual") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"title":"Dev","employmentType":"GIG"} + """)) + .andExpect(status().isBadRequest()); + + verifyNoInteractions(createManualJob); + } + + private Job job(UUID id, String title, String description) { + return Job.builder() + .id(id).source(JobSource.MANUAL).title(title).companyName("Acme") + .descriptionClean(description).postedAt(Instant.now()) + .build(); + } +} diff --git a/backend/src/test/java/com/autoapplicant/adapter/web/controller/ProfileSectionControllerTest.java b/backend/src/test/java/com/autoapplicant/adapter/web/controller/ProfileSectionControllerTest.java new file mode 100644 index 00000000..1dbe7890 --- /dev/null +++ b/backend/src/test/java/com/autoapplicant/adapter/web/controller/ProfileSectionControllerTest.java @@ -0,0 +1,197 @@ +package com.autoapplicant.adapter.web.controller; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import com.autoapplicant.adapter.security.SecurityContextHelper; +import com.autoapplicant.domain.user.Certification; +import com.autoapplicant.domain.user.Education; +import com.autoapplicant.domain.user.Project; +import com.autoapplicant.domain.user.WorkExperience; +import com.autoapplicant.port.in.auth.ProvisionFirebaseUserUseCase; +import com.autoapplicant.port.in.auth.ResolveLinkedInUserUseCase; +import com.autoapplicant.port.in.user.*; +import com.google.firebase.auth.FirebaseAuth; +import java.time.Instant; +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest(controllers = ProfileSectionController.class) +@AutoConfigureMockMvc(addFilters = false) +class ProfileSectionControllerTest { + + @Autowired MockMvc mvc; + + @MockitoBean ManageWorkExperienceUseCase workExpUseCase; + @MockitoBean ManageProjectsUseCase projectsUseCase; + @MockitoBean ManageEducationUseCase educationUseCase; + @MockitoBean ManageCertificationsUseCase certUseCase; + @MockitoBean GetUserProfileUseCase profileUseCase; + @MockitoBean ManageProfileSocialUseCase socialUseCase; + @MockitoBean ManageProfileStrengthUseCase strengthUseCase; + @MockitoBean ManageSpokenLanguagesUseCase languageUseCase; + @MockitoBean SecurityContextHelper secCtx; + @MockitoBean ProvisionFirebaseUserUseCase provisionUser; + @MockitoBean ResolveLinkedInUserUseCase resolveLinkedInUser; + @MockitoBean FirebaseAuth firebaseAuth; + + UUID userId = UUID.randomUUID(); + UUID entryId = UUID.randomUUID(); + + @BeforeEach + void setUp() { + when(secCtx.getCurrentUserId()).thenReturn(userId); + } + + @Test + void full_profile_aggregates_sections_with_null_profile_when_missing() throws Exception { + when(profileUseCase.getProfile(userId)).thenReturn(Optional.empty()); + when(workExpUseCase.getWorkExperience(userId)).thenReturn(List.of(experience(entryId))); + when(projectsUseCase.getProjects(userId)).thenReturn(List.of(project(UUID.randomUUID()))); + + mvc.perform(get("/api/v1/profile/full")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.profile").value(nullValue())) + .andExpect(jsonPath("$.experience", hasSize(1))) + .andExpect(jsonPath("$.experience[0].companyName").value("Acme")) + .andExpect(jsonPath("$.projects[0].name").value("JobBuddy")) + .andExpect(jsonPath("$.education", hasSize(0))) + .andExpect(jsonPath("$.strengths", hasSize(0))); + } + + @Test + void list_experience_returns_current_users_entries() throws Exception { + when(workExpUseCase.getWorkExperience(userId)).thenReturn(List.of(experience(entryId))); + + mvc.perform(get("/api/v1/profile/experience")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].id").value(entryId.toString())) + .andExpect(jsonPath("$[0].startDate").value("2022-01-01")); + } + + @Test + void add_experience_returns_201_with_location() throws Exception { + when(workExpUseCase.addWorkExperience(eq(userId), any(WorkExperience.class))) + .thenReturn(experience(entryId)); + + mvc.perform(post("/api/v1/profile/experience") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"companyName":"Acme","title":"Engineer","startDate":"2022-01-01"} + """)) + .andExpect(status().isCreated()) + .andExpect(header().string("Location", "/api/v1/profile/experience/" + entryId)) + .andExpect(jsonPath("$.title").value("Engineer")); + + ArgumentCaptor captor = ArgumentCaptor.forClass(WorkExperience.class); + verify(workExpUseCase).addWorkExperience(eq(userId), captor.capture()); + assertThat(captor.getValue().companyName()).isEqualTo("Acme"); + assertThat(captor.getValue().startDate()).isEqualTo(LocalDate.of(2022, 1, 1)); + } + + @Test + void update_experience_passes_path_id_and_user() throws Exception { + when(workExpUseCase.updateWorkExperience(eq(userId), eq(entryId), any(WorkExperience.class))) + .thenReturn(experience(entryId)); + + mvc.perform(put("/api/v1/profile/experience/{id}", entryId) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"companyName":"Acme","title":"Engineer"} + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(entryId.toString())); + + verify(workExpUseCase).updateWorkExperience(eq(userId), eq(entryId), any(WorkExperience.class)); + } + + @Test + void add_project_returns_201_with_location() throws Exception { + when(projectsUseCase.addProject(eq(userId), any(Project.class))).thenReturn(project(entryId)); + + mvc.perform(post("/api/v1/profile/projects") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"name":"JobBuddy","technologies":["Java","Angular"]} + """)) + .andExpect(status().isCreated()) + .andExpect(header().string("Location", "/api/v1/profile/projects/" + entryId)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Project.class); + verify(projectsUseCase).addProject(eq(userId), captor.capture()); + assertThat(captor.getValue().technologies()).containsExactly("Java", "Angular"); + } + + @Test + void add_education_returns_201_with_location() throws Exception { + Education saved = new Education(entryId, userId, "DTU", "MSc", "CS", null, null, null, + null, 0, Instant.now(), Instant.now(), List.of()); + when(educationUseCase.addEducation(eq(userId), any(Education.class))).thenReturn(saved); + + mvc.perform(post("/api/v1/profile/education") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"institution":"DTU","degree":"MSc"} + """)) + .andExpect(status().isCreated()) + .andExpect(header().string("Location", "/api/v1/profile/education/" + entryId)) + .andExpect(jsonPath("$.institution").value("DTU")); + } + + @Test + void add_certification_returns_201_with_location() throws Exception { + Certification saved = new Certification(entryId, userId, "AWS SAA", "Amazon", + LocalDate.of(2024, 5, 1), null, null, Instant.now()); + when(certUseCase.addCertification(eq(userId), any(Certification.class))).thenReturn(saved); + + mvc.perform(post("/api/v1/profile/certifications") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"name":"AWS SAA","issuer":"Amazon","issuedAt":"2024-05-01"} + """)) + .andExpect(status().isCreated()) + .andExpect(header().string("Location", "/api/v1/profile/certifications/" + entryId)) + .andExpect(jsonPath("$.issuedAt").value("2024-05-01")); + } + + @Test + void deletes_return_204_and_pass_user_then_id() throws Exception { + mvc.perform(delete("/api/v1/profile/experience/{id}", entryId)).andExpect(status().isNoContent()); + mvc.perform(delete("/api/v1/profile/projects/{id}", entryId)).andExpect(status().isNoContent()); + mvc.perform(delete("/api/v1/profile/education/{id}", entryId)).andExpect(status().isNoContent()); + mvc.perform(delete("/api/v1/profile/certifications/{id}", entryId)) + .andExpect(status().isNoContent()); + + verify(workExpUseCase).deleteWorkExperience(userId, entryId); + verify(projectsUseCase).deleteProject(userId, entryId); + verify(educationUseCase).deleteEducation(userId, entryId); + verify(certUseCase).deleteCertification(userId, entryId); + } + + private WorkExperience experience(UUID id) { + return new WorkExperience(id, userId, "Acme", "Engineer", "Copenhagen", null, + LocalDate.of(2022, 1, 1), null, true, List.of("Java"), List.of(), 0, + Instant.now(), Instant.now(), List.of()); + } + + private Project project(UUID id) { + return new Project(id, userId, "JobBuddy", null, List.of("Java", "Angular"), null, null, + null, null, null, null, null, false, 0, Instant.now(), Instant.now(), List.of()); + } +} diff --git a/backend/src/test/java/com/autoapplicant/adapter/web/controller/SkillControllerTest.java b/backend/src/test/java/com/autoapplicant/adapter/web/controller/SkillControllerTest.java new file mode 100644 index 00000000..bd72c1a5 --- /dev/null +++ b/backend/src/test/java/com/autoapplicant/adapter/web/controller/SkillControllerTest.java @@ -0,0 +1,216 @@ +package com.autoapplicant.adapter.web.controller; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import com.autoapplicant.adapter.security.SecurityContextHelper; +import com.autoapplicant.domain.skill.EvidenceDraft; +import com.autoapplicant.domain.skill.EvidenceGap; +import com.autoapplicant.domain.skill.ProfileSkill; +import com.autoapplicant.domain.skill.SkillConfirmation; +import com.autoapplicant.domain.skill.SkillTaxonomy; +import com.autoapplicant.port.in.auth.ProvisionFirebaseUserUseCase; +import com.autoapplicant.port.in.auth.ResolveLinkedInUserUseCase; +import com.autoapplicant.port.in.skills.*; +import com.google.firebase.auth.FirebaseAuth; +import java.util.List; +import java.util.UUID; +import org.hamcrest.Matchers; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest(controllers = SkillController.class) +@AutoConfigureMockMvc(addFilters = false) +class SkillControllerTest { + + @Autowired MockMvc mvc; + + @MockitoBean GetSkillTaxonomyUseCase taxonomy; + @MockitoBean ManageProfileSkillsUseCase profileSkills; + @MockitoBean GetSkillGapUseCase skillGap; + @MockitoBean SuggestSkillCandidatesUseCase skillCandidates; + @MockitoBean GetEvidenceGapsUseCase evidenceGaps; + @MockitoBean ElicitEvidenceUseCase elicitEvidence; + @MockitoBean SecurityContextHelper secCtx; + @MockitoBean ProvisionFirebaseUserUseCase provisionUser; + @MockitoBean ResolveLinkedInUserUseCase resolveLinkedInUser; + @MockitoBean FirebaseAuth firebaseAuth; + + UUID userId = UUID.randomUUID(); + UUID skillId = UUID.randomUUID(); + + @BeforeEach + void setUp() { + when(secCtx.getCurrentUserId()).thenReturn(userId); + } + + @Test + void search_uses_category_lookup_when_category_given() throws Exception { + when(taxonomy.getByCategory("backend")).thenReturn(List.of(taxonomy("Java"))); + + mvc.perform(get("/api/v1/skills").param("q", "ja").param("category", "backend")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].name").value("Java")); + + verify(taxonomy).getByCategory("backend"); + verify(taxonomy, never()).search(anyString()); + } + + @Test + void search_falls_back_to_text_search_when_category_blank() throws Exception { + when(taxonomy.search("ja")).thenReturn(List.of(taxonomy("Java"))); + + mvc.perform(get("/api/v1/skills").param("q", "ja").param("category", " ")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(1))); + + verify(taxonomy).search("ja"); + verify(taxonomy, never()).getByCategory(any()); + } + + @Test + void create_skill_returns_201_with_location() throws Exception { + SkillTaxonomy saved = taxonomy("Kotlin"); + when(taxonomy.createOrGet("Kotlin", "backend")).thenReturn(saved); + + mvc.perform(post("/api/v1/skills") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"name":"Kotlin","category":"backend"} + """)) + .andExpect(status().isCreated()) + .andExpect(header().string("Location", "/api/v1/skills/" + saved.id())) + .andExpect(jsonPath("$.name").value("Kotlin")); + } + + @Test + void add_profile_skill_ignores_client_id_and_user() throws Exception { + UUID otherUser = UUID.randomUUID(); + when(profileSkills.addSkill(any(ProfileSkill.class))).thenReturn(profileSkill(skillId)); + + mvc.perform(post("/api/v1/profile/skills") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"id":"%s","userId":"%s","skillName":"Java", + "yearsExperience":4,"category":"backend"} + """.formatted(UUID.randomUUID(), otherUser))) + .andExpect(status().isCreated()) + .andExpect(header().string("Location", "/api/v1/profile/skills/" + skillId)) + .andExpect(jsonPath("$.skillName").value("Java")); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ProfileSkill.class); + verify(profileSkills).addSkill(captor.capture()); + ProfileSkill sent = captor.getValue(); + assertThat(sent.id()).isNull(); + assertThat(sent.userId()).isEqualTo(userId); + assertThat(sent.yearsExperience()).isEqualTo(4); + assertThat(sent.category()).isEqualTo("backend"); + } + + @Test + void update_profile_skill_uses_path_id_and_current_user() throws Exception { + when(profileSkills.updateSkill(any(ProfileSkill.class))).thenReturn(profileSkill(skillId)); + + mvc.perform(put("/api/v1/profile/skills/{id}", skillId) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"id":"%s","skillName":"Java","proficiencyLevel":"EXPERT"} + """.formatted(UUID.randomUUID()))) + .andExpect(status().isOk()); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ProfileSkill.class); + verify(profileSkills).updateSkill(captor.capture()); + assertThat(captor.getValue().id()).isEqualTo(skillId); + assertThat(captor.getValue().userId()).isEqualTo(userId); + assertThat(captor.getValue().proficiencyLevel()).isEqualTo("EXPERT"); + } + + @Test + void delete_profile_skill_returns_204() throws Exception { + mvc.perform(delete("/api/v1/profile/skills/{id}", skillId)) + .andExpect(status().isNoContent()); + + verify(profileSkills).deleteSkill(skillId, userId); + } + + @Test + void skill_gap_is_analysed_for_current_user() throws Exception { + UUID jobId = UUID.randomUUID(); + when(skillGap.analyzeSkillGap(jobId, userId)).thenReturn( + new GetSkillGapUseCase.SkillGapResult(List.of("Java"), List.of("Go"), 50)); + + mvc.perform(get("/api/v1/jobs/{jobId}/skill-gap", jobId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.matched", Matchers.contains("Java"))) + .andExpect(jsonPath("$.missing", Matchers.contains("Go"))) + .andExpect(jsonPath("$.coveragePct").value(50)); + } + + @Test + void tailored_gaps_feed_evidence_gaps_into_tailoring() throws Exception { + List gaps = List.of(new EvidenceGap("Java", 12, "Template?")); + List tailored = List.of(new EvidenceGap("Java", 12, "Tailored?")); + when(evidenceGaps.evidenceGaps(userId, 3)).thenReturn(gaps); + when(elicitEvidence.tailorQuestions(userId, gaps)).thenReturn(tailored); + + mvc.perform(get("/api/v1/skills/evidence-gaps/tailored").param("limit", "3")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].question").value("Tailored?")); + + verify(elicitEvidence).tailorQuestions(userId, gaps); + } + + @Test + void draft_evidence_passes_answer_and_returns_draft() throws Exception { + when(elicitEvidence.draftFromAnswer(userId, "Java", "I built X")) + .thenReturn(new EvidenceDraft("Java", "s", "a", "r", List.of("40%"))); + + mvc.perform(post("/api/v1/skills/evidence/draft") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"skillName":"Java","answer":"I built X"} + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.situation").value("s")) + .andExpect(jsonPath("$.unsupportedFigures", Matchers.contains("40%"))); + } + + @Test + void confirm_candidates_passes_decisions() throws Exception { + when(skillCandidates.confirm(eq(userId), any())).thenReturn(List.of(profileSkill(skillId))); + + mvc.perform(post("/api/v1/skills/candidates/confirm") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"confirmations":[{"name":"Java","decision":"YES", + "usedInProduction":true,"yearsExperience":3}]} + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(1))); + + verify(skillCandidates).confirm(userId, List.of( + new SkillConfirmation("Java", SkillConfirmation.Decision.YES, true, 3))); + } + + private SkillTaxonomy taxonomy(String name) { + return new SkillTaxonomy(UUID.randomUUID(), name, name.toLowerCase(), null, "backend", + List.of()); + } + + private ProfileSkill profileSkill(UUID id) { + return new ProfileSkill(id, userId, "Java", null, "EXPERT", 4, true, 0, "backend"); + } +} diff --git a/backend/src/test/java/com/autoapplicant/adapter/web/controller/UserControllerTest.java b/backend/src/test/java/com/autoapplicant/adapter/web/controller/UserControllerTest.java new file mode 100644 index 00000000..e00739be --- /dev/null +++ b/backend/src/test/java/com/autoapplicant/adapter/web/controller/UserControllerTest.java @@ -0,0 +1,168 @@ +package com.autoapplicant.adapter.web.controller; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import com.autoapplicant.adapter.security.SecurityContextHelper; +import com.autoapplicant.domain.user.Profile; +import com.autoapplicant.domain.user.UserPreferences; +import com.autoapplicant.port.in.auth.ProvisionFirebaseUserUseCase; +import com.autoapplicant.port.in.auth.ResolveLinkedInUserUseCase; +import com.autoapplicant.port.in.user.*; +import com.google.firebase.auth.FirebaseAuth; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import org.hamcrest.Matchers; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest(controllers = UserController.class) +@AutoConfigureMockMvc(addFilters = false) +class UserControllerTest { + + @Autowired MockMvc mvc; + + @MockitoBean GetUserProfileUseCase getProfile; + @MockitoBean UpdateUserProfileUseCase updateProfile; + @MockitoBean UpdatePreferencesUseCase updatePreferences; + @MockitoBean DeleteUserAccountUseCase deleteAccount; + @MockitoBean ExportUserDataUseCase exportUserData; + @MockitoBean SecurityContextHelper secCtx; + @MockitoBean ProvisionFirebaseUserUseCase provisionUser; + @MockitoBean ResolveLinkedInUserUseCase resolveLinkedInUser; + @MockitoBean FirebaseAuth firebaseAuth; + + UUID userId = UUID.randomUUID(); + + @BeforeEach + void setUp() { + when(secCtx.getCurrentUserId()).thenReturn(userId); + } + + @Test + void get_profile_returns_current_users_profile() throws Exception { + when(getProfile.getProfile(userId)).thenReturn(Optional.of(profile("Backend engineer"))); + + mvc.perform(get("/api/v1/users/me/profile")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.userId").value(userId.toString())) + .andExpect(jsonPath("$.headline").value("Backend engineer")) + .andExpect(jsonPath("$.languages", Matchers.contains("Danish", "English"))); + } + + @Test + void get_profile_returns_404_when_missing() throws Exception { + when(getProfile.getProfile(userId)).thenReturn(Optional.empty()); + + mvc.perform(get("/api/v1/users/me/profile")) + .andExpect(status().isNotFound()); + } + + @Test + void update_profile_accepts_patch_and_passes_body() throws Exception { + when(updateProfile.updateProfile(eq(userId), any(Profile.class))) + .thenReturn(profile("Staff engineer")); + + mvc.perform(patch("/api/v1/users/me/profile") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"headline":"Staff engineer","yearsExperience":8} + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.headline").value("Staff engineer")); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Profile.class); + verify(updateProfile).updateProfile(eq(userId), captor.capture()); + assertThat(captor.getValue().headline()).isEqualTo("Staff engineer"); + assertThat(captor.getValue().yearsExperience()).isEqualTo(8); + } + + @Test + void update_profile_also_accepts_put() throws Exception { + when(updateProfile.updateProfile(eq(userId), any(Profile.class))) + .thenReturn(profile("Staff engineer")); + + mvc.perform(put("/api/v1/users/me/profile") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"headline":"Staff engineer"} + """)) + .andExpect(status().isOk()); + + verify(updateProfile).updateProfile(eq(userId), any(Profile.class)); + } + + @Test + void get_preferences_returns_defaults_when_none_saved() throws Exception { + when(updatePreferences.getPreferences(userId)).thenReturn(Optional.empty()); + + mvc.perform(get("/api/v1/users/me/preferences")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.userId").value(userId.toString())) + .andExpect(jsonPath("$.notificationFrequency").value("DAILY")) + .andExpect(jsonPath("$.notificationEnabled").value(false)) + .andExpect(jsonPath("$.preferredLocations", hasSize(0))); + } + + @Test + void update_preferences_passes_body_for_current_user() throws Exception { + UserPreferences saved = new UserPreferences(UUID.randomUUID(), userId, + List.of("Copenhagen"), List.of(), List.of(), List.of(), List.of(), + List.of(), List.of(), List.of(), List.of(), 50000, null, 30, + true, "WEEKLY", 5, Instant.now(), Instant.now()); + when(updatePreferences.updatePreferences(eq(userId), any(UserPreferences.class))) + .thenReturn(saved); + + mvc.perform(put("/api/v1/users/me/preferences") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"preferredLocations":["Copenhagen"],"notificationFrequency":"WEEKLY"} + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.preferredLocations[0]").value("Copenhagen")) + .andExpect(jsonPath("$.notificationFrequency").value("WEEKLY")); + + ArgumentCaptor captor = ArgumentCaptor.forClass(UserPreferences.class); + verify(updatePreferences).updatePreferences(eq(userId), captor.capture()); + assertThat(captor.getValue().preferredLocations()).containsExactly("Copenhagen"); + } + + @Test + void export_returns_use_case_payload() throws Exception { + when(exportUserData.exportUserData(userId)) + .thenReturn(Map.of("profile", Map.of("headline", "Dev"))); + + mvc.perform(get("/api/v1/users/me/export")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.profile.headline").value("Dev")); + } + + @Test + void delete_account_returns_204_and_deletes_current_user() throws Exception { + mvc.perform(delete("/api/v1/users/me")) + .andExpect(status().isNoContent()); + + verify(deleteAccount).deleteAccount(userId); + } + + private Profile profile(String headline) { + return new Profile(UUID.randomUUID(), userId, headline, null, 5, + List.of("Danish", "English"), List.of(), null, null, null, null, null, + Instant.now(), Instant.now()); + } +} From 0806b792a300fe7a519aa39e01571d582e6bf74e Mon Sep 17 00:00:00 2001 From: CMaintz Date: Wed, 30 Sep 2026 17:03:02 +0200 Subject: [PATCH 2/2] test: mock ManageCustomSectionsUseCase after #155 --- .../adapter/web/controller/ProfileSectionControllerTest.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/backend/src/test/java/com/autoapplicant/adapter/web/controller/ProfileSectionControllerTest.java b/backend/src/test/java/com/autoapplicant/adapter/web/controller/ProfileSectionControllerTest.java index 1dbe7890..988729ff 100644 --- a/backend/src/test/java/com/autoapplicant/adapter/web/controller/ProfileSectionControllerTest.java +++ b/backend/src/test/java/com/autoapplicant/adapter/web/controller/ProfileSectionControllerTest.java @@ -16,6 +16,7 @@ import com.autoapplicant.port.in.auth.ProvisionFirebaseUserUseCase; import com.autoapplicant.port.in.auth.ResolveLinkedInUserUseCase; import com.autoapplicant.port.in.user.*; +import com.autoapplicant.port.in.user.ManageCustomSectionsUseCase; import com.google.firebase.auth.FirebaseAuth; import java.time.Instant; import java.time.LocalDate; @@ -46,6 +47,7 @@ class ProfileSectionControllerTest { @MockitoBean ManageProfileSocialUseCase socialUseCase; @MockitoBean ManageProfileStrengthUseCase strengthUseCase; @MockitoBean ManageSpokenLanguagesUseCase languageUseCase; + @MockitoBean ManageCustomSectionsUseCase customSections; @MockitoBean SecurityContextHelper secCtx; @MockitoBean ProvisionFirebaseUserUseCase provisionUser; @MockitoBean ResolveLinkedInUserUseCase resolveLinkedInUser;