package com.mydreamjob.notification; import com.fasterxml.jackson.databind.JsonNode; import com.mydreamjob.auth.User; import com.mydreamjob.company.Company; import com.mydreamjob.company.CompanyRepository; import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.audit.AuditContext; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; import java.time.LocalDateTime; import java.util.*; import java.util.concurrent.*; import static org.junit.jupiter.api.Assertions.*; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; /** 复用第一批完整过滤链回归;新增回收站行为与并发测试,不使用真实用户数据。 */ class NotificationTrashIntegrationTest extends NotificationAssociationIntegrationTest { @Autowired CompanyRepository companies; @Test void runtimeExposesOnlySoftDeleteAndRestoreOperations() throws Exception { var doc=json.readTree(mvc.perform(get("/v3/api-docs")).andExpect(status().isOk()) .andReturn().getResponse().getContentAsByteArray()); java.nio.file.Files.write(java.nio.file.Path.of("target/notification-trash-runtime-openapi.json"),json.writeValueAsBytes(doc)); for(var entry:Map.of("/notifications/{id}","delete","/notifications/{id}/restore","post", "/notifications/trash","get","/notifications/batch-delete","post","/notifications/batch-restore","post").entrySet()) { assertTrue(doc.path("paths").path(entry.getKey()).has(entry.getValue())); assertEquals("bearerAuth",doc.path("paths").path(entry.getKey()).path(entry.getValue()) .path("security").get(0).fieldNames().next()); } assertFalse(doc.path("paths").has("/notifications/purge")); assertFalse(doc.path("paths").has("/notifications/trash/clear")); var ids=doc.path("components").path("schemas").path("NotificationBatchRequest").path("properties").path("ids"); assertEquals(1,ids.path("minItems").asInt()); assertEquals(100,ids.path("maxItems").asInt()); assertEquals("integer",ids.path("items").path("type").asText()); assertEquals("int64",ids.path("items").path("format").asText()); assertEquals(1,ids.path("items").path("minimum").asInt()); } JsonNode change(MockHttpServletRequestBuilder request, String token) throws Exception { return json.readTree(mvc.perform(request.header("Authorization", "Bearer " + token)) .andExpect(status().isOk()).andReturn().getResponse().getContentAsByteArray()).path("data"); } MockHttpServletRequestBuilder batch(String route, List ids) throws Exception { return post("/notifications/" + route).contentType("application/json") .content(json.writeValueAsBytes(Map.of("ids", ids))); } @Test void deletingUnreadHidesItAndRepeatedDeletePreservesDeletedAt() throws Exception { var n=notification(a,"INTERVIEW",123L); assertTrue(change(delete("/notifications/"+n.getId()),at).path("changed").asBoolean()); var deleted=notifications.findById(n.getId()).orElseThrow(); assertNotNull(deleted.getDeletedAt()); assertFalse(deleted.getRead()); assertNull(deleted.getReadAt()); assertEquals(0,count(at)); assertEquals(0,getData("/notifications",at).path("total").asInt()); var trash=getData("/notifications/trash",at); assertEquals(1,trash.path("total").asInt()); assertEquals(10,trash.path("items").get(0).size()); assertEquals(123L,trash.path("items").get(0).path("businessId").asLong()); assertFalse(change(delete("/notifications/"+n.getId()),at).path("changed").asBoolean()); assertEquals(deleted.getDeletedAt(),notifications.findById(n.getId()).orElseThrow().getDeletedAt()); // 软删除不等于物理清除,既有个人数据导出使用的全量归属查询仍可读到本人的行。 assertEquals(1,notifications.findByUserIdOrderByCreatedAtDesc(a.getId(),org.springframework.data.domain.PageRequest.of(0,20)).getTotalElements()); } @Test void restorePreservesOriginalUnreadStateAndIsIdempotent() throws Exception { var n=notification(a,null,null); change(delete("/notifications/"+n.getId()),at); assertTrue(change(post("/notifications/"+n.getId()+"/restore"),at).path("changed").asBoolean()); assertEquals(1,count(at)); assertEquals(0,getData("/notifications/trash",at).path("total").asInt()); assertNull(notifications.findById(n.getId()).orElseThrow().getDeletedAt()); assertFalse(change(post("/notifications/"+n.getId()+"/restore"),at).path("changed").asBoolean()); } @Test void recycleBinIsExcludedFromBothReadOperationsAndRestoredReadStateIsUnchanged() throws Exception { var unread=notification(a,"APPLICATION",123L); var read=notification(a,"OFFER",456L); read(read.getId(),at); var readAt=notifications.findById(read.getId()).orElseThrow().getReadAt(); change(batch("batch-delete",List.of(unread.getId(),read.getId())),at); mvc.perform(post("/notifications/"+unread.getId()+"/read").header("Authorization","Bearer "+at)) .andExpect(status().isBadRequest()).andExpect(jsonPath("$.code").value(9105)); assertEquals(0,change(post("/notifications/read-all"),at).path("marked").asInt()); assertFalse(notifications.findById(unread.getId()).orElseThrow().getRead()); assertEquals(2,change(batch("batch-restore",List.of(unread.getId(),read.getId())),at).path("changed").asInt()); assertEquals(1,count(at)); assertEquals(readAt,notifications.findById(read.getId()).orElseThrow().getReadAt()); } @Test void batchIsAtomicForForeignOrMissingIdsIncludingRestore() throws Exception { var own=notification(a,"APPLICATION",123L); var foreign=notification(b,"OFFER",456L); for (long bad : List.of(foreign.getId(),Long.MAX_VALUE)) { mvc.perform(batch("batch-delete",List.of(own.getId(),bad)).header("Authorization","Bearer "+at)) .andExpect(status().is(bad==Long.MAX_VALUE?400:403)); assertNull(notifications.findById(own.getId()).orElseThrow().getDeletedAt()); } change(delete("/notifications/"+own.getId()),at); for (long bad : List.of(foreign.getId(),Long.MAX_VALUE)) { mvc.perform(batch("batch-restore",List.of(own.getId(),bad)).header("Authorization","Bearer "+at)) .andExpect(status().is(bad==Long.MAX_VALUE?400:403)); assertNotNull(notifications.findById(own.getId()).orElseThrow().getDeletedAt()); } assertEquals(1,count(bt)); assertEquals(0,count(at)); } @Test void batchRejectsInvalidIdsAndStrictlyRejectsNumericCoercion() throws Exception { var own=notification(a,"APPLICATION",123L); for (String body : List.of("{}","{\"ids\":null}","{\"ids\":[]}","{\"ids\":[null]}", "{\"ids\":[0]}","{\"ids\":[-1]}","{\"ids\":[true]}","{\"ids\":[1.5]}", "{\"ids\":[\""+own.getId()+"\"]}","{\"ids\":[9223372036854775808]}")) { for (String route : List.of("batch-delete","batch-restore")) mvc.perform(post("/notifications/"+route).contentType("application/json").content(body) .header("Authorization","Bearer "+at)).andExpect(status().isBadRequest()); } mvc.perform(batch("batch-delete",Collections.nCopies(101,own.getId())).header("Authorization","Bearer "+at)) .andExpect(status().isBadRequest()); assertNull(notifications.findById(own.getId()).orElseThrow().getDeletedAt()); } @Test void batchDeduplicatesAndReturnsActualChanges() throws Exception { var n=notification(a,"MATCH",123L); var result=change(batch("batch-delete",List.of(n.getId(),n.getId())),at); assertEquals(1,result.path("requested").asInt()); assertEquals(1,result.path("changed").asInt()); assertEquals(0,change(batch("batch-delete",List.of(n.getId())),at).path("changed").asInt()); assertEquals(1,change(batch("batch-restore",List.of(n.getId(),n.getId())),at).path("changed").asInt()); assertEquals(0,change(batch("batch-restore",List.of(n.getId())),at).path("changed").asInt()); } @Test void batchOf100AndTrashPaginationHaveStableOrder() throws Exception { var ids=new ArrayList(); for(int i=0;i<100;i++)ids.add(notification(a,"APPLICATION",(long)i+1).getId()); assertEquals(100,change(batch("batch-delete",ids),at).path("changed").asInt()); var first=getData("/notifications/trash?size=50",at); var second=getData("/notifications/trash?page=1&size=50",at); assertEquals(100,first.path("total").asInt()); assertEquals(2,first.path("totalPages").asInt()); var actual=new ArrayList(); first.path("items").forEach(n->actual.add(n.path("id").asLong())); second.path("items").forEach(n->actual.add(n.path("id").asLong())); ids.sort(Comparator.reverseOrder()); assertEquals(ids,actual); assertEquals(100,change(batch("batch-restore",ids),at).path("changed").asInt()); assertEquals(100,count(at)); } @Test void trashPaginationRejectsInvalidQueries() throws Exception { for(String query:List.of("page=-1","size=0","size=51")) mvc.perform(get("/notifications/trash?"+query).header("Authorization","Bearer "+at)).andExpect(status().isBadRequest()); } List requests(long id) throws Exception { return List.of(get("/notifications/trash"),delete("/notifications/"+id),post("/notifications/"+id+"/restore"), batch("batch-delete",List.of(id)),batch("batch-restore",List.of(id))); } @Test void anonymousAndDisabledOriginalTokenCannotUseAnyNewOperation() throws Exception { var n=notification(a,"INTERVIEW",123L); a.setActive(false); users.saveAndFlush(a); for(String token:List.of("",at))for(var req:requests(n.getId())) mvc.perform(req.header("Authorization",token.isEmpty()?"":"Bearer "+token)).andExpect(status().isUnauthorized()); assertNull(notifications.findById(n.getId()).orElseThrow().getDeletedAt()); } @Test void everyMutationRejectsOtherAccountAndNoTrashLeaks() throws Exception { var foreign=notification(b,"OFFER",123L); for(var req:requests(foreign.getId()).subList(1,5)) mvc.perform(req.header("Authorization","Bearer "+at)).andExpect(status().isForbidden()); change(delete("/notifications/"+foreign.getId()),bt); assertEquals(0,getData("/notifications/trash?userId="+b.getId(),at).path("total").asInt()); assertEquals(1,getData("/notifications/trash",bt).path("total").asInt()); } @Test void unverifiedCompanyGuardAppliesToNewOperations() throws Exception { a.setRole(User.Role.COMPANY); a.setRoles(Set.of(User.Role.COMPANY)); users.saveAndFlush(a); companies.saveAndFlush(Company.builder().ownerUserId(a.getId()).name("未认证合成企业").verified(false).build()); String token=sessions.createSession(a,clock.instant(),new AuditContext()).accessToken(); var n=notification(a,"VERIFICATION",123L); for(var req:requests(n.getId())) mvc.perform(req.header("Authorization","Bearer "+token)).andExpect(status().isForbidden()); assertNull(notifications.findById(n.getId()).orElseThrow().getDeletedAt()); } @Test void deletedReportDecisionIsExcludedFromTypedUnreadCount() { var n=notification(a,"REPORT",123L); n.setType("REPORT_DECISION"); notifications.saveAndFlush(n); assertEquals(1,notifications.countUnreadByUserIdAndType(a.getId(),"REPORT_DECISION")); service.delete(a.getId(),n.getId()); assertEquals(0,notifications.countUnreadByUserIdAndType(a.getId(),"REPORT_DECISION")); service.restore(a.getId(),n.getId()); assertEquals(1,notifications.countUnreadByUserIdAndType(a.getId(),"REPORT_DECISION")); } @Test void concurrentReadAndDeleteNeverResurrectsTheNotification() throws Exception { var pool=Executors.newFixedThreadPool(2); try { for(int i=0;i<8;i++) { var n=notification(a,"APPLICATION",123L); var go=new CountDownLatch(1); var delete=pool.submit(()->{go.await();return service.delete(a.getId(),n.getId());}); var read=pool.submit(()->{go.await();try {service.markRead(a.getId(),n.getId());return 200;} catch(BusinessException ex){return ex.getErrorCodeInt();}}); go.countDown(); assertTrue(delete.get(30,TimeUnit.SECONDS)); assertTrue(Set.of(200,9105).contains(read.get(30,TimeUnit.SECONDS))); var result=notifications.findById(n.getId()).orElseThrow(); assertNotNull(result.getDeletedAt()); assertEquals(Boolean.TRUE.equals(result.getRead()),result.getReadAt()!=null); } assertEquals(0,count(at)); } finally {pool.shutdownNow();} } @Test void overlappingBatchRequestsUseConsistentLockOrderAndRemainIdempotent() throws Exception { var x=notification(a,"APPLICATION",123L);var y=notification(a,"OFFER",456L); var go=new CountDownLatch(1); var pool=Executors.newFixedThreadPool(2); try { var one=pool.submit(()->{go.await();return service.batchDelete(a.getId(),List.of(x.getId(),y.getId()));}); var two=pool.submit(()->{go.await();return service.batchDelete(a.getId(),List.of(y.getId(),x.getId()));}); go.countDown();assertEquals(2,one.get(30,TimeUnit.SECONDS).changed()+two.get(30,TimeUnit.SECONDS).changed()); assertEquals(2,getData("/notifications/trash",at).path("total").asInt()); } finally {pool.shutdownNow();} } }