diff --git a/config.yaml b/config.yaml
index 2d252ba255..f1617342ca 100644
--- a/config.yaml
+++ b/config.yaml
@@ -165,8 +165,7 @@ server:
DB_PASS: ""
INIT_CONNECTION_POOL_TIMEOUT: 90 # Seconds
- PG_DB_NAME: "cosmic"
- PG_DB_HOST: "localhost"
+ PG_DB_URL: "jdbc:postgresql://localhost:5432/cosmic"
PG_DB_SCHEMA: "cosmic"
PG_DB_ADMIN_USERNAME: "cosmic_admin"
PG_DB_ADMIN_PASSWORD: "redsnailshell"
diff --git a/pom.xml b/pom.xml
index 38c81dacb6..8e6bed13dc 100644
--- a/pom.xml
+++ b/pom.xml
@@ -69,6 +69,7 @@
3.45.1
5.10.2
5.11.0
+ 1.20.1
42.5.4
9.15.1
3.1.4
@@ -213,6 +214,25 @@
${mockito.version}
test
+
+ org.testcontainers
+ postgresql
+ ${testcontainers.version}
+ test
+
+
+ org.testcontainers
+ mysql
+ ${testcontainers.version}
+ test
+
+
+ org.testcontainers
+ junit-jupiter
+ ${testcontainers.version}
+ test
+
+
diff --git a/src/main/java/client/CharacterStats.java b/src/main/java/client/CharacterStats.java
index 23311bfbf3..5369d8227a 100644
--- a/src/main/java/client/CharacterStats.java
+++ b/src/main/java/client/CharacterStats.java
@@ -4,6 +4,9 @@ import lombok.Builder;
@Builder
public record CharacterStats(
+ int account,
+ int world,
+ String name,
int id,
int level,
int fame,
diff --git a/src/main/java/config/ServerConfig.java b/src/main/java/config/ServerConfig.java
index ad550e34d1..3e0bf8d74a 100644
--- a/src/main/java/config/ServerConfig.java
+++ b/src/main/java/config/ServerConfig.java
@@ -13,8 +13,7 @@ public class ServerConfig {
public int INIT_CONNECTION_POOL_TIMEOUT;
// PostgreSQL database configuration
- public String PG_DB_NAME;
- public String PG_DB_HOST;
+ public String PG_DB_URL;
public String PG_DB_SCHEMA;
public String PG_DB_ADMIN_USERNAME;
public String PG_DB_ADMIN_PASSWORD;
diff --git a/src/main/java/database/PgDatabaseConfig.java b/src/main/java/database/PgDatabaseConfig.java
index bec5c7a645..3e4a7dfe57 100644
--- a/src/main/java/database/PgDatabaseConfig.java
+++ b/src/main/java/database/PgDatabaseConfig.java
@@ -6,15 +6,15 @@ import java.time.Duration;
@Builder
public record PgDatabaseConfig(
- String databaseName, String host, String schema,
+ String url,
+ String schema,
String adminUsername, String adminPassword,
String username, String password,
Duration poolInitTimeout,
boolean clean
) {
public PgDatabaseConfig {
- verifyNotBlank(databaseName);
- verifyNotBlank(host);
+ verifyNotBlank(url);
verifyNotBlank(schema);
verifyNotBlank(adminUsername);
verifyNotBlank(adminPassword);
@@ -27,8 +27,4 @@ public record PgDatabaseConfig(
throw new IllegalArgumentException("Missing or blank value in PG database config");
}
}
-
- public String getJdbcUrl() {
- return "jdbc:postgresql://%s:5432/%s".formatted(host, databaseName);
- }
}
diff --git a/src/main/java/database/character/CharacterRepository.java b/src/main/java/database/character/CharacterRepository.java
index 0d544d4723..505148e1e5 100644
--- a/src/main/java/database/character/CharacterRepository.java
+++ b/src/main/java/database/character/CharacterRepository.java
@@ -7,6 +7,54 @@ import java.sql.Timestamp;
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) {
String sql = """
UPDATE chr
@@ -40,7 +88,7 @@ public class CharacterRepository {
.bind("max_hp", stats.maxHp())
.bind("max_mp", stats.maxMp())
.bind("ap", stats.ap())
- .bind("sp", parseMultiSkillbookSp(stats.sp()))
+ .bind("sp", parseSp(stats.sp()))
.bind("job", stats.job())
.bind("fame", stats.fame())
.bind("gender", stats.gender())
@@ -89,11 +137,16 @@ public class CharacterRepository {
return updatedRows > 0;
}
- private int parseMultiSkillbookSp(String sp) {
+ private int parseSp(String sp) {
if (sp == null) {
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]);
}
}
diff --git a/src/main/java/database/migration/FlywayRunner.java b/src/main/java/database/migration/FlywayRunner.java
index a846b38c64..81b81764f6 100644
--- a/src/main/java/database/migration/FlywayRunner.java
+++ b/src/main/java/database/migration/FlywayRunner.java
@@ -15,7 +15,7 @@ public class FlywayRunner {
public void migrate() throws FlywayException {
Flyway flyway = Flyway.configure()
- .dataSource(dbConfig.getJdbcUrl(), dbConfig.adminUsername(), dbConfig.adminPassword())
+ .dataSource(dbConfig.url(), dbConfig.adminUsername(), dbConfig.adminPassword())
.schemas(dbConfig.schema())
.createSchemas(true)
.connectRetries(10)
diff --git a/src/main/java/net/server/Server.java b/src/main/java/net/server/Server.java
index c58e0ba23b..b6ad554564 100644
--- a/src/main/java/net/server/Server.java
+++ b/src/main/java/net/server/Server.java
@@ -108,6 +108,7 @@ import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
+import java.util.Objects;
import java.util.Properties;
import java.util.Set;
import java.util.SortedMap;
@@ -969,13 +970,9 @@ public class Server {
private PgDatabaseConfig readPgDbConfig() {
final ServerConfig serverConfig = YamlConfig.config.server;
- String pgDbHost = System.getenv("PG_DB_HOST");
- if (pgDbHost == null) {
- pgDbHost = serverConfig.PG_DB_HOST;
- }
+ String url = Objects.requireNonNullElse(System.getenv("PG_DB_URL"), serverConfig.PG_DB_URL);
return PgDatabaseConfig.builder()
- .databaseName(serverConfig.PG_DB_NAME)
- .host(pgDbHost)
+ .url(url)
.schema(serverConfig.PG_DB_SCHEMA)
.adminUsername(serverConfig.PG_DB_ADMIN_USERNAME)
.adminPassword(serverConfig.PG_DB_ADMIN_PASSWORD)
@@ -999,8 +996,7 @@ public class Server {
private HikariConfig createHikariConfig(PgDatabaseConfig config) {
final HikariConfig hikariConfig = new HikariConfig();
- hikariConfig.setJdbcUrl(config.getJdbcUrl());
- hikariConfig.setSchema(config.schema());
+ hikariConfig.setJdbcUrl(config.url());
hikariConfig.setUsername(config.username());
hikariConfig.setPassword(config.password());
hikariConfig.setInitializationFailTimeout(config.poolInitTimeout().toMillis());
diff --git a/src/main/resources/db/migration/postgresql/V0.3__character.sql b/src/main/resources/db/migration/postgresql/V0.3__character.sql
index 7835fba30e..12c9ad7063 100644
--- a/src/main/resources/db/migration/postgresql/V0.3__character.sql
+++ b/src/main/resources/db/migration/postgresql/V0.3__character.sql
@@ -29,7 +29,7 @@ CREATE TABLE chr
used_hp_mp_ap integer NOT NULL,
gm_level smallint NOT NULL,
party_id integer,
- buddy_capacity smallint,
+ buddy_capacity smallint NOT NULL,
"rank" integer,
rank_move integer,
job_rank integer,
diff --git a/src/test/java/database/character/CharacterSaverTest.java b/src/test/java/database/character/CharacterSaverTest.java
new file mode 100644
index 0000000000..bc214bde11
--- /dev/null
+++ b/src/test/java/database/character/CharacterSaverTest.java
@@ -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();
+ }
+
+}
diff --git a/src/test/java/testutil/GeneratedIds.java b/src/test/java/testutil/GeneratedIds.java
new file mode 100644
index 0000000000..24bdc67925
--- /dev/null
+++ b/src/test/java/testutil/GeneratedIds.java
@@ -0,0 +1,4 @@
+package testutil;
+
+public record GeneratedIds(int accountId, int chrId) {
+}
diff --git a/src/test/java/testutil/TestData.java b/src/test/java/testutil/TestData.java
new file mode 100644
index 0000000000..a8a1bce717
--- /dev/null
+++ b/src/test/java/testutil/TestData.java
@@ -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);
+ }
+}