You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
206 lines
14 KiB
206 lines
14 KiB
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<Long> 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<Long>();
|
|
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<Long>();
|
|
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<MockHttpServletRequestBuilder> 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();}
|
|
}
|
|
}
|
|
|