diff --git a/platform/platform-metadata/build.gradle.kts b/platform/platform-metadata/build.gradle.kts index 983b996..018af27 100644 --- a/platform/platform-metadata/build.gradle.kts +++ b/platform/platform-metadata/build.gradle.kts @@ -22,6 +22,7 @@ kotlin { dependencies { api(project(":api:api-v1")) api(project(":platform:platform-persistence")) + implementation(project(":platform:platform-security")) // @RequirePermission on the write controllers implementation(libs.kotlin.stdlib) implementation(libs.kotlin.reflect) diff --git a/platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/web/CustomFieldWriteController.kt b/platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/web/CustomFieldWriteController.kt new file mode 100644 index 0000000..82ddb9a --- /dev/null +++ b/platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/web/CustomFieldWriteController.kt @@ -0,0 +1,141 @@ +package org.vibeerp.platform.metadata.web + +import com.fasterxml.jackson.databind.ObjectMapper +import org.springframework.http.HttpStatus +import org.springframework.jdbc.core.namedparam.MapSqlParameterSource +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.PutMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.ResponseStatus +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.server.ResponseStatusException +import org.vibeerp.platform.metadata.customfield.CustomFieldRegistry +import org.vibeerp.platform.security.authz.RequirePermission +import java.sql.Timestamp +import java.time.Instant +import java.util.UUID + +/** + * Write endpoints for custom field declarations stored in + * `metadata__custom_field`. + * + * Only rows with `source = 'user'` may be created, updated, or deleted + * through this controller. Rows seeded by core YAMLs or plug-in JARs + * are immutable from the REST surface. + * + * After every successful write, the [CustomFieldRegistry] is refreshed + * so the in-memory index (consumed by [ExtJsonValidator] on every + * entity save) immediately reflects the change. + * + * Read endpoints live in [MetadataController]. + */ +@RestController +@RequestMapping("/api/v1/_meta/metadata/custom-fields") +class CustomFieldWriteController( + private val jdbc: NamedParameterJdbcTemplate, + private val objectMapper: ObjectMapper, + private val customFieldRegistry: CustomFieldRegistry, +) { + + @PostMapping + @RequirePermission("admin.metadata.write") + @ResponseStatus(HttpStatus.CREATED) + fun create(@RequestBody body: Map): Map { + val key = body["key"]?.toString() + ?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "key is required") + + val existing = jdbc.query( + "SELECT id FROM metadata__custom_field WHERE payload->>'key' = :key", + MapSqlParameterSource("key", key), + ) { rs, _ -> rs.getString("id") } + + if (existing.isNotEmpty()) { + throw ResponseStatusException( + HttpStatus.CONFLICT, + "Custom field '$key' already exists", + ) + } + + val payload = body.toMutableMap().apply { put("key", key) } + val payloadJson = objectMapper.writeValueAsString(payload) + val now = Timestamp.from(Instant.now()) + + jdbc.update( + "INSERT INTO metadata__custom_field (id, source, payload, created_at, updated_at) VALUES (:id, 'user', CAST(:payload AS jsonb), :now, :now)", + MapSqlParameterSource() + .addValue("id", UUID.randomUUID()) + .addValue("payload", payloadJson) + .addValue("now", now), + ) + + customFieldRegistry.refresh() + return payload + mapOf("source" to "user") + } + + @PutMapping("/{key}") + @RequirePermission("admin.metadata.write") + fun update(@PathVariable key: String, @RequestBody body: Map): Map { + val existing = jdbc.query( + "SELECT id, source FROM metadata__custom_field WHERE payload->>'key' = :key", + MapSqlParameterSource("key", key), + ) { rs, _ -> rs.getString("id") to rs.getString("source") } + + if (existing.isEmpty()) { + throw ResponseStatusException(HttpStatus.NOT_FOUND, "Custom field '$key' not found") + } + val (id, source) = existing.first() + if (source != "user") { + throw ResponseStatusException( + HttpStatus.FORBIDDEN, + "Cannot modify custom field '$key' (source='$source')", + ) + } + + val payload = body.toMutableMap().apply { put("key", key) } + val payloadJson = objectMapper.writeValueAsString(payload) + val now = Timestamp.from(Instant.now()) + + jdbc.update( + "UPDATE metadata__custom_field SET payload = CAST(:payload AS jsonb), updated_at = :now WHERE id = CAST(:id AS uuid)", + MapSqlParameterSource() + .addValue("id", id) + .addValue("payload", payloadJson) + .addValue("now", now), + ) + + customFieldRegistry.refresh() + return payload + mapOf("source" to "user") + } + + @DeleteMapping("/{key}") + @RequirePermission("admin.metadata.write") + @ResponseStatus(HttpStatus.NO_CONTENT) + fun delete(@PathVariable key: String) { + val existing = jdbc.query( + "SELECT id, source FROM metadata__custom_field WHERE payload->>'key' = :key", + MapSqlParameterSource("key", key), + ) { rs, _ -> rs.getString("id") to rs.getString("source") } + + if (existing.isEmpty()) { + throw ResponseStatusException(HttpStatus.NOT_FOUND, "Custom field '$key' not found") + } + val (id, source) = existing.first() + if (source != "user") { + throw ResponseStatusException( + HttpStatus.FORBIDDEN, + "Cannot delete custom field '$key' (source='$source')", + ) + } + + jdbc.update( + "DELETE FROM metadata__custom_field WHERE id = CAST(:id AS uuid)", + MapSqlParameterSource("id", id), + ) + + customFieldRegistry.refresh() + } +} diff --git a/platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/web/FormDefinitionController.kt b/platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/web/FormDefinitionController.kt new file mode 100644 index 0000000..d799ee2 --- /dev/null +++ b/platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/web/FormDefinitionController.kt @@ -0,0 +1,100 @@ +package org.vibeerp.platform.metadata.web + +import com.fasterxml.jackson.databind.ObjectMapper +import org.springframework.http.HttpStatus +import org.springframework.jdbc.core.namedparam.MapSqlParameterSource +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PutMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.ResponseStatus +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.server.ResponseStatusException +import org.vibeerp.platform.security.authz.RequirePermission +import java.sql.Timestamp +import java.time.Instant +import java.util.UUID + +/** + * Write endpoints for form definitions stored in `metadata__form`. + * + * Only rows with `source = 'user'` may be created, updated, or deleted + * through this controller. Rows seeded by core YAMLs or plug-in JARs + * (`source = 'core'` or `source = 'plugin:*'`) are immutable from the + * REST surface -- they can only change by redeploying the owning module. + * + * Read endpoints live in [MetadataController]. + */ +@RestController +@RequestMapping("/api/v1/_meta/metadata/forms") +class FormDefinitionController( + private val jdbc: NamedParameterJdbcTemplate, + private val objectMapper: ObjectMapper, +) { + + @PutMapping("/{slug}") + @RequirePermission("admin.metadata.write") + fun upsert(@PathVariable slug: String, @RequestBody body: Map): Map { + val payload = body.toMutableMap().apply { put("slug", slug) } + val payloadJson = objectMapper.writeValueAsString(payload) + val now = Timestamp.from(Instant.now()) + + val existing = jdbc.query( + "SELECT id, source FROM metadata__form WHERE payload->>'slug' = :slug", + MapSqlParameterSource("slug", slug), + ) { rs, _ -> rs.getString("id") to rs.getString("source") } + + if (existing.isNotEmpty()) { + val (id, source) = existing.first() + if (source != "user") { + throw ResponseStatusException( + HttpStatus.FORBIDDEN, + "Cannot modify form '$slug' (source='$source')", + ) + } + jdbc.update( + "UPDATE metadata__form SET payload = CAST(:payload AS jsonb), updated_at = :now WHERE id = CAST(:id AS uuid)", + MapSqlParameterSource() + .addValue("id", id) + .addValue("payload", payloadJson) + .addValue("now", now), + ) + } else { + jdbc.update( + "INSERT INTO metadata__form (id, source, payload, created_at, updated_at) VALUES (:id, 'user', CAST(:payload AS jsonb), :now, :now)", + MapSqlParameterSource() + .addValue("id", UUID.randomUUID()) + .addValue("payload", payloadJson) + .addValue("now", now), + ) + } + return payload + mapOf("source" to "user") + } + + @DeleteMapping("/{slug}") + @RequirePermission("admin.metadata.write") + @ResponseStatus(HttpStatus.NO_CONTENT) + fun delete(@PathVariable slug: String) { + val existing = jdbc.query( + "SELECT id, source FROM metadata__form WHERE payload->>'slug' = :slug", + MapSqlParameterSource("slug", slug), + ) { rs, _ -> rs.getString("id") to rs.getString("source") } + + if (existing.isEmpty()) { + throw ResponseStatusException(HttpStatus.NOT_FOUND, "Form '$slug' not found") + } + val (id, source) = existing.first() + if (source != "user") { + throw ResponseStatusException( + HttpStatus.FORBIDDEN, + "Cannot delete form '$slug' (source='$source')", + ) + } + jdbc.update( + "DELETE FROM metadata__form WHERE id = CAST(:id AS uuid)", + MapSqlParameterSource("id", id), + ) + } +} diff --git a/platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/web/ListViewDefinitionController.kt b/platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/web/ListViewDefinitionController.kt new file mode 100644 index 0000000..5ff4d01 --- /dev/null +++ b/platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/web/ListViewDefinitionController.kt @@ -0,0 +1,100 @@ +package org.vibeerp.platform.metadata.web + +import com.fasterxml.jackson.databind.ObjectMapper +import org.springframework.http.HttpStatus +import org.springframework.jdbc.core.namedparam.MapSqlParameterSource +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PutMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.ResponseStatus +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.server.ResponseStatusException +import org.vibeerp.platform.security.authz.RequirePermission +import java.sql.Timestamp +import java.time.Instant +import java.util.UUID + +/** + * Write endpoints for list view definitions stored in `metadata__list_view`. + * + * Only rows with `source = 'user'` may be created, updated, or deleted + * through this controller. Rows seeded by core YAMLs or plug-in JARs + * (`source = 'core'` or `source = 'plugin:*'`) are immutable from the + * REST surface -- they can only change by redeploying the owning module. + * + * Read endpoints live in [MetadataController]. + */ +@RestController +@RequestMapping("/api/v1/_meta/metadata/list-views") +class ListViewDefinitionController( + private val jdbc: NamedParameterJdbcTemplate, + private val objectMapper: ObjectMapper, +) { + + @PutMapping("/{slug}") + @RequirePermission("admin.metadata.write") + fun upsert(@PathVariable slug: String, @RequestBody body: Map): Map { + val payload = body.toMutableMap().apply { put("slug", slug) } + val payloadJson = objectMapper.writeValueAsString(payload) + val now = Timestamp.from(Instant.now()) + + val existing = jdbc.query( + "SELECT id, source FROM metadata__list_view WHERE payload->>'slug' = :slug", + MapSqlParameterSource("slug", slug), + ) { rs, _ -> rs.getString("id") to rs.getString("source") } + + if (existing.isNotEmpty()) { + val (id, source) = existing.first() + if (source != "user") { + throw ResponseStatusException( + HttpStatus.FORBIDDEN, + "Cannot modify list view '$slug' (source='$source')", + ) + } + jdbc.update( + "UPDATE metadata__list_view SET payload = CAST(:payload AS jsonb), updated_at = :now WHERE id = CAST(:id AS uuid)", + MapSqlParameterSource() + .addValue("id", id) + .addValue("payload", payloadJson) + .addValue("now", now), + ) + } else { + jdbc.update( + "INSERT INTO metadata__list_view (id, source, payload, created_at, updated_at) VALUES (:id, 'user', CAST(:payload AS jsonb), :now, :now)", + MapSqlParameterSource() + .addValue("id", UUID.randomUUID()) + .addValue("payload", payloadJson) + .addValue("now", now), + ) + } + return payload + mapOf("source" to "user") + } + + @DeleteMapping("/{slug}") + @RequirePermission("admin.metadata.write") + @ResponseStatus(HttpStatus.NO_CONTENT) + fun delete(@PathVariable slug: String) { + val existing = jdbc.query( + "SELECT id, source FROM metadata__list_view WHERE payload->>'slug' = :slug", + MapSqlParameterSource("slug", slug), + ) { rs, _ -> rs.getString("id") to rs.getString("source") } + + if (existing.isEmpty()) { + throw ResponseStatusException(HttpStatus.NOT_FOUND, "List view '$slug' not found") + } + val (id, source) = existing.first() + if (source != "user") { + throw ResponseStatusException( + HttpStatus.FORBIDDEN, + "Cannot delete list view '$slug' (source='$source')", + ) + } + jdbc.update( + "DELETE FROM metadata__list_view WHERE id = CAST(:id AS uuid)", + MapSqlParameterSource("id", id), + ) + } +} diff --git a/platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/web/MetadataController.kt b/platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/web/MetadataController.kt index e980c82..c5fb899 100644 --- a/platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/web/MetadataController.kt +++ b/platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/web/MetadataController.kt @@ -1,11 +1,14 @@ package org.vibeerp.platform.metadata.web import com.fasterxml.jackson.databind.ObjectMapper +import org.springframework.jdbc.core.namedparam.MapSqlParameterSource import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate +import org.springframework.http.HttpStatus import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RestController +import org.springframework.web.server.ResponseStatusException import org.vibeerp.api.v1.entity.CustomField import org.vibeerp.api.v1.entity.FieldType import org.vibeerp.platform.metadata.customfield.CustomFieldRegistry @@ -40,6 +43,8 @@ class MetadataController( "permissions" to readPayloads("metadata__permission"), "menus" to readPayloads("metadata__menu"), "customFields" to readPayloads("metadata__custom_field"), + "forms" to readPayloads("metadata__form"), + "listViews" to readPayloads("metadata__list_view"), ) @GetMapping("/entities") @@ -51,6 +56,22 @@ class MetadataController( @GetMapping("/menus") fun menus(): List> = readPayloads("metadata__menu") + @GetMapping("/forms") + fun forms(): List> = readPayloads("metadata__form") + + @GetMapping("/forms/{slug}") + fun formBySlug(@PathVariable slug: String): Map = + readPayloadBySlug("metadata__form", slug) + ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Form '$slug' not found") + + @GetMapping("/list-views") + fun listViews(): List> = readPayloads("metadata__list_view") + + @GetMapping("/list-views/{slug}") + fun listViewBySlug(@PathVariable slug: String): Map = + readPayloadBySlug("metadata__list_view", slug) + ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "List view '$slug' not found") + /** * Read every custom-field declaration as raw `metadata__custom_field` * payload rows. Returns the YAML wire format unchanged so the SPA @@ -109,4 +130,18 @@ class MetadataController( payload + mapOf("source" to source) } } + + private fun readPayloadBySlug(table: String, slug: String): Map? { + val rows = jdbc.query( + "SELECT source, payload FROM $table WHERE payload->>'slug' = :slug", + MapSqlParameterSource("slug", slug), + ) { rs, _ -> + val source = rs.getString("source") + val payloadJson = rs.getString("payload") ?: "{}" + @Suppress("UNCHECKED_CAST") + val payload = objectMapper.readValue(payloadJson, Map::class.java) as Map + payload + mapOf("source" to source) + } + return rows.firstOrNull() + } } diff --git a/platform/platform-metadata/src/main/resources/META-INF/vibe-erp/metadata/platform-metadata.yml b/platform/platform-metadata/src/main/resources/META-INF/vibe-erp/metadata/platform-metadata.yml new file mode 100644 index 0000000..335e10c --- /dev/null +++ b/platform/platform-metadata/src/main/resources/META-INF/vibe-erp/metadata/platform-metadata.yml @@ -0,0 +1,16 @@ +# platform-metadata metadata. +# +# Loaded at boot by MetadataLoader, tagged source='core'. + +permissions: + - key: admin.metadata.read + description: View metadata configuration + - key: admin.metadata.write + description: Create, edit, and delete user metadata + +menus: + - path: /admin/metadata + label: Metadata Admin + icon: database-cog + section: System + order: 900 diff --git a/platform/platform-metadata/src/test/kotlin/org/vibeerp/platform/metadata/web/CustomFieldWriteControllerTest.kt b/platform/platform-metadata/src/test/kotlin/org/vibeerp/platform/metadata/web/CustomFieldWriteControllerTest.kt new file mode 100644 index 0000000..e672f06 --- /dev/null +++ b/platform/platform-metadata/src/test/kotlin/org/vibeerp/platform/metadata/web/CustomFieldWriteControllerTest.kt @@ -0,0 +1,147 @@ +package org.vibeerp.platform.metadata.web + +import assertk.assertThat +import assertk.assertions.isEqualTo +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.registerKotlinModule +import io.mockk.every +import io.mockk.justRun +import io.mockk.mockk +import io.mockk.verify +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.springframework.http.HttpStatus +import org.springframework.jdbc.core.RowMapper +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate +import org.springframework.jdbc.core.namedparam.SqlParameterSource +import org.springframework.web.server.ResponseStatusException +import org.vibeerp.platform.metadata.customfield.CustomFieldRegistry + +class CustomFieldWriteControllerTest { + + private lateinit var jdbc: NamedParameterJdbcTemplate + private lateinit var objectMapper: ObjectMapper + private lateinit var registry: CustomFieldRegistry + private lateinit var controller: CustomFieldWriteController + + @BeforeEach + fun setUp() { + jdbc = mockk(relaxed = false) + objectMapper = ObjectMapper().registerKotlinModule() + registry = mockk() + justRun { registry.refresh() } + controller = CustomFieldWriteController(jdbc, objectMapper, registry) + } + + @Test + fun `POST creates source=user row and calls registry refresh`() { + // No duplicate + every { jdbc.query(match { it.contains("SELECT id FROM metadata__custom_field") }, any(), any>()) } returns emptyList() + every { jdbc.update(match { it.contains("INSERT INTO metadata__custom_field") }, any()) } returns 1 + + val result = controller.create(mapOf( + "key" to "test_field", + "targetEntity" to "Item", + "type" to mapOf("kind" to "string"), + )) + + assertThat(result["key"]).isEqualTo("test_field") + assertThat(result["source"]).isEqualTo("user") + verify(exactly = 1) { + jdbc.update(match { it.contains("INSERT INTO metadata__custom_field") }, any()) + } + verify(exactly = 1) { registry.refresh() } + } + + @Test + fun `POST rejects duplicate key with 409`() { + val existingId = "550e8400-e29b-41d4-a716-446655440000" + every { jdbc.query(match { it.contains("SELECT id FROM metadata__custom_field") }, any(), any>()) } returns listOf(existingId) + + val ex = assertThrows { + controller.create(mapOf("key" to "duplicate_field", "targetEntity" to "Item")) + } + assertThat(ex.statusCode).isEqualTo(HttpStatus.CONFLICT) + } + + @Test + fun `POST rejects missing key with 400`() { + val ex = assertThrows { + controller.create(mapOf("targetEntity" to "Item")) + } + assertThat(ex.statusCode).isEqualTo(HttpStatus.BAD_REQUEST) + } + + @Test + fun `PUT updates existing source=user row and calls registry refresh`() { + val existingId = "550e8400-e29b-41d4-a716-446655440000" + every { jdbc.query(match { it.contains("SELECT id, source FROM metadata__custom_field") }, any(), any>>()) } returns listOf(existingId to "user") + every { jdbc.update(match { it.contains("UPDATE metadata__custom_field") }, any()) } returns 1 + + val result = controller.update("test_field", mapOf("targetEntity" to "Item", "type" to mapOf("kind" to "integer"))) + + assertThat(result["key"]).isEqualTo("test_field") + assertThat(result["source"]).isEqualTo("user") + verify(exactly = 1) { + jdbc.update(match { it.contains("UPDATE metadata__custom_field") }, any()) + } + verify(exactly = 1) { registry.refresh() } + } + + @Test + fun `PUT rejects source=core row with 403`() { + val existingId = "550e8400-e29b-41d4-a716-446655440000" + every { jdbc.query(match { it.contains("SELECT id, source FROM metadata__custom_field") }, any(), any>>()) } returns listOf(existingId to "core") + + val ex = assertThrows { + controller.update("core_field", mapOf("targetEntity" to "Item")) + } + assertThat(ex.statusCode).isEqualTo(HttpStatus.FORBIDDEN) + } + + @Test + fun `PUT returns 404 for unknown key`() { + every { jdbc.query(match { it.contains("SELECT id, source FROM metadata__custom_field") }, any(), any>>()) } returns emptyList() + + val ex = assertThrows { + controller.update("nonexistent", mapOf("targetEntity" to "Item")) + } + assertThat(ex.statusCode).isEqualTo(HttpStatus.NOT_FOUND) + } + + @Test + fun `DELETE removes source=user row and calls registry refresh`() { + val existingId = "550e8400-e29b-41d4-a716-446655440000" + every { jdbc.query(match { it.contains("SELECT id, source FROM metadata__custom_field") }, any(), any>>()) } returns listOf(existingId to "user") + every { jdbc.update(match { it.contains("DELETE FROM metadata__custom_field") }, any()) } returns 1 + + controller.delete("test_field") + + verify(exactly = 1) { + jdbc.update(match { it.contains("DELETE FROM metadata__custom_field") }, any()) + } + verify(exactly = 1) { registry.refresh() } + } + + @Test + fun `DELETE rejects source=plugin row with 403`() { + val existingId = "550e8400-e29b-41d4-a716-446655440000" + every { jdbc.query(match { it.contains("SELECT id, source FROM metadata__custom_field") }, any(), any>>()) } returns listOf(existingId to "plugin:printing-shop") + + val ex = assertThrows { + controller.delete("plugin_field") + } + assertThat(ex.statusCode).isEqualTo(HttpStatus.FORBIDDEN) + } + + @Test + fun `DELETE returns 404 for unknown key`() { + every { jdbc.query(match { it.contains("SELECT id, source FROM metadata__custom_field") }, any(), any>>()) } returns emptyList() + + val ex = assertThrows { + controller.delete("nonexistent") + } + assertThat(ex.statusCode).isEqualTo(HttpStatus.NOT_FOUND) + } +} diff --git a/platform/platform-metadata/src/test/kotlin/org/vibeerp/platform/metadata/web/FormDefinitionControllerTest.kt b/platform/platform-metadata/src/test/kotlin/org/vibeerp/platform/metadata/web/FormDefinitionControllerTest.kt new file mode 100644 index 0000000..b22f2fa --- /dev/null +++ b/platform/platform-metadata/src/test/kotlin/org/vibeerp/platform/metadata/web/FormDefinitionControllerTest.kt @@ -0,0 +1,108 @@ +package org.vibeerp.platform.metadata.web + +import assertk.assertThat +import assertk.assertions.isEqualTo +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.registerKotlinModule +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.springframework.http.HttpStatus +import org.springframework.jdbc.core.RowMapper +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate +import org.springframework.jdbc.core.namedparam.SqlParameterSource +import org.springframework.web.server.ResponseStatusException + +class FormDefinitionControllerTest { + + private lateinit var jdbc: NamedParameterJdbcTemplate + private lateinit var objectMapper: ObjectMapper + private lateinit var controller: FormDefinitionController + + @BeforeEach + fun setUp() { + jdbc = mockk(relaxed = false) + objectMapper = ObjectMapper().registerKotlinModule() + controller = FormDefinitionController(jdbc, objectMapper) + } + + @Test + fun `PUT creates new source=user row when slug does not exist`() { + // No existing row + every { jdbc.query(match { it.contains("SELECT id, source FROM metadata__form") }, any(), any>>()) } returns emptyList() + every { jdbc.update(match { it.contains("INSERT INTO metadata__form") }, any()) } returns 1 + + val result = controller.upsert("my-form", mapOf("entityName" to "Item", "title" to "My Form")) + + assertThat(result["slug"]).isEqualTo("my-form") + assertThat(result["source"]).isEqualTo("user") + assertThat(result["entityName"]).isEqualTo("Item") + verify(exactly = 1) { + jdbc.update(match { it.contains("INSERT INTO metadata__form") }, any()) + } + } + + @Test + fun `PUT updates existing source=user row`() { + val existingId = "550e8400-e29b-41d4-a716-446655440000" + every { jdbc.query(match { it.contains("SELECT id, source FROM metadata__form") }, any(), any>>()) } returns listOf(existingId to "user") + every { jdbc.update(match { it.contains("UPDATE metadata__form") }, any()) } returns 1 + + val result = controller.upsert("my-form", mapOf("entityName" to "Item", "title" to "Updated")) + + assertThat(result["slug"]).isEqualTo("my-form") + assertThat(result["source"]).isEqualTo("user") + assertThat(result["title"]).isEqualTo("Updated") + verify(exactly = 1) { + jdbc.update(match { it.contains("UPDATE metadata__form") }, any()) + } + } + + @Test + fun `PUT rejects source=core row with 403`() { + val existingId = "550e8400-e29b-41d4-a716-446655440000" + every { jdbc.query(match { it.contains("SELECT id, source FROM metadata__form") }, any(), any>>()) } returns listOf(existingId to "core") + + val ex = assertThrows { + controller.upsert("core-form", mapOf("title" to "Nope")) + } + assertThat(ex.statusCode).isEqualTo(HttpStatus.FORBIDDEN) + } + + @Test + fun `DELETE removes source=user row`() { + val existingId = "550e8400-e29b-41d4-a716-446655440000" + every { jdbc.query(match { it.contains("SELECT id, source FROM metadata__form") }, any(), any>>()) } returns listOf(existingId to "user") + every { jdbc.update(match { it.contains("DELETE FROM metadata__form") }, any()) } returns 1 + + controller.delete("my-form") + + verify(exactly = 1) { + jdbc.update(match { it.contains("DELETE FROM metadata__form") }, any()) + } + } + + @Test + fun `DELETE rejects source=core row with 403`() { + val existingId = "550e8400-e29b-41d4-a716-446655440000" + every { jdbc.query(match { it.contains("SELECT id, source FROM metadata__form") }, any(), any>>()) } returns listOf(existingId to "core") + + val ex = assertThrows { + controller.delete("core-form") + } + assertThat(ex.statusCode).isEqualTo(HttpStatus.FORBIDDEN) + } + + @Test + fun `DELETE returns 404 for unknown slug`() { + every { jdbc.query(match { it.contains("SELECT id, source FROM metadata__form") }, any(), any>>()) } returns emptyList() + + val ex = assertThrows { + controller.delete("nonexistent") + } + assertThat(ex.statusCode).isEqualTo(HttpStatus.NOT_FOUND) + } +} diff --git a/platform/platform-metadata/src/test/kotlin/org/vibeerp/platform/metadata/web/ListViewDefinitionControllerTest.kt b/platform/platform-metadata/src/test/kotlin/org/vibeerp/platform/metadata/web/ListViewDefinitionControllerTest.kt new file mode 100644 index 0000000..76dfdf9 --- /dev/null +++ b/platform/platform-metadata/src/test/kotlin/org/vibeerp/platform/metadata/web/ListViewDefinitionControllerTest.kt @@ -0,0 +1,106 @@ +package org.vibeerp.platform.metadata.web + +import assertk.assertThat +import assertk.assertions.isEqualTo +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.registerKotlinModule +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.springframework.http.HttpStatus +import org.springframework.jdbc.core.RowMapper +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate +import org.springframework.jdbc.core.namedparam.SqlParameterSource +import org.springframework.web.server.ResponseStatusException + +class ListViewDefinitionControllerTest { + + private lateinit var jdbc: NamedParameterJdbcTemplate + private lateinit var objectMapper: ObjectMapper + private lateinit var controller: ListViewDefinitionController + + @BeforeEach + fun setUp() { + jdbc = mockk(relaxed = false) + objectMapper = ObjectMapper().registerKotlinModule() + controller = ListViewDefinitionController(jdbc, objectMapper) + } + + @Test + fun `PUT creates new source=user row when slug does not exist`() { + every { jdbc.query(match { it.contains("SELECT id, source FROM metadata__list_view") }, any(), any>>()) } returns emptyList() + every { jdbc.update(match { it.contains("INSERT INTO metadata__list_view") }, any()) } returns 1 + + val result = controller.upsert("my-list", mapOf("entityName" to "Item", "title" to "Items")) + + assertThat(result["slug"]).isEqualTo("my-list") + assertThat(result["source"]).isEqualTo("user") + assertThat(result["entityName"]).isEqualTo("Item") + verify(exactly = 1) { + jdbc.update(match { it.contains("INSERT INTO metadata__list_view") }, any()) + } + } + + @Test + fun `PUT updates existing source=user row`() { + val existingId = "550e8400-e29b-41d4-a716-446655440000" + every { jdbc.query(match { it.contains("SELECT id, source FROM metadata__list_view") }, any(), any>>()) } returns listOf(existingId to "user") + every { jdbc.update(match { it.contains("UPDATE metadata__list_view") }, any()) } returns 1 + + val result = controller.upsert("my-list", mapOf("entityName" to "Item", "title" to "Updated")) + + assertThat(result["slug"]).isEqualTo("my-list") + assertThat(result["source"]).isEqualTo("user") + verify(exactly = 1) { + jdbc.update(match { it.contains("UPDATE metadata__list_view") }, any()) + } + } + + @Test + fun `PUT rejects source=core row with 403`() { + val existingId = "550e8400-e29b-41d4-a716-446655440000" + every { jdbc.query(match { it.contains("SELECT id, source FROM metadata__list_view") }, any(), any>>()) } returns listOf(existingId to "core") + + val ex = assertThrows { + controller.upsert("core-list", mapOf("title" to "Nope")) + } + assertThat(ex.statusCode).isEqualTo(HttpStatus.FORBIDDEN) + } + + @Test + fun `DELETE removes source=user row`() { + val existingId = "550e8400-e29b-41d4-a716-446655440000" + every { jdbc.query(match { it.contains("SELECT id, source FROM metadata__list_view") }, any(), any>>()) } returns listOf(existingId to "user") + every { jdbc.update(match { it.contains("DELETE FROM metadata__list_view") }, any()) } returns 1 + + controller.delete("my-list") + + verify(exactly = 1) { + jdbc.update(match { it.contains("DELETE FROM metadata__list_view") }, any()) + } + } + + @Test + fun `DELETE rejects source=core row with 403`() { + val existingId = "550e8400-e29b-41d4-a716-446655440000" + every { jdbc.query(match { it.contains("SELECT id, source FROM metadata__list_view") }, any(), any>>()) } returns listOf(existingId to "core") + + val ex = assertThrows { + controller.delete("core-list") + } + assertThat(ex.statusCode).isEqualTo(HttpStatus.FORBIDDEN) + } + + @Test + fun `DELETE returns 404 for unknown slug`() { + every { jdbc.query(match { it.contains("SELECT id, source FROM metadata__list_view") }, any(), any>>()) } returns emptyList() + + val ex = assertThrows { + controller.delete("nonexistent") + } + assertThat(ex.statusCode).isEqualTo(HttpStatus.NOT_FOUND) + } +}