diff --git a/readme.md b/readme.md index 75bdcff..fdb1523 100644 --- a/readme.md +++ b/readme.md @@ -7,7 +7,7 @@ To Be Played is split into three repositories: | 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-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. "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`. +- **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 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 ". 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 | | `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 | +| `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 | | `GET` | `/api/steam-link/callback` | public, Steam's redirect back; binding cookie; redirects to the frontend settings | | `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. `hint` (dashboard hints and their admin API) uses `JdbcTemplate` in a `repo` package too, and audits through `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 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. diff --git a/src/main/java/auth_service/modules/news/api/AdminNewsController.java b/src/main/java/auth_service/modules/news/api/AdminNewsController.java new file mode 100644 index 0000000..b865d03 --- /dev/null +++ b/src/main/java/auth_service/modules/news/api/AdminNewsController.java @@ -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 findAll() { + return ResponseEntity.ok(new AdminNewsListResponse(adminNewsService.findAll().stream() + .map(AdminNewsController::toApi).toList())); + } + + @PostMapping + public ResponseEntity 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 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 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()); + } +} diff --git a/src/main/java/auth_service/modules/news/api/NewsController.java b/src/main/java/auth_service/modules/news/api/NewsController.java new file mode 100644 index 0000000..3b61056 --- /dev/null +++ b/src/main/java/auth_service/modules/news/api/NewsController.java @@ -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 findAll(@AuthenticationPrincipal UserPrincipal principal) { + return ResponseEntity.ok(new NewsListResponse(newsService.findAllFor(principal.getIdAsLong()).stream() + .map(NewsController::toApi).toList())); + } + + @PutMapping("/seen") + public ResponseEntity 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()); + } +} diff --git a/src/main/java/auth_service/modules/news/api/NewsExceptionController.java b/src/main/java/auth_service/modules/news/api/NewsExceptionController.java new file mode 100644 index 0000000..2e91d24 --- /dev/null +++ b/src/main/java/auth_service/modules/news/api/NewsExceptionController.java @@ -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 clientError(NewsNotFoundException ex) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).contentType(MediaType.APPLICATION_JSON) + .body(new ErrorResponse("NEWS_NOT_FOUND", ex.getMessage())); + } +} diff --git a/src/main/java/auth_service/modules/news/api/dto/request/NewsRequest.java b/src/main/java/auth_service/modules/news/api/dto/request/NewsRequest.java new file mode 100644 index 0000000..2ce97e0 --- /dev/null +++ b/src/main/java/auth_service/modules/news/api/dto/request/NewsRequest.java @@ -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) { +} diff --git a/src/main/java/auth_service/modules/news/api/dto/response/AdminNewsListResponse.java b/src/main/java/auth_service/modules/news/api/dto/response/AdminNewsListResponse.java new file mode 100644 index 0000000..00edd37 --- /dev/null +++ b/src/main/java/auth_service/modules/news/api/dto/response/AdminNewsListResponse.java @@ -0,0 +1,6 @@ +package auth_service.modules.news.api.dto.response; + +import java.util.List; + +public record AdminNewsListResponse(List results) { +} diff --git a/src/main/java/auth_service/modules/news/api/dto/response/AdminNewsResponse.java b/src/main/java/auth_service/modules/news/api/dto/response/AdminNewsResponse.java new file mode 100644 index 0000000..47cd3b2 --- /dev/null +++ b/src/main/java/auth_service/modules/news/api/dto/response/AdminNewsResponse.java @@ -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) { +} diff --git a/src/main/java/auth_service/modules/news/api/dto/response/CreatedNewsResponse.java b/src/main/java/auth_service/modules/news/api/dto/response/CreatedNewsResponse.java new file mode 100644 index 0000000..8f3f1c1 --- /dev/null +++ b/src/main/java/auth_service/modules/news/api/dto/response/CreatedNewsResponse.java @@ -0,0 +1,4 @@ +package auth_service.modules.news.api.dto.response; + +public record CreatedNewsResponse(long id) { +} diff --git a/src/main/java/auth_service/modules/news/api/dto/response/NewsListResponse.java b/src/main/java/auth_service/modules/news/api/dto/response/NewsListResponse.java new file mode 100644 index 0000000..ebcbe4f --- /dev/null +++ b/src/main/java/auth_service/modules/news/api/dto/response/NewsListResponse.java @@ -0,0 +1,6 @@ +package auth_service.modules.news.api.dto.response; + +import java.util.List; + +public record NewsListResponse(List results) { +} diff --git a/src/main/java/auth_service/modules/news/api/dto/response/NewsResponse.java b/src/main/java/auth_service/modules/news/api/dto/response/NewsResponse.java new file mode 100644 index 0000000..b156a43 --- /dev/null +++ b/src/main/java/auth_service/modules/news/api/dto/response/NewsResponse.java @@ -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) { +} diff --git a/src/main/java/auth_service/modules/news/exception/NewsNotFoundException.java b/src/main/java/auth_service/modules/news/exception/NewsNotFoundException.java new file mode 100644 index 0000000..02f32a9 --- /dev/null +++ b/src/main/java/auth_service/modules/news/exception/NewsNotFoundException.java @@ -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); + } +} diff --git a/src/main/java/auth_service/modules/news/model/AdminNews.java b/src/main/java/auth_service/modules/news/model/AdminNews.java new file mode 100644 index 0000000..e06571b --- /dev/null +++ b/src/main/java/auth_service/modules/news/model/AdminNews.java @@ -0,0 +1,6 @@ +package auth_service.modules.news.model; + +import java.time.Instant; + +public record AdminNews(News news, long createdBy, Instant createdAt) { +} diff --git a/src/main/java/auth_service/modules/news/model/News.java b/src/main/java/auth_service/modules/news/model/News.java new file mode 100644 index 0000000..3fffb77 --- /dev/null +++ b/src/main/java/auth_service/modules/news/model/News.java @@ -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) { +} diff --git a/src/main/java/auth_service/modules/news/model/NewsText.java b/src/main/java/auth_service/modules/news/model/NewsText.java new file mode 100644 index 0000000..a2c27c8 --- /dev/null +++ b/src/main/java/auth_service/modules/news/model/NewsText.java @@ -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; + } +} diff --git a/src/main/java/auth_service/modules/news/model/UserNews.java b/src/main/java/auth_service/modules/news/model/UserNews.java new file mode 100644 index 0000000..63c0d3b --- /dev/null +++ b/src/main/java/auth_service/modules/news/model/UserNews.java @@ -0,0 +1,4 @@ +package auth_service.modules.news.model; + +public record UserNews(News news, boolean seen) { +} diff --git a/src/main/java/auth_service/modules/news/repo/NewsRepository.java b/src/main/java/auth_service/modules/news/repo/NewsRepository.java new file mode 100644 index 0000000..fa3a80c --- /dev/null +++ b/src/main/java/auth_service/modules/news/repo/NewsRepository.java @@ -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 = (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 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 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); + } +} diff --git a/src/main/java/auth_service/modules/news/service/AdminNewsService.java b/src/main/java/auth_service/modules/news/service/AdminNewsService.java new file mode 100644 index 0000000..455dbba --- /dev/null +++ b/src/main/java/auth_service/modules/news/service/AdminNewsService.java @@ -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 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); + } +} diff --git a/src/main/java/auth_service/modules/news/service/NewsService.java b/src/main/java/auth_service/modules/news/service/NewsService.java new file mode 100644 index 0000000..80c5e89 --- /dev/null +++ b/src/main/java/auth_service/modules/news/service/NewsService.java @@ -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 findAllFor(long userId) { + return newsRepository.findAllFor(userId); + } + + public void markSeen(long userId) { + newsRepository.markSeen(userId, Instant.now()); + } +} diff --git a/src/main/resources/db/migration/V23__News.sql b/src/main/resources/db/migration/V23__News.sql new file mode 100644 index 0000000..0c924d2 --- /dev/null +++ b/src/main/resources/db/migration/V23__News.sql @@ -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(); diff --git a/src/test/java/auth_service/modules/news/api/NewsFlowTest.java b/src/test/java/auth_service/modules/news/api/NewsFlowTest.java new file mode 100644 index 0000000..bd34dc0 --- /dev/null +++ b/src/test/java/auth_service/modules/news/api/NewsFlowTest.java @@ -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()); + } +} diff --git a/src/test/java/auth_service/modules/news/repo/NewsRepositoryTest.java b/src/test/java/auth_service/modules/news/repo/NewsRepositoryTest.java new file mode 100644 index 0000000..19514ef --- /dev/null +++ b/src/test/java/auth_service/modules/news/repo/NewsRepositoryTest.java @@ -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(); + } +} diff --git a/src/test/java/auth_service/modules/news/service/AdminNewsServiceTest.java b/src/test/java/auth_service/modules/news/service/AdminNewsServiceTest.java new file mode 100644 index 0000000..de85ff1 --- /dev/null +++ b/src/test/java/auth_service/modules/news/service/AdminNewsServiceTest.java @@ -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 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")); + } +}