package com.mydreamjob.notification; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.mydreamjob.auth.*; import com.mydreamjob.audit.AuditContext; import org.junit.jupiter.api.*; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; import java.time.Clock; import java.util.*; import java.util.concurrent.atomic.AtomicLong; 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.*; /** 完整过滤链 + H2 持久化;不冒充真实 MySQL 或 JAR 黑盒。 */ @SpringBootTest(properties = "spring.datasource.url=jdbc:h2:mem:notification_associations;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE") @AutoConfigureMockMvc @ActiveProfiles("integration-test") class NotificationAssociationIntegrationTest { @Autowired MockMvc mvc; @Autowired ObjectMapper json; @Autowired UserRepository users; @Autowired AuthSessionService sessions; @Autowired Clock clock; @Autowired NotificationRepository notifications; @Autowired NotificationService service; @MockBean RedisTemplate redis; static final AtomicLong SEQ = new AtomicLong(78000000); User a, b; String at, bt; User user() { return users.saveAndFlush(User.builder().phone("166" + SEQ.incrementAndGet()).nickname("合成通知测试") .role(User.Role.STUDENT).roles(Set.of(User.Role.STUDENT)).active(true).verified(true).build()); } @BeforeEach void setup() { a = user(); b = user(); at = sessions.createSession(a, clock.instant(), new AuditContext()).accessToken(); bt = sessions.createSession(b, clock.instant(), new AuditContext()).accessToken(); } Notification notification(User owner, String kind, Long businessId) { return notifications.saveAndFlush(Notification.builder().userId(owner.getId()).type("TEST_NOTICE") .title("通知").content("文案 #999 不用于推断关联").businessType(kind).businessId(businessId).build()); } JsonNode getData(String path, String token) throws Exception { return json.readTree(mvc.perform(get(path).header("Authorization", "Bearer " + token)) .andExpect(status().isOk()).andReturn().getResponse().getContentAsByteArray()).path("data"); } void read(long id, String token) throws Exception { mvc.perform(post("/notifications/" + id + "/read").header("Authorization", "Bearer " + token)) .andExpect(status().isOk()); } long count(String token) throws Exception { return getData("/notifications/unread-count", token).path("unreadCount").asLong(); } @Test void listSerializesKnownUnknownAndLegacyAssociationsAndNoInternalFields() throws Exception { var unrelated = new HashMap(); unrelated.put("omitted", null); assertFalse(json.readTree(json.writeValueAsBytes(unrelated)).has("omitted"), "global non_null policy must not change"); for (String kind : List.of("INTERVIEW", "APPLICATION", "OFFER", "MATCH", "FUTURE_TYPE")) notification(a, kind, 123L); var legacy = notification(a, null, null); legacy.setContent(null); notifications.saveAndFlush(legacy); notification(b, "INTERVIEW", 456L); var data = getData("/notifications", at); assertEquals(6, data.path("total").asInt()); var kinds = new HashSet(); for (JsonNode item : data.path("items")) { var keys = new HashSet(); item.fieldNames().forEachRemaining(keys::add); assertEquals(Set.of("id", "type", "title", "content", "read", "readAt", "createdAt", "businessType", "businessId"), keys); if (item.path("id").asLong() == legacy.getId()) { assertTrue(item.has("businessType") && item.get("businessType").isNull()); assertTrue(item.has("businessId") && item.get("businessId").isNull()); assertTrue(item.has("content") && item.get("content").isNull()); } else { kinds.add(item.path("businessType").asText()); assertTrue(item.path("businessId").isIntegralNumber()); assertEquals(123L, item.path("businessId").asLong()); } } assertEquals(Set.of("INTERVIEW", "APPLICATION", "OFFER", "MATCH", "FUTURE_TYPE"), kinds); assertEquals(1, getData("/notifications", bt).path("total").asInt()); } @Test void readingLegacyNotificationIsDurableAndIdempotent() throws Exception { var n = notification(a, null, null); assertEquals(1, count(at)); read(n.getId(), at); var first = notifications.findById(n.getId()).orElseThrow(); assertTrue(first.getRead()); assertNotNull(first.getReadAt()); read(n.getId(), at); assertEquals(first.getReadAt(), notifications.findById(n.getId()).orElseThrow().getReadAt()); assertEquals(0, count(at)); var item = getData("/notifications", at).path("items").get(0); assertTrue(item.path("read").asBoolean()); assertFalse(item.path("readAt").isNull()); } @Test void markAllAcrossMultiplePagesOnlyAffectsOwnerAndIsIdempotent() throws Exception { var sameTime = java.time.LocalDateTime.of(2026, 10, 1, 12, 0); for (int i = 0; i < 105; i++) { var n = Notification.builder().userId(a.getId()).type("TEST_NOTICE").title("同刻通知") .businessType("APPLICATION").businessId((long) i + 1).createdAt(sameTime).build(); notifications.saveAndFlush(n); } var other = notification(b, "OFFER", 999L); read(getData("/notifications", at).path("items").get(0).path("id").asLong(), at); mvc.perform(post("/notifications/read-all").header("Authorization", "Bearer " + at)) .andExpect(status().isOk()).andExpect(jsonPath("$.data.marked").value(104)); assertEquals(0, count(at)); assertEquals(1, count(bt)); assertFalse(notifications.findById(other.getId()).orElseThrow().getRead()); mvc.perform(post("/notifications/read-all").header("Authorization", "Bearer " + at)) .andExpect(status().isOk()).andExpect(jsonPath("$.data.marked").value(0)); } @Test void crossAccountAndMissingReadHaveNoSideEffects() throws Exception { var other = notification(b, "INTERVIEW", 456L); mvc.perform(post("/notifications/" + other.getId() + "/read").header("Authorization", "Bearer " + at)) .andExpect(status().isForbidden()); mvc.perform(post("/notifications/9223372036854775807/read").header("Authorization", "Bearer " + at)) .andExpect(status().isBadRequest()).andExpect(jsonPath("$.code").value(9105)); assertFalse(notifications.findById(other.getId()).orElseThrow().getRead()); assertEquals(1, count(bt)); } @Test void anonymousAndDisabledOriginalTokenCannotAccessAnyNotificationOperation() throws Exception { var n = notification(a, "APPLICATION", 123L); a.setActive(false); users.saveAndFlush(a); for (String token : List.of("", at)) { String header = token.isEmpty() ? "" : "Bearer " + token; for (String path : List.of("/notifications", "/notifications/unread-count")) mvc.perform(get(path).header("Authorization", header)).andExpect(status().isUnauthorized()); for (String path : List.of("/notifications/" + n.getId() + "/read", "/notifications/read-all")) mvc.perform(post(path).header("Authorization", header)).andExpect(status().isUnauthorized()); } assertFalse(notifications.findById(n.getId()).orElseThrow().getRead()); } @Test void paginationAndValidationStayCompatible() throws Exception { for (int i = 0; i < 3; i++) notification(a, "INTERVIEW", (long) i + 1); var data = getData("/notifications?page=1&size=2", at); assertEquals(3, data.path("total").asInt()); assertEquals(1, data.path("page").asInt()); assertEquals(2, data.path("size").asInt()); assertEquals(2, data.path("totalPages").asInt()); assertEquals(1, data.path("items").size()); for (String query : List.of("page=-1", "size=0", "size=51")) mvc.perform(get("/notifications?" + query).header("Authorization", "Bearer " + at)) .andExpect(status().isBadRequest()); } JsonNode resolve(JsonNode doc, JsonNode schema) { return schema.has("$ref") ? doc.at(schema.path("$ref").asText().substring(1)) : schema; } JsonNode itemSchema(JsonNode doc) { var content = doc.path("paths").path("/notifications").path("get").path("responses").path("200").path("content"); var envelope = resolve(doc, content.elements().next().path("schema")); var data = resolve(doc, envelope.path("properties").path("data")); return resolve(doc, data.path("properties").path("items").path("items")); } @Test void runtimeAndStaticNotificationContractAgree() throws Exception { JsonNode runtime = json.readTree(mvc.perform(get("/v3/api-docs")).andExpect(status().isOk()) .andReturn().getResponse().getContentAsByteArray()); JsonNode contract = json.readTree(java.nio.file.Path.of("../docs/openapi-rc.json").toFile()); java.nio.file.Files.write(java.nio.file.Path.of("target/notification-runtime-openapi.json"), json.writeValueAsBytes(runtime)); var actual = itemSchema(runtime); var expected = itemSchema(contract); var actualNames = new HashSet(); actual.path("properties").fieldNames().forEachRemaining(actualNames::add); var expectedNames = new HashSet(); expected.path("properties").fieldNames().forEachRemaining(expectedNames::add); assertEquals(expectedNames, actualNames); assertEquals(9, actualNames.size()); for (String name : actualNames) { assertEquals(expected.path("properties").path(name).path("type"), actual.path("properties").path(name).path("type"), name); assertEquals(expected.path("properties").path(name).path("format"), actual.path("properties").path(name).path("format"), name); } for (String name : List.of("businessType", "businessId")) { assertTrue(actual.path("properties").path(name).path("type").toString().contains("null")); assertTrue(expected.path("properties").path(name).path("type").toString().contains("null")); assertTrue(actual.path("required").toString().contains('"' + name + '"')); } assertEquals(contract.path("paths").size(), runtime.path("paths").size()); } }