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.
183 lines
11 KiB
183 lines
11 KiB
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<String, Object> 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<String, Object>(); 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<String>();
|
|
for (JsonNode item : data.path("items")) {
|
|
var keys = new HashSet<String>(); 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<String>(); actual.path("properties").fieldNames().forEachRemaining(actualNames::add);
|
|
var expectedNames = new HashSet<String>(); 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());
|
|
}
|
|
}
|
|
|