CharacterSaver integration test with Testcontainers

This commit is contained in:
P0nk
2024-09-15 09:25:35 +02:00
parent ce5dee39ae
commit 0c9643fd7e
12 changed files with 263 additions and 23 deletions

View File

@@ -165,8 +165,7 @@ server:
DB_PASS: "" DB_PASS: ""
INIT_CONNECTION_POOL_TIMEOUT: 90 # Seconds INIT_CONNECTION_POOL_TIMEOUT: 90 # Seconds
PG_DB_NAME: "cosmic" PG_DB_URL: "jdbc:postgresql://localhost:5432/cosmic"
PG_DB_HOST: "localhost"
PG_DB_SCHEMA: "cosmic" PG_DB_SCHEMA: "cosmic"
PG_DB_ADMIN_USERNAME: "cosmic_admin" PG_DB_ADMIN_USERNAME: "cosmic_admin"
PG_DB_ADMIN_PASSWORD: "redsnailshell" PG_DB_ADMIN_PASSWORD: "redsnailshell"

20
pom.xml
View File

@@ -69,6 +69,7 @@
<jdbi-version>3.45.1</jdbi-version> <!-- Convenience wrapper around JDBC --> <jdbi-version>3.45.1</jdbi-version> <!-- Convenience wrapper around JDBC -->
<junit.version>5.10.2</junit.version> <!-- Unit test --> <junit.version>5.10.2</junit.version> <!-- Unit test -->
<mockito.version>5.11.0</mockito.version> <!-- Unit test --> <mockito.version>5.11.0</mockito.version> <!-- Unit test -->
<testcontainers.version>1.20.1</testcontainers.version> <!-- Docker test with real temporary database -->
<postgresql.version>42.5.4</postgresql.version> <!-- PostgreSQL JDBC driver --> <postgresql.version>42.5.4</postgresql.version> <!-- PostgreSQL JDBC driver -->
<flyway.version>9.15.1</flyway.version> <!-- Database migration --> <flyway.version>9.15.1</flyway.version> <!-- Database migration -->
<caffeine.version>3.1.4</caffeine.version> <!-- Caching --> <caffeine.version>3.1.4</caffeine.version> <!-- Caching -->
@@ -213,6 +214,25 @@
<version>${mockito.version}</version> <version>${mockito.version}</version>
<scope>test</scope> <scope>test</scope>
</dependency> </dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<version>${testcontainers.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>mysql</artifactId>
<version>${testcontainers.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<version>${testcontainers.version}</version>
<scope>test</scope>
</dependency>
</dependencies> </dependencies>

View File

@@ -4,6 +4,9 @@ import lombok.Builder;
@Builder @Builder
public record CharacterStats( public record CharacterStats(
int account,
int world,
String name,
int id, int id,
int level, int level,
int fame, int fame,

View File

@@ -13,8 +13,7 @@ public class ServerConfig {
public int INIT_CONNECTION_POOL_TIMEOUT; public int INIT_CONNECTION_POOL_TIMEOUT;
// PostgreSQL database configuration // PostgreSQL database configuration
public String PG_DB_NAME; public String PG_DB_URL;
public String PG_DB_HOST;
public String PG_DB_SCHEMA; public String PG_DB_SCHEMA;
public String PG_DB_ADMIN_USERNAME; public String PG_DB_ADMIN_USERNAME;
public String PG_DB_ADMIN_PASSWORD; public String PG_DB_ADMIN_PASSWORD;

View File

@@ -6,15 +6,15 @@ import java.time.Duration;
@Builder @Builder
public record PgDatabaseConfig( public record PgDatabaseConfig(
String databaseName, String host, String schema, String url,
String schema,
String adminUsername, String adminPassword, String adminUsername, String adminPassword,
String username, String password, String username, String password,
Duration poolInitTimeout, Duration poolInitTimeout,
boolean clean boolean clean
) { ) {
public PgDatabaseConfig { public PgDatabaseConfig {
verifyNotBlank(databaseName); verifyNotBlank(url);
verifyNotBlank(host);
verifyNotBlank(schema); verifyNotBlank(schema);
verifyNotBlank(adminUsername); verifyNotBlank(adminUsername);
verifyNotBlank(adminPassword); verifyNotBlank(adminPassword);
@@ -27,8 +27,4 @@ public record PgDatabaseConfig(
throw new IllegalArgumentException("Missing or blank value in PG database config"); throw new IllegalArgumentException("Missing or blank value in PG database config");
} }
} }
public String getJdbcUrl() {
return "jdbc:postgresql://%s:5432/%s".formatted(host, databaseName);
}
} }

View File

@@ -7,6 +7,54 @@ import java.sql.Timestamp;
public class CharacterRepository { public class CharacterRepository {
public int insert(Handle handle, CharacterStats stats) {
String sql = """
INSERT INTO chr (account, world, name, level, exp, str, dex, "int", luk, hp, mp, max_hp, max_mp, ap, sp,
job, fame, gender, skin, hair, face, meso, map_id, spawn_portal, gacha_exp, used_hp_mp_ap, gm_level,
party_id, buddy_capacity, equip_slots, use_slots, setup_slots, etc_slots)
VALUES (:account, :world, :name, :level, :exp, :str, :dex, :int, :luk, :hp, :mp, :max_hp, :max_mp, :ap,
:sp, :job, :fame, :gender, :skin, :hair, :face, :meso, :map_id, :spawn_portal, :gacha_exp,
:used_hp_mp_ap, :gm_level, :party_id, :buddy_capacity, :equip_slots, :use_slots, :setup_slots,
:etc_slots)""";
return handle.createUpdate(sql)
.bind("account", stats.account())
.bind("world", stats.world())
.bind("name", stats.name())
.bind("level", stats.level())
.bind("exp", stats.exp())
.bind("str", stats.str())
.bind("dex", stats.dex())
.bind("int", stats.int_())
.bind("luk", stats.luk())
.bind("hp", stats.hp())
.bind("mp", stats.mp())
.bind("max_hp", stats.maxHp())
.bind("max_mp", stats.maxMp())
.bind("ap", stats.ap())
.bind("sp", parseSp(stats.sp()))
.bind("job", stats.job())
.bind("fame", stats.fame())
.bind("gender", stats.gender())
.bind("skin", stats.skin())
.bind("hair", stats.hair())
.bind("face", stats.face())
.bind("meso", stats.meso())
.bind("map_id", stats.mapId())
.bind("spawn_portal", stats.spawnPortal())
.bind("gacha_exp", stats.gachaExp())
.bind("used_hp_mp_ap", stats.hpMpApUsed())
.bind("gm_level", stats.gmLevel())
.bind("party_id", stats.party())
.bind("buddy_capacity", stats.buddyCapacity())
.bind("equip_slots", stats.equipSlots())
.bind("use_slots", stats.useSlots())
.bind("setup_slots", stats.setupSlots())
.bind("etc_slots", stats.etcSlots())
.executeAndReturnGeneratedKeys("id")
.mapTo(Integer.class)
.one();
}
public boolean update(Handle handle, CharacterStats stats) { public boolean update(Handle handle, CharacterStats stats) {
String sql = """ String sql = """
UPDATE chr UPDATE chr
@@ -40,7 +88,7 @@ public class CharacterRepository {
.bind("max_hp", stats.maxHp()) .bind("max_hp", stats.maxHp())
.bind("max_mp", stats.maxMp()) .bind("max_mp", stats.maxMp())
.bind("ap", stats.ap()) .bind("ap", stats.ap())
.bind("sp", parseMultiSkillbookSp(stats.sp())) .bind("sp", parseSp(stats.sp()))
.bind("job", stats.job()) .bind("job", stats.job())
.bind("fame", stats.fame()) .bind("fame", stats.fame())
.bind("gender", stats.gender()) .bind("gender", stats.gender())
@@ -89,11 +137,16 @@ public class CharacterRepository {
return updatedRows > 0; return updatedRows > 0;
} }
private int parseMultiSkillbookSp(String sp) { private int parseSp(String sp) {
if (sp == null) { if (sp == null) {
return 0; return 0;
} }
if (!sp.contains(",")) {
return Integer.parseInt(sp);
}
// Old multi skillbook sp to support Evan skills. To be changed - sp will be simple integer in new db.
return Integer.parseInt(sp.split(",")[0]); return Integer.parseInt(sp.split(",")[0]);
} }
} }

View File

@@ -15,7 +15,7 @@ public class FlywayRunner {
public void migrate() throws FlywayException { public void migrate() throws FlywayException {
Flyway flyway = Flyway.configure() Flyway flyway = Flyway.configure()
.dataSource(dbConfig.getJdbcUrl(), dbConfig.adminUsername(), dbConfig.adminPassword()) .dataSource(dbConfig.url(), dbConfig.adminUsername(), dbConfig.adminPassword())
.schemas(dbConfig.schema()) .schemas(dbConfig.schema())
.createSchemas(true) .createSchemas(true)
.connectRetries(10) .connectRetries(10)

View File

@@ -108,6 +108,7 @@ import java.util.LinkedList;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Map.Entry; import java.util.Map.Entry;
import java.util.Objects;
import java.util.Properties; import java.util.Properties;
import java.util.Set; import java.util.Set;
import java.util.SortedMap; import java.util.SortedMap;
@@ -969,13 +970,9 @@ public class Server {
private PgDatabaseConfig readPgDbConfig() { private PgDatabaseConfig readPgDbConfig() {
final ServerConfig serverConfig = YamlConfig.config.server; final ServerConfig serverConfig = YamlConfig.config.server;
String pgDbHost = System.getenv("PG_DB_HOST"); String url = Objects.requireNonNullElse(System.getenv("PG_DB_URL"), serverConfig.PG_DB_URL);
if (pgDbHost == null) {
pgDbHost = serverConfig.PG_DB_HOST;
}
return PgDatabaseConfig.builder() return PgDatabaseConfig.builder()
.databaseName(serverConfig.PG_DB_NAME) .url(url)
.host(pgDbHost)
.schema(serverConfig.PG_DB_SCHEMA) .schema(serverConfig.PG_DB_SCHEMA)
.adminUsername(serverConfig.PG_DB_ADMIN_USERNAME) .adminUsername(serverConfig.PG_DB_ADMIN_USERNAME)
.adminPassword(serverConfig.PG_DB_ADMIN_PASSWORD) .adminPassword(serverConfig.PG_DB_ADMIN_PASSWORD)
@@ -999,8 +996,7 @@ public class Server {
private HikariConfig createHikariConfig(PgDatabaseConfig config) { private HikariConfig createHikariConfig(PgDatabaseConfig config) {
final HikariConfig hikariConfig = new HikariConfig(); final HikariConfig hikariConfig = new HikariConfig();
hikariConfig.setJdbcUrl(config.getJdbcUrl()); hikariConfig.setJdbcUrl(config.url());
hikariConfig.setSchema(config.schema());
hikariConfig.setUsername(config.username()); hikariConfig.setUsername(config.username());
hikariConfig.setPassword(config.password()); hikariConfig.setPassword(config.password());
hikariConfig.setInitializationFailTimeout(config.poolInitTimeout().toMillis()); hikariConfig.setInitializationFailTimeout(config.poolInitTimeout().toMillis());

View File

@@ -29,7 +29,7 @@ CREATE TABLE chr
used_hp_mp_ap integer NOT NULL, used_hp_mp_ap integer NOT NULL,
gm_level smallint NOT NULL, gm_level smallint NOT NULL,
party_id integer, party_id integer,
buddy_capacity smallint, buddy_capacity smallint NOT NULL,
"rank" integer, "rank" integer,
rank_move integer, rank_move integer,
job_rank integer, job_rank integer,

View File

@@ -0,0 +1,129 @@
package database.character;
import client.Character;
import client.CharacterStats;
import client.MonsterBook;
import config.ServerConfig;
import config.YamlConfig;
import database.PgDatabaseConfig;
import database.PgDatabaseConnection;
import database.migration.FlywayRunner;
import database.monsterbook.MonsterCardRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.postgresql.ds.PGSimpleDataSource;
import org.testcontainers.containers.MySQLContainer;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import testutil.GeneratedIds;
import testutil.TestData;
import tools.DatabaseConnection;
import java.time.Duration;
import java.util.Collections;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.when;
@Testcontainers
class CharacterSaverTest {
private static final String MYSQL_VERSION = "8.4";
private static final String POSTGRES_VERSION = "16.4";
private static final String SCHEMA_NAME = "cosmic";
@Container
static MySQLContainer<?> mySql = new MySQLContainer<>("mysql:%s".formatted(MYSQL_VERSION));
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:%s".formatted(POSTGRES_VERSION));
private PgDatabaseConnection pgConnection;
private CharacterSaver characterSaver;
@BeforeEach
void setUp() {
prepareMysqlConnection();
runDbMigrations();
PgDatabaseConnection pgDatabaseConnection = createPgConnection();
this.pgConnection = pgDatabaseConnection;
this.characterSaver = new CharacterSaver(pgDatabaseConnection, new CharacterRepository(),
new MonsterCardRepository(pgDatabaseConnection));
}
// Not using this, but due to the nature of how the db connections are set up, the application requires
// a real database to connect to.
private void prepareMysqlConnection() {
ServerConfig serverConfig = new ServerConfig();
serverConfig.DB_URL_FORMAT = "%s";
serverConfig.DB_HOST = mySql.getJdbcUrl();
serverConfig.DB_USER = mySql.getUsername();
serverConfig.DB_PASS = mySql.getPassword();
serverConfig.INIT_CONNECTION_POOL_TIMEOUT = 60;
YamlConfig.config.server = serverConfig;
DatabaseConnection.initializeConnectionPool();
}
private void runDbMigrations() {
PgDatabaseConfig config = PgDatabaseConfig.builder()
.url(postgres.getJdbcUrl())
.schema(SCHEMA_NAME)
.adminUsername(postgres.getUsername())
.adminPassword(postgres.getPassword())
.username(postgres.getUsername())
.password(postgres.getPassword())
.poolInitTimeout(Duration.ofSeconds(60))
.clean(false)
.build();
new FlywayRunner(config).migrate();
}
private PgDatabaseConnection createPgConnection() {
return new PgDatabaseConnection(createDataSource());
}
private PGSimpleDataSource createDataSource() {
PGSimpleDataSource dataSource = new PGSimpleDataSource();
dataSource.setUrl(postgres.getJdbcUrl());
dataSource.setCurrentSchema(SCHEMA_NAME);
dataSource.setUser(postgres.getUsername());
dataSource.setPassword(postgres.getPassword());
return dataSource;
}
@Test
void saveCharacter_shouldUpdateChrTable() {
GeneratedIds ids = TestData.create(pgConnection);
Character mockChr = Mockito.mock(Character.class);
when(mockChr.isLoggedin()).thenReturn(true);
addEmptyMonsterBook(mockChr);
when(mockChr.getCharacterStats()).thenReturn(CharacterStats.builder()
.id(ids.chrId())
.level(200)
.build());
assertEquals(0, getChrLevel(ids.chrId()));
characterSaver.save(mockChr);
assertEquals(200, getChrLevel(ids.chrId()));
}
private static void addEmptyMonsterBook(Character mockChr) {
MonsterBook mockMonsterBook = Mockito.mock(MonsterBook.class);
when(mockMonsterBook.getCards()).thenReturn(Collections.emptyList());
when(mockChr.getMonsterBook()).thenReturn(mockMonsterBook);
}
private int getChrLevel(int chrId) {
String sql = """
SELECT level
FROM chr
WHERE id = :id""";
return pgConnection.getHandle().createQuery(sql)
.bind("id", chrId)
.mapTo(Integer.class)
.one();
}
}

View File

@@ -0,0 +1,4 @@
package testutil;
public record GeneratedIds(int accountId, int chrId) {
}

View File

@@ -0,0 +1,41 @@
package testutil;
import client.CharacterStats;
import database.PgDatabaseConnection;
import database.character.CharacterRepository;
import org.jdbi.v3.core.Handle;
import java.time.LocalDate;
public class TestData {
public static GeneratedIds create(PgDatabaseConnection connection) {
try (Handle handle = connection.getHandle()) {
int accountId = insertAccount(handle);
int chrId = insertChr(handle, accountId);
return new GeneratedIds(accountId, chrId);
}
}
private static int insertAccount(Handle handle) {
String sql = """
INSERT INTO account (name, password, birthday)
VALUES (:name, :password, :birthday)""";
return handle.createUpdate(sql)
.bind("name", "accountname")
.bind("password", "accountpassword")
.bind("birthday", LocalDate.of(2005, 5, 11))
.executeAndReturnGeneratedKeys()
.mapTo(Integer.class)
.one();
}
private static int insertChr(Handle handle, int accountId) {
CharacterRepository chrRepository = new CharacterRepository();
CharacterStats stats = CharacterStats.builder()
.account(accountId)
.name("chrname")
.build();
return chrRepository.insert(handle, stats);
}
}