From 6b01091b951a65bbcceeb6a52872910da6bfebcf Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 09:48:53 +0000 Subject: [PATCH] Add SQLite integration tests for all DAO classes Add integration tests using real in-memory SQLite databases for TableVersionHistoryDAO, MutexDAO, PlayerSettingDAO, and PlayerStatisticDAO. These complement the existing mock-based tests by verifying actual SQL execution, column mappings, and data roundtrip correctness that mocked JDBC objects cannot catch. Coverage includes: - Table creation and duplicate-table guard - Version get/set roundtrip and updateTable lifecycle - Mutex lock/unlock roundtrip with both UUID and CorePlayer overloads - Player setting save/load/update with registry integration - Statistic save/load for all 6 StatisticType variants (INT, LONG, DOUBLE, STRING, TIMESTAMP, SET_STRING) - Upsert (REPLACE INTO) behavior verification - Player isolation across all DAOs - Edge cases: empty sets, special characters, batch operations Co-Authored-By: Claude Opus 4.6 Claude-Session: https://claude.ai/code/session_015nDpvsMNTH667BixNik4jV --- .../table/impl/MutexDAOIntegrationTest.java | 182 ++++++++ .../impl/PlayerSettingDAOIntegrationTest.java | 272 ++++++++++++ .../PlayerStatisticDAOIntegrationTest.java | 389 ++++++++++++++++++ ...TableVersionHistoryDAOIntegrationTest.java | 149 +++++++ 4 files changed, 992 insertions(+) create mode 100644 src/test/java/com/diamonddagger590/mccore/database/table/impl/MutexDAOIntegrationTest.java create mode 100644 src/test/java/com/diamonddagger590/mccore/database/table/impl/PlayerSettingDAOIntegrationTest.java create mode 100644 src/test/java/com/diamonddagger590/mccore/database/table/impl/PlayerStatisticDAOIntegrationTest.java create mode 100644 src/test/java/com/diamonddagger590/mccore/database/table/impl/TableVersionHistoryDAOIntegrationTest.java diff --git a/src/test/java/com/diamonddagger590/mccore/database/table/impl/MutexDAOIntegrationTest.java b/src/test/java/com/diamonddagger590/mccore/database/table/impl/MutexDAOIntegrationTest.java new file mode 100644 index 0000000..6abc1e2 --- /dev/null +++ b/src/test/java/com/diamonddagger590/mccore/database/table/impl/MutexDAOIntegrationTest.java @@ -0,0 +1,182 @@ +package com.diamonddagger590.mccore.database.table.impl; + +import com.diamonddagger590.mccore.database.Database; +import com.diamonddagger590.mccore.player.CorePlayer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.SQLException; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Integration tests for {@link MutexDAO} using a real in-memory SQLite database. + * Verifies actual SQL execution and mutex state roundtrip correctness. + */ +class MutexDAOIntegrationTest { + + private Connection connection; + private Database mockDatabase; + + private static final UUID PLAYER_UUID = UUID.fromString("12345678-1234-1234-1234-123456789abc"); + private static final UUID OTHER_UUID = UUID.fromString("87654321-4321-4321-4321-cba987654321"); + + @BeforeEach + void setUp() throws SQLException { + connection = DriverManager.getConnection("jdbc:sqlite::memory:"); + mockDatabase = mock(Database.class); + when(mockDatabase.tableExists(any(Connection.class), anyString())).thenReturn(false); + + TableVersionHistoryDAO.attemptCreateTable(connection, mockDatabase); + MutexDAO.attemptCreateTable(connection, mockDatabase); + } + + @AfterEach + void tearDown() throws SQLException { + if (connection != null && !connection.isClosed()) { + connection.close(); + } + } + + @Nested + @DisplayName("attemptCreateTable") + class AttemptCreateTable { + + @Test + @DisplayName("Given no existing table, when attemptCreateTable is called, then creates table and returns true") + void attemptCreateTable_createsTable() throws SQLException { + Connection freshConn = DriverManager.getConnection("jdbc:sqlite::memory:"); + Database freshDb = mock(Database.class); + when(freshDb.tableExists(any(Connection.class), anyString())).thenReturn(false); + + boolean result = MutexDAO.attemptCreateTable(freshConn, freshDb); + assertTrue(result); + freshConn.close(); + } + + @Test + @DisplayName("Given table already exists, when attemptCreateTable is called, then returns false") + void attemptCreateTable_returnsFalse_whenExists() { + when(mockDatabase.tableExists(any(Connection.class), anyString())).thenReturn(true); + boolean result = MutexDAO.attemptCreateTable(connection, mockDatabase); + assertFalse(result); + } + } + + @Nested + @DisplayName("isUserMutexLocked and updateUserMutex roundtrip") + class MutexRoundtrip { + + @Test + @DisplayName("Given no mutex row exists, when checking lock status, then returns false") + void isUserMutexLocked_returnsFalse_whenNoRowExists() { + boolean locked = MutexDAO.isUserMutexLocked(connection, PLAYER_UUID); + assertFalse(locked); + } + + @Test + @DisplayName("Given mutex is set to locked, when checking lock status, then returns true") + void lockAndCheck_roundTrips() { + MutexDAO.updateUserMutex(connection, PLAYER_UUID, true); + + boolean locked = MutexDAO.isUserMutexLocked(connection, PLAYER_UUID); + assertTrue(locked); + } + + @Test + @DisplayName("Given mutex is locked then unlocked, when checking lock status, then returns false") + void unlockAfterLock_roundTrips() { + MutexDAO.updateUserMutex(connection, PLAYER_UUID, true); + MutexDAO.updateUserMutex(connection, PLAYER_UUID, false); + + boolean locked = MutexDAO.isUserMutexLocked(connection, PLAYER_UUID); + assertFalse(locked); + } + + @Test + @DisplayName("Given different players have different mutex states, when checking each, then returns correct state") + void differentPlayersHaveIndependentMutexStates() { + MutexDAO.updateUserMutex(connection, PLAYER_UUID, true); + MutexDAO.updateUserMutex(connection, OTHER_UUID, false); + + assertTrue(MutexDAO.isUserMutexLocked(connection, PLAYER_UUID)); + assertFalse(MutexDAO.isUserMutexLocked(connection, OTHER_UUID)); + } + + @Test + @DisplayName("Given updateUserMutex is called with locked=true, when called, then returns true") + void updateUserMutex_returnsLockedState_whenTrue() { + boolean result = MutexDAO.updateUserMutex(connection, PLAYER_UUID, true); + assertTrue(result); + } + + @Test + @DisplayName("Given updateUserMutex is called with locked=false, when called, then returns false") + void updateUserMutex_returnsLockedState_whenFalse() { + boolean result = MutexDAO.updateUserMutex(connection, PLAYER_UUID, false); + assertFalse(result); + } + + @Test + @DisplayName("Given a locked CorePlayer, when updateUserMutex with CorePlayer is called, then persists locked state") + void updateUserMutex_withCorePlayer_persistsLockedState() { + CorePlayer mockPlayer = mock(CorePlayer.class); + when(mockPlayer.getUUID()).thenReturn(PLAYER_UUID); + when(mockPlayer.isLocked()).thenReturn(true); + + boolean result = MutexDAO.updateUserMutex(connection, mockPlayer); + assertTrue(result); + assertTrue(MutexDAO.isUserMutexLocked(connection, PLAYER_UUID)); + } + + @Test + @DisplayName("Given an unlocked CorePlayer, when updateUserMutex with CorePlayer is called, then persists unlocked state") + void updateUserMutex_withCorePlayer_persistsUnlockedState() { + CorePlayer mockPlayer = mock(CorePlayer.class); + when(mockPlayer.getUUID()).thenReturn(PLAYER_UUID); + when(mockPlayer.isLocked()).thenReturn(false); + + MutexDAO.updateUserMutex(connection, PLAYER_UUID, true); + boolean result = MutexDAO.updateUserMutex(connection, mockPlayer); + assertFalse(result); + assertFalse(MutexDAO.isUserMutexLocked(connection, PLAYER_UUID)); + } + } + + @Nested + @DisplayName("updateTable") + class UpdateTable { + + @Test + @DisplayName("Given player_mutex has no version, when updateTable is called, then version is set to 1") + void updateTable_setsVersionToOne_whenNoVersion() { + MutexDAO.updateTable(connection); + + int version = TableVersionHistoryDAO.getLatestVersion(connection, "player_mutex"); + assertEquals(1, version); + } + + @Test + @DisplayName("Given player_mutex is at version 1, when updateTable is called, then version remains 1") + void updateTable_doesNothing_whenAlreadyCurrent() { + TableVersionHistoryDAO.setTableVersion(connection, "player_mutex", 1); + MutexDAO.updateTable(connection); + + int version = TableVersionHistoryDAO.getLatestVersion(connection, "player_mutex"); + assertEquals(1, version); + } + + } +} diff --git a/src/test/java/com/diamonddagger590/mccore/database/table/impl/PlayerSettingDAOIntegrationTest.java b/src/test/java/com/diamonddagger590/mccore/database/table/impl/PlayerSettingDAOIntegrationTest.java new file mode 100644 index 0000000..f283e85 --- /dev/null +++ b/src/test/java/com/diamonddagger590/mccore/database/table/impl/PlayerSettingDAOIntegrationTest.java @@ -0,0 +1,272 @@ +package com.diamonddagger590.mccore.database.table.impl; + +import com.diamonddagger590.mccore.CorePlugin; +import com.diamonddagger590.mccore.database.Database; +import com.diamonddagger590.mccore.player.CorePlayer; +import com.diamonddagger590.mccore.registry.RegistryAccess; +import com.diamonddagger590.mccore.registry.RegistryKey; +import com.diamonddagger590.mccore.setting.PlayerSetting; +import com.diamonddagger590.mccore.setting.PlayerSettingRegistry; +import com.diamonddagger590.mccore.testing.RegistryResetExtension; +import com.diamonddagger590.mccore.util.LinkedNode; +import org.bukkit.NamespacedKey; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; +import org.jetbrains.annotations.NotNull; + +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.PreparedStatement; +import java.sql.SQLException; +import java.util.HashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.logging.Logger; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; + +/** + * Integration tests for {@link PlayerSettingDAO} using a real in-memory SQLite database. + * Verifies actual SQL execution and setting save/load roundtrip correctness. + */ +class PlayerSettingDAOIntegrationTest { + + private Connection connection; + private Database mockDatabase; + private MockedStatic corePluginStatic; + + private static final UUID PLAYER_UUID = UUID.fromString("12345678-1234-1234-1234-123456789abc"); + private static final UUID OTHER_UUID = UUID.fromString("87654321-4321-4321-4321-cba987654321"); + + private enum TestSetting implements PlayerSetting { + ON, + OFF; + + private static final NamespacedKey KEY = new NamespacedKey("mccore", "test_setting"); + + @Override + @NotNull + public NamespacedKey getSettingKey() { + return KEY; + } + + @Override + @NotNull + public LinkedNode getFirstSetting() { + return new LinkedNode<>(ON, new LinkedNode<>(OFF, null)); + } + + @Override + @NotNull + public LinkedNode getNextSetting() { + return this == ON ? new LinkedNode<>(OFF, null) : new LinkedNode<>(ON, null); + } + + @Override + public void onSettingChange(@NotNull CorePlayer player, @NotNull Optional oldSetting) { + } + + @Override + @NotNull + public Optional fromString(@NotNull String setting) { + try { + return Optional.of(TestSetting.valueOf(setting)); + } catch (IllegalArgumentException e) { + return Optional.empty(); + } + } + } + + private enum AnotherSetting implements PlayerSetting { + ENABLED, + DISABLED; + + private static final NamespacedKey KEY = new NamespacedKey("mccore", "another_setting"); + + @Override + @NotNull + public NamespacedKey getSettingKey() { + return KEY; + } + + @Override + @NotNull + public LinkedNode getFirstSetting() { + return new LinkedNode<>(ENABLED, new LinkedNode<>(DISABLED, null)); + } + + @Override + @NotNull + public LinkedNode getNextSetting() { + return this == ENABLED ? new LinkedNode<>(DISABLED, null) : new LinkedNode<>(ENABLED, null); + } + + @Override + public void onSettingChange(@NotNull CorePlayer player, @NotNull Optional oldSetting) { + } + + @Override + @NotNull + public Optional fromString(@NotNull String setting) { + try { + return Optional.of(AnotherSetting.valueOf(setting)); + } catch (IllegalArgumentException e) { + return Optional.empty(); + } + } + } + + @BeforeEach + void setUp() throws SQLException { + connection = DriverManager.getConnection("jdbc:sqlite::memory:"); + mockDatabase = mock(Database.class); + when(mockDatabase.tableExists(any(Connection.class), anyString())).thenReturn(false); + + TableVersionHistoryDAO.attemptCreateTable(connection, mockDatabase); + PlayerSettingDAO.attemptCreateTable(connection, mockDatabase); + + RegistryResetExtension.setupRegistry(); + PlayerSettingRegistry registry = RegistryAccess.registryAccess().registry(RegistryKey.PLAYER_SETTING); + registry.register(TestSetting.ON); + registry.register(AnotherSetting.ENABLED); + + CorePlugin mockPlugin = mock(CorePlugin.class); + when(mockPlugin.registryAccess()).thenReturn(RegistryAccess.registryAccess()); + when(mockPlugin.getLogger()).thenReturn(Logger.getLogger("TestLogger")); + + corePluginStatic = mockStatic(CorePlugin.class); + corePluginStatic.when(CorePlugin::getInstance).thenReturn(mockPlugin); + } + + @AfterEach + void tearDown() throws SQLException { + corePluginStatic.close(); + RegistryResetExtension.resetRegistry(); + if (connection != null && !connection.isClosed()) { + connection.close(); + } + } + + @Nested + @DisplayName("save and load single setting roundtrip") + class SingleSettingRoundtrip { + + @Test + @DisplayName("Given a setting is saved, when getPlayerSetting is called, then returns the saved value") + void saveAndLoad_roundTrips() throws SQLException { + PreparedStatement saveStmt = PlayerSettingDAO.savePlayerSetting(connection, PLAYER_UUID, TestSetting.OFF); + saveStmt.executeUpdate(); + saveStmt.close(); + + PlayerSetting loaded = PlayerSettingDAO.getPlayerSetting(connection, PLAYER_UUID, TestSetting.KEY); + assertEquals("OFF", loaded.name()); + } + + @Test + @DisplayName("Given no setting is saved, when getPlayerSetting is called, then returns the default") + void load_returnsDefault_whenNothingSaved() { + PlayerSetting loaded = PlayerSettingDAO.getPlayerSetting(connection, PLAYER_UUID, TestSetting.KEY); + assertEquals("ON", loaded.name()); + } + + @Test + @DisplayName("Given a setting is saved then updated, when getPlayerSetting is called, then returns the updated value") + void saveUpdate_returnsLatestValue() throws SQLException { + PreparedStatement stmt1 = PlayerSettingDAO.savePlayerSetting(connection, PLAYER_UUID, TestSetting.OFF); + stmt1.executeUpdate(); + stmt1.close(); + + PreparedStatement stmt2 = PlayerSettingDAO.savePlayerSetting(connection, PLAYER_UUID, TestSetting.ON); + stmt2.executeUpdate(); + stmt2.close(); + + PlayerSetting loaded = PlayerSettingDAO.getPlayerSetting(connection, PLAYER_UUID, TestSetting.KEY); + assertEquals("ON", loaded.name()); + } + } + + @Nested + @DisplayName("save and load multiple settings roundtrip") + class MultipleSettingsRoundtrip { + + @Test + @DisplayName("Given multiple settings are saved, when getPlayerSettings is called, then returns all saved values") + void saveMultipleAndLoadAll_roundTrips() throws SQLException { + Set toSave = new HashSet<>(); + toSave.add(TestSetting.OFF); + toSave.add(AnotherSetting.DISABLED); + + List stmts = PlayerSettingDAO.savePlayerSettings(connection, PLAYER_UUID, toSave); + for (PreparedStatement stmt : stmts) { + stmt.executeUpdate(); + stmt.close(); + } + + Set loaded = PlayerSettingDAO.getPlayerSettings(connection, PLAYER_UUID); + + assertEquals(2, loaded.size()); + assertTrue(loaded.stream().anyMatch(s -> s.name().equals("OFF"))); + assertTrue(loaded.stream().anyMatch(s -> s.name().equals("DISABLED"))); + } + + @Test + @DisplayName("Given no settings are saved, when getPlayerSettings is called, then returns defaults for all registered settings") + void loadAll_returnsDefaults_whenNothingSaved() { + Set loaded = PlayerSettingDAO.getPlayerSettings(connection, PLAYER_UUID); + + assertEquals(2, loaded.size()); + assertTrue(loaded.stream().anyMatch(s -> s.name().equals("ON"))); + assertTrue(loaded.stream().anyMatch(s -> s.name().equals("ENABLED"))); + } + } + + @Nested + @DisplayName("player isolation") + class PlayerIsolation { + + @Test + @DisplayName("Given different players have different settings, when loading each, then returns correct values") + void differentPlayersHaveIndependentSettings() throws SQLException { + PreparedStatement stmt1 = PlayerSettingDAO.savePlayerSetting(connection, PLAYER_UUID, TestSetting.OFF); + stmt1.executeUpdate(); + stmt1.close(); + + PreparedStatement stmt2 = PlayerSettingDAO.savePlayerSetting(connection, OTHER_UUID, TestSetting.ON); + stmt2.executeUpdate(); + stmt2.close(); + + PlayerSetting player1Setting = PlayerSettingDAO.getPlayerSetting(connection, PLAYER_UUID, TestSetting.KEY); + PlayerSetting player2Setting = PlayerSettingDAO.getPlayerSetting(connection, OTHER_UUID, TestSetting.KEY); + + assertEquals("OFF", player1Setting.name()); + assertEquals("ON", player2Setting.name()); + } + } + + @Nested + @DisplayName("updateTable") + class UpdateTable { + + @Test + @DisplayName("Given player_settings has no version, when updateTable is called, then sets version to 1") + void updateTable_setsVersionToOne() { + PlayerSettingDAO.updateTable(connection); + + int version = TableVersionHistoryDAO.getLatestVersion(connection, "player_settings"); + assertEquals(1, version); + } + } +} diff --git a/src/test/java/com/diamonddagger590/mccore/database/table/impl/PlayerStatisticDAOIntegrationTest.java b/src/test/java/com/diamonddagger590/mccore/database/table/impl/PlayerStatisticDAOIntegrationTest.java new file mode 100644 index 0000000..ba0419e --- /dev/null +++ b/src/test/java/com/diamonddagger590/mccore/database/table/impl/PlayerStatisticDAOIntegrationTest.java @@ -0,0 +1,389 @@ +package com.diamonddagger590.mccore.database.table.impl; + +import com.diamonddagger590.mccore.CorePlugin; +import com.diamonddagger590.mccore.database.Database; +import com.diamonddagger590.mccore.statistic.StatisticEntry; +import com.diamonddagger590.mccore.statistic.StatisticType; +import org.bukkit.NamespacedKey; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; + +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.PreparedStatement; +import java.sql.SQLException; +import java.time.Instant; +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.logging.Logger; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; + +/** + * Integration tests for {@link PlayerStatisticDAO} using a real in-memory SQLite database. + * Verifies actual SQL execution and statistic save/load roundtrip for every {@link StatisticType}. + */ +class PlayerStatisticDAOIntegrationTest { + + private Connection connection; + private Database mockDatabase; + private MockedStatic corePluginStatic; + + private static final UUID PLAYER_UUID = UUID.fromString("12345678-1234-1234-1234-123456789abc"); + private static final UUID OTHER_UUID = UUID.fromString("87654321-4321-4321-4321-cba987654321"); + + @BeforeEach + void setUp() throws SQLException { + connection = DriverManager.getConnection("jdbc:sqlite::memory:"); + mockDatabase = mock(Database.class); + when(mockDatabase.tableExists(any(Connection.class), anyString())).thenReturn(false); + + TableVersionHistoryDAO.attemptCreateTable(connection, mockDatabase); + PlayerStatisticDAO.attemptCreateTable(connection, mockDatabase); + + CorePlugin mockPlugin = mock(CorePlugin.class); + when(mockPlugin.getLogger()).thenReturn(Logger.getLogger("TestLogger")); + + corePluginStatic = mockStatic(CorePlugin.class); + corePluginStatic.when(CorePlugin::getInstance).thenReturn(mockPlugin); + } + + @AfterEach + void tearDown() throws SQLException { + corePluginStatic.close(); + if (connection != null && !connection.isClosed()) { + connection.close(); + } + } + + private void executeSave(PreparedStatement stmt) throws SQLException { + stmt.executeUpdate(); + stmt.close(); + } + + @Nested + @DisplayName("INT statistic roundtrip") + class IntRoundtrip { + + private static final NamespacedKey KEY = new NamespacedKey("mccore", "kills"); + + @Test + @DisplayName("Given an INT statistic is saved, when loading it, then returns the correct value") + void saveAndLoad_intStatistic() throws SQLException { + StatisticEntry entry = new StatisticEntry(KEY, StatisticType.INT, 42); + executeSave(PlayerStatisticDAO.savePlayerStatistic(connection, PLAYER_UUID, entry)); + + Optional loaded = PlayerStatisticDAO.getPlayerStatistic(connection, PLAYER_UUID, KEY); + assertTrue(loaded.isPresent()); + assertEquals(StatisticType.INT, loaded.get().type()); + assertEquals(42, (int) loaded.get().value()); + } + + @Test + @DisplayName("Given an INT statistic is saved then updated, when loading it, then returns the updated value") + void saveAndUpdate_intStatistic_returnsUpdatedValue() throws SQLException { + StatisticEntry original = new StatisticEntry(KEY, StatisticType.INT, 42); + executeSave(PlayerStatisticDAO.savePlayerStatistic(connection, PLAYER_UUID, original)); + + StatisticEntry updated = new StatisticEntry(KEY, StatisticType.INT, 99); + executeSave(PlayerStatisticDAO.savePlayerStatistic(connection, PLAYER_UUID, updated)); + + Optional loaded = PlayerStatisticDAO.getPlayerStatistic(connection, PLAYER_UUID, KEY); + assertTrue(loaded.isPresent()); + assertEquals(99, (int) loaded.get().value()); + } + } + + @Nested + @DisplayName("LONG statistic roundtrip") + class LongRoundtrip { + + private static final NamespacedKey KEY = new NamespacedKey("mccore", "experience"); + + @Test + @DisplayName("Given a LONG statistic is saved, when loading it, then returns the correct value") + void saveAndLoad_longStatistic() throws SQLException { + StatisticEntry entry = new StatisticEntry(KEY, StatisticType.LONG, 9999999999L); + executeSave(PlayerStatisticDAO.savePlayerStatistic(connection, PLAYER_UUID, entry)); + + Optional loaded = PlayerStatisticDAO.getPlayerStatistic(connection, PLAYER_UUID, KEY); + assertTrue(loaded.isPresent()); + assertEquals(StatisticType.LONG, loaded.get().type()); + assertEquals(9999999999L, (long) loaded.get().value()); + } + } + + @Nested + @DisplayName("DOUBLE statistic roundtrip") + class DoubleRoundtrip { + + private static final NamespacedKey KEY = new NamespacedKey("mccore", "accuracy"); + + @Test + @DisplayName("Given a DOUBLE statistic is saved, when loading it, then returns the correct value") + void saveAndLoad_doubleStatistic() throws SQLException { + StatisticEntry entry = new StatisticEntry(KEY, StatisticType.DOUBLE, 3.14159); + executeSave(PlayerStatisticDAO.savePlayerStatistic(connection, PLAYER_UUID, entry)); + + Optional loaded = PlayerStatisticDAO.getPlayerStatistic(connection, PLAYER_UUID, KEY); + assertTrue(loaded.isPresent()); + assertEquals(StatisticType.DOUBLE, loaded.get().type()); + assertEquals(3.14159, (double) loaded.get().value(), 0.0001); + } + } + + @Nested + @DisplayName("STRING statistic roundtrip") + class StringRoundtrip { + + private static final NamespacedKey KEY = new NamespacedKey("mccore", "nickname"); + + @Test + @DisplayName("Given a STRING statistic is saved, when loading it, then returns the correct value") + void saveAndLoad_stringStatistic() throws SQLException { + StatisticEntry entry = new StatisticEntry(KEY, StatisticType.STRING, "TheHero"); + executeSave(PlayerStatisticDAO.savePlayerStatistic(connection, PLAYER_UUID, entry)); + + Optional loaded = PlayerStatisticDAO.getPlayerStatistic(connection, PLAYER_UUID, KEY); + assertTrue(loaded.isPresent()); + assertEquals(StatisticType.STRING, loaded.get().type()); + assertEquals("TheHero", loaded.get().value()); + } + + @Test + @DisplayName("Given a STRING statistic with special characters, when roundtripped, then preserves value") + void saveAndLoad_stringWithSpecialChars() throws SQLException { + StatisticEntry entry = new StatisticEntry(KEY, StatisticType.STRING, "it's a \"test\" with, commas"); + executeSave(PlayerStatisticDAO.savePlayerStatistic(connection, PLAYER_UUID, entry)); + + Optional loaded = PlayerStatisticDAO.getPlayerStatistic(connection, PLAYER_UUID, KEY); + assertTrue(loaded.isPresent()); + assertEquals("it's a \"test\" with, commas", loaded.get().value()); + } + } + + @Nested + @DisplayName("TIMESTAMP statistic roundtrip") + class TimestampRoundtrip { + + private static final NamespacedKey KEY = new NamespacedKey("mccore", "last_login"); + + @Test + @DisplayName("Given a TIMESTAMP statistic is saved, when loading it, then returns the correct instant") + void saveAndLoad_timestampStatistic() throws SQLException { + Instant now = Instant.ofEpochMilli(1700000000000L); + StatisticEntry entry = new StatisticEntry(KEY, StatisticType.TIMESTAMP, now); + executeSave(PlayerStatisticDAO.savePlayerStatistic(connection, PLAYER_UUID, entry)); + + Optional loaded = PlayerStatisticDAO.getPlayerStatistic(connection, PLAYER_UUID, KEY); + assertTrue(loaded.isPresent()); + assertEquals(StatisticType.TIMESTAMP, loaded.get().type()); + assertEquals(now, loaded.get().value()); + } + } + + @Nested + @DisplayName("SET_STRING statistic roundtrip") + class SetStringRoundtrip { + + private static final NamespacedKey KEY = new NamespacedKey("mccore", "unlocked_skills"); + + @Test + @DisplayName("Given a SET_STRING statistic is saved, when loading it, then returns the correct set") + void saveAndLoad_setStringStatistic() throws SQLException { + Set skills = new LinkedHashSet<>(); + skills.add("sword"); + skills.add("archery"); + skills.add("mining"); + StatisticEntry entry = new StatisticEntry(KEY, StatisticType.SET_STRING, skills); + executeSave(PlayerStatisticDAO.savePlayerStatistic(connection, PLAYER_UUID, entry)); + + Optional loaded = PlayerStatisticDAO.getPlayerStatistic(connection, PLAYER_UUID, KEY); + assertTrue(loaded.isPresent()); + assertEquals(StatisticType.SET_STRING, loaded.get().type()); + @SuppressWarnings("unchecked") + Set loadedSet = (Set) loaded.get().value(); + assertEquals(skills, loadedSet); + } + + @Test + @DisplayName("Given an empty SET_STRING is saved, when loading it, then returns empty set") + void saveAndLoad_emptySetString() throws SQLException { + Set emptySet = new LinkedHashSet<>(); + StatisticEntry entry = new StatisticEntry(KEY, StatisticType.SET_STRING, emptySet); + executeSave(PlayerStatisticDAO.savePlayerStatistic(connection, PLAYER_UUID, entry)); + + Optional loaded = PlayerStatisticDAO.getPlayerStatistic(connection, PLAYER_UUID, KEY); + assertTrue(loaded.isPresent()); + @SuppressWarnings("unchecked") + Set loadedSet = (Set) loaded.get().value(); + assertTrue(loadedSet.isEmpty()); + } + + @Test + @DisplayName("Given a SET_STRING with commas and quotes, when roundtripped, then preserves elements") + void saveAndLoad_setStringWithSpecialChars() throws SQLException { + Set set = new LinkedHashSet<>(); + set.add("item,with,commas"); + set.add("item \"with\" quotes"); + StatisticEntry entry = new StatisticEntry(KEY, StatisticType.SET_STRING, set); + executeSave(PlayerStatisticDAO.savePlayerStatistic(connection, PLAYER_UUID, entry)); + + Optional loaded = PlayerStatisticDAO.getPlayerStatistic(connection, PLAYER_UUID, KEY); + assertTrue(loaded.isPresent()); + @SuppressWarnings("unchecked") + Set loadedSet = (Set) loaded.get().value(); + assertEquals(set, loadedSet); + } + } + + @Nested + @DisplayName("getAllPlayerStatistics") + class GetAllStatistics { + + @Test + @DisplayName("Given multiple statistics are saved, when getAllPlayerStatistics is called, then returns all") + void getAllStatistics_returnsAllSaved() throws SQLException { + NamespacedKey killsKey = new NamespacedKey("mccore", "kills"); + NamespacedKey xpKey = new NamespacedKey("mccore", "xp"); + NamespacedKey nameKey = new NamespacedKey("mccore", "name"); + + executeSave(PlayerStatisticDAO.savePlayerStatistic(connection, PLAYER_UUID, + new StatisticEntry(killsKey, StatisticType.INT, 100))); + executeSave(PlayerStatisticDAO.savePlayerStatistic(connection, PLAYER_UUID, + new StatisticEntry(xpKey, StatisticType.LONG, 50000L))); + executeSave(PlayerStatisticDAO.savePlayerStatistic(connection, PLAYER_UUID, + new StatisticEntry(nameKey, StatisticType.STRING, "TestPlayer"))); + + Map all = PlayerStatisticDAO.getAllPlayerStatistics(connection, PLAYER_UUID); + + assertEquals(3, all.size()); + assertEquals(100, (int) all.get(killsKey).value()); + assertEquals(50000L, (long) all.get(xpKey).value()); + assertEquals("TestPlayer", all.get(nameKey).value()); + } + + @Test + @DisplayName("Given no statistics exist, when getAllPlayerStatistics is called, then returns empty map") + void getAllStatistics_returnsEmpty_whenNothingSaved() { + Map all = PlayerStatisticDAO.getAllPlayerStatistics(connection, PLAYER_UUID); + assertTrue(all.isEmpty()); + } + } + + @Nested + @DisplayName("deletePlayerStatistic") + class DeleteStatistic { + + @Test + @DisplayName("Given a statistic exists, when deletePlayerStatistic is called, then it is removed") + void deleteStatistic_removesEntry() throws SQLException { + NamespacedKey key = new NamespacedKey("mccore", "kills"); + executeSave(PlayerStatisticDAO.savePlayerStatistic(connection, PLAYER_UUID, + new StatisticEntry(key, StatisticType.INT, 42))); + + executeSave(PlayerStatisticDAO.deletePlayerStatistic(connection, PLAYER_UUID, key)); + + Optional loaded = PlayerStatisticDAO.getPlayerStatistic(connection, PLAYER_UUID, key); + assertFalse(loaded.isPresent()); + } + + @Test + @DisplayName("Given a statistic is deleted, when other statistics exist, then they are not affected") + void deleteStatistic_doesNotAffectOtherStats() throws SQLException { + NamespacedKey killsKey = new NamespacedKey("mccore", "kills"); + NamespacedKey xpKey = new NamespacedKey("mccore", "xp"); + + executeSave(PlayerStatisticDAO.savePlayerStatistic(connection, PLAYER_UUID, + new StatisticEntry(killsKey, StatisticType.INT, 42))); + executeSave(PlayerStatisticDAO.savePlayerStatistic(connection, PLAYER_UUID, + new StatisticEntry(xpKey, StatisticType.LONG, 1000L))); + + executeSave(PlayerStatisticDAO.deletePlayerStatistic(connection, PLAYER_UUID, killsKey)); + + assertFalse(PlayerStatisticDAO.getPlayerStatistic(connection, PLAYER_UUID, killsKey).isPresent()); + assertTrue(PlayerStatisticDAO.getPlayerStatistic(connection, PLAYER_UUID, xpKey).isPresent()); + } + } + + @Nested + @DisplayName("player isolation") + class PlayerIsolation { + + @Test + @DisplayName("Given different players have statistics, when loading each, then returns only their own") + void differentPlayersHaveIsolatedStatistics() throws SQLException { + NamespacedKey key = new NamespacedKey("mccore", "kills"); + + executeSave(PlayerStatisticDAO.savePlayerStatistic(connection, PLAYER_UUID, + new StatisticEntry(key, StatisticType.INT, 100))); + executeSave(PlayerStatisticDAO.savePlayerStatistic(connection, OTHER_UUID, + new StatisticEntry(key, StatisticType.INT, 200))); + + Optional player1 = PlayerStatisticDAO.getPlayerStatistic(connection, PLAYER_UUID, key); + Optional player2 = PlayerStatisticDAO.getPlayerStatistic(connection, OTHER_UUID, key); + + assertTrue(player1.isPresent()); + assertTrue(player2.isPresent()); + assertEquals(100, (int) player1.get().value()); + assertEquals(200, (int) player2.get().value()); + } + } + + @Nested + @DisplayName("savePlayerStatistics batch") + class BatchSave { + + @Test + @DisplayName("Given multiple entries, when savePlayerStatistics is called, then all entries are saved correctly") + void batchSave_savesAllEntries() throws SQLException { + NamespacedKey killsKey = new NamespacedKey("mccore", "kills"); + NamespacedKey xpKey = new NamespacedKey("mccore", "xp"); + + Map entries = new HashMap<>(); + entries.put(killsKey, new StatisticEntry(killsKey, StatisticType.INT, 50)); + entries.put(xpKey, new StatisticEntry(xpKey, StatisticType.DOUBLE, 99.5)); + + List stmts = PlayerStatisticDAO.savePlayerStatistics(connection, PLAYER_UUID, entries); + for (PreparedStatement stmt : stmts) { + stmt.executeUpdate(); + stmt.close(); + } + + Map loaded = PlayerStatisticDAO.getAllPlayerStatistics(connection, PLAYER_UUID); + assertEquals(2, loaded.size()); + assertEquals(50, (int) loaded.get(killsKey).value()); + assertEquals(99.5, (double) loaded.get(xpKey).value(), 0.001); + } + } + + @Nested + @DisplayName("updateTable") + class UpdateTable { + + @Test + @DisplayName("Given core_player_statistics has no version, when updateTable is called, then sets version to 1") + void updateTable_setsVersionToOne() { + PlayerStatisticDAO.updateTable(connection); + + int version = TableVersionHistoryDAO.getLatestVersion(connection, "core_player_statistics"); + assertEquals(1, version); + } + } +} diff --git a/src/test/java/com/diamonddagger590/mccore/database/table/impl/TableVersionHistoryDAOIntegrationTest.java b/src/test/java/com/diamonddagger590/mccore/database/table/impl/TableVersionHistoryDAOIntegrationTest.java new file mode 100644 index 0000000..57a6f20 --- /dev/null +++ b/src/test/java/com/diamonddagger590/mccore/database/table/impl/TableVersionHistoryDAOIntegrationTest.java @@ -0,0 +1,149 @@ +package com.diamonddagger590.mccore.database.table.impl; + +import com.diamonddagger590.mccore.database.Database; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.SQLException; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Integration tests for {@link TableVersionHistoryDAO} using a real in-memory SQLite database. + * Unlike the mock-based tests in {@link TableVersionHistoryDAOTest}, these verify actual SQL + * execution and data roundtrip correctness. + */ +class TableVersionHistoryDAOIntegrationTest { + + private Connection connection; + private Database mockDatabase; + + @BeforeEach + void setUp() throws SQLException { + connection = DriverManager.getConnection("jdbc:sqlite::memory:"); + mockDatabase = mock(Database.class); + when(mockDatabase.tableExists(any(Connection.class), eq("table_history"))).thenReturn(false); + } + + @AfterEach + void tearDown() throws SQLException { + if (connection != null && !connection.isClosed()) { + connection.close(); + } + } + + @Nested + @DisplayName("attemptCreateTable") + class AttemptCreateTable { + + @Test + @DisplayName("Given no existing table, when attemptCreateTable is called, then creates table and returns true") + void attemptCreateTable_createsTable_whenTableDoesNotExist() { + boolean result = TableVersionHistoryDAO.attemptCreateTable(connection, mockDatabase); + assertTrue(result); + } + + @Test + @DisplayName("Given table already exists, when attemptCreateTable is called, then returns false") + void attemptCreateTable_returnsFalse_whenTableAlreadyExists() { + TableVersionHistoryDAO.attemptCreateTable(connection, mockDatabase); + + when(mockDatabase.tableExists(any(Connection.class), eq("table_history"))).thenReturn(true); + boolean result = TableVersionHistoryDAO.attemptCreateTable(connection, mockDatabase); + assertFalse(result); + } + } + + @Nested + @DisplayName("getLatestVersion and setTableVersion roundtrip") + class VersionRoundtrip { + + @BeforeEach + void createTable() { + TableVersionHistoryDAO.attemptCreateTable(connection, mockDatabase); + } + + @Test + @DisplayName("Given no version stored, when getLatestVersion is called, then returns 0") + void getLatestVersion_returnsZero_whenNoVersionStored() { + int version = TableVersionHistoryDAO.getLatestVersion(connection, "some_table"); + assertEquals(0, version); + } + + @Test + @DisplayName("Given a version is set, when getLatestVersion is called, then returns that version") + void setAndGetVersion_roundTrips() { + TableVersionHistoryDAO.setTableVersion(connection, "test_table", 3); + + int version = TableVersionHistoryDAO.getLatestVersion(connection, "test_table"); + assertEquals(3, version); + } + + @Test + @DisplayName("Given a version is updated, when getLatestVersion is called, then returns updated version") + void setTableVersion_updatesExistingVersion() { + TableVersionHistoryDAO.setTableVersion(connection, "test_table", 1); + TableVersionHistoryDAO.setTableVersion(connection, "test_table", 2); + + int version = TableVersionHistoryDAO.getLatestVersion(connection, "test_table"); + assertEquals(2, version); + } + + @Test + @DisplayName("Given multiple tables with versions, when querying each, then returns correct version per table") + void multipleTablesHaveIndependentVersions() { + TableVersionHistoryDAO.setTableVersion(connection, "table_a", 5); + TableVersionHistoryDAO.setTableVersion(connection, "table_b", 10); + + assertEquals(5, TableVersionHistoryDAO.getLatestVersion(connection, "table_a")); + assertEquals(10, TableVersionHistoryDAO.getLatestVersion(connection, "table_b")); + } + + @Test + @DisplayName("Given setTableVersion succeeds, when called, then returns true") + void setTableVersion_returnsTrue_onSuccess() { + boolean result = TableVersionHistoryDAO.setTableVersion(connection, "test_table", 1); + assertTrue(result); + } + } + + @Nested + @DisplayName("updateTable") + class UpdateTable { + + @BeforeEach + void createTable() { + TableVersionHistoryDAO.attemptCreateTable(connection, mockDatabase); + } + + @Test + @DisplayName("Given table_history has no version for itself, when updateTable is called, then sets version to 1") + void updateTable_setsVersionToOne_whenNoVersionExists() { + TableVersionHistoryDAO.updateTable(connection); + + int version = TableVersionHistoryDAO.getLatestVersion(connection, "table_history"); + assertEquals(1, version); + } + + @Test + @DisplayName("Given table_history is already at version 1, when updateTable is called, then version remains 1") + void updateTable_doesNothing_whenAlreadyCurrent() { + TableVersionHistoryDAO.setTableVersion(connection, "table_history", 1); + TableVersionHistoryDAO.updateTable(connection); + + int version = TableVersionHistoryDAO.getLatestVersion(connection, "table_history"); + assertEquals(1, version); + } + } +}