diff --git a/build.gradle.kts b/build.gradle.kts index 636cb3f..eaaf69d 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -34,6 +34,7 @@ dependencies { testImplementation(libs.paper.api) testImplementation(libs.junit4) testImplementation(libs.syntaxdb) + testImplementation(libs.syntaxcore) // Direct JDBC dependencies remain test-only for low-level persistence contract tests. testImplementation(libs.sqlite) testRuntimeOnly(libs.mariadb) diff --git a/docs/wiki/en/Configuration.md b/docs/wiki/en/Configuration.md index 01ce8eb..ee7306b 100644 --- a/docs/wiki/en/Configuration.md +++ b/docs/wiki/en/Configuration.md @@ -7,6 +7,60 @@ For rank-based limits and multi-world configuration, see: Settings are stored in `plugins/PlotsX/config.yml`. A value of `true` means “yes” and `false` means “no”. Preserve indentation and use spaces instead of tabs. +## Versioned loading and reload + +PlotsX initializes SyntaxCore first and reuses `SyntaxCore.logger`. Its configuration +is then loaded by SyntaxCore's ConfigManager before plot/database services start: + +```kotlin +SyntaxCore.init(this, versionType = "paper") +logger = SyntaxCore.logger +// PluginInitializer.setupConfig(): +configManager = ConfigManager( + plugin = this, + logger = logger, + initialVersion = 100, + targetVersion = 103 +).registerMigrations(ConfigMigrations.steps()) +configManager.load() +reloadConfig() // refresh Bukkit's separate view used by PlotsX services +pluginConfig = config +``` + +The current bundled schema is **103**. Existing unversioned files are treated as 100. +Steps 100 → 101 → 102 → 103 preserve user settings and add missing template fields; +101/102 were documentation changes, so there is no automatic database conversion or +backend switch. Keep your actual database selection and credentials. Unsupported +backends fail through normal database validation. + +Before migration, ConfigManager creates a backup such as `config.yml.102.bak`. +It validates the staged YAML before atomically replacing the live file. Failed migration +leaves the original file intact. Files already at schema 103 or a newer schema are +read without rewriting or downgrading. New installations use the current template. +Do not manually raise `config-version` to skip a required migration. + +`/ptx reload` uses the same loader and refreshes Bukkit's view before rebuilding +runtime caches. Changes to `plots.claiming.mode` still require a restart. A failed +configuration load keeps existing runtime caches; correct the YAML and try again. +Startup-only services, including the database connection and SyntaxCore's own startup +settings, require a restart to apply changes. Core services start before PlotsX's +migration; configuration reload does not automatically reinitialize them. + +Backup is per migration attempt/chain, not per individual version step. Retries create +unique backups and retain previous copies. If backup creation fails, migration callbacks +do not run. Parse validation happens before backup/mutation: malformed YAML is rejected +without replacing it with a default file. Current/future schemas do not trigger a backup +because no migration occurs. Missing fields in a current/future file are not automatically +written back; add a new versioned step when evolving the schema. + +Language files and the reload prevalidation use strict Bukkit YAML parsing as well: +syntax errors propagate and leave the original bytes intact. They cannot be converted +into an empty document and overwritten with defaults after a parse failure. + +Language-file completion remains separate from the versioned main configuration. +For adding a field or renaming a path in a future release, raise the template and +`targetVersion` together and add the corresponding step in `ConfigMigrations`. + ## Full default configuration ```yaml diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d3536e6..b548f2a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -5,7 +5,7 @@ run-paper = "3.1.0" plugin-deployer = "1.0.6-R0.2-SNAPSHOT" foojay-resolver = "1.0.0" paper = "1.21.11-R0.1-SNAPSHOT" -syntaxcore = "1.4.1-R0.1-SNAPSHOT" +syntaxcore = "2.0.0-R0.1-SNAPSHOT" syntaxdb = "0.1.0-R0.1-SNAPSHOT" messagehandler = "1.3.0-R0.2-SNAPSHOT" cleanerx = "1.5.8" diff --git a/src/main/kotlin/pl/syntaxdevteam/plotsx/PlotsX.kt b/src/main/kotlin/pl/syntaxdevteam/plotsx/PlotsX.kt index bbb6339..1dc27a8 100644 --- a/src/main/kotlin/pl/syntaxdevteam/plotsx/PlotsX.kt +++ b/src/main/kotlin/pl/syntaxdevteam/plotsx/PlotsX.kt @@ -61,6 +61,7 @@ class PlotsX : JavaPlugin() { lateinit var pluginsManager: PluginManagerX lateinit var uuidManager: UUIDManager + lateinit var configManager: pl.syntaxdevteam.core.config.ConfigManager lateinit var configHandler: ConfigHandler lateinit var pluginConfig: FileConfiguration lateinit var statsCollector: StatsCollector @@ -105,6 +106,7 @@ class PlotsX : JavaPlugin() { ModrinthSource("") ) SyntaxCore.init(this, versionType = "paper") + logger = SyntaxCore.logger borderVisualizer = pl.syntaxdevteam.plotsx.visualization.PlotBorderVisualizer(this) server.pluginManager.registerEvents(borderVisualizer, this) pluginInitializer = PluginInitializer(this) @@ -153,6 +155,7 @@ class PlotsX : JavaPlugin() { if (::databaseHandler.isInitialized) databaseHandler.closeConnection() if (::pluginInitializer.isInitialized) pluginInitializer.onDisable() + SyntaxCore.close() } /** @@ -161,10 +164,10 @@ class PlotsX : JavaPlugin() { */ @JvmOverloads fun onReload(completion: (Exception?) -> Unit = {}) { - val candidate = org.bukkit.configuration.file.YamlConfiguration.loadConfiguration(File(dataFolder, "config.yml")) + val candidate = pl.syntaxdevteam.plotsx.common.YamlFiles.read(File(dataFolder, "config.yml")) require(validateClaimConfig(candidate) == claimMode) { "Changing plots.claiming.mode requires a server restart" } + configHandler.verifyAndUpdateConfig() if (::borderVisualizer.isInitialized) borderVisualizer.close() - super.reloadConfig() schedulerAdapter.runAsync(Runnable { val failure = try { diff --git a/src/main/kotlin/pl/syntaxdevteam/plotsx/commands/PlotsXCMD.kt b/src/main/kotlin/pl/syntaxdevteam/plotsx/commands/PlotsXCMD.kt index 51b9261..fc610bb 100644 --- a/src/main/kotlin/pl/syntaxdevteam/plotsx/commands/PlotsXCMD.kt +++ b/src/main/kotlin/pl/syntaxdevteam/plotsx/commands/PlotsXCMD.kt @@ -57,6 +57,9 @@ class PlotsXCMD(private val plugin: PlotsX) : BasicCommand { stack.sender.sendMessage("Reload rejected: ${failure.message}") } catch (failure: IllegalStateException) { stack.sender.sendMessage("Reload unavailable: ${failure.message}") + } catch (failure: Exception) { + plugin.logger.err("Config reload failed: ${failure.message}") + stack.sender.sendMessage("Reload failed; check the server log and configuration file.") } } diff --git a/src/main/kotlin/pl/syntaxdevteam/plotsx/common/ConfigHandler.kt b/src/main/kotlin/pl/syntaxdevteam/plotsx/common/ConfigHandler.kt index 21d4023..b219631 100644 --- a/src/main/kotlin/pl/syntaxdevteam/plotsx/common/ConfigHandler.kt +++ b/src/main/kotlin/pl/syntaxdevteam/plotsx/common/ConfigHandler.kt @@ -1,7 +1,5 @@ package pl.syntaxdevteam.plotsx.common -import org.bukkit.configuration.ConfigurationSection -import org.bukkit.configuration.file.YamlConfiguration import pl.syntaxdevteam.plotsx.PlotsX import java.io.File @@ -10,21 +8,10 @@ class ConfigHandler(private val plugin: PlotsX) { private val bundledLanguages = listOf("pl", "en") fun verifyAndUpdateConfig() { - val confFile = File(plugin.dataFolder, "config.yml") - val defaultConfStream = plugin.getResource("config.yml") - - if (defaultConfStream == null) { - plugin.logger.err("Default $confFile file not found in plugin resources!") - return - } - - val defaultConfig = YamlConfiguration.loadConfiguration(defaultConfStream.reader()) - val currentConfig = YamlConfiguration.loadConfiguration(confFile) - - if (synchronizeSections(defaultConfig, currentConfig)) { - plugin.logger.success("Updating $confFile file with missing entries.") - currentConfig.save(confFile) - } + plugin.configManager.load() + // PlotsX services consume Bukkit's view, not the separate BoostedYAML document. + plugin.reloadConfig() + plugin.pluginConfig = plugin.config } /** @@ -43,43 +30,15 @@ class ConfigHandler(private val plugin: PlotsX) { return@forEach } - if (!messageFile.exists()) { - messageFile.parentFile.mkdirs() - plugin.saveResource(resourcePath, false) - return@forEach - } - - val defaultMessages = defaultStream.reader().use { reader -> - YamlConfiguration.loadConfiguration(reader) - } - val currentMessages = YamlConfiguration.loadConfiguration(messageFile) - - if (synchronizeSections(defaultMessages, currentMessages)) { - plugin.logger.success("Updating $messageFile file with missing entries.") - currentMessages.save(messageFile) - } - } - } - - private fun synchronizeSections( - defaultSection: ConfigurationSection, - currentSection: ConfigurationSection - ): Boolean { - var updated = false - for (key in defaultSection.getKeys(false)) { - if (!currentSection.contains(key)) { - currentSection[key] = defaultSection[key] - updated = true - } else if (defaultSection.isConfigurationSection(key)) { - require(currentSection.isConfigurationSection(key)) { - "${currentSection.currentPath}.$key must be a configuration section" + defaultStream.reader().use { reader -> + if (!messageFile.exists()) { + messageFile.parentFile.mkdirs() + plugin.saveResource(resourcePath, false) + } else if (YamlFiles.updateMissing(messageFile, reader)) { + plugin.logger.success("Updating $messageFile file with missing entries.") } - updated = synchronizeSections( - defaultSection.getConfigurationSection(key)!!, - currentSection.getConfigurationSection(key)!! - ) || updated } } - return updated } + } diff --git a/src/main/kotlin/pl/syntaxdevteam/plotsx/common/ConfigMigrations.kt b/src/main/kotlin/pl/syntaxdevteam/plotsx/common/ConfigMigrations.kt new file mode 100644 index 0000000..d809077 --- /dev/null +++ b/src/main/kotlin/pl/syntaxdevteam/plotsx/common/ConfigMigrations.kt @@ -0,0 +1,44 @@ +package pl.syntaxdevteam.plotsx.common + +import pl.syntaxdevteam.core.config.BaseConfigMigration +import pl.syntaxdevteam.core.config.ConfigMigration +import pl.syntaxdevteam.core.config.ConfigMigrationContext + +/** Historical 101/102 releases changed backend documentation, not configuration paths. + * 103 adopts the versioned loader while retaining the old additive loader's custom keys. + * Database backend selections are preserved; migration never silently switches storage. + */ +internal object ConfigMigrations { + fun steps(): List = listOf( + documentationStep(100, 101), documentationStep(101, 102), + object : BaseConfigMigration(102, 103) { + override fun migrate(context: ConfigMigrationContext) { + val user = context.userConfig ?: return + for (key in user.keys) { + val path = key.toString() + val section = context.readUserSectionMap(path) + context.config.set(path, if (section == null) user.get(path) + else merge(context.readSectionMap(path).orEmpty(), section)) + } + } + } + ) + + // Merge defaults first and raw user settings last, preserving unknown nested fields. + private fun merge(defaults: Map, user: Map): Map { + val result = defaults.toMutableMap() + for ((key, value) in user) { + val fallback = defaults[key] + result[key] = if (value is Map<*, *> && fallback is Map<*, *>) { + merge(stringMap(fallback), stringMap(value)) + } else value + } + return result + } + private fun stringMap(value: Map<*, *>): Map = + value.entries.associate { it.key.toString() to it.value } + + private fun documentationStep(from: Int, to: Int) = object : BaseConfigMigration(from, to) { + override fun migrate(context: ConfigMigrationContext) = Unit + } +} diff --git a/src/main/kotlin/pl/syntaxdevteam/plotsx/common/YamlFiles.kt b/src/main/kotlin/pl/syntaxdevteam/plotsx/common/YamlFiles.kt new file mode 100644 index 0000000..315927e --- /dev/null +++ b/src/main/kotlin/pl/syntaxdevteam/plotsx/common/YamlFiles.kt @@ -0,0 +1,36 @@ +package pl.syntaxdevteam.plotsx.common + +import org.bukkit.configuration.ConfigurationSection +import org.bukkit.configuration.file.YamlConfiguration +import java.io.File +import java.io.Reader + +/** Unlike loadConfiguration, strict load throws on malformed YAML instead of returning an empty document. */ +internal object YamlFiles { + fun read(file: File): YamlConfiguration = YamlConfiguration().apply { load(file) } + + fun updateMissing(file: File, template: Reader): Boolean { + val defaults = YamlConfiguration().apply { load(template) } + val current = read(file) + val changed = synchronizeSections(defaults, current) + if (changed) current.save(file) + return changed + } + + private fun synchronizeSections(defaults: ConfigurationSection, current: ConfigurationSection): Boolean { + var updated = false + for (key in defaults.getKeys(false)) { + if (!current.contains(key)) { + current[key] = defaults[key] + updated = true + } else if (defaults.isConfigurationSection(key)) { + require(current.isConfigurationSection(key)) { + "${current.currentPath}.$key must be a configuration section" + } + updated = synchronizeSections(defaults.getConfigurationSection(key)!!, + current.getConfigurationSection(key)!!) || updated + } + } + return updated + } +} diff --git a/src/main/kotlin/pl/syntaxdevteam/plotsx/loader/PluginInitializer.kt b/src/main/kotlin/pl/syntaxdevteam/plotsx/loader/PluginInitializer.kt index 4814c95..68f1b0a 100644 --- a/src/main/kotlin/pl/syntaxdevteam/plotsx/loader/PluginInitializer.kt +++ b/src/main/kotlin/pl/syntaxdevteam/plotsx/loader/PluginInitializer.kt @@ -27,7 +27,6 @@ import pl.syntaxdevteam.plotsx.listener.RenamePlotChatListener class PluginInitializer(private val plugin: PlotsX) { fun onEnable() { - setUpLogger() setupCompatibility() setupConfig() setupUUID() @@ -42,11 +41,6 @@ class PluginInitializer(private val plugin: PlotsX) { plugin.logger.err(plugin.pluginMeta.name + " " + plugin.pluginMeta.version + " has been disabled ☹️") } - private fun setUpLogger() { - plugin.pluginConfig = plugin.config - plugin.logger = SyntaxCore.logger - } - private fun setupCompatibility() { plugin.versionChecker = VersionChecker(plugin) plugin.versionCompatibility = VersionCompatibility(plugin.versionChecker) @@ -54,7 +48,12 @@ class PluginInitializer(private val plugin: PlotsX) { } private fun setupConfig() { - plugin.saveDefaultConfig() + plugin.configManager = pl.syntaxdevteam.core.config.ConfigManager( + plugin = plugin, + logger = plugin.logger, + initialVersion = 100, + targetVersion = 103 + ).registerMigrations(pl.syntaxdevteam.plotsx.common.ConfigMigrations.steps()) plugin.configHandler = ConfigHandler(plugin) plugin.configHandler.verifyAndUpdateConfig() plugin.configHandler.verifyAndUpdateLanguageFiles() diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index dd5e19f..3112d90 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -1,4 +1,4 @@ -config-version: 102 +config-version: 103 # /********************************************/ # Global settings diff --git a/src/test/kotlin/pl/syntaxdevteam/plotsx/common/ConfigMigrationsTest.kt b/src/test/kotlin/pl/syntaxdevteam/plotsx/common/ConfigMigrationsTest.kt new file mode 100644 index 0000000..f9b94e7 --- /dev/null +++ b/src/test/kotlin/pl/syntaxdevteam/plotsx/common/ConfigMigrationsTest.kt @@ -0,0 +1,236 @@ +package pl.syntaxdevteam.plotsx.common + +import dev.dejvokep.boostedyaml.YamlDocument +import org.junit.Assert.* +import org.junit.Test +import pl.syntaxdevteam.core.logging.DebugLevel +import pl.syntaxdevteam.core.logging.Logger +import pl.syntaxdevteam.core.plugin.PluginMetaProvider +import java.io.File +import java.io.InputStream +import java.nio.file.Files + +/** Consumer contract for our actual template/steps against the resolved SyntaxCore loader. + * The public facade requires a live JavaPlugin. Reflect only the file engine in tests + * to exercise the identical flow without starting a server or adding a mocking framework. + */ +class ConfigMigrationsTest { + private val logger = Logger(object : PluginMetaProvider { + override val name = "test" + override val version = "1" + override val debugLevel = DebugLevel.OFF + }) + private fun load( + file: File, + steps: List = ConfigMigrations.steps() + ): YamlDocument { + val type = Class.forName("pl.syntaxdevteam.core.config.ConfigFileLoader") + val template: () -> InputStream = { + requireNotNull(javaClass.classLoader.getResourceAsStream("config.yml")) + } + val loader = type.declaredConstructors.single().newInstance( + file, template, "config-version", 100, 103, true, logger) + return type.getMethod("load", List::class.java).invoke(loader, steps) as YamlDocument + } + private fun fixture(action: (File) -> Unit) { + val folder = Files.createTempDirectory("plotsx-config") + try { action(folder.resolve("config.yml").toFile()) } + finally { folder.toFile().deleteRecursively() } + } + + @Test fun `all historical schemas keep custom values and create original backup`() { + for (version in listOf(null, 100, 101, 102)) fixture { file -> + val original = (version?.let { "config-version: $it\n" } ?: "") + + "database:\n type: postgresql\n sql:\n password: custom-secret\n" + + "plots:\n radius: 23\n custom-list: [alpha, beta]\n custom-map:\n enabled: true\nlanguage: PL\ncustom-extension: keep\n" + file.writeText(original) + val config = load(file) + assertEquals(103, config.getInt("config-version")) + assertEquals("postgresql", config.getString("database.type")) + assertEquals("custom-secret", config.getString("database.sql.password")) + assertEquals(23, config.getInt("plots.radius")) + assertEquals("PL", config.getString("language")) + assertEquals("keep", config.getString("custom-extension")) + assertEquals(listOf("alpha", "beta"), config.getStringList("plots.custom-list")) + assertTrue(config.getBoolean("plots.custom-map.enabled")) + assertTrue(config.contains("plots.claiming.mode")) + assertEquals(original, file.resolveSibling("config.yml.${version ?: 100}.bak").readText()) + val bukkit = org.bukkit.configuration.file.YamlConfiguration.loadConfiguration(file) + assertEquals(103, bukkit.getInt("config-version")) + assertEquals(23, bukkit.getInt("plots.radius")) + } + } + + @Test fun `fresh config uses 103 and current or future files remain untouched`() { + fixture { file -> + assertEquals(103, load(file).getInt("config-version")) + assertFalse(file.resolveSibling("config.yml.100.bak").exists()) + } + for (version in listOf(103, 104)) fixture { file -> + val original = "# custom comment\nconfig-version: $version\nplots:\n radius: 31\n" + file.writeText(original) + assertEquals(version, load(file).getInt("config-version")) + assertEquals(original, file.readText()) + } + } + + @Test fun `repeated load is stable and malformed YAML never replaces original`() = fixture { file -> + file.writeText("config-version: 102\nplots:\n radius: 29\n") + load(file) + val migrated = file.readText() + load(file) + assertEquals(migrated, file.readText()) + val invalid = "config-version: 102\nplots: [unterminated\n" + file.writeText(invalid) + assertThrows(java.lang.reflect.InvocationTargetException::class.java) { load(file) } + assertEquals(invalid, file.readText()) + } + @Test fun `every customized template leaf survives migration`() = fixture { file -> + val original = javaClass.classLoader.getResourceAsStream("config.yml")!!.use { + YamlDocument.create(it) + } + val expected = linkedMapOf() + fun customize(section: dev.dejvokep.boostedyaml.block.implementation.Section, prefix: String) { + for (key in section.keys) { + val path = if (prefix.isEmpty()) key.toString() else "$prefix.$key" + if (path == "config-version") continue + val value = section.get(key.toString()) + if (value is dev.dejvokep.boostedyaml.block.implementation.Section) customize(value, path) + else { + val changed: Any? = when (value) { + is Boolean -> !value + is Int -> value + 37 + is Double -> value + 0.37 + is List<*> -> listOf("custom-A", "custom-B") + is String -> "custom-$value" + else -> error("Unexpected template type at $path: $value") + } + original.set(path, changed) + expected[path] = changed + } + } + } + customize(original, "") + assertTrue("Exercise the whole bundled template", expected.size > 40) + original.set("config-version", 102) + original.save(file) + val migrated = load(file) + for ((path, value) in expected) assertEquals(path, value, migrated.get(path)) + } + + @Test fun `missing nested fields are added but false zero empty and custom values survive`() = fixture { file -> + file.writeText(""" + config-version: 102 + language: '' + plots: + maxPlots: 0 + world: [] + chunks: + expansion: + price: 0.0 + standingCommand: false + update: + check-for-updates: false + stats: + enabled: false + webhook: + discord: + url: '' + plot-roles: + manager: + permissions: [] + privateChests: + protectOnPlace: true + """.trimIndent()) + val migrated = load(file) + assertEquals("", migrated.getString("language")) + assertEquals(0, migrated.getInt("plots.maxPlots")) + assertEquals(emptyList(), migrated.getStringList("plots.world")) + assertEquals(0.0, migrated.getDouble("plots.chunks.expansion.price"), 0.0) + assertFalse(migrated.getBoolean("plots.chunks.expansion.standingCommand")) + assertFalse(migrated.getBoolean("update.check-for-updates")) + assertFalse(migrated.getBoolean("stats.enabled")) + assertEquals("", migrated.getString("webhook.discord.url")) + assertEquals(emptyList(), migrated.getStringList("plot-roles.manager.permissions")) + assertTrue(migrated.getBoolean("privateChests.protectOnPlace")) + for (path in listOf("plots.chunks.expansion.priceMultiplier", "plots.chunks.maxTotalOwned", + "plots.radius", "database.sql.host", "webhook.discord.enabled", "update.github")) { + assertTrue("Missing default supplied at $path", migrated.contains(path)) + } + } + + @Test fun `backup precedes callbacks and retries preserve each exact original`() = fixture { file -> + val attempts = listOf("first", "second", "third") + for (marker in attempts) { + val original = "# $marker\nconfig-version: 102\nlanguage: PL\n" + file.writeText(original) + val failing = object : pl.syntaxdevteam.core.config.BaseConfigMigration(102, 103) { + override fun migrate(context: pl.syntaxdevteam.core.config.ConfigMigrationContext) { + val backups = file.parentFile.listFiles()!!.filter { it.name.endsWith(".bak") } + assertTrue("Exact backup exists before migration callback", backups.any { it.readText() == original }) + context.config.set("language", "overwrite-attempt") + error("simulated migration failure") + } + } + assertThrows(java.lang.reflect.InvocationTargetException::class.java) { load(file, listOf(failing)) } + assertEquals(original, file.readText()) + assertFalse(file.parentFile.listFiles()!!.any { it.name.startsWith(".syntaxcore-config") }) + } + val backupContents = file.parentFile.listFiles()!!.filter { it.name.endsWith(".bak") }.map { it.readText() } + assertEquals(attempts.size, backupContents.size) + for (marker in attempts) assertTrue(backupContents.any { it.startsWith("# $marker\n") }) + load(file) + assertEquals(4, file.parentFile.listFiles()!!.count { it.name.endsWith(".bak") }) + } + + @Test fun `existing backup directory is preserved and unique backup protects original`() = fixture { file -> + val original = "config-version: 102\nlanguage: PL\n" + file.writeText(original) + // Backup path cannot be used: occupy it with a nonempty directory. + val blocked = file.resolveSibling("config.yml.102.bak") + blocked.mkdirs() + blocked.resolve("occupied").writeText("keep") + // Existing named backup gets a unique sibling instead, so this must remain intact. + load(file) + assertEquals("keep", blocked.resolve("occupied").readText()) + assertTrue(file.parentFile.listFiles()!!.any { it.isFile && it.name.endsWith(".bak") && it.readText() == original }) + } + + @Test fun `invalid YAML at every schema never copies defaults or alters original bytes`() { + val badBodies = listOf("plots: [unterminated\n", "language: \"unterminated\n", + "plots:\n\tradius: 20\n", "plots: {radius: 20\n") + for (version in listOf(null, 100, 102, 103, 104)) for (body in badBodies) fixture { file -> + val original = ((version?.let { "config-version: $it\n" } ?: "") + body).toByteArray() + file.writeBytes(original) + assertThrows(java.lang.reflect.InvocationTargetException::class.java) { load(file) } + assertArrayEquals(original, file.readBytes()) + assertEquals(listOf("config.yml"), file.parentFile.listFiles()!!.map { it.name }) + } + } + + @Test fun `unwritable backup directory stops migration before callbacks`() = fixture { file -> + val original = "config-version: 102\nlanguage: PL\n".toByteArray() + file.writeBytes(original) + val directory = file.parentFile.toPath() + org.junit.Assume.assumeNotNull(Files.getFileAttributeView(directory, + java.nio.file.attribute.PosixFileAttributeView::class.java)) + val permissions = Files.getPosixFilePermissions(directory) + var callbackStarted = false + val step = object : pl.syntaxdevteam.core.config.BaseConfigMigration(102, 103) { + override fun migrate(context: pl.syntaxdevteam.core.config.ConfigMigrationContext) { + callbackStarted = true + context.config.set("language", "overwrite-attempt") + } + } + try { + Files.setPosixFilePermissions(directory, java.nio.file.attribute.PosixFilePermissions.fromString("r-xr-xr-x")) + org.junit.Assume.assumeFalse("Privileged users bypass filesystem permissions", Files.isWritable(directory)) + val failure = assertThrows(java.lang.reflect.InvocationTargetException::class.java) { load(file, listOf(step)) } + assertTrue(failure.cause is java.io.IOException) + assertFalse(callbackStarted) + assertArrayEquals(original, file.readBytes()) + assertEquals(listOf("config.yml"), file.parentFile.listFiles()!!.map { it.name }) + } finally { Files.setPosixFilePermissions(directory, permissions) } + } + +} diff --git a/src/test/kotlin/pl/syntaxdevteam/plotsx/common/YamlFilesTest.kt b/src/test/kotlin/pl/syntaxdevteam/plotsx/common/YamlFilesTest.kt new file mode 100644 index 0000000..b3f5591 --- /dev/null +++ b/src/test/kotlin/pl/syntaxdevteam/plotsx/common/YamlFilesTest.kt @@ -0,0 +1,54 @@ +package pl.syntaxdevteam.plotsx.common + +import org.junit.Assert.* +import org.junit.Test +import java.nio.file.Files + +class YamlFilesTest { + private val template = "messages:\n greeting: default\n farewell: bye\nflags:\n enabled: true\n" + @Test fun `language completion preserves customized values and adds only missing entries`() { + val folder = Files.createTempDirectory("plotsx-language") + try { + val file = folder.resolve("messages_pl.yml").toFile() + file.writeText("messages:\n greeting: własna wiadomość\nflags:\n enabled: false\nextra: keep\n") + assertTrue(YamlFiles.updateMissing(file, template.reader())) + val config = YamlFiles.read(file) + assertEquals("własna wiadomość", config.getString("messages.greeting")) + assertEquals("bye", config.getString("messages.farewell")) + assertFalse(config.getBoolean("flags.enabled")) + assertEquals("keep", config.getString("extra")) + val before = file.readBytes() + assertFalse(YamlFiles.updateMissing(file, template.reader())) + assertArrayEquals(before, file.readBytes()) + } finally { folder.toFile().deleteRecursively() } + } + @Test fun `malformed language or reload candidate is never converted into defaults`() { + val folder = Files.createTempDirectory("plotsx-bad-yaml") + try { + for (invalid in listOf("messages: [unterminated\n", "messages:\n\tgreeting: tab\n")) { + val file = folder.resolve("config.yml").toFile() + file.writeText(invalid) + val before = file.readBytes() + assertThrows(org.bukkit.configuration.InvalidConfigurationException::class.java) { YamlFiles.read(file) } + assertThrows(org.bukkit.configuration.InvalidConfigurationException::class.java) { + YamlFiles.updateMissing(file, template.reader()) + } + assertArrayEquals(before, file.readBytes()) + } + } finally { folder.toFile().deleteRecursively() } + } + @Test fun `invalid language template and incompatible section leave original intact`() { + val folder = Files.createTempDirectory("plotsx-template") + try { + val file = folder.resolve("messages_en.yml").toFile() + file.writeText("messages: customized-scalar\n") + val before = file.readBytes() + assertThrows(IllegalArgumentException::class.java) { YamlFiles.updateMissing(file, template.reader()) } + assertArrayEquals(before, file.readBytes()) + assertThrows(org.bukkit.configuration.InvalidConfigurationException::class.java) { + YamlFiles.updateMissing(file, "messages: [bad\n".reader()) + } + assertArrayEquals(before, file.readBytes()) + } finally { folder.toFile().deleteRecursively() } + } +}