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
@@ -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 <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 |
|
||||
| `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.
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user