Release: Quarkus backend, Keycloak login, game history, clock, bot, tutorial and rematch #3
@ -0,0 +1,91 @@
|
||||
package app.playmancala.history;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.function.Supplier;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.jboss.logging.Logger;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
|
||||
import app.playmancala.live.Cache;
|
||||
import app.playmancala.model.FinishedGameSummary;
|
||||
import app.playmancala.model.GameRecord;
|
||||
|
||||
// Reads finished games for the API, including ones not saved yet. Lists are cached briefly and come back without the
|
||||
// saved games while the database is unreachable, so the home page still loads. Blocks; call it off the game loop.
|
||||
public class GameHistoryService {
|
||||
public static final int MAX_LIST_LIMIT = 20;
|
||||
private static final Logger LOG = Logger.getLogger(GameHistoryService.class);
|
||||
private static final long CACHE_TTL_SECONDS = 10;
|
||||
private static final String POPULAR_KEY = "popular-finished";
|
||||
private static final TypeReference<List<FinishedGameSummary>> SUMMARIES = new TypeReference<>() { };
|
||||
|
||||
private static final Comparator<FinishedGameSummary> BY_NEWEST =
|
||||
Comparator.comparingLong(FinishedGameSummary::endedAt).reversed();
|
||||
private static final Comparator<FinishedGameSummary> BY_POPULARITY =
|
||||
Comparator.comparingInt(FinishedGameSummary::peakSpectators).reversed().thenComparing(BY_NEWEST);
|
||||
|
||||
private final GameHistoryRepository repository;
|
||||
private final Cache cache;
|
||||
// Games that ended but are not in the database yet (being saved, or waiting for a retry).
|
||||
private final Supplier<List<GameRecord>> unsaved;
|
||||
|
||||
public GameHistoryService(GameHistoryRepository repository, Cache cache, Supplier<List<GameRecord>> unsaved) {
|
||||
this.repository = repository;
|
||||
this.cache = cache;
|
||||
this.unsaved = unsaved;
|
||||
}
|
||||
|
||||
private static String recentKey(String userId) {
|
||||
return "recent:" + userId;
|
||||
}
|
||||
|
||||
// Null if there is no such game. Throws if the database cannot be asked.
|
||||
public GameRecord get(String id) {
|
||||
return unsaved.get().stream().filter(record -> record.id().equals(id)).findFirst()
|
||||
.orElseGet(() -> repository.get(id));
|
||||
}
|
||||
|
||||
public List<FinishedGameSummary> getRecentForUser(String userId, int limit) {
|
||||
List<FinishedGameSummary> saved = cached(recentKey(userId), () -> repository.getRecentForUser(userId, MAX_LIST_LIMIT));
|
||||
List<GameRecord> unsavedGames = unsaved.get().stream().filter(record -> record.hasPlayer(userId)).toList();
|
||||
return merge(saved, unsavedGames, BY_NEWEST, limit);
|
||||
}
|
||||
|
||||
public List<FinishedGameSummary> getPopular(int limit) {
|
||||
List<FinishedGameSummary> saved = cached(POPULAR_KEY, () -> repository.getPopular(MAX_LIST_LIMIT));
|
||||
return merge(saved, unsaved.get(), BY_POPULARITY, limit);
|
||||
}
|
||||
|
||||
// Call when a game has been saved, so lists show it right away.
|
||||
public void onGameSaved(GameRecord record) {
|
||||
cache.delete(POPULAR_KEY, recentKey(record.player1().userId()), recentKey(record.player2().userId()));
|
||||
}
|
||||
|
||||
private List<FinishedGameSummary> cached(String key, Supplier<List<FinishedGameSummary>> load) {
|
||||
Optional<List<FinishedGameSummary>> hit = cache.get(key, SUMMARIES);
|
||||
if (hit.isPresent()) return hit.get();
|
||||
try {
|
||||
List<FinishedGameSummary> games = load.get();
|
||||
cache.set(key, games, CACHE_TTL_SECONDS);
|
||||
return games;
|
||||
} catch (RuntimeException e) {
|
||||
LOG.errorf("(History) Could not load games: %s", e.getMessage());
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
|
||||
private static List<FinishedGameSummary> merge(List<FinishedGameSummary> saved, List<GameRecord> unsaved,
|
||||
Comparator<FinishedGameSummary> order, int limit) {
|
||||
Set<String> savedIds = saved.stream().map(FinishedGameSummary::id).collect(Collectors.toSet());
|
||||
List<FinishedGameSummary> all = new ArrayList<>(saved);
|
||||
unsaved.stream().filter(record -> !savedIds.contains(record.id())).map(GameRecord::toSummary).forEach(all::add);
|
||||
all.sort(order);
|
||||
return all.stream().limit(limit).toList();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,100 @@
|
||||
package app.playmancala.history;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Statement;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
import javax.sql.DataSource;
|
||||
|
||||
import org.jboss.logging.Logger;
|
||||
|
||||
// Applies the migrations in src/main/resources/db/migrations that have not run yet, each in its own transaction.
|
||||
// Same table and lock as the Node backend's runner, so databases it migrated are recognised.
|
||||
public final class Migrations {
|
||||
private static final Logger LOG = Logger.getLogger(Migrations.class);
|
||||
private static final long MIGRATION_LOCK_ID = 727274;
|
||||
|
||||
// Applied in this order; never change a migration that has been released, add a new one instead.
|
||||
public static final List<String> VERSIONS = List.of("001_games", "002_clock");
|
||||
|
||||
private Migrations() {
|
||||
}
|
||||
|
||||
public static void run(DataSource dataSource) throws SQLException {
|
||||
try (Connection connection = dataSource.getConnection()) {
|
||||
connection.setAutoCommit(true);
|
||||
advisoryLock(connection, "pg_advisory_lock");
|
||||
try {
|
||||
try (Statement statement = connection.createStatement()) {
|
||||
statement.execute("""
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version text PRIMARY KEY,
|
||||
applied_at timestamptz NOT NULL DEFAULT now()
|
||||
)""");
|
||||
}
|
||||
Set<String> applied = appliedVersions(connection);
|
||||
for (String version : VERSIONS) {
|
||||
if (applied.contains(version)) continue;
|
||||
apply(connection, version);
|
||||
LOG.infof("(DB) Applied migration %s", version);
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
advisoryLock(connection, "pg_advisory_unlock");
|
||||
} catch (SQLException ignored) {
|
||||
// Released with the connection.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void advisoryLock(Connection connection, String function) throws SQLException {
|
||||
try (PreparedStatement statement = connection.prepareStatement("SELECT " + function + "(?)")) {
|
||||
statement.setLong(1, MIGRATION_LOCK_ID);
|
||||
statement.execute();
|
||||
}
|
||||
}
|
||||
|
||||
private static Set<String> appliedVersions(Connection connection) throws SQLException {
|
||||
Set<String> applied = new HashSet<>();
|
||||
try (Statement statement = connection.createStatement();
|
||||
ResultSet rows = statement.executeQuery("SELECT version FROM schema_migrations")) {
|
||||
while (rows.next()) applied.add(rows.getString(1));
|
||||
}
|
||||
return applied;
|
||||
}
|
||||
|
||||
private static void apply(Connection connection, String version) throws SQLException {
|
||||
connection.setAutoCommit(false);
|
||||
try (Statement statement = connection.createStatement();
|
||||
PreparedStatement insert = connection.prepareStatement("INSERT INTO schema_migrations (version) VALUES (?)")) {
|
||||
statement.execute(sql(version));
|
||||
insert.setString(1, version);
|
||||
insert.executeUpdate();
|
||||
connection.commit();
|
||||
} catch (SQLException e) {
|
||||
connection.rollback();
|
||||
throw e;
|
||||
} finally {
|
||||
connection.setAutoCommit(true);
|
||||
}
|
||||
}
|
||||
|
||||
private static String sql(String version) {
|
||||
String path = "/db/migrations/" + version + ".sql";
|
||||
try (InputStream in = Migrations.class.getResourceAsStream(path)) {
|
||||
if (in == null) throw new IllegalStateException("Missing migration " + path);
|
||||
return new String(in.readAllBytes(), StandardCharsets.UTF_8);
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,200 @@
|
||||
package app.playmancala.history;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Types;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import javax.sql.DataSource;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
|
||||
import app.playmancala.model.EndReason;
|
||||
import app.playmancala.model.FinishedGameSummary;
|
||||
import app.playmancala.model.GameRecord;
|
||||
import app.playmancala.model.GameResult;
|
||||
import app.playmancala.model.Json;
|
||||
import app.playmancala.model.MoveRecord;
|
||||
import app.playmancala.model.PlayerSummary;
|
||||
|
||||
// Throws IllegalStateException when the database cannot be reached or a query fails.
|
||||
public class PostgresGameHistoryRepository implements GameHistoryRepository {
|
||||
private static final Pattern UUID_PATTERN =
|
||||
Pattern.compile("^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", Pattern.CASE_INSENSITIVE);
|
||||
|
||||
private static final String SUMMARY_COLUMNS = """
|
||||
g.id, g.result, g.end_reason, g.left_user_id, g.started_at, g.ended_at, g.move_count,
|
||||
g.final_board, g.peak_spectators, g.clock_initial_ms,
|
||||
p1.user_id AS p1_user_id, p1.name AS p1_name, p1.is_guest AS p1_is_guest,
|
||||
p2.user_id AS p2_user_id, p2.name AS p2_name, p2.is_guest AS p2_is_guest""";
|
||||
|
||||
private static final String FROM_GAMES = """
|
||||
FROM games g
|
||||
JOIN game_players p1 ON p1.game_id = g.id AND p1.seat = 1
|
||||
JOIN game_players p2 ON p2.game_id = g.id AND p2.seat = 2""";
|
||||
|
||||
private static final TypeReference<List<Integer>> BOARD = new TypeReference<>() { };
|
||||
private static final TypeReference<List<MoveRecord>> MOVES = new TypeReference<>() { };
|
||||
|
||||
private final DataSource dataSource;
|
||||
|
||||
public PostgresGameHistoryRepository(DataSource dataSource) {
|
||||
this.dataSource = dataSource;
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
private interface Work<T> {
|
||||
T run(Connection connection) throws SQLException, JsonProcessingException;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void save(GameRecord record) {
|
||||
inTransaction(connection -> {
|
||||
int inserted;
|
||||
try (PreparedStatement statement = connection.prepareStatement("""
|
||||
INSERT INTO games (id, player1_id, player2_id, result, end_reason, left_user_id, started_at, ended_at,
|
||||
move_count, final_board, moves, peak_spectators, total_spectators, clock_initial_ms)
|
||||
VALUES (?::uuid, ?, ?, ?, ?, ?, to_timestamp(? / 1000.0), to_timestamp(? / 1000.0),
|
||||
?, ?::jsonb, ?::jsonb, ?, ?, ?)
|
||||
ON CONFLICT (id) DO NOTHING""")) {
|
||||
statement.setString(1, record.id());
|
||||
statement.setString(2, record.player1().userId());
|
||||
statement.setString(3, record.player2().userId());
|
||||
statement.setString(4, record.result() == null ? null : record.result().wireName());
|
||||
statement.setString(5, record.endReason().wireName());
|
||||
statement.setString(6, record.leftUserId());
|
||||
statement.setLong(7, record.startedAt());
|
||||
statement.setLong(8, record.endedAt());
|
||||
statement.setInt(9, record.moveCount());
|
||||
statement.setString(10, Json.write(record.board()));
|
||||
statement.setString(11, Json.write(record.moves()));
|
||||
statement.setInt(12, record.peakSpectators());
|
||||
statement.setInt(13, record.totalSpectators());
|
||||
if (record.clockInitialMs() == null) statement.setNull(14, Types.INTEGER);
|
||||
else statement.setInt(14, Math.toIntExact(record.clockInitialMs()));
|
||||
inserted = statement.executeUpdate();
|
||||
}
|
||||
if (inserted == 0) return null;
|
||||
try (PreparedStatement statement = connection.prepareStatement(
|
||||
"INSERT INTO game_players (game_id, seat, user_id, name, is_guest) VALUES (?::uuid, ?, ?, ?, ?)")) {
|
||||
PlayerSummary[] players = { record.player1(), record.player2() };
|
||||
for (int seat = 1; seat <= 2; seat++) {
|
||||
PlayerSummary player = players[seat - 1];
|
||||
statement.setString(1, record.id());
|
||||
statement.setInt(2, seat);
|
||||
statement.setString(3, player.userId());
|
||||
statement.setString(4, player.name());
|
||||
statement.setBoolean(5, player.isGuest());
|
||||
statement.addBatch();
|
||||
}
|
||||
statement.executeBatch();
|
||||
}
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public GameRecord get(String id) {
|
||||
if (!UUID_PATTERN.matcher(id).matches()) return null;
|
||||
return query(connection -> {
|
||||
try (PreparedStatement statement = connection.prepareStatement(
|
||||
"SELECT " + SUMMARY_COLUMNS + ", g.moves, g.total_spectators " + FROM_GAMES + " WHERE g.id = ?::uuid")) {
|
||||
statement.setString(1, id);
|
||||
try (ResultSet row = statement.executeQuery()) {
|
||||
if (!row.next()) return null;
|
||||
FinishedGameSummary summary = toSummary(row);
|
||||
return new GameRecord(summary.id(), summary.player1(), summary.player2(), summary.board(),
|
||||
summary.result(), summary.endReason(), summary.leftUserId(), summary.startedAt(),
|
||||
summary.endedAt(), summary.moveCount(), summary.peakSpectators(), summary.clockInitialMs(),
|
||||
Json.MAPPER.readValue(row.getString("moves"), MOVES), row.getInt("total_spectators"));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<FinishedGameSummary> getRecentForUser(String userId, int limit) {
|
||||
return query(connection -> {
|
||||
try (PreparedStatement statement = connection.prepareStatement("SELECT " + SUMMARY_COLUMNS + " " + FROM_GAMES
|
||||
+ " WHERE g.id IN (SELECT game_id FROM game_players WHERE user_id = ?) ORDER BY g.ended_at DESC LIMIT ?")) {
|
||||
statement.setString(1, userId);
|
||||
statement.setInt(2, limit);
|
||||
return summaries(statement);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<FinishedGameSummary> getPopular(int limit) {
|
||||
return query(connection -> {
|
||||
try (PreparedStatement statement = connection.prepareStatement("SELECT " + SUMMARY_COLUMNS + " " + FROM_GAMES
|
||||
+ " ORDER BY g.peak_spectators DESC, g.ended_at DESC LIMIT ?")) {
|
||||
statement.setInt(1, limit);
|
||||
return summaries(statement);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static List<FinishedGameSummary> summaries(PreparedStatement statement) throws SQLException, JsonProcessingException {
|
||||
List<FinishedGameSummary> summaries = new ArrayList<>();
|
||||
try (ResultSet rows = statement.executeQuery()) {
|
||||
while (rows.next()) summaries.add(toSummary(rows));
|
||||
}
|
||||
return summaries;
|
||||
}
|
||||
|
||||
private static FinishedGameSummary toSummary(ResultSet row) throws SQLException, JsonProcessingException {
|
||||
Object clockInitialMs = row.getObject("clock_initial_ms");
|
||||
return new FinishedGameSummary(
|
||||
row.getString("id"),
|
||||
player(row, "p1_"),
|
||||
player(row, "p2_"),
|
||||
Json.MAPPER.readValue(row.getString("final_board"), BOARD),
|
||||
GameResult.fromWireName(row.getString("result")),
|
||||
EndReason.fromWireName(row.getString("end_reason")),
|
||||
row.getString("left_user_id"),
|
||||
row.getTimestamp("started_at").getTime(),
|
||||
row.getTimestamp("ended_at").getTime(),
|
||||
row.getInt("move_count"),
|
||||
row.getInt("peak_spectators"),
|
||||
clockInitialMs == null ? null : ((Number) clockInitialMs).longValue());
|
||||
}
|
||||
|
||||
private static PlayerSummary player(ResultSet row, String prefix) throws SQLException {
|
||||
return new PlayerSummary(row.getString(prefix + "user_id"), row.getString(prefix + "name"),
|
||||
row.getBoolean(prefix + "is_guest"));
|
||||
}
|
||||
|
||||
private <T> T query(Work<T> work) {
|
||||
try (Connection connection = dataSource.getConnection()) {
|
||||
return work.run(connection);
|
||||
} catch (SQLException | JsonProcessingException e) {
|
||||
throw new IllegalStateException(e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
private void inTransaction(Work<Void> work) {
|
||||
query(connection -> {
|
||||
connection.setAutoCommit(false);
|
||||
try {
|
||||
work.run(connection);
|
||||
connection.commit();
|
||||
} catch (SQLException | JsonProcessingException | RuntimeException e) {
|
||||
try {
|
||||
connection.rollback();
|
||||
} catch (SQLException ignored) {
|
||||
// The connection is broken; the transaction ends with it.
|
||||
}
|
||||
throw e;
|
||||
} finally {
|
||||
connection.setAutoCommit(true);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,14 @@
|
||||
package app.playmancala.live;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
|
||||
// Short-lived cache of JSON values. Never fails: errors count as a miss, so callers just compute the value.
|
||||
public interface Cache {
|
||||
<T> Optional<T> get(String key, TypeReference<T> type);
|
||||
|
||||
void set(String key, Object value, long ttlSeconds);
|
||||
|
||||
void delete(String... keys);
|
||||
}
|
||||
@ -0,0 +1,52 @@
|
||||
package app.playmancala.live;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Optional;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
|
||||
import app.playmancala.model.Json;
|
||||
import io.quarkus.redis.datasource.RedisDataSource;
|
||||
import io.quarkus.redis.datasource.value.SetArgs;
|
||||
import io.quarkus.redis.datasource.value.ValueCommands;
|
||||
|
||||
public class ValkeyCache implements Cache {
|
||||
private static final String PREFIX = "mancala:cache:";
|
||||
|
||||
private final RedisDataSource redis;
|
||||
private final ValueCommands<String, String> values;
|
||||
|
||||
public ValkeyCache(RedisDataSource redis) {
|
||||
this.redis = redis;
|
||||
this.values = redis.value(String.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
public <T> Optional<T> get(String key, TypeReference<T> type) {
|
||||
try {
|
||||
String value = values.get(PREFIX + key);
|
||||
return value == null ? Optional.empty() : Optional.of(Json.MAPPER.readValue(value, type));
|
||||
} catch (Exception e) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void set(String key, Object value, long ttlSeconds) {
|
||||
try {
|
||||
values.set(PREFIX + key, Json.write(value), new SetArgs().ex(ttlSeconds));
|
||||
} catch (RuntimeException e) {
|
||||
// A missing cache entry only costs a database query.
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void delete(String... keys) {
|
||||
if (keys.length == 0) return;
|
||||
try {
|
||||
redis.key().del(Arrays.stream(keys).map(key -> PREFIX + key).toArray(String[]::new));
|
||||
} catch (RuntimeException e) {
|
||||
// Entries expire on their own.
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,87 @@
|
||||
package app.playmancala.live;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.jboss.logging.Logger;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
|
||||
import app.playmancala.model.GameRecord;
|
||||
import app.playmancala.model.Json;
|
||||
import io.quarkus.redis.datasource.RedisDataSource;
|
||||
import io.quarkus.redis.datasource.keys.KeyScanArgs;
|
||||
import io.quarkus.redis.datasource.value.ValueCommands;
|
||||
|
||||
// Keys and values are the Node backend's, so either backend reads what the other wrote.
|
||||
public class ValkeyLiveGameStore implements LiveGameStore {
|
||||
private static final Logger LOG = Logger.getLogger(ValkeyLiveGameStore.class);
|
||||
private static final String LIVE_PREFIX = "mancala:live:";
|
||||
private static final String PENDING_SAVE_PREFIX = "mancala:pending-save:";
|
||||
|
||||
private final RedisDataSource redis;
|
||||
private final ValueCommands<String, String> values;
|
||||
|
||||
public ValkeyLiveGameStore(RedisDataSource redis) {
|
||||
this.redis = redis;
|
||||
this.values = redis.value(String.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void saveGame(String gameId, Object snapshot) {
|
||||
values.set(LIVE_PREFIX + gameId, Json.write(snapshot));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void removeGame(String gameId) {
|
||||
redis.key().del(LIVE_PREFIX + gameId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<JsonNode> loadGames() {
|
||||
return loadAll(LIVE_PREFIX);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void savePendingRecord(GameRecord record) {
|
||||
values.set(PENDING_SAVE_PREFIX + record.id(), Json.write(record));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void removePendingRecord(String gameId) {
|
||||
redis.key().del(PENDING_SAVE_PREFIX + gameId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<GameRecord> loadPendingRecords() {
|
||||
List<GameRecord> records = new ArrayList<>();
|
||||
for (JsonNode json : loadAll(PENDING_SAVE_PREFIX)) {
|
||||
try {
|
||||
records.add(Json.MAPPER.treeToValue(json, GameRecord.class));
|
||||
} catch (Exception e) {
|
||||
LOG.errorf("(Valkey) Ignoring unreadable pending record %s", json.path("id").asText());
|
||||
}
|
||||
}
|
||||
return records;
|
||||
}
|
||||
|
||||
private List<JsonNode> loadAll(String prefix) {
|
||||
// SCAN rather than KEYS, which would block Valkey.
|
||||
List<String> keys = new ArrayList<>();
|
||||
redis.key().scan(new KeyScanArgs().match(prefix + "*").count(100)).toIterable().forEach(keys::add);
|
||||
if (keys.isEmpty()) return List.of();
|
||||
Map<String, String> stored = values.mget(keys.toArray(String[]::new));
|
||||
List<JsonNode> parsed = new ArrayList<>();
|
||||
for (String key : keys) {
|
||||
String value = stored.get(key);
|
||||
if (value == null) continue;
|
||||
try {
|
||||
parsed.add(Json.parse(value));
|
||||
} catch (Exception e) {
|
||||
LOG.errorf("(Valkey) Ignoring unreadable value in %s", key);
|
||||
}
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,41 @@
|
||||
# Settings come from environment variables with the Node backend's names; see README.md.
|
||||
|
||||
# HTTP and WebSocket
|
||||
quarkus.http.port=${PORT:5005}
|
||||
quarkus.http.test-port=0
|
||||
quarkus.http.cors.enabled=true
|
||||
quarkus.http.cors.origins=*
|
||||
quarkus.http.access-log.enabled=true
|
||||
quarkus.http.access-log.pattern=common
|
||||
quarkus.http.access-log.log-to-file=true
|
||||
quarkus.http.access-log.base-file-name=access
|
||||
quarkus.http.access-log.log-directory=.
|
||||
quarkus.http.access-log.rotate=false
|
||||
%test.quarkus.http.access-log.enabled=false
|
||||
|
||||
# Keycloak
|
||||
mancala.auth.issuer=${KEYCLOAK_ISSUER:http://localhost:8080/realms/mancala}
|
||||
# Empty: the issuer's certs endpoint.
|
||||
mancala.auth.jwks-url=${KEYCLOAK_JWKS_URL:}
|
||||
mancala.auth.client-ids=${KEYCLOAK_CLIENT_IDS:mancala-web}
|
||||
mancala.auth.legacy-userkey-auth=${LEGACY_USERKEY_AUTH:true}
|
||||
|
||||
# Time each player has for a whole game (docs/specs/game-clock).
|
||||
mancala.game-clock-ms=${GAME_CLOCK_MS:300000}
|
||||
|
||||
# Postgres. Tests start one with Dev Services. The backend starts without it; migrations are retried.
|
||||
quarkus.datasource.db-kind=postgresql
|
||||
%dev,prod.quarkus.datasource.jdbc.url=${DATABASE_JDBC_URL:jdbc:postgresql://localhost:5433/mancala}
|
||||
%dev,prod.quarkus.datasource.username=${DATABASE_USERNAME:mancala}
|
||||
%dev,prod.quarkus.datasource.password=${DATABASE_PASSWORD:mancala}
|
||||
quarkus.datasource.jdbc.max-size=5
|
||||
quarkus.datasource.jdbc.acquisition-timeout=5S
|
||||
quarkus.datasource.devservices.image-name=postgres:17.6
|
||||
|
||||
# Valkey. Tests start one with Dev Services. Short timeouts, so callers fall back quickly while it is down.
|
||||
%dev,prod.quarkus.redis.hosts=${VALKEY_URL:redis://localhost:6379}
|
||||
quarkus.redis.timeout=2S
|
||||
quarkus.redis.tcp.connect-timeout=1S
|
||||
quarkus.redis.devservices.image-name=valkey/valkey:9.1.2
|
||||
# Nothing listens on port 1; for the tests of an unreachable Valkey.
|
||||
%test.quarkus.redis.offline.hosts=redis://127.0.0.1:1
|
||||
@ -0,0 +1,27 @@
|
||||
CREATE TABLE games (
|
||||
id uuid PRIMARY KEY,
|
||||
player1_id text NOT NULL,
|
||||
player2_id text NOT NULL,
|
||||
result text CHECK (result IN ('player1', 'player2', 'draw')),
|
||||
end_reason text NOT NULL CHECK (end_reason IN ('completed', 'left', 'timeout', 'inactive')),
|
||||
left_user_id text,
|
||||
started_at timestamptz NOT NULL,
|
||||
ended_at timestamptz NOT NULL,
|
||||
move_count integer NOT NULL,
|
||||
final_board jsonb NOT NULL,
|
||||
moves jsonb NOT NULL,
|
||||
peak_spectators integer NOT NULL DEFAULT 0,
|
||||
total_spectators integer NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE game_players (
|
||||
game_id uuid NOT NULL REFERENCES games (id) ON DELETE CASCADE,
|
||||
seat smallint NOT NULL CHECK (seat IN (1, 2)),
|
||||
user_id text NOT NULL,
|
||||
name text,
|
||||
is_guest boolean NOT NULL,
|
||||
PRIMARY KEY (game_id, seat)
|
||||
);
|
||||
|
||||
CREATE INDEX game_players_user_id ON game_players (user_id);
|
||||
CREATE INDEX games_popular ON games (peak_spectators DESC, ended_at DESC);
|
||||
@ -0,0 +1,2 @@
|
||||
-- Time each player started with; NULL for games saved before the game clock. Each move's clock is in `moves`.
|
||||
ALTER TABLE games ADD COLUMN clock_initial_ms integer;
|
||||
@ -0,0 +1,189 @@
|
||||
package app.playmancala.history;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.Statement;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import javax.sql.DataSource;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
|
||||
import app.playmancala.engine.GameStep;
|
||||
import app.playmancala.model.EndReason;
|
||||
import app.playmancala.model.FinishedGameSummary;
|
||||
import app.playmancala.model.GameRecord;
|
||||
import app.playmancala.model.GameResult;
|
||||
import app.playmancala.model.Json;
|
||||
import app.playmancala.model.MoveRecord;
|
||||
import app.playmancala.model.PlayerSummary;
|
||||
import io.quarkus.test.junit.QuarkusTest;
|
||||
import jakarta.inject.Inject;
|
||||
|
||||
// Ported from the Node backend's tests/history/PostgresGameHistoryRepository.test.ts. Postgres runs in Docker.
|
||||
@QuarkusTest
|
||||
class PostgresGameHistoryRepositoryTest {
|
||||
static final PlayerSummary ALICE = new PlayerSummary("kc:alice", "alice", false);
|
||||
static final PlayerSummary BOB = new PlayerSummary("kc:bob", "bob", false);
|
||||
static final PlayerSummary GUEST = new PlayerSummary("kc:guest", "guest-1a2b3c4d", true);
|
||||
static final PlayerSummary LEGACY = new PlayerSummary("legacy:0123456789abcdef0123", null, true);
|
||||
|
||||
static long endedAt = Instant.parse("2026-10-01T00:00:00Z").toEpochMilli();
|
||||
|
||||
@Inject
|
||||
DataSource dataSource;
|
||||
|
||||
PostgresGameHistoryRepository repository;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
Migrations.run(dataSource);
|
||||
execute("TRUNCATE games CASCADE");
|
||||
repository = new PostgresGameHistoryRepository(dataSource);
|
||||
}
|
||||
|
||||
void execute(String sql) throws Exception {
|
||||
try (Connection connection = dataSource.getConnection(); Statement statement = connection.createStatement()) {
|
||||
statement.execute(sql);
|
||||
}
|
||||
}
|
||||
|
||||
static GameRecord record(PlayerSummary player1, PlayerSummary player2, GameResult result, EndReason endReason,
|
||||
String leftUserId, int peakSpectators, int totalSpectators, Long clockInitialMs, Long remainingMs) {
|
||||
endedAt += 60_000;
|
||||
MoveRecord move = new MoveRecord(player1.userId(), 3, List.of(4, 4, 4, 0, 5, 5, 1, 4, 4, 4, 4, 4, 4, 0),
|
||||
List.of(new GameStep(6, "GAME_STEP_LAST_STONE_IN_BANK")), endedAt - 599_000, remainingMs);
|
||||
return new GameRecord(UUID.randomUUID().toString(), player1, player2,
|
||||
List.of(0, 0, 0, 0, 0, 0, 30, 0, 0, 0, 0, 0, 0, 18), result, endReason, leftUserId, endedAt - 600_000,
|
||||
endedAt, 1, peakSpectators, clockInitialMs, List.of(move), totalSpectators);
|
||||
}
|
||||
|
||||
static GameRecord record() {
|
||||
return record(ALICE, BOB, GameResult.PLAYER1, EndReason.COMPLETED, null, 0, 0, null, null);
|
||||
}
|
||||
|
||||
static GameRecord record(PlayerSummary player1, PlayerSummary player2) {
|
||||
return record(player1, player2, GameResult.PLAYER1, EndReason.COMPLETED, null, 0, 0, null, null);
|
||||
}
|
||||
|
||||
static GameRecord withPeak(int peakSpectators) {
|
||||
return record(ALICE, BOB, GameResult.PLAYER1, EndReason.COMPLETED, null, peakSpectators, 0, null, null);
|
||||
}
|
||||
|
||||
@Test
|
||||
void runsMigrationsOnlyOnce() throws Exception {
|
||||
Migrations.run(dataSource);
|
||||
List<String> versions = new ArrayList<>();
|
||||
try (Connection connection = dataSource.getConnection(); Statement statement = connection.createStatement();
|
||||
ResultSet rows = statement.executeQuery("SELECT version FROM schema_migrations ORDER BY version")) {
|
||||
while (rows.next()) versions.add(rows.getString(1));
|
||||
}
|
||||
assertEquals(List.of("001_games", "002_clock"), versions);
|
||||
}
|
||||
|
||||
@Test
|
||||
void savesAGameAndReadsItBackUnchanged() {
|
||||
GameRecord game = record(ALICE, LEGACY, null, EndReason.INACTIVE, null, 3, 5, null, null);
|
||||
repository.save(game);
|
||||
assertEquals(Json.tree(game), Json.tree(repository.get(game.id())));
|
||||
}
|
||||
|
||||
@Test
|
||||
void keepsTheClockTheInitialTimeAndEachMovesTimeLeft() {
|
||||
GameRecord game = record(ALICE, BOB, GameResult.PLAYER1, EndReason.COMPLETED, null, 0, 0, 300_000L, 291_500L);
|
||||
repository.save(game);
|
||||
assertEquals(Json.tree(game), Json.tree(repository.get(game.id())));
|
||||
assertEquals(300_000L, repository.getRecentForUser(ALICE.userId(), 1).getFirst().clockInitialMs());
|
||||
}
|
||||
|
||||
@Test
|
||||
void readsGamesSavedBeforeTheGameClockWithoutClockData() {
|
||||
GameRecord game = record();
|
||||
repository.save(game);
|
||||
JsonNode saved = Json.tree(repository.get(game.id()));
|
||||
assertFalse(saved.has("clockInitialMs"));
|
||||
assertFalse(saved.at("/moves/0").has("remainingMs"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void keepsLeftUserIdForGamesAPlayerLeft() {
|
||||
GameRecord game = record(ALICE, BOB, GameResult.PLAYER2, EndReason.LEFT, ALICE.userId(), 0, 0, null, null);
|
||||
repository.save(game);
|
||||
assertEquals(ALICE.userId(), repository.get(game.id()).leftUserId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void ignoresSavingTheSameGameAgain() {
|
||||
GameRecord game = record();
|
||||
repository.save(game);
|
||||
repository.save(new GameRecord(game.id(), game.player1(), game.player2(), game.board(), GameResult.DRAW,
|
||||
game.endReason(), null, game.startedAt(), game.endedAt(), game.moveCount(), game.peakSpectators(), null,
|
||||
game.moves(), game.totalSpectators()));
|
||||
assertEquals(GameResult.PLAYER1, repository.get(game.id()).result());
|
||||
}
|
||||
|
||||
@Test
|
||||
void returnsNullForUnknownAndMalformedIds() {
|
||||
assertNull(repository.get(UUID.randomUUID().toString()));
|
||||
assertNull(repository.get("not-a-uuid"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void listsAUsersGamesNewestFirstWithoutTheMoves() {
|
||||
GameRecord first = record();
|
||||
GameRecord second = record(GUEST, ALICE);
|
||||
GameRecord otherPlayers = record(BOB, GUEST);
|
||||
List.of(first, second, otherPlayers).forEach(repository::save);
|
||||
|
||||
List<FinishedGameSummary> recent = repository.getRecentForUser(ALICE.userId(), 3);
|
||||
assertEquals(List.of(second.id(), first.id()), recent.stream().map(FinishedGameSummary::id).toList());
|
||||
assertFalse(Json.tree(recent.getFirst()).has("moves"));
|
||||
assertEquals(1, repository.getRecentForUser(ALICE.userId(), 1).size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void listsPopularGamesByPeakSpectatorsThenNewest() {
|
||||
GameRecord quiet = withPeak(0);
|
||||
GameRecord popular = withPeak(9);
|
||||
GameRecord alsoPopularButNewer = withPeak(9);
|
||||
List.of(quiet, popular, alsoPopularButNewer).forEach(repository::save);
|
||||
|
||||
assertEquals(List.of(alsoPopularButNewer.id(), popular.id(), quiet.id()),
|
||||
repository.getPopular(5).stream().map(FinishedGameSummary::id).toList());
|
||||
}
|
||||
|
||||
@Test
|
||||
void readsAGameSavedByTheNodeBackend() throws Exception {
|
||||
// As the Node backend's repository inserted it.
|
||||
String id = UUID.randomUUID().toString();
|
||||
execute("""
|
||||
INSERT INTO games (id, player1_id, player2_id, result, end_reason, left_user_id, started_at, ended_at,
|
||||
move_count, final_board, moves, peak_spectators, total_spectators, clock_initial_ms)
|
||||
VALUES ('%s', 'kc:alice', 'legacy:0123456789abcdef0123', 'player2', 'left', 'kc:alice',
|
||||
to_timestamp(1792152000000 / 1000.0), to_timestamp(1792152061234 / 1000.0), 1,
|
||||
'[4,4,4,0,5,5,1,4,4,4,4,4,4,0]',
|
||||
'[{"playerId":"kc:alice","moveIndex":3,"boardSnapshot":[4,4,4,0,5,5,1,4,4,4,4,4,4,0],"gameSteps":[{"index":3,"type":"GAME_STEP_GAME_MOVE","data":null},{"index":6,"type":"GAME_STEP_BOARD_CLEARED","data":{"pitIndexesThatHasStone":[12]}}],"at":1792152001000,"remainingMs":299000}]',
|
||||
2, 4, 300000);
|
||||
INSERT INTO game_players (game_id, seat, user_id, name, is_guest)
|
||||
VALUES ('%s', 1, 'kc:alice', 'alice', false), ('%s', 2, 'legacy:0123456789abcdef0123', NULL, true);
|
||||
""".formatted(id, id, id));
|
||||
GameRecord game = repository.get(id);
|
||||
assertEquals(1792152061234L, game.endedAt());
|
||||
assertEquals(GameResult.PLAYER2, game.result());
|
||||
assertEquals(new PlayerSummary("legacy:0123456789abcdef0123", null, true), game.player2());
|
||||
assertEquals("{\"playerId\":\"kc:alice\",\"moveIndex\":3,\"boardSnapshot\":[4,4,4,0,5,5,1,4,4,4,4,4,4,0],"
|
||||
+ "\"gameSteps\":[{\"index\":3,\"type\":\"GAME_STEP_GAME_MOVE\",\"data\":null},{\"index\":6,"
|
||||
+ "\"type\":\"GAME_STEP_BOARD_CLEARED\",\"data\":{\"pitIndexesThatHasStone\":[12]}}],"
|
||||
+ "\"at\":1792152001000,\"remainingMs\":299000}", Json.write(game.moves().getFirst()));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,131 @@
|
||||
package app.playmancala.live;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
|
||||
import app.playmancala.model.EndReason;
|
||||
import app.playmancala.model.GameRecord;
|
||||
import app.playmancala.model.GameResult;
|
||||
import app.playmancala.model.Json;
|
||||
import app.playmancala.model.PlayerSummary;
|
||||
import io.quarkus.redis.client.RedisClientName;
|
||||
import io.quarkus.redis.datasource.RedisDataSource;
|
||||
import io.quarkus.test.junit.QuarkusTest;
|
||||
import jakarta.inject.Inject;
|
||||
|
||||
// Ported from the Node backend's tests/live/ValkeyStore.test.ts. Valkey runs in Docker.
|
||||
@QuarkusTest
|
||||
class ValkeyStoreTest {
|
||||
static final TypeReference<List<Integer>> NUMBERS = new TypeReference<>() { };
|
||||
|
||||
@Inject
|
||||
RedisDataSource redis;
|
||||
|
||||
// Points at a port nothing listens on.
|
||||
@Inject
|
||||
@RedisClientName("offline")
|
||||
RedisDataSource offline;
|
||||
|
||||
@BeforeEach
|
||||
void flush() {
|
||||
redis.flushall();
|
||||
}
|
||||
|
||||
static GameRecord record(String id) {
|
||||
return new GameRecord(id, new PlayerSummary("kc:alice", "alice", false), new PlayerSummary("kc:bob", "bob", false),
|
||||
List.of(0, 0, 0, 0, 0, 0, 30, 0, 0, 0, 0, 0, 0, 18), GameResult.PLAYER1, EndReason.COMPLETED, null, 1, 2, 0,
|
||||
0, null, List.of(), 0);
|
||||
}
|
||||
|
||||
static List<JsonNode> sortById(List<JsonNode> games) {
|
||||
return games.stream().sorted(Comparator.comparing(game -> game.get("id").asText())).toList();
|
||||
}
|
||||
|
||||
@Test
|
||||
void savesLoadsAndRemovesLiveGameSnapshots() {
|
||||
ValkeyLiveGameStore store = new ValkeyLiveGameStore(redis);
|
||||
store.saveGame("g1", Map.of("id", "g1", "moves", 1));
|
||||
store.saveGame("g2", Map.of("id", "g2", "moves", 2));
|
||||
store.saveGame("g1", Map.of("id", "g1", "moves", 3));
|
||||
assertEquals(List.of(Json.tree(Map.of("id", "g1", "moves", 3)), Json.tree(Map.of("id", "g2", "moves", 2))),
|
||||
sortById(store.loadGames()));
|
||||
|
||||
store.removeGame("g1");
|
||||
assertEquals(List.of(Json.tree(Map.of("id", "g2", "moves", 2))), store.loadGames());
|
||||
}
|
||||
|
||||
@Test
|
||||
void keepsPendingRecordsSeparateFromLiveGames() {
|
||||
ValkeyLiveGameStore store = new ValkeyLiveGameStore(redis);
|
||||
store.saveGame("g1", Map.of("id", "g1"));
|
||||
store.savePendingRecord(record("r1"));
|
||||
assertEquals(List.of(record("r1")), store.loadPendingRecords());
|
||||
assertEquals(List.of(Json.tree(Map.of("id", "g1"))), store.loadGames());
|
||||
|
||||
store.removePendingRecord("r1");
|
||||
assertEquals(List.of(), store.loadPendingRecords());
|
||||
}
|
||||
|
||||
@Test
|
||||
void skipsValuesItCannotRead() {
|
||||
ValkeyLiveGameStore store = new ValkeyLiveGameStore(redis);
|
||||
redis.value(String.class).set("mancala:live:broken", "{not json");
|
||||
store.saveGame("g1", Map.of("id", "g1"));
|
||||
assertEquals(List.of(Json.tree(Map.of("id", "g1"))), store.loadGames());
|
||||
}
|
||||
|
||||
@Test
|
||||
void storesValuesAsTheNodeBackendDid() {
|
||||
new ValkeyLiveGameStore(redis).saveGame("g1", Map.of("id", "g1"));
|
||||
assertEquals("{\"id\":\"g1\"}", redis.value(String.class).get("mancala:live:g1"));
|
||||
new ValkeyCache(redis).set("popular", List.of(1, 2), 60);
|
||||
assertEquals("[1,2]", redis.value(String.class).get("mancala:cache:popular"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void cacheReturnsValuesUntilTheyExpire() throws InterruptedException {
|
||||
ValkeyCache cache = new ValkeyCache(redis);
|
||||
cache.set("popular", List.of(1, 2, 3), 1);
|
||||
assertEquals(Optional.of(List.of(1, 2, 3)), cache.get("popular", NUMBERS));
|
||||
Thread.sleep(1500);
|
||||
assertEquals(Optional.empty(), cache.get("popular", NUMBERS));
|
||||
}
|
||||
|
||||
@Test
|
||||
void cacheDeletesEntries() {
|
||||
ValkeyCache cache = new ValkeyCache(redis);
|
||||
cache.set("a", 1, 60);
|
||||
cache.set("b", 2, 60);
|
||||
cache.delete("a", "b");
|
||||
assertEquals(Optional.empty(), cache.get("a", NUMBERS));
|
||||
assertEquals(Optional.empty(), cache.get("b", NUMBERS));
|
||||
}
|
||||
|
||||
@Test
|
||||
void whenValkeyIsUnreachableTheCacheAnswersWithMisses() {
|
||||
ValkeyCache cache = new ValkeyCache(offline);
|
||||
cache.set("a", 1, 60);
|
||||
assertEquals(Optional.empty(), cache.get("a", NUMBERS));
|
||||
cache.delete("a");
|
||||
}
|
||||
|
||||
@Test
|
||||
void whenValkeyIsUnreachableTheStoreFailsAtOnce() {
|
||||
ValkeyLiveGameStore store = new ValkeyLiveGameStore(offline);
|
||||
long started = System.currentTimeMillis();
|
||||
assertThrows(RuntimeException.class, () -> store.saveGame("g1", Map.of()));
|
||||
assertTrue(System.currentTimeMillis() - started < 1000, "took " + (System.currentTimeMillis() - started) + " ms");
|
||||
}
|
||||
}
|
||||
@ -10,7 +10,7 @@ Work through the tasks in order; each ends with its tests passing and a commit.
|
||||
- [x] 4. **Game logic**: `GameStore`, `MatchMaker`, `GameClocks`, `GameSerialization`, `GameRecorder`,
|
||||
`GameManager`, `GameClockScheduler`, `LiveGames`, `PublicIds`, `CrashLog`; the in-memory fakes and the ported
|
||||
unit tests.
|
||||
- [ ] 5. **Storage**: migrations and their runner, `PostgresGameHistoryRepository`, `GameHistoryService`,
|
||||
- [x] 5. **Storage**: migrations and their runner, `PostgresGameHistoryRepository`, `GameHistoryService`,
|
||||
`ValkeyLiveGameStore`, `ValkeyCache`; integration tests with Dev Services.
|
||||
- [ ] 6. **WebSocket and HTTP**: `RtmtServer` with unit tests, `RtmtEndpoint`, the HTTP resources, `Backend`
|
||||
wiring and startup, configuration; `@QuarkusTest`s for the API and the WebSocket.
|
||||
|
||||
Loading…
Reference in New Issue
Block a user