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

+13 -1
View File
@@ -7,7 +7,7 @@ To Be Played is split into three repositories:
| Repository | Role | | Repository | Role |
|---|---| |---|---|
| **ToBePlayed-Auth** (this one) | Users, login, settings, signs JWTs | | **ToBePlayed-Auth** (this one) | Users, login, settings, platform messages (hints, mail, news), signs JWTs |
| ToBePlayed-Core | Steam library, backlog, wishlist, stats; verifies JWTs | | ToBePlayed-Core | Steam library, backlog, wishlist, stats; verifies JWTs |
| ToBePlayed-Frontend | React single-page app | | ToBePlayed-Frontend | React single-page app |
@@ -74,6 +74,10 @@ To Be Played is split into three repositories:
and end time, type `DISMISS` or `ACCEPT`). The dashboard shows the running hints one at a time, newest first. and end time, type `DISMISS` or `ACCEPT`). The dashboard shows the running hints one at a time, newest first.
"Do not show again" on a `DISMISS` hint or "OK" on an `ACCEPT` hint is a row in `hint_done`, so the hint never "Do not show again" on a `DISMISS` hint or "OK" on an `ACCEPT` hint is a row in `hint_done`, so the hint never
comes back for this user on any device. Creating and ending a hint are rows in `admin_audit`. comes back for this user on any device. Creating and ending a hint are rows in `admin_audit`.
- **What's new**: a PLATFORM_ADMIN publishes news entries (plain text, English, German optional; edit and delete, no
drafts). The dashboard shows the entries published after `user_settings.news_seen_at` once in a dialog; "Got it"
sets the stamp to now (`PUT /api/news/seen`). A new user starts at the registration time. `/whats-new` lists all
entries. Editing an entry does not make it unseen again. Create, update and delete are rows in `admin_audit`.
- **Mails** from the platform mailbox: "a new registration waits" to the admin (`APP_MAIL_ADMIN_TO`, with the link - **Mails** from the platform mailbox: "a new registration waits" to the admin (`APP_MAIL_ADMIN_TO`, with the link
to the admin page), and to the user "we got your registration", then "your account is active" or "your to the admin page), and to the user "we got your registration", then "your account is active" or "your
registration was denied", later "your account is blocked" and "your account will be deleted from <date>". The registration sends the browser language (`de` for a German browser, else `en`), which also becomes registration was denied", later "your account is blocked" and "your account will be deleted from <date>". The registration sends the browser language (`de` for a German browser, else `en`), which also becomes
@@ -108,6 +112,12 @@ To Be Played is split into three repositories:
| `GET` | `/api/admin/hints` (`{results}` with `status` PLANNED/ACTIVE/ENDED by the server clock and `doneCount`, newest first) | PLATFORM_ADMIN | | `GET` | `/api/admin/hints` (`{results}` with `status` PLANNED/ACTIVE/ENDED by the server clock and `doneCount`, newest first) | PLATFORM_ADMIN |
| `POST` | `/api/admin/hints` (`{textEn, textDe, kind, startsAt, endsAt}`; `201 {id}`, `400 INVALID_HINT`) | PLATFORM_ADMIN | | `POST` | `/api/admin/hints` (`{textEn, textDe, kind, startsAt, endsAt}`; `201 {id}`, `400 INVALID_HINT`) | PLATFORM_ADMIN |
| `PUT` | `/api/admin/hints/{id}/end` (`204`; a planned hint never starts) | PLATFORM_ADMIN | | `PUT` | `/api/admin/hints/{id}/end` (`204`; a planned hint never starts) | PLATFORM_ADMIN |
| `GET` | `/api/news` (`{results}` with `seen`, newest first) | access token |
| `PUT` | `/api/news/seen` (`204`) | access token |
| `GET` | `/api/admin/news` (`{results}`, newest first) | PLATFORM_ADMIN |
| `POST` | `/api/admin/news` (`{titleEn, titleDe, textEn, textDe}`; `201 {id}`) | PLATFORM_ADMIN |
| `PUT` | `/api/admin/news/{id}` (same body; `204`; `404 NEWS_NOT_FOUND`) | PLATFORM_ADMIN |
| `DELETE` | `/api/admin/news/{id}` (`204`; `404 NEWS_NOT_FOUND`) | PLATFORM_ADMIN |
| `DELETE` | `/api/users/me` (`{password}`; `204` and both cookies cleared, `403 INVALID_PASSWORD`, `429`) | access token | | `DELETE` | `/api/users/me` (`{password}`; `204` and both cookies cleared, `403 INVALID_PASSWORD`, `429`) | access token |
| `GET` | `/api/steam-link/callback` | public, Steam's redirect back; binding cookie; redirects to the frontend settings | | `GET` | `/api/steam-link/callback` | public, Steam's redirect back; binding cookie; redirects to the frontend settings |
| `PUT` | `/api/users/settings/appearance` | access token | | `PUT` | `/api/users/settings/appearance` | access token |
@@ -159,6 +169,8 @@ the login module writes "view as" to the audit table through `shared/audit/Admin
use each other. use each other.
`hint` (dashboard hints and their admin API) uses `JdbcTemplate` in a `repo` package too, and audits through `hint` (dashboard hints and their admin API) uses `JdbcTemplate` in a `repo` package too, and audits through
`shared/audit/AdminAuditLog`. `shared/audit/AdminAuditLog`.
`news` (what's new entries and their admin API) is built the same way as `hint`; the seen stamp is a column of
`user_settings`, read and written by SQL in its own repo.
`mail` writes each mail as a finished text into `mail_outbox` (`JdbcTemplate`, `repo`), in the transaction of the `mail` writes each mail as a finished text into `mail_outbox` (`JdbcTemplate`, `repo`), in the transaction of the
event, and `MailOutboxService` sends the rows every minute over SMTP. Other modules reach it through the port event, and `MailOutboxService` sends the rows every minute over SMTP. Other modules reach it through the port
`shared/mail/AccountMailer`. Without `SPRING_MAIL_HOST` mail is off: the rows wait and go out once a host is set. `shared/mail/AccountMailer`. Without `SPRING_MAIL_HOST` mail is off: the rows wait and go out once a host is set.
@@ -0,0 +1,68 @@
package auth_service.modules.news.api;
import auth_service.modules.news.api.dto.request.NewsRequest;
import auth_service.modules.news.api.dto.response.AdminNewsListResponse;
import auth_service.modules.news.api.dto.response.AdminNewsResponse;
import auth_service.modules.news.api.dto.response.CreatedNewsResponse;
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.service.AdminNewsService;
import auth_service.shared.security.UserPrincipal;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/admin/news")
@RequiredArgsConstructor
public class AdminNewsController {
private final AdminNewsService adminNewsService;
@GetMapping
public ResponseEntity<AdminNewsListResponse> findAll() {
return ResponseEntity.ok(new AdminNewsListResponse(adminNewsService.findAll().stream()
.map(AdminNewsController::toApi).toList()));
}
@PostMapping
public ResponseEntity<CreatedNewsResponse> create(@AuthenticationPrincipal UserPrincipal principal,
@Valid @RequestBody NewsRequest request) {
long id = adminNewsService.create(principal.getIdAsLong(), toText(request));
return ResponseEntity.status(HttpStatus.CREATED).body(new CreatedNewsResponse(id));
}
@PutMapping("/{newsId}")
public ResponseEntity<Void> update(@AuthenticationPrincipal UserPrincipal principal, @PathVariable long newsId,
@Valid @RequestBody NewsRequest request) {
adminNewsService.update(principal.getIdAsLong(), newsId, toText(request));
return ResponseEntity.noContent().build();
}
@DeleteMapping("/{newsId}")
public ResponseEntity<Void> delete(@AuthenticationPrincipal UserPrincipal principal, @PathVariable long newsId) {
adminNewsService.delete(principal.getIdAsLong(), newsId);
return ResponseEntity.noContent().build();
}
private static NewsText toText(NewsRequest request) {
return new NewsText(request.titleEn(), request.titleDe(), request.textEn(), request.textDe());
}
private static AdminNewsResponse toApi(AdminNews admin) {
News news = admin.news();
return new AdminNewsResponse(news.id(), news.titleEn(), news.titleDe(), news.textEn(), news.textDe(),
news.publishedAt(), admin.createdBy(), admin.createdAt());
}
}
@@ -0,0 +1,41 @@
package auth_service.modules.news.api;
import auth_service.modules.news.api.dto.response.NewsListResponse;
import auth_service.modules.news.api.dto.response.NewsResponse;
import auth_service.modules.news.model.News;
import auth_service.modules.news.model.UserNews;
import auth_service.modules.news.service.NewsService;
import auth_service.shared.security.UserPrincipal;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/news")
@RequiredArgsConstructor
public class NewsController {
private final NewsService newsService;
@GetMapping
public ResponseEntity<NewsListResponse> findAll(@AuthenticationPrincipal UserPrincipal principal) {
return ResponseEntity.ok(new NewsListResponse(newsService.findAllFor(principal.getIdAsLong()).stream()
.map(NewsController::toApi).toList()));
}
@PutMapping("/seen")
public ResponseEntity<Void> markSeen(@AuthenticationPrincipal UserPrincipal principal) {
newsService.markSeen(principal.getIdAsLong());
return ResponseEntity.noContent().build();
}
private static NewsResponse toApi(UserNews userNews) {
News news = userNews.news();
return new NewsResponse(news.id(), news.titleEn(), news.titleDe(), news.textEn(), news.textDe(),
news.publishedAt(), userNews.seen());
}
}
@@ -0,0 +1,21 @@
package auth_service.modules.news.api;
import auth_service.modules.news.exception.NewsNotFoundException;
import auth_service.shared.api.dto.response.ErrorResponse;
import org.springframework.core.annotation.Order;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice(assignableTypes = {NewsController.class, AdminNewsController.class})
@Order(value = 1)
public class NewsExceptionController {
@ExceptionHandler(NewsNotFoundException.class)
public ResponseEntity<ErrorResponse> clientError(NewsNotFoundException ex) {
return ResponseEntity.status(HttpStatus.NOT_FOUND).contentType(MediaType.APPLICATION_JSON)
.body(new ErrorResponse("NEWS_NOT_FOUND", ex.getMessage()));
}
}
@@ -0,0 +1,11 @@
package auth_service.modules.news.api.dto.request;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
/**
* Plain text, never read as HTML.
*/
public record NewsRequest(@NotBlank @Size(max = 100) String titleEn, @Size(max = 100) String titleDe,
@NotBlank @Size(max = 2000) String textEn, @Size(max = 2000) String textDe) {
}
@@ -0,0 +1,6 @@
package auth_service.modules.news.api.dto.response;
import java.util.List;
public record AdminNewsListResponse(List<AdminNewsResponse> results) {
}
@@ -0,0 +1,7 @@
package auth_service.modules.news.api.dto.response;
import java.time.Instant;
public record AdminNewsResponse(long id, String titleEn, String titleDe, String textEn, String textDe,
Instant publishedAt, long createdBy, Instant createdAt) {
}
@@ -0,0 +1,4 @@
package auth_service.modules.news.api.dto.response;
public record CreatedNewsResponse(long id) {
}
@@ -0,0 +1,6 @@
package auth_service.modules.news.api.dto.response;
import java.util.List;
public record NewsListResponse(List<NewsResponse> results) {
}
@@ -0,0 +1,7 @@
package auth_service.modules.news.api.dto.response;
import java.time.Instant;
public record NewsResponse(long id, String titleEn, String titleDe, String textEn, String textDe,
Instant publishedAt, boolean seen) {
}
@@ -0,0 +1,7 @@
package auth_service.modules.news.exception;
public class NewsNotFoundException extends RuntimeException {
public NewsNotFoundException(long newsId) {
super("No news with id " + newsId);
}
}
@@ -0,0 +1,6 @@
package auth_service.modules.news.model;
import java.time.Instant;
public record AdminNews(News news, long createdBy, Instant createdAt) {
}
@@ -0,0 +1,10 @@
package auth_service.modules.news.model;
import java.time.Instant;
/**
* @param titleDe null: German users see {@code titleEn}
* @param textDe null: German users see {@code textEn}
*/
public record News(long id, String titleEn, String titleDe, String textEn, String textDe, Instant publishedAt) {
}
@@ -0,0 +1,12 @@
package auth_service.modules.news.model;
/**
* A blank German title or text is stored as null.
*/
public record NewsText(String titleEn, String titleDe, String textEn, String textDe) {
public NewsText {
titleDe = titleDe == null || titleDe.isBlank() ? null : titleDe;
textDe = textDe == null || textDe.isBlank() ? null : textDe;
}
}
@@ -0,0 +1,4 @@
package auth_service.modules.news.model;
public record UserNews(News news, boolean seen) {
}
@@ -0,0 +1,76 @@
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 org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.stereotype.Repository;
import java.sql.Timestamp;
import java.time.Instant;
import java.util.List;
import java.util.Objects;
@Repository
public class NewsRepository {
private static final String COLUMNS = "n.id, n.title_en, n.title_de, n.text_en, n.text_de, n.published_at";
private static final RowMapper<News> NEWS = (rs, row) -> new News(
rs.getLong("id"),
rs.getString("title_en"),
rs.getString("title_de"),
rs.getString("text_en"),
rs.getString("text_de"),
rs.getTimestamp("published_at").toInstant());
private final JdbcTemplate jdbc;
public NewsRepository(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}
public List<UserNews> findAllFor(long userId) {
return jdbc.query("""
SELECT %s, n.published_at <= s.news_seen_at AS seen
FROM news n JOIN user_settings s ON s.user_id = ?
ORDER BY n.published_at DESC, n.id DESC""".formatted(COLUMNS),
(rs, row) -> new UserNews(NEWS.mapRow(rs, row), rs.getBoolean("seen")), userId);
}
public List<AdminNews> findAll() {
return jdbc.query("""
SELECT %s, n.created_by, n.created_at FROM news n
ORDER BY n.published_at DESC, n.id DESC""".formatted(COLUMNS),
(rs, row) -> new AdminNews(NEWS.mapRow(rs, row), rs.getLong("created_by"),
rs.getTimestamp("created_at").toInstant()));
}
public long insert(NewsText text, long createdBy, Instant publishedAt) {
return Objects.requireNonNull(jdbc.queryForObject("""
INSERT INTO news (title_en, title_de, text_en, text_de, published_at, created_by)
VALUES (?, ?, ?, ?, ?, ?) RETURNING id""", Long.class, text.titleEn(), text.titleDe(), text.textEn(),
text.textDe(), Timestamp.from(publishedAt), createdBy));
}
/**
* @return false when the entry does not exist
*/
public boolean update(long newsId, NewsText text) {
return jdbc.update("UPDATE news SET title_en = ?, title_de = ?, text_en = ?, text_de = ? WHERE id = ?",
text.titleEn(), text.titleDe(), text.textEn(), text.textDe(), newsId) > 0;
}
/**
* @return false when the entry does not exist
*/
public boolean delete(long newsId) {
return jdbc.update("DELETE FROM news WHERE id = ?", newsId) > 0;
}
public void markSeen(long userId, Instant seenAt) {
jdbc.update("UPDATE user_settings SET news_seen_at = ? WHERE user_id = ?", Timestamp.from(seenAt), userId);
}
}
@@ -0,0 +1,56 @@
package auth_service.modules.news.service;
import auth_service.modules.news.exception.NewsNotFoundException;
import auth_service.modules.news.model.AdminNews;
import auth_service.modules.news.model.NewsText;
import auth_service.modules.news.repo.NewsRepository;
import auth_service.shared.audit.AdminAuditLog;
import auth_service.shared.security.RequiresRole;
import auth_service.shared.security.Role;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.util.List;
@Service
@RequiresRole(Role.PLATFORM_ADMIN)
@RequiredArgsConstructor
@Slf4j
public class AdminNewsService {
private final NewsRepository newsRepository;
private final AdminAuditLog adminAuditLog;
public List<AdminNews> findAll() {
return newsRepository.findAll();
}
@Transactional
public long create(long actorUserId, NewsText text) {
long id = newsRepository.insert(text, actorUserId, Instant.now());
adminAuditLog.record(actorUserId, "NEWS_CREATE", null, String.valueOf(id));
log.info("Admin {} created news {}", actorUserId, id);
return id;
}
@Transactional
public void update(long actorUserId, long newsId, NewsText text) {
if (!newsRepository.update(newsId, text)) {
throw new NewsNotFoundException(newsId);
}
adminAuditLog.record(actorUserId, "NEWS_UPDATE", null, String.valueOf(newsId));
log.info("Admin {} updated news {}", actorUserId, newsId);
}
@Transactional
public void delete(long actorUserId, long newsId) {
if (!newsRepository.delete(newsId)) {
throw new NewsNotFoundException(newsId);
}
adminAuditLog.record(actorUserId, "NEWS_DELETE", null, String.valueOf(newsId));
log.info("Admin {} deleted news {}", actorUserId, newsId);
}
}
@@ -0,0 +1,27 @@
package auth_service.modules.news.service;
import auth_service.modules.news.model.UserNews;
import auth_service.modules.news.repo.NewsRepository;
import auth_service.shared.security.RequiresRole;
import auth_service.shared.security.Role;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.util.List;
@Service
@RequiresRole(Role.USER)
@RequiredArgsConstructor
public class NewsService {
private final NewsRepository newsRepository;
public List<UserNews> findAllFor(long userId) {
return newsRepository.findAllFor(userId);
}
public void markSeen(long userId) {
newsRepository.markSeen(userId, Instant.now());
}
}
@@ -0,0 +1,16 @@
-- "What's new" entries from a PLATFORM_ADMIN. No FK on created_by, like hint: the row outlives a deleted admin.
CREATE TABLE news (
id BIGSERIAL PRIMARY KEY,
title_en VARCHAR(100) NOT NULL,
title_de VARCHAR(100),
text_en VARCHAR(2000) NOT NULL,
text_de VARCHAR(2000),
published_at TIMESTAMPTZ NOT NULL,
created_by BIGINT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- An entry published after this stamp is unseen. The default makes the registration the start for a new user
-- (the registration inserts the settings row): the old change log is not news to them. Existing users get the
-- migration time; no entry is older.
ALTER TABLE user_settings ADD COLUMN news_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW();
@@ -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"));
}
}