Public Access
feat(news): add news endpoints and admin CRUD
Users see new entries once in a dialog after publication. Admins manage entries via /api/admin/news with full audit trail.
This commit is contained in:
1 parent
39a4d5ae49
commit
2b2f45b9af
22 files changed
+694
-1
No files matched your search
@@ -0,0 +1,116 @@
|
||||
package auth_service.modules.news.api;
|
||||
|
||||
import auth_service.configuration.security.ClientHeaderFilter;
|
||||
import auth_service.shared.api.AuthCookies;
|
||||
import auth_service.shared.test.AbstractIntegrationTest;
|
||||
import jakarta.servlet.http.Cookie;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.ResultActions;
|
||||
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
class NewsFlowTest extends AbstractIntegrationTest {
|
||||
|
||||
private static final String PASSWORD = "correct horse battery";
|
||||
private static final String ADMIN = "news-flow-admin@example.com";
|
||||
private static final String PLAYER = "news-flow-player@example.com";
|
||||
private static final String OTHER = "news-flow-other@example.com";
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@Autowired
|
||||
private JdbcTemplate jdbc;
|
||||
|
||||
@Autowired
|
||||
private PasswordEncoder passwordEncoder;
|
||||
|
||||
private void insertUser(String email, String role) {
|
||||
jdbc.update("DELETE FROM users WHERE email = ?", email);
|
||||
jdbc.update("INSERT INTO users (email, password, enabled, role, display_name) VALUES (?, ?, true, ?, ?)",
|
||||
email, passwordEncoder.encode(PASSWORD), role, email.substring(0, 16));
|
||||
addSettingsRows();
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void seed() {
|
||||
jdbc.update("DELETE FROM news");
|
||||
insertUser(ADMIN, "PLATFORM_ADMIN");
|
||||
insertUser(PLAYER, "USER");
|
||||
insertUser(OTHER, "USER");
|
||||
}
|
||||
|
||||
private Cookie login(String email) throws Exception {
|
||||
return new Cookie(AuthCookies.ACCESS_COOKIE, mockMvc.perform(post("/api/auth/login")
|
||||
.header(ClientHeaderFilter.CLIENT_HEADER, "web").contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"email\":\"" + email + "\",\"password\":\"" + PASSWORD + "\"}"))
|
||||
.andReturn().getResponse().getCookie(AuthCookies.ACCESS_COOKIE).getValue());
|
||||
}
|
||||
|
||||
private static String news(String titleEn, String textEn) {
|
||||
return "{\"titleEn\":\"" + titleEn + "\",\"titleDe\":\" \",\"textEn\":\"" + textEn + "\",\"textDe\":null}";
|
||||
}
|
||||
|
||||
private ResultActions create(Cookie session, String body) throws Exception {
|
||||
return send(post("/api/admin/news").content(body), session);
|
||||
}
|
||||
|
||||
private ResultActions send(MockHttpServletRequestBuilder request, Cookie session) throws Exception {
|
||||
return mockMvc.perform(request.header(ClientHeaderFilter.CLIENT_HEADER, "web").cookie(session)
|
||||
.contentType(MediaType.APPLICATION_JSON));
|
||||
}
|
||||
|
||||
@Test
|
||||
void seenEntry_StaysSeenForThisUserOnly() throws Exception {
|
||||
Cookie admin = login(ADMIN);
|
||||
Cookie player = login(PLAYER);
|
||||
Cookie other = login(OTHER);
|
||||
Thread.sleep(20); // news_seen_at is NOW() of the settings insert; publish after it
|
||||
String body = create(admin, news("Title", "Text")).andExpect(status().isCreated()).andReturn().getResponse()
|
||||
.getContentAsString();
|
||||
long id = Long.parseLong(body.replaceAll("\\D", ""));
|
||||
|
||||
mockMvc.perform(get("/api/news").cookie(player)).andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.results[0].id").value(id))
|
||||
.andExpect(jsonPath("$.results[0].seen").value(false))
|
||||
.andExpect(jsonPath("$.results[0].titleDe").doesNotExist());
|
||||
send(put("/api/news/seen"), player).andExpect(status().isNoContent());
|
||||
mockMvc.perform(get("/api/news").cookie(player)).andExpect(jsonPath("$.results[0].seen").value(true));
|
||||
mockMvc.perform(get("/api/news").cookie(other)).andExpect(jsonPath("$.results[0].seen").value(false));
|
||||
|
||||
send(put("/api/admin/news/" + id).content(news("New title", "Text")), admin).andExpect(status().isNoContent());
|
||||
mockMvc.perform(get("/api/news").cookie(player)).andExpect(jsonPath("$.results[0].seen").value(true))
|
||||
.andExpect(jsonPath("$.results[0].titleEn").value("New title"));
|
||||
mockMvc.perform(get("/api/admin/news").cookie(admin)).andExpect(jsonPath("$.results[0].createdBy").exists());
|
||||
|
||||
send(delete("/api/admin/news/" + id), admin).andExpect(status().isNoContent());
|
||||
send(delete("/api/admin/news/" + id), admin).andExpect(status().isNotFound())
|
||||
.andExpect(jsonPath("$.code").value("NEWS_NOT_FOUND"));
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM admin_audit WHERE action IN "
|
||||
+ "('NEWS_CREATE', 'NEWS_UPDATE', 'NEWS_DELETE') AND detail = ?", Long.class, String.valueOf(id)))
|
||||
.isEqualTo(3);
|
||||
}
|
||||
|
||||
@Test
|
||||
void create_RefusesUsersAndInvalidInput() throws Exception {
|
||||
create(login(PLAYER), news("Title", "Text")).andExpect(status().isForbidden());
|
||||
Cookie admin = login(ADMIN);
|
||||
create(admin, news(" ", "Text")).andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.code").value("VALIDATION_FAILED"));
|
||||
create(admin, news("x".repeat(101), "Text")).andExpect(status().isBadRequest());
|
||||
create(admin, news("Title", "x".repeat(2001))).andExpect(status().isBadRequest());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package auth_service.modules.news.repo;
|
||||
|
||||
import auth_service.modules.news.model.AdminNews;
|
||||
import auth_service.modules.news.model.News;
|
||||
import auth_service.modules.news.model.NewsText;
|
||||
import auth_service.modules.news.model.UserNews;
|
||||
import auth_service.shared.test.AbstractPersistenceTest;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
|
||||
import java.sql.Timestamp;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.tuple;
|
||||
|
||||
class NewsRepositoryTest extends AbstractPersistenceTest {
|
||||
|
||||
private static final Instant NOW = Instant.now().truncatedTo(ChronoUnit.MILLIS);
|
||||
private static final Duration DAY = Duration.ofDays(1);
|
||||
|
||||
@Autowired
|
||||
private NewsRepository repository;
|
||||
|
||||
@Autowired
|
||||
private JdbcTemplate jdbc;
|
||||
|
||||
private long alice;
|
||||
private long bob;
|
||||
|
||||
private long user(String email, Instant seenAt) {
|
||||
long id = jdbc.queryForObject("INSERT INTO users (email, password, enabled) VALUES (?, 'x', true) RETURNING id",
|
||||
Long.class, email);
|
||||
jdbc.update("INSERT INTO user_settings (user_id, news_seen_at) VALUES (?, ?)", id,
|
||||
Timestamp.from(seenAt));
|
||||
return id;
|
||||
}
|
||||
|
||||
private long news(String title, Instant publishedAt) {
|
||||
return repository.insert(new NewsText(title, null, "Text", null), alice, publishedAt);
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void seed() {
|
||||
jdbc.update("DELETE FROM news");
|
||||
jdbc.update("DELETE FROM users");
|
||||
alice = user("alice@example.com", NOW.minus(DAY));
|
||||
bob = user("bob@example.com", NOW.minus(DAY));
|
||||
}
|
||||
|
||||
@Test
|
||||
void findAllFor_FlagsEntriesAfterTheSeenStampNewestFirst() {
|
||||
news("old", NOW.minus(DAY.multipliedBy(2)));
|
||||
news("new", NOW);
|
||||
|
||||
assertThat(repository.findAllFor(alice)).extracting(entry -> entry.news().titleEn(), UserNews::seen)
|
||||
.containsExactly(tuple("new", false), tuple("old", true));
|
||||
}
|
||||
|
||||
@Test
|
||||
void findAllFor_SameInstantNewestIdFirst() {
|
||||
news("first", NOW);
|
||||
news("second", NOW);
|
||||
|
||||
assertThat(repository.findAllFor(alice)).extracting(entry -> entry.news().titleEn())
|
||||
.containsExactly("second", "first");
|
||||
}
|
||||
|
||||
@Test
|
||||
void markSeen_ChangesOnlyThisUser() {
|
||||
news("new", NOW);
|
||||
|
||||
repository.markSeen(alice, NOW);
|
||||
|
||||
assertThat(repository.findAllFor(alice)).extracting(UserNews::seen).containsExactly(true);
|
||||
assertThat(repository.findAllFor(bob)).extracting(UserNews::seen).containsExactly(false);
|
||||
}
|
||||
|
||||
@Test
|
||||
void update_KeepsPublishedAtAndReportsUnknown() {
|
||||
long id = news("old title", NOW);
|
||||
|
||||
assertThat(repository.update(id, new NewsText("new title", "de", "new text", null))).isTrue();
|
||||
|
||||
assertThat(repository.findAll()).extracting(AdminNews::news)
|
||||
.extracting(News::titleEn, News::titleDe, News::textEn, News::publishedAt)
|
||||
.containsExactly(tuple("new title", "de", "new text", NOW));
|
||||
assertThat(repository.update(id + 1, new NewsText("x", null, "y", null))).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void delete_ReportsUnknown() {
|
||||
long id = news("gone", NOW);
|
||||
|
||||
assertThat(repository.delete(id)).isTrue();
|
||||
assertThat(repository.delete(id)).isFalse();
|
||||
assertThat(repository.findAll()).isEmpty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package auth_service.modules.news.service;
|
||||
|
||||
import auth_service.modules.news.exception.NewsNotFoundException;
|
||||
import auth_service.modules.news.model.NewsText;
|
||||
import auth_service.modules.news.repo.NewsRepository;
|
||||
import auth_service.shared.audit.AdminAuditLog;
|
||||
import auth_service.shared.test.AbstractUnitTest;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Mock;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.ArgumentMatchers.isNull;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class AdminNewsServiceTest extends AbstractUnitTest {
|
||||
|
||||
private static final long ADMIN = 1L;
|
||||
private static final NewsText TEXT = new NewsText("Title", null, "Text", null);
|
||||
|
||||
@Mock
|
||||
private NewsRepository newsRepository;
|
||||
|
||||
@Mock
|
||||
private AdminAuditLog adminAuditLog;
|
||||
|
||||
private AdminNewsService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
service = new AdminNewsService(newsRepository, adminAuditLog);
|
||||
}
|
||||
|
||||
@Test
|
||||
void create_StoresBlankGermanAsNullAndAudits() {
|
||||
NewsText text = new NewsText("Title", " ", "Text", "");
|
||||
when(newsRepository.insert(any(), anyLong(), any())).thenReturn(7L);
|
||||
|
||||
assertThat(service.create(ADMIN, text)).isEqualTo(7L);
|
||||
|
||||
ArgumentCaptor<NewsText> stored = ArgumentCaptor.forClass(NewsText.class);
|
||||
verify(newsRepository).insert(stored.capture(), anyLong(), any(Instant.class));
|
||||
assertThat(stored.getValue().titleDe()).isNull();
|
||||
assertThat(stored.getValue().textDe()).isNull();
|
||||
verify(adminAuditLog).record(ADMIN, "NEWS_CREATE", null, "7");
|
||||
}
|
||||
|
||||
@Test
|
||||
void update_AuditsOrThrowsForUnknown() {
|
||||
when(newsRepository.update(7L, TEXT)).thenReturn(true);
|
||||
service.update(ADMIN, 7L, TEXT);
|
||||
verify(adminAuditLog).record(ADMIN, "NEWS_UPDATE", null, "7");
|
||||
|
||||
assertThatThrownBy(() -> service.update(ADMIN, 8L, TEXT)).isInstanceOf(NewsNotFoundException.class);
|
||||
verify(adminAuditLog, never()).record(anyLong(), anyString(), isNull(), eq("8"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void delete_AuditsOrThrowsForUnknown() {
|
||||
when(newsRepository.delete(7L)).thenReturn(true);
|
||||
service.delete(ADMIN, 7L);
|
||||
verify(adminAuditLog).record(ADMIN, "NEWS_DELETE", null, "7");
|
||||
|
||||
assertThatThrownBy(() -> service.delete(ADMIN, 8L)).isInstanceOf(NewsNotFoundException.class);
|
||||
verify(adminAuditLog, never()).record(anyLong(), anyString(), isNull(), eq("8"));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user