diff --git a/readme.md b/readme.md index b99f286..4ee355a 100644 --- a/readme.md +++ b/readme.md @@ -139,12 +139,15 @@ flowchart LR track reaches the item's target, retroactively and never lost. The five tracks (`Unlocks`): games finished (`FINISHED` + `FINISHED_MANUAL` rows + starter grant / 20), games completed, weeks in a row with a finish, months in a row with a `MONTH` bonus, months in a row with more Steam playtime than the month before (hidden games count too). + A set (`user_game_set`, see "Editions of one game") pays `STARTED`, `FINISHED`/`FINISHED_MANUAL` and `COMPLETED` once: a + game whose other edition was paid is no candidate, and two editions of a set give one candidate (the first event). A `BUY` or `UNLOCK` row proves ownership, `reward_equipped` holds what is worn. A platform admin owns every item (the role of the login token, or the `role` claim of the profile ticket for visitors). -- **Editions and links**: editions, remasters and ports of one game stay separate library rows, but an AUDITOR can - link their keys as one game for every user (see "Editions of one game"). IGDB suggests pairs, users can ask for - one, and one queue holds both. Every link, unlink and dismissal goes to `admin_audit` +- **Editions and links**: editions, remasters and ports of one game stay separate library rows, but their keys are one + game (a set): IGDB's "same game" pairs are linked automatically, an AUDITOR can link or unlink keys for every user, + and a user can split a key off a set for themselves (see "Editions of one game"). A set shares one status, rating, + note and priority. Users can ask for a pair; the auditor queue holds user requests and the IGDB pairs not linked yet (`OPEN` only). Every link, unlink and dismissal goes to `admin_audit` (`GAME_LINK`, `GAME_UNLINK`, `GAME_LINK_DISMISS`); auditors read that log through `GET /api/library/audit-log`, with the actor's display name from Auth's account state push. - **Wishlist**: synced from Steam with store price and discount. @@ -220,6 +223,7 @@ ArchUnit role rules (`RoleAnnotationRules`) and `src/test/resources/contract/aut | `GET` | `/api/library/catalog/{appid}` (what the correction dialog reads: name, image, speedrun, moods, endless, genres, timeToBeat, corrections; `404 GAME_NOT_FOUND`) | JWT, AUDITOR | | `POST` | `/api/library/games/{appid}/merge` (`{otherAppid}`; `200 {appid}` = the key that stays, `400 SAME_GAME` / `STEAM_GAMES`, `404 GAME_NOT_FOUND`) | JWT, AUDITOR | | `POST` | `/api/library/merges/{mergeId}/undo` (`mergeId` = the `id` of a `GAME_MERGE` audit row, within 30 days; `204`, `404 MERGE_NOT_FOUND`, `409 UNDO_NOT_AVAILABLE` / `NEWER_MERGE`, `410 UNDO_EXPIRED`) | JWT, AUDITOR | +| `DELETE` | `/api/library/games/{appid}/set` (the user splits the key off its set for themselves; `204`, `404 NOT_LINKED` when no other listed edition is in the user's set) | JWT | | `POST`, `DELETE` | `/api/library/games/{appid}/link` (POST `{otherAppid}`, `204`, `400 SAME_GAME`, `404 GAME_NOT_FOUND`, `409 ALREADY_LINKED`; DELETE takes the game out of its set, `204`, `404 NOT_LINKED`) | JWT, AUDITOR | | `GET` | `/api/library/audit-log?action=&page=&size=` (the newest `admin_audit` rows, `PageDto` of `{id, actorUserId, actorName, action, appid, gameName, targetAppid, targetGameName, detail, createdAt, undoable}`; `action` is `GAME_MERGE`, `GAME_MERGE_UNDO`, `GAME_LINK`, `GAME_UNLINK`, `GAME_LINK_DISMISS`, `GAME_CORRECT` or `GAME_CORRECTION_RESET`; size 20, 40 or 80) | JWT, AUDITOR | | `GET` | `/api/library/link-queue?page=&size=` (`PageDto` of `{a, b, igdb, requests, createdAt}`: both games, most requested first; size 20, 40 or 80) | JWT, AUDITOR | @@ -338,7 +342,8 @@ flowchart LR SQL, which ArchUnit cannot see: `stats` reads `listed_user`, `account_name`, `game_alias`, `listed_user_game`, `counted_user_game`, `steam_game`, `game_mood`, `coming_game`, `user_game_status_change`, `deleted_user` and `blocked_user`; `account` deletes the per-user rows of the library tables (`AccountCleanupRepository`; a new library table with `user_id` goes into its list) and writes `user_seen_at` and `last_synced_at` of `user_steam_sync` (`AccountStateRepository`); `library` -reads `deleted_user` and `blocked_user`, and for the picker `follow`, `public_user` and `account_name`. A module that moves to its own service takes these reads along as calls. +reads `deleted_user` and `blocked_user`, and for the picker `follow`, `public_user` and `account_name`; `rewards` reads +`user_game_set` (a set pays once). A module that moves to its own service takes these reads along as calls. The listed rule ("still owned, not hidden") is `UserGameRepository.LISTED` for JPQL and the view `listed_user_game` for native SQL; V24 says what a migration of `user_game` must do with the view. The numbers that count games once per linked set read `counted_user_game` (V28), built on `listed_user_game`: a migration that changes @@ -521,18 +526,49 @@ like `game_mood` (stale check, nightly sync, add, detail view; `app.library.edit `version_parent` for an edition, else `parent_game` when `game_type` is 9 (remaster) or 11 (port), else the game's own IGDB id; remakes (8) keep their own id. `null` when IGDB does not know the game. -- `game_link(appid, set_id)`: the keys an auditor linked, for every user. A set is every row with one `set_id`. +- `game_link(appid, set_id)`: the keys that are one game, for every user. A set is every row with one `set_id`. An unlink takes one key out; a set of one is dropped. A merge puts the kept key in the merged key's set; when both have a set, the sets join. -- `game_link_pair` is the auditor queue (`OPEN`, `DISMISSED`, `LINKED`). IGDB pairs are inserted when the queue is - read; a row in any state keeps the pair out for good. An unlink dismisses the pairs of that key with the former +- **Auto link** (V42): `GameEditionService.linkSameGames` links every pair of keys with the same `same_game_id`, after each + editions refresh and once at start for the games already there (`linkExisting`). No auditor, so no audit row. A pair + in `DISMISSED` (an auditor dismissal or an unlink) is never linked again, also not through a third edition: a link + is skipped when a dismissed pair lies between the two sets. The link is global: a wrong IGDB key groups + the game for every user until an auditor unlinks it. +- **Split** (`DELETE /api/library/games/{appid}/set`): `user_game_split(user_id, appid)` makes the key stand alone for + this user, only while another listed edition is in the user's set. No sync or link touches it, so it stays split. + The view `user_game_set(user_id, appid, set_id)` is `game_link` for the user's rows minus the split keys; every + reader of a user's set (`counted_user_game`, the editions of the detail, the `source` filter, the questions, rewards) + reads it. There is no undo of a split yet. +- **One status per set** (V42 triggers, like V14, so no code path can forget it): a change of `status`, + `status_set_by_user`, `rating`, `notes` or `priority` on one row is copied to the other rows of the user's set, + also hidden and removed ones (`user_game_set_share`; `pg_trigger_depth()` stops the echo). When rows join a set (a new + row, a new link, a merge of sets), `align_user_game_set` applies the join rule: the winner is the counted edition + (listed first, then status rank, playtime, older `first_seen_at`, lower appid); its status wins, `status_set_by_user` + is true if any row has it, rating and priority fall back to the first row that has one, and the distinct notes are + joined with a blank line, cut to 2000. A row that changes is first copied to `user_game_set_backup` (the first copy + wins; kept until the account is deleted). The status log of a join is no hand change (`by_user` false), and a row that + takes a finished status takes the set's first finish time, so "finished per year" does not move. +- `game_link_pair` is the auditor queue (`OPEN`, `DISMISSED`, `LINKED`). IGDB pairs are inserted when the queue is read. A row in any state keeps the pair out for good. An unlink dismisses the pairs of that key with the former members. `game_link_request` holds who asked for a pair (the queue shows only the count). A user asks for a pair of two games of their own library; at most 20 open requests per user (a per-user advisory lock makes the cap hold for parallel calls), a dismissed pair answers `409 PAIR_DISMISSED`. - Pile, stats, snapshots and the dashboard count a set once (`counted_user_game`, see "Stats"); the library - filter `hideDuplicates=true` hides the lesser editions. Pile Points are unchanged: `STARTED` and `FINISHED` stay per row, - and the `MONTH` bonus reads `backlog_snapshot`, which counts a set once. + filter `hideDuplicates=true` hides the lesser editions. Pile Points pay a set once (see "Pile Points"), and the + `MONTH` bonus reads `backlog_snapshot`, which counts a set once. - The game detail has `linked` and `editions` (the user's other listed games of the set); a game row has `editions` (size of the set in the user's library, `null` below 2). +```mermaid +flowchart LR + Edition["game_edition (IGDB same_game_id)"] -->|"linkSameGames"| Link["game_link (global sets)"] + Auditor["auditor link / unlink"] --> Link + Link --> Set["user_game_set"] + Split["user_game_split (user splits)"] -->|"minus"| Set + Set --> Counted["counted_user_game"] + Set -->|"join: align_user_game_set"| Row["user_game (status, rating, notes, priority)"] + Row -->|"update: user_game_set_share"| Row + Row -.->|"old values"| Backup["user_game_set_backup"] + Set --> Rewards["rewards: a set pays once"] +``` + ### Deleted accounts Auth drives it (see Auth's readme). `POST /internal/accounts/deleted` adds a tombstone in `deleted_user`: the user @@ -542,7 +578,8 @@ user ids, which removes the rows of users Auth does not know (refused for more t `app.account-cleanup.max-orphans`, default 5). `app.account-cleanup.dry-run` (default `true`) only counts the rows. The tombstone also ends the user's follows in both directions without a note and removes the user's `public_user` and `follow_digest` rows; the purge removes the user's inbox notes and the notes about the user. -The purge also removes the user's `account_name` and `listed_user` rows and link requests (`game_link_request`). +The purge also removes the user's `account_name` and `listed_user` rows, link requests (`game_link_request`), splits +(`user_game_split`) and set backups (`user_game_set_backup`). The tombstone also deletes the user's stored PlayStation token (`user_psn_account`) at once. ### Blocked users and Steam ID changes diff --git a/src/main/java/core_service/modules/account/repo/AccountCleanupRepository.java b/src/main/java/core_service/modules/account/repo/AccountCleanupRepository.java index 35d6fde..a96b19b 100644 --- a/src/main/java/core_service/modules/account/repo/AccountCleanupRepository.java +++ b/src/main/java/core_service/modules/account/repo/AccountCleanupRepository.java @@ -19,10 +19,10 @@ import java.util.Map; @RequiredArgsConstructor public class AccountCleanupRepository { - // Children before user_game (user_game_status_change would also go by its cascade), the tombstones last. + // Children before user_game (several would also go by its cascade), the tombstones last. static final List TABLES = List.of("user_game_question", "user_game_status_change", "user_game_achievement", "user_game_achievement_meta", "user_game_playtime_day", "wishlist_game", "backlog_snapshot", "backlog_order", - "sync_change", "sync_result", "user_steam_sync", "game_merge_undo_user", "user_psn_trophy", "user_psn_trophy_set", "user_game", "game_link_request", "account_name", "game_import", "user_psn_title", "user_psn_account", "reward_ledger", "reward_equipped", + "sync_change", "sync_result", "user_steam_sync", "game_merge_undo_user", "user_psn_trophy", "user_psn_trophy_set", "user_game_split", "user_game_set_backup", "user_game", "game_link_request", "account_name", "game_import", "user_psn_title", "user_psn_account", "reward_ledger", "reward_equipped", "account_role_change", "blocked_user", "listed_user", "follow", "follow_digest", "inbox_note", "public_user", "deleted_user"); // Every user with data has a row in one of these; deleteRows then removes the child rows too. diff --git a/src/main/java/core_service/modules/library/api/LibraryController.java b/src/main/java/core_service/modules/library/api/LibraryController.java index 93a1b82..1afe7c6 100644 --- a/src/main/java/core_service/modules/library/api/LibraryController.java +++ b/src/main/java/core_service/modules/library/api/LibraryController.java @@ -299,6 +299,17 @@ public class LibraryController { : ResponseEntity.status(HttpStatus.NOT_FOUND).body(new ErrorResponse("NOT_LINKED", "The game is not linked")); } + /** + * The user says the game is not the same as its other editions: it stands alone for this user from now on. + */ + @DeleteMapping("/games/{appid}/set") + public ResponseEntity splitGame(@AuthenticationPrincipal UserPrincipal principal, + @PathVariable("appid") Long appid) { + return gameLinkService.split(principal.getIdAsLong(), appid) ? ResponseEntity.noContent().build() + : ResponseEntity.status(HttpStatus.NOT_FOUND).body(new ErrorResponse("NOT_LINKED", + "The game has no other edition in your library")); + } + @GetMapping("/link-queue") public ResponseEntity> getLinkQueue(@RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size) { diff --git a/src/main/java/core_service/modules/library/domain/GameLinkMember.java b/src/main/java/core_service/modules/library/domain/UserGameSetMember.java similarity index 60% rename from src/main/java/core_service/modules/library/domain/GameLinkMember.java rename to src/main/java/core_service/modules/library/domain/UserGameSetMember.java index 280d7b5..a56f66e 100644 --- a/src/main/java/core_service/modules/library/domain/GameLinkMember.java +++ b/src/main/java/core_service/modules/library/domain/UserGameSetMember.java @@ -3,6 +3,7 @@ package core_service.modules.library.domain; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.Id; +import jakarta.persistence.IdClass; import jakarta.persistence.Table; import lombok.AccessLevel; import lombok.Getter; @@ -10,14 +11,20 @@ import lombok.NoArgsConstructor; import org.hibernate.annotations.Immutable; /** - * A key in a linked set: read-only, see V28. Writes go through {@code GameLinkRepository}. + * A key in a user's set (game_link minus the keys the user split off): read-only view, see V42. Writes go through + * {@code GameLinkRepository}. */ @Entity @Immutable -@Table(name = "game_link") +@Table(name = "user_game_set") +@IdClass(UserGameId.class) @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) -public class GameLinkMember { +public class UserGameSetMember { + + @Id + @Column(name = "user_id") + private Long userId; @Id @Column(name = "appid") diff --git a/src/main/java/core_service/modules/library/repo/GameLinkRepository.java b/src/main/java/core_service/modules/library/repo/GameLinkRepository.java index c354e60..3fd1766 100644 --- a/src/main/java/core_service/modules/library/repo/GameLinkRepository.java +++ b/src/main/java/core_service/modules/library/repo/GameLinkRepository.java @@ -19,8 +19,8 @@ import java.util.Map; import java.util.Optional; /** - * The global game sets and the auditor queue (V28). Plain SQL: sets span keys and users. Every pair is stored as - * (least, greatest). + * The global game sets and the auditor queue (V28), and the keys a user split off a set (V42). Plain SQL: sets span + * keys and users. Every pair is stored as (least, greatest). */ @Repository @RequiredArgsConstructor @@ -55,6 +55,11 @@ public class GameLinkRepository { (rs, row) -> rs.getLong("set_id")).stream().findFirst(); } + public boolean inOneSet(long appid, long otherAppid) { + Optional set = setOf(appid); + return set.isPresent() && set.equals(setOf(otherAppid)); + } + /** * Puts both keys in one set, joining two sets when each key has one, and marks the pair LINKED. * @@ -128,6 +133,69 @@ public class GameLinkRepository { """, Map.of()); } + /** + * The pairs of keys that share an IGDB "same game" key, are not in one set and were not dismissed (by an auditor + * or an unlink), as (least, greatest). + * + * @param appids at least one key of a pair is in it; null for every pair + */ + public List sameGamePairs(Collection appids) { + if (appids != null && appids.isEmpty()) { + return List.of(); + } + return jdbc.query(""" + select e1.appid as a, e2.appid as b + from game_edition e1 + join game_edition e2 on e2.same_game_id = e1.same_game_id and e1.appid < e2.appid + where (cast(:all as boolean) or e1.appid in (:appids) or e2.appid in (:appids)) + and not exists (select 1 from game_link l1 join game_link l2 on l2.set_id = l1.set_id + where l1.appid = e1.appid and l2.appid = e2.appid) + and not exists (select 1 from game_link_pair p + where p.appid_a = e1.appid and p.appid_b = e2.appid and p.state = 'DISMISSED') + order by e1.appid, e2.appid + """, new MapSqlParameterSource("all", appids == null) + .addValue("appids", appids == null ? List.of(0L) : appids), + (rs, row) -> new long[]{rs.getLong("a"), rs.getLong("b")}); + } + + /** + * True when a dismissed pair has one key in the set of {@code appid} and the other in the set of + * {@code otherAppid}; the keys are not in a set of their own when they have no link row. + */ + public boolean dismissedBetweenSets(long appid, long otherAppid) { + return Boolean.TRUE.equals(jdbc.queryForObject(""" + select exists ( + select 1 from game_link_pair p + where p.state = 'DISMISSED' + and (p.appid_a in (select appid from game_link where set_id in (select set_id from game_link where appid = :a) + union select :a) + and p.appid_b in (select appid from game_link where set_id in (select set_id from game_link where appid = :b) + union select :b) + or p.appid_b in (select appid from game_link where set_id in (select set_id from game_link where appid = :a) + union select :a) + and p.appid_a in (select appid from game_link where set_id in (select set_id from game_link where appid = :b) + union select :b))) + """, new MapSqlParameterSource("a", appid).addValue("b", otherAppid), Boolean.class)); + } + + /** + * Makes the key stand alone for the user, only while the user's set has another listed game; no link or sync + * puts it back. + * + * @return false when there is no other listed edition, or the key is split already + */ + public boolean split(long userId, long appid) { + return jdbc.update(""" + insert into user_game_split (user_id, appid) + select :user, :appid + where exists (select 1 from user_game_set me + join user_game_set o on o.user_id = me.user_id and o.set_id = me.set_id and o.appid <> me.appid + join listed_user_game og on og.user_id = o.user_id and og.appid = o.appid + where me.user_id = :user and me.appid = :appid) + on conflict do nothing + """, new MapSqlParameterSource("user", userId).addValue("appid", appid)) == 1; + } + /** * Holds until the end of the transaction, so one user's link requests run one after the other. */ @@ -198,16 +266,16 @@ public class GameLinkRepository { } /** - * The other listed games of the user in the set of {@code appid}. + * The other listed games in the user's set of {@code appid} (see V42). */ public List linkedEditions(long userId, long appid) { return jdbc.query(""" select ug.appid, g.name, ug.platform, ug.source, ug.status, g.image_url - from game_link me - join game_link o on o.set_id = me.set_id and o.appid <> me.appid - join listed_user_game ug on ug.user_id = :user and ug.appid = o.appid + from user_game_set me + join user_game_set o on o.user_id = me.user_id and o.set_id = me.set_id and o.appid <> me.appid + join listed_user_game ug on ug.user_id = o.user_id and ug.appid = o.appid join steam_game g on g.appid = ug.appid - where me.appid = :appid + where me.user_id = :user and me.appid = :appid order by g.name, ug.appid """, new MapSqlParameterSource("user", userId).addValue("appid", appid), (rs, row) -> new Edition(rs.getLong("appid"), rs.getString("name"), rs.getString("platform"), @@ -226,10 +294,10 @@ public class GameLinkRepository { } jdbc.query(""" select l.appid, count(*) as owned - from game_link l - join game_link o on o.set_id = l.set_id - join listed_user_game ug on ug.user_id = :user and ug.appid = o.appid - where l.appid in (:appids) + from user_game_set l + join user_game_set o on o.user_id = l.user_id and o.set_id = l.set_id + join listed_user_game ug on ug.user_id = o.user_id and ug.appid = o.appid + where l.user_id = :user and l.appid in (:appids) group by l.appid having count(*) >= 2 """, new MapSqlParameterSource("user", userId).addValue("appids", appids), diff --git a/src/main/java/core_service/modules/library/repo/QuestionRepository.java b/src/main/java/core_service/modules/library/repo/QuestionRepository.java index 7074fd0..a361f5a 100644 --- a/src/main/java/core_service/modules/library/repo/QuestionRepository.java +++ b/src/main/java/core_service/modules/library/repo/QuestionRepository.java @@ -38,8 +38,8 @@ public class QuestionRepository { select ug.appid as counted, ug.appid as member from counted_user_game ug where ug.user_id = :userId union select ug.appid, o.appid from counted_user_game ug - join game_link l on l.appid = ug.appid - join game_link o on o.set_id = l.set_id and o.appid <> l.appid + join user_game_set l on l.user_id = ug.user_id and l.appid = ug.appid + join user_game_set o on o.user_id = l.user_id and o.set_id = l.set_id and o.appid <> l.appid join listed_user_game og on og.user_id = ug.user_id and og.appid = o.appid where ug.user_id = :userId ) diff --git a/src/main/java/core_service/modules/library/repo/UserGameRepository.java b/src/main/java/core_service/modules/library/repo/UserGameRepository.java index ec591ce..6316f30 100644 --- a/src/main/java/core_service/modules/library/repo/UserGameRepository.java +++ b/src/main/java/core_service/modules/library/repo/UserGameRepository.java @@ -321,7 +321,7 @@ public interface UserGameRepository extends JpaRepository * @param namePattern a {@link #containsPattern} result, null for every name * @param maxMinutes only games whose normal time to beat is known and at most this; null for every game * @param hideDuplicates only the games a linked set counts (see V28), not its lesser editions - * @param sources the sources a row or one of its listed linked editions must have; never empty + * @param sources the sources a row or one of its listed editions in the user's set (see V42) must have; never empty */ @Query("select ug from UserGame ug join SteamGame sg on sg.appid = ug.appid " + "where ug.userId = :userId and ug.removedAt is null " + @@ -332,10 +332,10 @@ public interface UserGameRepository extends JpaRepository " where t.appid = ug.appid and t.normallyMinutes <= :maxMinutes)) " + "and (:hideDuplicates = false or exists (select 1 from CountedUserGame c " + " where c.userId = ug.userId and c.appid = ug.appid)) " + - "and (ug.source in :sources or exists (select 1 from GameLinkMember l " + - " join GameLinkMember o on o.setId = l.setId and o.appid <> l.appid " + + "and (ug.source in :sources or exists (select 1 from UserGameSetMember l " + + " join UserGameSetMember o on o.userId = l.userId and o.setId = l.setId and o.appid <> l.appid " + " join UserGame og on og.userId = ug.userId and og.appid = o.appid " + - " where l.appid = ug.appid and og.removedAt is null and og.hiddenAt is null and og.source in :sources))") + " where l.userId = ug.userId and l.appid = ug.appid and og.removedAt is null and og.hiddenAt is null and og.source in :sources))") Page findOwnedGames(@Param("userId") Long userId, @Param("hidden") boolean hidden, @Param("status") GameStatus status, diff --git a/src/main/java/core_service/modules/library/service/GameEditionService.java b/src/main/java/core_service/modules/library/service/GameEditionService.java index 0d6f0cc..db95fd3 100644 --- a/src/main/java/core_service/modules/library/service/GameEditionService.java +++ b/src/main/java/core_service/modules/library/service/GameEditionService.java @@ -2,11 +2,15 @@ package core_service.modules.library.service; import core_service.modules.igdb_adapter.service.IgdbApiService; import core_service.modules.library.repo.GameEditionRepository; -import lombok.RequiredArgsConstructor; +import core_service.modules.library.repo.GameLinkRepository; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.event.EventListener; import org.springframework.core.task.TaskExecutor; import org.springframework.stereotype.Service; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; import java.time.Instant; import java.util.Collection; @@ -15,20 +19,32 @@ import java.util.Set; /** * Stores IGDB's "same game" key per library key, the same way {@link MoodService} stores moods: only for games - * without a fresh row, and nothing when IGDB fails. + * without a fresh row, and nothing when IGDB fails. Keys with the same "same game" key are linked into one set at once + * (no auditor, so no audit row); a pair an auditor or an unlink dismissed is never linked again. */ @Service -@RequiredArgsConstructor @Slf4j public class GameEditionService { private final IgdbApiService igdbApiService; private final GameEditionRepository gameEditionRepository; + private final GameLinkRepository gameLinkRepository; private final TaskExecutor applicationTaskExecutor; + private final TransactionTemplate transaction; @Value("${app.library.edition-ttl-days:30}") private long ttlDays; + public GameEditionService(IgdbApiService igdbApiService, GameEditionRepository gameEditionRepository, + GameLinkRepository gameLinkRepository, TaskExecutor applicationTaskExecutor, + PlatformTransactionManager transactionManager) { + this.igdbApiService = igdbApiService; + this.gameEditionRepository = gameEditionRepository; + this.gameLinkRepository = gameLinkRepository; + this.applicationTaskExecutor = applicationTaskExecutor; + this.transaction = new TransactionTemplate(transactionManager); + } + public void refresh(Collection appids) { if (!igdbApiService.isEnabled() || appids.isEmpty()) return; Set stale = stale(appids); @@ -46,6 +62,50 @@ public class GameEditionService { gameEditionRepository.upsert(appid, edition == null ? null : edition.igdbId(), edition == null ? null : edition.sameGameId(), now); } + linkSameGames(answer.answered()); + } + + /** + * Links every pair of keys that share a "same game" key, one transaction per pair. + * + * @param appids at least one key of a pair is in it; null for every pair + * @return the number of pairs linked + */ + public int linkSameGames(Collection appids) { + int linked = 0; + for (long[] pair : gameLinkRepository.sameGamePairs(appids)) { + if (Boolean.TRUE.equals(transaction.execute(tx -> linkPair(pair[0], pair[1])))) { + linked++; + } + } + if (linked > 0) { + log.info("Linked {} pairs of the same game from IGDB", linked); + } + return linked; + } + + private boolean linkPair(long appid, long otherAppid) { + if (gameLinkRepository.lockGames(appid, otherAppid).size() < 2 + || gameLinkRepository.inOneSet(appid, otherAppid) + || gameLinkRepository.dismissedBetweenSets(appid, otherAppid)) { + return false; + } + gameLinkRepository.link(appid, otherAppid); + return true; + } + + /** + * Groups the games already in the database; the editions refresh only sees new ones. + */ + @EventListener(ApplicationReadyEvent.class) + public void linkExisting() { + applicationTaskExecutor.execute(() -> { + try { + linkSameGames(null); + } catch (RuntimeException e) { + log.warn("Existing games not grouped: {}", e.getMessage()); + } + }); } /** diff --git a/src/main/java/core_service/modules/library/service/GameLinkService.java b/src/main/java/core_service/modules/library/service/GameLinkService.java index 57dab3d..667ff3e 100644 --- a/src/main/java/core_service/modules/library/service/GameLinkService.java +++ b/src/main/java/core_service/modules/library/service/GameLinkService.java @@ -16,12 +16,12 @@ import org.springframework.transaction.support.TransactionTemplate; import java.util.List; import java.util.Map; -import java.util.Optional; import java.util.stream.Collectors; /** * An auditor links keys of one game for every user, from IGDB's suggestions and the users' requests; a user can - * ask for a link. Every change is written to the audit log. + * ask for a link, and split a key off a set for himself. Every auditor change is written to the audit log. Links + * also come from IGDB without an auditor, see {@link GameEditionService#linkSameGames}. */ @Service @RequiresRole(Role.AUDITOR) @@ -56,7 +56,7 @@ public class GameLinkService { if (names.size() < 2) { return LinkResult.NOT_FOUND; } - if (inOneSet(appid, otherAppid)) { + if (linkRepository.inOneSet(appid, otherAppid)) { return LinkResult.ALREADY_LINKED; } linkRepository.link(appid, otherAppid); @@ -123,7 +123,7 @@ public class GameLinkService { return RequestResult.NOT_FOUND; } String state = linkRepository.pairState(appid, otherAppid); - if ("LINKED".equals(state) || inOneSet(appid, otherAppid)) { + if ("LINKED".equals(state) || linkRepository.inOneSet(appid, otherAppid)) { return RequestResult.ALREADY_LINKED; } if ("DISMISSED".equals(state)) { @@ -137,8 +137,11 @@ public class GameLinkService { }); } - private boolean inOneSet(long appid, long otherAppid) { - Optional set = linkRepository.setOf(appid); - return set.isPresent() && set.equals(linkRepository.setOf(otherAppid)); + /** + * @return false when the game has no other listed edition in the user's set + */ + @RequiresRole(Role.USER) + public boolean split(long userId, long appid) { + return Boolean.TRUE.equals(transaction.execute(tx -> linkRepository.split(userId, appid))); } } diff --git a/src/main/java/core_service/modules/rewards/repo/RewardFactsRepository.java b/src/main/java/core_service/modules/rewards/repo/RewardFactsRepository.java index 3035630..2ea32ad 100644 --- a/src/main/java/core_service/modules/rewards/repo/RewardFactsRepository.java +++ b/src/main/java/core_service/modules/rewards/repo/RewardFactsRepository.java @@ -1,6 +1,7 @@ package core_service.modules.rewards.repo; import core_service.modules.rewards.domain.FinishFacts; +import core_service.modules.rewards.domain.RewardKind; import core_service.modules.rewards.domain.RewardRules; import core_service.modules.rewards.domain.StartFacts; import lombok.RequiredArgsConstructor; @@ -17,6 +18,7 @@ import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Optional; +import java.util.stream.Collectors; /** * What the library tables say about a user's games, as raw facts for {@link RewardRules}. Plain SQL, because the @@ -32,6 +34,32 @@ public class RewardFactsRepository { private final NamedParameterJdbcTemplate jdbc; + /** + * A set pays once: true when the game or another edition in the user's set has a ledger row of the kinds. + */ + private static String paidInSet(String alias, String kinds) { + return """ + exists (select 1 from reward_ledger l where l.user_id = %1$s.user_id and l.kind in (%2$s) + and (l.ref = %1$s.appid::text + or l.ref in (select o.appid::text from user_game_set o + where o.user_id = st.user_id and o.set_id = st.set_id))) + """.formatted(alias, kinds); + } + + /** + * True when the game or another edition in the user's set has a ledger row of the kinds: the rows booked earlier + * in a settle run count. + */ + public boolean paidInSet(long userId, long appid, List kinds) { + String quoted = kinds.stream().map(k -> "'" + k.name() + "'").collect(Collectors.joining(", ")); + return Boolean.TRUE.equals(jdbc.queryForObject(""" + select %s + from (select cast(:userId as bigint) as user_id, cast(:appid as bigint) as appid) x + left join user_game_set st on st.user_id = x.user_id and st.appid = x.appid + """.formatted(paidInSet("x", quoted)), + new MapSqlParameterSource("userId", userId).addValue("appid", appid), Boolean.class)); + } + public boolean isActive(long userId) { return Boolean.TRUE.equals(jdbc.queryForObject(""" select not exists (select 1 from deleted_user where user_id = :userId) @@ -45,7 +73,8 @@ public class RewardFactsRepository { } /** - * The first BACKLOG to PLAYING change of each game not paid yet. + * The first BACKLOG to PLAYING change of each game not paid yet. Every edition of a + * linked set is a candidate; the service books one per set. */ public List startCandidates(long userId, ZoneId zone) { return jdbc.query(""" @@ -59,10 +88,11 @@ public class RewardFactsRepository { and ug.hidden_at is null and ug.source <> 'PSN' and not exists (select 1 from user_psn_title t where t.user_id = ug.user_id and t.appid = ug.appid and t.state = 'LINKED') - where not exists (select 1 from reward_ledger l where l.user_id = c.user_id and l.kind = 'STARTED' - and l.ref = c.appid::text) - order by c.started_at - """, new MapSqlParameterSource("userId", userId).addValue("zone", zone.getId()), + left join user_game_set st on st.user_id = c.user_id and st.appid = c.appid + where not %1$s + order by c.started_at, c.appid + """.formatted(paidInSet("c", "'STARTED'")), + new MapSqlParameterSource("userId", userId).addValue("zone", zone.getId()), (rs, i) -> new StartFacts(rs.getLong("appid"), rs.getBoolean("manual"), rs.getTimestamp("started_at").toInstant(), rs.getBoolean("played_after"))); } @@ -71,7 +101,8 @@ public class RewardFactsRepository { * Games that are FINISHED or COMPLETED now, with their first change to one of the two, and no FINISHED or * FINISHED_MANUAL row yet. Oldest first. Only fresh play proves a finish: the ending achievement must be unlocked * no earlier than {@link RewardRules#PLAYED_AROUND_DAYS} before that change, and the playtime share counts only - * the playtime days since it. Hidden games pay nothing. + * the playtime days since it. Hidden games pay nothing. Every + * edition of a linked set is a candidate; the service books one per set. */ public List finishCandidates(long userId, ZoneId zone) { return jdbc.query(""" @@ -99,10 +130,11 @@ public class RewardFactsRepository { left join game_time_to_beat t on t.appid = c.appid left join user_game_achievement_meta m on m.user_id = c.user_id and m.appid = c.appid left join game_achievement_schema_meta s on s.appid = c.appid - where not exists (select 1 from reward_ledger l where l.user_id = c.user_id - and l.kind in ('FINISHED', 'FINISHED_MANUAL') and l.ref = c.appid::text) - order by c.finished_at - """, new MapSqlParameterSource("userId", userId).addValue("zone", zone.getId()) + left join user_game_set st on st.user_id = c.user_id and st.appid = c.appid + where not %1$s + order by c.finished_at, c.appid + """.formatted(paidInSet("c", "'FINISHED', 'FINISHED_MANUAL'")), + new MapSqlParameterSource("userId", userId).addValue("zone", zone.getId()) .addValue("aroundDays", RewardRules.PLAYED_AROUND_DAYS), (rs, i) -> new FinishFacts(rs.getLong("appid"), rs.getBoolean("manual"), rs.getBoolean("completed"), rs.getInt("playtime_minutes"), rs.getInt("played_since"), @@ -112,7 +144,8 @@ public class RewardFactsRepository { } /** - * COMPLETED Steam games with a paid FINISHED row and no COMPLETED row yet; finishedAt is the FINISHED event. + * COMPLETED Steam games with a paid FINISHED row (on the key or any key of its set) and no COMPLETED row yet; finishedAt is the FINISHED event. + * Every edition of a linked set is a candidate; the service books one per set. */ public List completedCandidates(long userId) { return jdbc.query(""" @@ -120,16 +153,22 @@ public class RewardFactsRepository { coalesce(s.achievements_total > 0 and m.achievements_unlocked >= s.achievements_total, false) as all_achievements from user_game ug - join reward_ledger f on f.user_id = ug.user_id and f.kind = 'FINISHED' and f.ref = ug.appid::text + left join user_game_set st on st.user_id = ug.user_id and st.appid = ug.appid + join lateral (select min(l.event_at) as event_at from reward_ledger l + where l.user_id = ug.user_id and l.kind = 'FINISHED' + and (l.ref = ug.appid::text + or l.ref in (select o.appid::text from user_game_set o + where o.user_id = st.user_id and o.set_id = st.set_id))) f + on f.event_at is not null left join user_game_achievement_meta m on m.user_id = ug.user_id and m.appid = ug.appid left join game_achievement_schema_meta s on s.appid = ug.appid where ug.user_id = :userId and ug.status = 'COMPLETED' and ug.removed_at is null and ug.hidden_at is null and ug.source <> 'PSN' and not exists (select 1 from user_psn_title t where t.user_id = ug.user_id and t.appid = ug.appid and t.state = 'LINKED') - and not exists (select 1 from reward_ledger l where l.user_id = ug.user_id and l.kind = 'COMPLETED' - and l.ref = ug.appid::text) - """, new MapSqlParameterSource("userId", userId), + and not %1$s + order by f.event_at, ug.appid + """.formatted(paidInSet("ug", "'COMPLETED'")), new MapSqlParameterSource("userId", userId), (rs, i) -> new FinishFacts(rs.getLong("appid"), false, true, rs.getInt("playtime_minutes"), 0, null, false, false, rs.getBoolean("all_achievements"), rs.getTimestamp("event_at").toInstant())); } diff --git a/src/main/java/core_service/modules/rewards/service/RewardService.java b/src/main/java/core_service/modules/rewards/service/RewardService.java index ef39687..e8d5975 100644 --- a/src/main/java/core_service/modules/rewards/service/RewardService.java +++ b/src/main/java/core_service/modules/rewards/service/RewardService.java @@ -247,9 +247,16 @@ public class RewardService { return ledger.add(userId, entry) ? 1 : 0; } + // A linked set pays once: every edition is a candidate, the first that passed the rules books, the others see its row. + private int bookOnePerSet(long userId, RewardEntry entry) { + List kinds = entry.kind() == RewardKind.FINISHED || entry.kind() == RewardKind.FINISHED_MANUAL + ? List.of(RewardKind.FINISHED, RewardKind.FINISHED_MANUAL) : List.of(entry.kind()); + return facts.paidInSet(userId, Long.parseLong(entry.ref()), kinds) ? 0 : book(userId, entry); + } + private int settleStarts(long userId) { return facts.startCandidates(userId, zone).stream() - .flatMap(f -> RewardRules.start(f).stream()).mapToInt(e -> book(userId, e)).sum(); + .flatMap(f -> RewardRules.start(f).stream()).mapToInt(e -> bookOnePerSet(userId, e)).sum(); } private int settleFinishes(long userId) { @@ -268,14 +275,14 @@ public class RewardService { continue; } } - paid += book(userId, entry); + paid += bookOnePerSet(userId, entry); } return paid; } private int settleCompleted(long userId) { return facts.completedCandidates(userId).stream() - .flatMap(f -> RewardRules.completed(f).stream()).mapToInt(e -> book(userId, e)).sum(); + .flatMap(f -> RewardRules.completed(f).stream()).mapToInt(e -> bookOnePerSet(userId, e)).sum(); } // Only full months: the running month is not over. diff --git a/src/main/resources/db/migration/V42__Game_Sets.sql b/src/main/resources/db/migration/V42__Game_Sets.sql new file mode 100644 index 0000000..e02d338 --- /dev/null +++ b/src/main/resources/db/migration/V42__Game_Sets.sql @@ -0,0 +1,179 @@ +-- One entry per game across platforms (Gitea #34, slice 1). The sets of game_link (V28) now also come from IGDB +-- without an auditor. A user can split one key off the set for himself; no sync or link touches it afterwards. +-- Status, rating, notes and priority are one value per set, kept by the triggers below for every code path (like V14). +CREATE TABLE user_game_split ( + user_id BIGINT NOT NULL, + appid BIGINT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (user_id, appid), + FOREIGN KEY (user_id, appid) REFERENCES user_game (user_id, appid) ON DELETE CASCADE ON UPDATE CASCADE +); +-- The values a row had before a join changed them (first copy wins). Holds notes, so it goes with the account. +CREATE TABLE user_game_set_backup ( + user_id BIGINT NOT NULL, + appid BIGINT NOT NULL, + status VARCHAR(16) NOT NULL, + status_set_by_user BOOLEAN NOT NULL, + rating INTEGER, + notes VARCHAR(2000), + priority VARCHAR(8), + saved_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (user_id, appid), + FOREIGN KEY (user_id, appid) REFERENCES user_game (user_id, appid) ON DELETE CASCADE ON UPDATE CASCADE +); +-- The game_link triggers look the rows of a key up by appid. +CREATE INDEX user_game_appid_idx ON user_game (appid); + +-- A user's sets: the user's rows in game_link, minus the keys the user split off. +CREATE VIEW user_game_set AS +SELECT ug.user_id, ug.appid, l.set_id +FROM user_game ug +JOIN game_link l ON l.appid = ug.appid +WHERE NOT EXISTS (SELECT 1 FROM user_game_split s WHERE s.user_id = ug.user_id AND s.appid = ug.appid); + +-- Same rule as V28 (same column rule too), but a set is the user's set. +DROP VIEW counted_user_game; +CREATE VIEW counted_user_game AS +SELECT ug.* FROM listed_user_game ug +WHERE NOT EXISTS ( + SELECT 1 FROM user_game_set me + JOIN user_game_set o ON o.user_id = me.user_id AND o.set_id = me.set_id AND o.appid <> me.appid + JOIN listed_user_game og ON og.user_id = o.user_id AND og.appid = o.appid + WHERE me.user_id = ug.user_id AND me.appid = ug.appid + AND (CASE og.status WHEN 'COMPLETED' THEN 4 WHEN 'FINISHED' THEN 3 WHEN 'PLAYING' THEN 2 WHEN 'DROPPED' THEN 1 ELSE 0 END, + og.playtime_minutes, -extract(epoch from og.first_seen_at), -og.appid) + > (CASE ug.status WHEN 'COMPLETED' THEN 4 WHEN 'FINISHED' THEN 3 WHEN 'PLAYING' THEN 2 WHEN 'DROPPED' THEN 1 ELSE 0 END, + ug.playtime_minutes, -extract(epoch from ug.first_seen_at), -ug.appid)); + +-- The join rule: the winner is the counted rule (listed rows first, then the rank of counted_user_game, keep both +-- equal). Status from the winner, status_set_by_user true if any row has it, rating and priority from the winner or +-- else the first row that has one, notes: the distinct non-blank notes in winner order, cut to 2000. A row that +-- changes is copied to user_game_set_backup first. The status log of the change is no hand change (see below). +CREATE FUNCTION align_user_game_set(p_user BIGINT, p_set BIGINT) RETURNS void AS $$ +DECLARE + v_count INTEGER; + v_status VARCHAR(16); + v_by_user BOOLEAN; + v_rating INTEGER; + v_notes VARCHAR(2000); + v_priority VARCHAR(8); + v_log BIGINT; +BEGIN + WITH members AS ( + SELECT ug.status, ug.status_set_by_user, ug.rating, ug.notes, ug.priority, + row_number() OVER (ORDER BY (ug.removed_at IS NULL AND ug.hidden_at IS NULL) DESC, + CASE ug.status WHEN 'COMPLETED' THEN 4 WHEN 'FINISHED' THEN 3 WHEN 'PLAYING' THEN 2 WHEN 'DROPPED' THEN 1 ELSE 0 END DESC, + ug.playtime_minutes DESC, ug.first_seen_at, ug.appid) AS pos + FROM user_game ug + JOIN user_game_set s ON s.user_id = ug.user_id AND s.appid = ug.appid + WHERE s.user_id = p_user AND s.set_id = p_set + ) + SELECT count(*), + (array_agg(status ORDER BY pos))[1], + bool_or(status_set_by_user), + (array_agg(rating ORDER BY pos) FILTER (WHERE rating IS NOT NULL))[1], + (array_agg(priority ORDER BY pos) FILTER (WHERE priority IS NOT NULL))[1], + coalesce((SELECT left(string_agg(d.notes, E'\n\n' ORDER BY d.first_pos), 2000) + FROM (SELECT m.notes, min(m.pos) AS first_pos FROM members m + WHERE btrim(m.notes) <> '' GROUP BY m.notes) d), + (array_agg(notes ORDER BY pos))[1]) + INTO v_count, v_status, v_by_user, v_rating, v_priority, v_notes + FROM members; + IF v_count < 2 THEN + RETURN; + END IF; + + INSERT INTO user_game_set_backup (user_id, appid, status, status_set_by_user, rating, notes, priority) + SELECT ug.user_id, ug.appid, ug.status, ug.status_set_by_user, ug.rating, ug.notes, ug.priority + FROM user_game ug + JOIN user_game_set s ON s.user_id = ug.user_id AND s.appid = ug.appid + WHERE s.user_id = p_user AND s.set_id = p_set + AND (ug.status, ug.status_set_by_user, ug.rating, ug.notes, ug.priority) + IS DISTINCT FROM (v_status, v_by_user, v_rating, v_notes, v_priority) + ON CONFLICT DO NOTHING; + + SELECT coalesce(max(id), 0) INTO v_log FROM user_game_status_change; + UPDATE user_game ug + SET status = v_status, status_set_by_user = v_by_user, rating = v_rating, notes = v_notes, priority = v_priority + FROM user_game_set s + WHERE s.user_id = p_user AND s.set_id = p_set AND ug.user_id = s.user_id AND ug.appid = s.appid + AND (ug.status, ug.status_set_by_user, ug.rating, ug.notes, ug.priority) + IS DISTINCT FROM (v_status, v_by_user, v_rating, v_notes, v_priority); + + -- The V14 trigger logged the status changes of the update as hand changes of now. A join is no hand change, and + -- a row that takes a finished status takes the first finish of the set, so "finished per year" does not move. + UPDATE user_game_status_change sc + SET by_user = false, + changed_at = coalesce(CASE WHEN sc.to_status IN ('FINISHED', 'COMPLETED') THEN + (SELECT min(o.changed_at) FROM user_game_status_change o + JOIN user_game_set s ON s.user_id = o.user_id AND s.appid = o.appid + WHERE s.user_id = p_user AND s.set_id = p_set AND o.id <= v_log + AND o.to_status IN ('FINISHED', 'COMPLETED')) END, sc.changed_at) + WHERE sc.id > v_log AND sc.user_id = p_user + AND sc.appid IN (SELECT appid FROM user_game_set WHERE user_id = p_user AND set_id = p_set); +END; +$$ LANGUAGE plpgsql; + +-- A change on one edition goes to the other rows of the user's set, also hidden and removed ones, so a later unhide +-- matches. The update of the other rows fires this trigger again: depth 2 ends it. +CREATE FUNCTION share_user_game_set() RETURNS TRIGGER AS $$ +BEGIN + IF pg_trigger_depth() > 1 THEN + RETURN NULL; + END IF; + UPDATE user_game ug + SET status = NEW.status, status_set_by_user = NEW.status_set_by_user, rating = NEW.rating, notes = NEW.notes, + priority = NEW.priority + FROM user_game_set me + JOIN user_game_set o ON o.user_id = me.user_id AND o.set_id = me.set_id AND o.appid <> me.appid + WHERE me.user_id = NEW.user_id AND me.appid = NEW.appid AND ug.user_id = o.user_id AND ug.appid = o.appid + AND (ug.status, ug.status_set_by_user, ug.rating, ug.notes, ug.priority) + IS DISTINCT FROM (NEW.status, NEW.status_set_by_user, NEW.rating, NEW.notes, NEW.priority); + RETURN NULL; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER user_game_set_share AFTER UPDATE OF status, status_set_by_user, rating, notes, priority ON user_game + FOR EACH ROW WHEN ((OLD.status, OLD.status_set_by_user, OLD.rating, OLD.notes, OLD.priority) + IS DISTINCT FROM (NEW.status, NEW.status_set_by_user, NEW.rating, NEW.notes, NEW.priority)) + EXECUTE FUNCTION share_user_game_set(); + +-- A new row joins the set its key is in. +CREATE FUNCTION join_user_game_set() RETURNS TRIGGER AS $$ +DECLARE + v_set BIGINT; +BEGIN + IF pg_trigger_depth() > 1 THEN + RETURN NULL; + END IF; + SELECT set_id INTO v_set FROM user_game_set WHERE user_id = NEW.user_id AND appid = NEW.appid; + IF v_set IS NOT NULL THEN + PERFORM align_user_game_set(NEW.user_id, v_set); + END IF; + RETURN NULL; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER user_game_set_join AFTER INSERT ON user_game + FOR EACH ROW EXECUTE FUNCTION join_user_game_set(); + +-- A key joins a set (link, or a merge of two sets): every user with a row on it gets the set aligned. +CREATE FUNCTION join_game_link() RETURNS TRIGGER AS $$ +DECLARE + v_user BIGINT; +BEGIN + IF pg_trigger_depth() > 1 THEN + RETURN NULL; + END IF; + FOR v_user IN SELECT user_id FROM user_game_set WHERE appid = NEW.appid LOOP + PERFORM align_user_game_set(v_user, NEW.set_id); + END LOOP; + RETURN NULL; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER game_link_join AFTER INSERT OR UPDATE OF set_id ON game_link + FOR EACH ROW EXECUTE FUNCTION join_game_link(); + +-- Existing sets with different values: the same rule, once. +SELECT align_user_game_set(user_id, set_id) FROM user_game_set GROUP BY user_id, set_id HAVING count(*) > 1; diff --git a/src/test/java/core_service/modules/account/repo/AccountCleanupRepositoryTest.java b/src/test/java/core_service/modules/account/repo/AccountCleanupRepositoryTest.java index a29e07c..95f3d1c 100644 --- a/src/test/java/core_service/modules/account/repo/AccountCleanupRepositoryTest.java +++ b/src/test/java/core_service/modules/account/repo/AccountCleanupRepositoryTest.java @@ -17,7 +17,7 @@ class AccountCleanupRepositoryTest extends AbstractPersistenceTest { private static final List USER_TABLES = List.of("user_game_question", "user_game_status_change", "user_game_achievement", "user_game_achievement_meta", "user_game_playtime_day", "wishlist_game", "backlog_snapshot", - "sync_change", "sync_result", "user_steam_sync", "game_merge_undo_user", "user_psn_trophy", "user_psn_trophy_set", "user_game", "game_link_request", "account_name", "game_import", "reward_ledger", + "sync_change", "sync_result", "user_steam_sync", "game_merge_undo_user", "user_psn_trophy", "user_psn_trophy_set", "user_game_split", "user_game_set_backup", "user_game", "game_link_request", "account_name", "game_import", "reward_ledger", "reward_equipped", "user_psn_account", "listed_user", "public_user", "follow", "follow_digest", "inbox_note"); @Autowired @@ -35,6 +35,9 @@ class AccountCleanupRepositoryTest extends AbstractPersistenceTest { for (long userId : List.of(1L, 2L)) { // The V14 trigger adds the user_game_status_change row. jdbc.update("insert into user_game (user_id, appid, status) values (?, 220, 'BACKLOG')", userId); + jdbc.update("insert into user_game_split (user_id, appid) values (?, 220)", userId); + jdbc.update("insert into user_game_set_backup (user_id, appid, status, status_set_by_user, notes) " + + "values (?, 220, 'PLAYING', true, 'old note')", userId); jdbc.update("insert into user_game_question (user_id, appid, question, playtime_minutes) values (?, 220, 'START', 0)", userId); jdbc.update("insert into user_game_achievement (user_id, appid, api_name) values (?, 220, 'ACH_1')", userId); jdbc.update("insert into user_game_achievement_meta (user_id, appid) values (?, 220)", userId); diff --git a/src/test/java/core_service/modules/library/api/GameLinkFlowTest.java b/src/test/java/core_service/modules/library/api/GameLinkFlowTest.java index 2fddf49..5b99985 100644 --- a/src/test/java/core_service/modules/library/api/GameLinkFlowTest.java +++ b/src/test/java/core_service/modules/library/api/GameLinkFlowTest.java @@ -8,6 +8,7 @@ import core_service.modules.library.domain.UserGame; import core_service.modules.library.repo.GameLinkRepository; import core_service.modules.library.repo.SteamGameRepository; import core_service.modules.library.repo.UserGameRepository; +import core_service.modules.library.service.GameEditionService; import core_service.shared.security.Role; import core_service.shared.test.AbstractIntegrationTest; import core_service.shared.test.ContractTokens; @@ -41,6 +42,7 @@ import static org.mockito.Mockito.doAnswer; 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; @@ -67,6 +69,9 @@ class GameLinkFlowTest extends AbstractIntegrationTest { @Autowired private UserGameRepository userGameRepository; + @Autowired + private GameEditionService gameEditionService; + @MockitoSpyBean private GameLinkRepository linkRepository; @@ -351,4 +356,95 @@ class GameLinkFlowTest extends AbstractIntegrationTest { .andExpect(jsonPath("$.sources[1]").value("PSN")) .andExpect(jsonPath("$.sources", hasSize(2))); } + + private ResultActions splitAs(long userId, long appid) throws Exception { + return mockMvc.perform(as(delete("/api/library/games/" + appid + "/set"), userId, Role.USER)); + } + + @Test + void autoLink_LinksTheIgdbPairWithoutAnAuditor() throws Exception { + assertThat(gameEditionService.linkSameGames(List.of(GAME))).isEqualTo(1); + assertThat(gameEditionService.linkSameGames(List.of(GAME))).isZero(); + + queue().andExpect(jsonPath("$.content", hasSize(0))); + assertThat(audit()).isEmpty(); + mockMvc.perform(as(get("/api/library/games/" + GAME), USER_A, Role.USER)).andExpect(status().isOk()) + .andExpect(jsonPath("$.editions", hasSize(1))); + } + + @Test + void autoLink_NeverLinksADismissedPair() throws Exception { + queue(); + mockMvc.perform(as(delete("/api/library/link-queue/" + GAME_2 + "/" + GAME), AUDITOR_ID, Role.AUDITOR)) + .andExpect(status().isNoContent()); + + assertThat(gameEditionService.linkSameGames(null)).isZero(); + } + + @Test + void split_StandsAloneForTheUserOnly_AndStaysSplit() throws Exception { + gameEditionService.linkSameGames(null); + + splitAs(USER_A, GAME).andExpect(status().isNoContent()); + splitAs(USER_A, GAME).andExpect(status().isNotFound()).andExpect(jsonPath("$.code").value("NOT_LINKED")); + gameEditionService.linkSameGames(null); + + mockMvc.perform(as(get("/api/library/games/" + GAME), USER_A, Role.USER)).andExpect(status().isOk()) + .andExpect(jsonPath("$.editions", hasSize(0))); + mockMvc.perform(as(get("/api/library/summary"), USER_A, Role.USER)).andExpect(jsonPath("$.total").value(2)); + mockMvc.perform(as(get("/api/library/games/" + GAME), USER_B, Role.USER)).andExpect(status().isOk()) + .andExpect(jsonPath("$.editions", hasSize(1))); + mockMvc.perform(as(get("/api/library/summary"), USER_B, Role.USER)).andExpect(jsonPath("$.total").value(1)); + } + + @Test + void split_GameWithoutAnotherEdition_Is404() throws Exception { + splitAs(USER_A, GAME).andExpect(status().isNotFound()).andExpect(jsonPath("$.code").value("NOT_LINKED")); + } + + @Test + void status_ChangeOnOneEditionShowsOnTheOther_UntilTheUserSplits() throws Exception { + gameEditionService.linkSameGames(null); + + mockMvc.perform(as(put("/api/library/games/" + GAME + "/status"), USER_A, Role.USER) + .contentType(MediaType.APPLICATION_JSON).content("{\"status\": \"PLAYING\"}")).andExpect(status().isOk()); + + mockMvc.perform(as(get("/api/library/games/" + GAME_2), USER_A, Role.USER)).andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("PLAYING")); + mockMvc.perform(as(get("/api/library/games/" + GAME_2), USER_B, Role.USER)).andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("BACKLOG")); + + splitAs(USER_A, GAME_2).andExpect(status().isNoContent()); + mockMvc.perform(as(put("/api/library/games/" + GAME + "/status"), USER_A, Role.USER) + .contentType(MediaType.APPLICATION_JSON).content("{\"status\": \"FINISHED\"}")).andExpect(status().isOk()); + + mockMvc.perform(as(get("/api/library/games/" + GAME_2), USER_A, Role.USER)).andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("PLAYING")); + } + + @Test + void sourceFilter_AfterASplitTheOtherEditionNoLongerMatches() throws Exception { + setSource(GAME_2, GameSource.PSN); + gameEditionService.linkSameGames(null); + games("?source=PSN").andExpect(jsonPath("$.totalElements").value(2)); + + splitAs(USER_A, GAME).andExpect(status().isNoContent()); + + games("?source=PSN").andExpect(jsonPath("$.totalElements").value(1)) + .andExpect(jsonPath("$.content[0].appid").value(GAME_2)); + } + + @Test + void autoLink_DismissedPairStaysApartAlsoThroughAThirdEdition() throws Exception { + long third = FILLER - 1; + steamGameRepository.save(new SteamGame(third, "Hades (Xbox)", null, Instant.now())); + jdbc.update("insert into game_edition (appid, igdb_id, same_game_id, checked_at) values (?, 9, 7, now())", third); + queue(); + mockMvc.perform(as(delete("/api/library/link-queue/" + GAME_2 + "/" + GAME), AUDITOR_ID, Role.AUDITOR)) + .andExpect(status().isNoContent()); + + gameEditionService.linkSameGames(null); + + assertThat(linkRepository.inOneSet(GAME, GAME_2)).isFalse(); + } } diff --git a/src/test/java/core_service/modules/library/repo/CountedUserGameViewTest.java b/src/test/java/core_service/modules/library/repo/CountedUserGameViewTest.java index 0f20174..f27fb4e 100644 --- a/src/test/java/core_service/modules/library/repo/CountedUserGameViewTest.java +++ b/src/test/java/core_service/modules/library/repo/CountedUserGameViewTest.java @@ -39,6 +39,13 @@ class CountedUserGameViewTest extends AbstractPersistenceTest { userId, appid, status, playtime); } + // The set trigger would copy the status to the sibling; the view rule is about rows that differ. + private void differ(long appid, String status) { + jdbc.execute("alter table user_game disable trigger user_game_set_share"); + jdbc.update("update user_game set status = ? where appid = ?", status, appid); + jdbc.execute("alter table user_game enable trigger user_game_set_share"); + } + private List counted(long userId) { return jdbc.queryForList("select appid from counted_user_game where user_id = ? order by appid", Long.class, userId); } @@ -49,12 +56,25 @@ class CountedUserGameViewTest extends AbstractPersistenceTest { jdbc.queryForList(COLUMNS, String.class, "counted_user_game")); } + @Test + void join_CountsTheEditionThatWasMoreAdvancedBeforeIt() { + own(USER, 10, "BACKLOG", 500); + own(USER, 20, "BACKLOG", 100); + jdbc.update("update user_game set status = 'FINISHED' where appid = 20"); + + set(10, 1); + set(20, 1); + + assertThat(counted(USER)).containsExactly(10L); + } + @Test void set_CountsTheMostAdvancedEdition() { set(10, 1); set(20, 1); own(USER, 10, "BACKLOG", 0); - own(USER, 20, "COMPLETED", 0); + own(USER, 20, "BACKLOG", 0); + differ(20, "COMPLETED"); assertThat(counted(USER)).containsExactly(20L); } @@ -97,7 +117,8 @@ class CountedUserGameViewTest extends AbstractPersistenceTest { set(10, 1); set(20, 1); own(USER, 10, "BACKLOG", 0); - own(USER, 20, "DROPPED", 0); + own(USER, 20, "BACKLOG", 0); + differ(20, "DROPPED"); assertThat(counted(USER)).containsExactly(20L); } @@ -113,6 +134,31 @@ class CountedUserGameViewTest extends AbstractPersistenceTest { assertThat(counted(USER)).containsExactly(10L); } + @Test + void splitEdition_IsCountedOnItsOwn() { + set(10, 1); + set(20, 1); + own(USER, 10, "BACKLOG", 0); + own(USER, 20, "BACKLOG", 0); + jdbc.update("insert into user_game_split (user_id, appid) values (?, 20)", USER); + + assertThat(counted(USER)).containsExactly(10L, 20L); + } + + @Test + void splitOfOneUser_DoesNotChangeTheSetOfAnotherUser() { + set(10, 1); + set(20, 1); + own(USER, 10, "BACKLOG", 0); + own(USER, 20, "BACKLOG", 0); + own(OTHER_USER, 10, "BACKLOG", 0); + own(OTHER_USER, 20, "BACKLOG", 0); + jdbc.update("insert into user_game_split (user_id, appid) values (?, 20)", USER); + + assertThat(counted(USER)).containsExactly(10L, 20L); + assertThat(counted(OTHER_USER)).hasSize(1); + } + @Test void keyInASetTheUserOwnsOnce_IsCounted_AndOtherUsersRowsHaveNoEffect() { set(10, 1); diff --git a/src/test/java/core_service/modules/library/repo/GameLinkRepositoryTest.java b/src/test/java/core_service/modules/library/repo/GameLinkRepositoryTest.java index 8322a6f..9088f83 100644 --- a/src/test/java/core_service/modules/library/repo/GameLinkRepositoryTest.java +++ b/src/test/java/core_service/modules/library/repo/GameLinkRepositoryTest.java @@ -267,4 +267,63 @@ class GameLinkRepositoryTest extends AbstractPersistenceTest { assertThat(repository.editionCounts(OTHER_USER, List.of(100L))).isEmpty(); assertThat(repository.editionCounts(USER, List.of())).isEmpty(); } + + @Test + void sameGamePairs_FindsEveryPairOnceAndFiltersByKey() { + assertThat(repository.sameGamePairs(null)).containsExactly(new long[]{-7, 100}, new long[]{-7, 200}, + new long[]{100, 200}); + assertThat(repository.sameGamePairs(List.of(200L))).containsExactly(new long[]{-7, 200}, new long[]{100, 200}); + assertThat(repository.sameGamePairs(List.of(-8L, 300L))).isEmpty(); + assertThat(repository.sameGamePairs(List.of())).isEmpty(); + } + + @Test + void sameGamePairs_SkipsPairsInOneSetAndDismissedPairs() { + repository.link(100, 200); + repository.addIgdbPairs(); + repository.dismiss(-7, 100); + + assertThat(repository.sameGamePairs(null)).containsExactly(new long[]{-7, 200}); + } + + @Test + void split_OnlyWhileAnotherListedEditionExists_AndOnlyOnce() { + repository.link(100, 200); + own(USER, 100, "BACKLOG"); + own(USER, 200, "BACKLOG"); + + assertThat(repository.split(USER, 100)).isTrue(); + assertThat(repository.split(USER, 100)).isFalse(); + assertThat(repository.split(USER, 200)).isFalse(); + assertThat(repository.split(USER, 300)).isFalse(); + assertThat(repository.split(OTHER_USER, 100)).isFalse(); + } + + @Test + void split_AHiddenOrRemovedEditionDoesNotCount() { + repository.link(100, 200); + own(USER, 100, "BACKLOG"); + own(USER, 200, "BACKLOG"); + jdbc.update("update user_game set hidden_at = now() where appid = 200"); + + assertThat(repository.split(USER, 100)).isFalse(); + } + + @Test + void linkedEditionsAndEditionCounts_IgnoreASplitKey() { + repository.link(100, 200); + repository.link(100, -7); + own(USER, 100, "BACKLOG"); + own(USER, 200, "BACKLOG"); + own(USER, -7, "BACKLOG"); + own(OTHER_USER, 100, "BACKLOG"); + own(OTHER_USER, 200, "BACKLOG"); + + repository.split(USER, -7); + + assertThat(repository.linkedEditions(USER, 100)).extracting(Edition::appid).containsExactly(200L); + assertThat(repository.linkedEditions(USER, -7)).isEmpty(); + assertThat(repository.editionCounts(USER, List.of(100L, 200L, -7L))).isEqualTo(Map.of(100L, 2, 200L, 2)); + assertThat(repository.editionCounts(OTHER_USER, List.of(100L, -7L))).isEqualTo(Map.of(100L, 2)); + } } diff --git a/src/test/java/core_service/modules/library/repo/GameSetTriggerTest.java b/src/test/java/core_service/modules/library/repo/GameSetTriggerTest.java new file mode 100644 index 0000000..8907dad --- /dev/null +++ b/src/test/java/core_service/modules/library/repo/GameSetTriggerTest.java @@ -0,0 +1,239 @@ +package core_service.modules.library.repo; + +import core_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.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * The triggers and functions of V42: one status, rating, notes and priority per user set. + */ +class GameSetTriggerTest extends AbstractPersistenceTest { + + private static final long USER = 1L; + private static final long OTHER_USER = 2L; + + @Autowired + private JdbcTemplate jdbc; + + @Autowired + private UserGameRepository userGameRepository; + + @BeforeEach + void seed() { + jdbc.update("delete from game_link"); + jdbc.update("delete from user_game"); + for (long appid : List.of(10L, 20L, 30L)) { + jdbc.update("insert into steam_game (appid, name) values (?, ?) on conflict do nothing", appid, "Game " + appid); + } + } + + private void link(long appid, long other) { + jdbc.update("insert into game_link (appid, set_id) values (?, 1), (?, 1)", appid, other); + } + + private void own(long userId, long appid, String status, int playtime) { + jdbc.update("insert into user_game (user_id, appid, status, playtime_minutes) values (?, ?, ?, ?)", + userId, appid, status, playtime); + } + + private Map row(long userId, long appid) { + return jdbc.queryForMap("select status, status_set_by_user, rating, notes, priority from user_game " + + "where user_id = ? and appid = ?", userId, appid); + } + + private Map backup(long appid) { + return jdbc.queryForMap("select status, rating, notes from user_game_set_backup where user_id = ? and appid = ?", + USER, appid); + } + + @Test + void statusChange_GoesToTheOtherEditionOfTheSet() { + link(10, 20); + own(USER, 10, "BACKLOG", 0); + own(USER, 20, "BACKLOG", 0); + + jdbc.update("update user_game set status = 'PLAYING', status_set_by_user = true where appid = 10"); + + assertThat(row(USER, 20)).containsEntry("status", "PLAYING").containsEntry("status_set_by_user", true); + } + + @Test + void ratingNotesAndPriority_GoToTheOtherEditionToo() { + link(10, 20); + own(USER, 10, "BACKLOG", 0); + own(USER, 20, "BACKLOG", 0); + + jdbc.update("update user_game set rating = 4, notes = 'great', priority = 'HIGH' where appid = 20"); + + assertThat(row(USER, 10)).containsEntry("rating", 4).containsEntry("notes", "great") + .containsEntry("priority", "HIGH"); + } + + @Test + void otherUsersAndUnlinkedGames_AreNotTouched() { + link(10, 20); + own(USER, 10, "BACKLOG", 0); + own(USER, 20, "BACKLOG", 0); + own(OTHER_USER, 20, "BACKLOG", 0); + own(USER, 30, "BACKLOG", 0); + + jdbc.update("update user_game set status = 'DROPPED' where user_id = ? and appid = 10", USER); + + assertThat(row(OTHER_USER, 20)).containsEntry("status", "BACKLOG"); + assertThat(row(USER, 30)).containsEntry("status", "BACKLOG"); + } + + @Test + void splitEdition_IsNotChanged_ButAHiddenEditionIs() { + link(10, 20); + jdbc.update("insert into game_link (appid, set_id) values (30, 1)"); + own(USER, 10, "BACKLOG", 0); + own(USER, 20, "BACKLOG", 0); + own(USER, 30, "BACKLOG", 0); + jdbc.update("insert into user_game_split (user_id, appid) values (?, 20)", USER); + jdbc.update("update user_game set hidden_at = now() where appid = 30"); + + jdbc.update("update user_game set status = 'FINISHED' where appid = 10"); + + assertThat(row(USER, 20)).containsEntry("status", "BACKLOG"); + assertThat(row(USER, 30)).containsEntry("status", "FINISHED"); + } + + @Test + void statusLog_HasOneRowPerEditionAndNoEndlessLoop() { + link(10, 20); + own(USER, 10, "BACKLOG", 0); + own(USER, 20, "BACKLOG", 0); + jdbc.update("delete from user_game_status_change"); + + jdbc.update("update user_game set status = 'PLAYING' where appid = 10"); + + assertThat(jdbc.queryForObject("select count(*) from user_game_status_change", Integer.class)).isEqualTo(2); + } + + @Test + void finishIfUntouched_OnTheSteamEdition_AlsoFinishesTheOtherEdition() { + link(10, 20); + own(USER, 10, "PLAYING", 60); + own(USER, 20, "PLAYING", 0); + jdbc.update("update user_game set source = 'PSN' where appid = 20"); + + int changed = userGameRepository.finishIfUntouched(USER, 10L); + + assertThat(changed).isEqualTo(1); + assertThat(row(USER, 20)).containsEntry("status", "FINISHED"); + } + + @Test + void join_TheMostAdvancedEditionWins_GapsAreFilledAndNotesJoined() { + own(USER, 10, "BACKLOG", 0); + own(USER, 20, "PLAYING", 100); + jdbc.update("update user_game set rating = 4, notes = 'first' where appid = 10"); + jdbc.update("update user_game set notes = 'second', status_set_by_user = true where appid = 20"); + + link(10, 20); + + for (long appid : List.of(10L, 20L)) { + assertThat(row(USER, appid)).containsEntry("status", "PLAYING").containsEntry("status_set_by_user", true) + .containsEntry("rating", 4).containsEntry("notes", "second\n\nfirst"); + } + assertThat(backup(10)).containsEntry("status", "BACKLOG").containsEntry("notes", "first"); + assertThat(backup(20)).containsEntry("status", "PLAYING").containsEntry("notes", "second"); + } + + @Test + void join_NotesAreCutTo2000() { + own(USER, 10, "BACKLOG", 0); + own(USER, 20, "PLAYING", 100); + jdbc.update("update user_game set notes = ? where appid = 10", "a".repeat(1500)); + jdbc.update("update user_game set notes = ? where appid = 20", "b".repeat(1500)); + + link(10, 20); + + assertThat((String) row(USER, 10).get("notes")).hasSize(2000).startsWith("bbb").endsWith("aaa"); + } + + @Test + void join_ASecondJoinDoesNotOverwriteTheBackup() { + own(USER, 10, "BACKLOG", 0); + own(USER, 20, "PLAYING", 100); + jdbc.update("update user_game set notes = 'kept' where appid = 20"); + link(10, 20); + jdbc.update("delete from game_link where appid = 10"); + jdbc.update("update user_game set status = 'DROPPED', notes = 'newer' where appid = 10"); + + jdbc.update("insert into game_link (appid, set_id) values (10, 1)"); + + assertThat(row(USER, 10)).containsEntry("status", "PLAYING"); + assertThat(backup(10)).containsEntry("status", "BACKLOG").containsEntry("notes", null); + } + + @Test + void join_ASplitEditionStaysOutOfTheRule() { + own(USER, 10, "BACKLOG", 0); + own(USER, 20, "PLAYING", 100); + jdbc.update("insert into user_game_split (user_id, appid) values (?, 20)", USER); + + link(10, 20); + + assertThat(row(USER, 10)).containsEntry("status", "BACKLOG"); + assertThat(row(USER, 20)).containsEntry("status", "PLAYING"); + } + + @Test + void newRow_TakesTheValuesOfTheSet() { + link(10, 20); + own(USER, 10, "PLAYING", 0); + jdbc.update("update user_game set rating = 3 where appid = 10"); + + own(USER, 20, "BACKLOG", 0); + + assertThat(row(USER, 20)).containsEntry("status", "PLAYING").containsEntry("rating", 3); + } + + @Test + void newRow_MoreAdvancedThanTheSet_WinsForAllEditions() { + link(10, 20); + own(USER, 10, "BACKLOG", 0); + + own(USER, 20, "FINISHED", 0); + + assertThat(row(USER, 10)).containsEntry("status", "FINISHED"); + } + + @Test + void join_IsNoHandStatusChangeOfTheUser() { + own(USER, 10, "BACKLOG", 0); + own(USER, 20, "BACKLOG", 0); + jdbc.update("update user_game set status = 'FINISHED', status_set_by_user = true where appid = 20"); + jdbc.update("delete from user_game_status_change"); + + link(10, 20); + + assertThat(jdbc.queryForObject("select count(*) from user_game_status_change where user_id = ? and appid = 10 " + + "and by_user", Integer.class, USER)).isZero(); + } + + @Test + void join_KeepsTheFinishDateOfTheCountedEdition() { + own(USER, 10, "BACKLOG", 500); + own(USER, 20, "BACKLOG", 100); + jdbc.update("update user_game set status = 'FINISHED', status_set_by_user = true where appid = 20"); + jdbc.update("update user_game_status_change set changed_at = '2025-03-01T10:00:00Z' where appid = 20"); + + link(10, 20); + + assertThat(jdbc.queryForObject(""" + select extract(year from min(sc.changed_at))::int from counted_user_game c + join user_game_status_change sc on sc.user_id = c.user_id and sc.appid = c.appid + where c.user_id = ? and sc.to_status in ('FINISHED', 'COMPLETED') + and sc.from_status not in ('FINISHED', 'COMPLETED')""", Integer.class, USER)).isEqualTo(2025); + } +} diff --git a/src/test/java/core_service/modules/library/repo/QuestionRepositoryTest.java b/src/test/java/core_service/modules/library/repo/QuestionRepositoryTest.java index fe71af0..d1c7abc 100644 --- a/src/test/java/core_service/modules/library/repo/QuestionRepositoryTest.java +++ b/src/test/java/core_service/modules/library/repo/QuestionRepositoryTest.java @@ -115,6 +115,15 @@ class QuestionRepositoryTest extends AbstractPersistenceTest { assertThat(repository.facts(USER, null)).extracting(QuestionFacts::appid).containsExactly(10L); } + @Test + void facts_ASplitEditionIsItsOwnEntry() { + game(USER, 20, "BACKLOG", 0); + jdbc.update("insert into game_link (appid, set_id) values (10, 1), (20, 1)"); + jdbc.update("insert into user_game_split (user_id, appid) values (?, 20)", USER); + + assertThat(repository.facts(USER, null)).extracting(QuestionFacts::appid).containsExactlyInAnyOrder(10L, 20L); + } + @Test void facts_AppidFilter() { game(USER, 20, "BACKLOG", 0); diff --git a/src/test/java/core_service/modules/library/service/GameEditionServiceTest.java b/src/test/java/core_service/modules/library/service/GameEditionServiceTest.java index 3d25436..302eca0 100644 --- a/src/test/java/core_service/modules/library/service/GameEditionServiceTest.java +++ b/src/test/java/core_service/modules/library/service/GameEditionServiceTest.java @@ -2,6 +2,7 @@ package core_service.modules.library.service; import core_service.modules.igdb_adapter.service.IgdbApiService; import core_service.modules.library.repo.GameEditionRepository; +import core_service.modules.library.repo.GameLinkRepository; import core_service.shared.test.AbstractUnitTest; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -11,6 +12,7 @@ import org.mockito.Spy; import org.springframework.core.task.SyncTaskExecutor; import org.springframework.core.task.TaskExecutor; import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.transaction.PlatformTransactionManager; import java.time.Instant; import java.util.List; @@ -19,7 +21,9 @@ import java.util.Optional; import java.util.Set; import static org.mockito.ArgumentMatchers.any; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.ArgumentMatchers.anyCollection; +import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.Mockito.never; @@ -35,6 +39,12 @@ class GameEditionServiceTest extends AbstractUnitTest { @Mock private GameEditionRepository gameEditionRepository; + @Mock + private GameLinkRepository gameLinkRepository; + + @Mock + private PlatformTransactionManager transactionManager; + @Spy private TaskExecutor applicationTaskExecutor = new SyncTaskExecutor(); @@ -89,6 +99,42 @@ class GameEditionServiceTest extends AbstractUnitTest { service.refresh(List.of(620L)); - verifyNoInteractions(gameEditionRepository); + verifyNoInteractions(gameEditionRepository, gameLinkRepository); + } + + @Test + void refresh_LinksThePairsOfTheAnsweredGames() { + when(igdbApiService.isEnabled()).thenReturn(true); + when(gameEditionRepository.findFreshAppids(anyCollection(), any())).thenReturn(Set.of()); + when(igdbApiService.getEditions(Set.of(620L, 700L))).thenReturn(Optional.of(Map.of( + 620L, new IgdbApiService.Edition(72, 10), 700L, new IgdbApiService.Edition(73, 10)))); + when(gameLinkRepository.sameGamePairs(any())).thenReturn(List.of(new long[]{620, 700})); + when(gameLinkRepository.lockGames(620, 700)).thenReturn(Map.of(620L, "A", 700L, "B")); + + service.refresh(List.of(620L, 700L)); + + verify(gameLinkRepository).sameGamePairs(Set.of(620L, 700L)); + verify(gameLinkRepository).link(620, 700); + } + + @Test + void linkSameGames_PairAlreadyInOneSet_IsNotLinkedAgain() { + when(gameLinkRepository.sameGamePairs(null)).thenReturn(List.of(new long[]{620, 700})); + when(gameLinkRepository.lockGames(620, 700)).thenReturn(Map.of(620L, "A", 700L, "B")); + when(gameLinkRepository.inOneSet(620, 700)).thenReturn(true); + + assertEquals(0, service.linkSameGames(null)); + + verify(gameLinkRepository, never()).link(anyLong(), anyLong()); + } + + @Test + void linkSameGames_UnknownKey_IsNotLinked() { + when(gameLinkRepository.sameGamePairs(null)).thenReturn(List.of(new long[]{620, 700})); + when(gameLinkRepository.lockGames(620, 700)).thenReturn(Map.of(620L, "A")); + + assertEquals(0, service.linkSameGames(null)); + + verify(gameLinkRepository, never()).link(anyLong(), anyLong()); } } diff --git a/src/test/java/core_service/modules/rewards/repo/RewardFactsRepositoryTest.java b/src/test/java/core_service/modules/rewards/repo/RewardFactsRepositoryTest.java index e520d9c..5aff5ee 100644 --- a/src/test/java/core_service/modules/rewards/repo/RewardFactsRepositoryTest.java +++ b/src/test/java/core_service/modules/rewards/repo/RewardFactsRepositoryTest.java @@ -23,6 +23,7 @@ class RewardFactsRepositoryTest extends AbstractPersistenceTest { private static final long USER = 7L; private static final long GAME = 220L; + private static final long EDITION = 221L; private static final ZoneId BERLIN = ZoneId.of("Europe/Berlin"); @Autowired @@ -222,6 +223,75 @@ class RewardFactsRepositoryTest extends AbstractPersistenceTest { assertThat(repository.completedCandidates(USER)).isEmpty(); } + // The game and a manual edition of it, one set, both with the given status. + private void setOfTwo(String status) { + jdbc.update("insert into steam_game (appid, name) values (?, 'Half-Life 2 (manual)') on conflict do nothing", EDITION); + game("BACKLOG", 120); + jdbc.update("insert into user_game (user_id, appid, status, source) values (?, ?, 'BACKLOG', 'MANUAL')", USER, EDITION); + jdbc.update("insert into game_link (appid, set_id) values (?, 1), (?, 1)", GAME, EDITION); + setStatus(status); + } + + private void paid(RewardKind kind, long appid) { + ledger.add(USER, new RewardEntry(kind, String.valueOf(appid), 10, Instant.now())); + } + + @Test + void finishCandidates_ASetGivesACandidatePerEdition() { + setOfTwo("FINISHED"); + + assertThat(repository.finishCandidates(USER, BERLIN)).hasSize(2); + } + + @Test + void finishCandidates_NoneWhenAnotherEditionOfTheSetWasPaid() { + setOfTwo("FINISHED"); + paid(RewardKind.FINISHED_MANUAL, EDITION); + + assertThat(repository.finishCandidates(USER, BERLIN)).isEmpty(); + } + + @Test + void startCandidates_NoneWhenAnotherEditionOfTheSetWasPaid() { + setOfTwo("PLAYING"); + assertThat(repository.startCandidates(USER, BERLIN)).hasSize(2); + + paid(RewardKind.STARTED, EDITION); + + assertThat(repository.startCandidates(USER, BERLIN)).isEmpty(); + } + + @Test + void completedCandidates_NoneWhenAnotherEditionOfTheSetWasPaid() { + setOfTwo("COMPLETED"); + paid(RewardKind.FINISHED, GAME); + paid(RewardKind.FINISHED, EDITION); + assertThat(repository.completedCandidates(USER)).hasSize(2); + + paid(RewardKind.COMPLETED, EDITION); + + assertThat(repository.completedCandidates(USER)).isEmpty(); + } + + @Test + void candidates_ASplitEditionIsItsOwnCandidateAgain() { + setOfTwo("FINISHED"); + jdbc.update("insert into user_game_split (user_id, appid) values (?, ?)", USER, EDITION); + paid(RewardKind.FINISHED, GAME); + + assertThat(repository.finishCandidates(USER, BERLIN)).singleElement() + .satisfies(f -> assertThat(f.appid()).isEqualTo(EDITION)); + } + + @Test + void finishCandidates_AGameInNoSetIsUnchanged() { + game("BACKLOG", 120); + setStatus("FINISHED"); + paid(RewardKind.FINISHED, EDITION); + + assertThat(repository.finishCandidates(USER, BERLIN)).hasSize(1); + } + @Test void pileFirstAndLast_NeedsBothDays() { YearMonth month = YearMonth.of(2026, 9); @@ -310,4 +380,46 @@ class RewardFactsRepositoryTest extends AbstractPersistenceTest { assertThat(repository.minutesPerMonth(USER)).containsOnly(java.util.Map.entry(YearMonth.of(2026, 9), 30L)); } + + @Test + void finishCandidates_SetKeepsTheEditionThatCanPay() { + long unplayed = 219L; + jdbc.update("insert into steam_game (appid, name) values (?, 'Half-Life 2 (old key)') on conflict do nothing", unplayed); + game("BACKLOG", 120); + jdbc.update("insert into user_game (user_id, appid, status, playtime_minutes) values (?, ?, 'BACKLOG', 0)", + USER, unplayed); + jdbc.update("insert into game_link (appid, set_id) values (?, 1), (?, 1)", GAME, unplayed); + setStatus("FINISHED"); + + assertThat(repository.finishCandidates(USER, BERLIN)).extracting(FinishFacts::appid).contains(GAME); + } + + @Test + void startCandidates_ManualEditionDoesNotBlockTheSteamStart() { + long manual = -1_220L; + jdbc.update("insert into steam_game (appid, name) values (?, 'Half-Life 2 (Switch)') on conflict do nothing", manual); + game("BACKLOG", 120); + jdbc.update("insert into user_game (user_id, appid, status, source) values (?, ?, 'BACKLOG', 'MANUAL')", USER, manual); + jdbc.update("insert into game_link (appid, set_id) values (?, 1), (?, 1)", GAME, manual); + setStatus("PLAYING"); + + assertThat(repository.startCandidates(USER, BERLIN)).extracting(StartFacts::appid).contains(GAME); + } + + @Test + void completedCandidates_FinishPaidOnAnotherEditionOfTheSet() { + long other = 219L; + jdbc.update("insert into steam_game (appid, name) values (?, 'Half-Life 2 (old key)') on conflict do nothing", other); + game("BACKLOG", 120); + jdbc.update("insert into user_game (user_id, appid, status, playtime_minutes) values (?, ?, 'BACKLOG', 30)", + USER, other); + jdbc.update("insert into game_link (appid, set_id) values (?, 1), (?, 1)", GAME, other); + setStatus("COMPLETED"); + jdbc.update("insert into game_achievement_schema_meta (appid, achievements_total, has_schema) values (?, 10, true) " + + "on conflict do nothing", GAME); + jdbc.update("insert into user_game_achievement_meta (user_id, appid, achievements_unlocked) values (?, ?, 10)", USER, GAME); + paid(RewardKind.FINISHED, other); + + assertThat(repository.completedCandidates(USER)).extracting(FinishFacts::appid).contains(GAME); + } } diff --git a/src/test/java/core_service/modules/rewards/service/RewardServiceSetTest.java b/src/test/java/core_service/modules/rewards/service/RewardServiceSetTest.java new file mode 100644 index 0000000..ed77494 --- /dev/null +++ b/src/test/java/core_service/modules/rewards/service/RewardServiceSetTest.java @@ -0,0 +1,49 @@ +package core_service.modules.rewards.service; + +import core_service.shared.security.Role; +import core_service.shared.test.AbstractIntegrationTest; +import org.junit.jupiter.api.AfterEach; +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 static org.assertj.core.api.Assertions.assertThat; + +class RewardServiceSetTest extends AbstractIntegrationTest { + + private static final long USER = 14L; + private static final long GAME = 230L; + private static final long EDITION = 231L; + + @Autowired + private RewardService rewardService; + + @Autowired + private JdbcTemplate jdbc; + + @BeforeEach + @AfterEach + void clean() { + jdbc.update("delete from reward_ledger where user_id = ?", USER); + jdbc.update("delete from game_link where appid in (?, ?)", GAME, EDITION); + jdbc.update("delete from user_game where user_id = ?", USER); + } + + @Test + void wallet_TwoEditionsThatCouldPay_BooksTheStartOnce() { + for (long appid : new long[]{GAME, EDITION}) { + jdbc.update("insert into steam_game (appid, name) values (?, 'Set game') on conflict do nothing", appid); + jdbc.update("insert into user_game (user_id, appid, status) values (?, ?, 'BACKLOG')", USER, appid); + jdbc.update("insert into user_game_playtime_day (user_id, day, appid, minutes) values (?, current_date, ?, 30)", + USER, appid); + } + jdbc.update("insert into game_link (appid, set_id) values (?, 2300), (?, 2300)", GAME, EDITION); + jdbc.update("update user_game set status = 'PLAYING' where user_id = ?", USER); + + rewardService.wallet(USER, Role.USER); + + assertThat(jdbc.queryForObject("select count(*) from reward_ledger where user_id = ? and kind = 'STARTED'", + Integer.class, USER)).isEqualTo(1); + } +}