feat(news): add news endpoints and admin CRUD
Build, Test & Push Auth Service / quality-check (push) Successful in 51s
Build, Test & Push Auth Service / docker (push) Successful in 1m26s

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:
lobo committed 2026-10-01 10:03:07 +02:00
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"));
}
}