Commit dc8ee0ff3153ac50e3216bcbb65e6b9180c3793a

Authored by zichun
1 parent 0d038647

feat(metadata): form/list-view/custom-field CRUD endpoints with source enforcement

Add three write controllers behind @RequirePermission("admin.metadata.write"):

- FormDefinitionController (PUT/DELETE /api/v1/_meta/metadata/forms/{slug})
- ListViewDefinitionController (PUT/DELETE /api/v1/_meta/metadata/list-views/{slug})
- CustomFieldWriteController (POST/PUT/DELETE /api/v1/_meta/metadata/custom-fields)

All three enforce source='user' immutability: rows seeded by core or
plug-in YAMLs cannot be modified or deleted through the REST surface.
CustomFieldWriteController calls CustomFieldRegistry.refresh() after
every successful write so the in-memory index stays current.

MetadataController gains GET endpoints for forms, list views (including
by-slug lookup), and the aggregate /metadata response now includes
forms and listViews sections.

Ships platform-metadata.yml declaring admin.metadata.read/write
permissions and a Metadata Admin menu entry.

22 unit tests cover the full CRUD + source-enforcement matrix.
platform/platform-metadata/build.gradle.kts
... ... @@ -22,6 +22,7 @@ kotlin {
22 22 dependencies {
23 23 api(project(":api:api-v1"))
24 24 api(project(":platform:platform-persistence"))
  25 + implementation(project(":platform:platform-security")) // @RequirePermission on the write controllers
25 26  
26 27 implementation(libs.kotlin.stdlib)
27 28 implementation(libs.kotlin.reflect)
... ...
platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/web/CustomFieldWriteController.kt 0 → 100644
  1 +package org.vibeerp.platform.metadata.web
  2 +
  3 +import com.fasterxml.jackson.databind.ObjectMapper
  4 +import org.springframework.http.HttpStatus
  5 +import org.springframework.jdbc.core.namedparam.MapSqlParameterSource
  6 +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate
  7 +import org.springframework.web.bind.annotation.DeleteMapping
  8 +import org.springframework.web.bind.annotation.PathVariable
  9 +import org.springframework.web.bind.annotation.PostMapping
  10 +import org.springframework.web.bind.annotation.PutMapping
  11 +import org.springframework.web.bind.annotation.RequestBody
  12 +import org.springframework.web.bind.annotation.RequestMapping
  13 +import org.springframework.web.bind.annotation.ResponseStatus
  14 +import org.springframework.web.bind.annotation.RestController
  15 +import org.springframework.web.server.ResponseStatusException
  16 +import org.vibeerp.platform.metadata.customfield.CustomFieldRegistry
  17 +import org.vibeerp.platform.security.authz.RequirePermission
  18 +import java.sql.Timestamp
  19 +import java.time.Instant
  20 +import java.util.UUID
  21 +
  22 +/**
  23 + * Write endpoints for custom field declarations stored in
  24 + * `metadata__custom_field`.
  25 + *
  26 + * Only rows with `source = 'user'` may be created, updated, or deleted
  27 + * through this controller. Rows seeded by core YAMLs or plug-in JARs
  28 + * are immutable from the REST surface.
  29 + *
  30 + * After every successful write, the [CustomFieldRegistry] is refreshed
  31 + * so the in-memory index (consumed by [ExtJsonValidator] on every
  32 + * entity save) immediately reflects the change.
  33 + *
  34 + * Read endpoints live in [MetadataController].
  35 + */
  36 +@RestController
  37 +@RequestMapping("/api/v1/_meta/metadata/custom-fields")
  38 +class CustomFieldWriteController(
  39 + private val jdbc: NamedParameterJdbcTemplate,
  40 + private val objectMapper: ObjectMapper,
  41 + private val customFieldRegistry: CustomFieldRegistry,
  42 +) {
  43 +
  44 + @PostMapping
  45 + @RequirePermission("admin.metadata.write")
  46 + @ResponseStatus(HttpStatus.CREATED)
  47 + fun create(@RequestBody body: Map<String, Any?>): Map<String, Any?> {
  48 + val key = body["key"]?.toString()
  49 + ?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "key is required")
  50 +
  51 + val existing = jdbc.query(
  52 + "SELECT id FROM metadata__custom_field WHERE payload->>'key' = :key",
  53 + MapSqlParameterSource("key", key),
  54 + ) { rs, _ -> rs.getString("id") }
  55 +
  56 + if (existing.isNotEmpty()) {
  57 + throw ResponseStatusException(
  58 + HttpStatus.CONFLICT,
  59 + "Custom field '$key' already exists",
  60 + )
  61 + }
  62 +
  63 + val payload = body.toMutableMap().apply { put("key", key) }
  64 + val payloadJson = objectMapper.writeValueAsString(payload)
  65 + val now = Timestamp.from(Instant.now())
  66 +
  67 + jdbc.update(
  68 + "INSERT INTO metadata__custom_field (id, source, payload, created_at, updated_at) VALUES (:id, 'user', CAST(:payload AS jsonb), :now, :now)",
  69 + MapSqlParameterSource()
  70 + .addValue("id", UUID.randomUUID())
  71 + .addValue("payload", payloadJson)
  72 + .addValue("now", now),
  73 + )
  74 +
  75 + customFieldRegistry.refresh()
  76 + return payload + mapOf("source" to "user")
  77 + }
  78 +
  79 + @PutMapping("/{key}")
  80 + @RequirePermission("admin.metadata.write")
  81 + fun update(@PathVariable key: String, @RequestBody body: Map<String, Any?>): Map<String, Any?> {
  82 + val existing = jdbc.query(
  83 + "SELECT id, source FROM metadata__custom_field WHERE payload->>'key' = :key",
  84 + MapSqlParameterSource("key", key),
  85 + ) { rs, _ -> rs.getString("id") to rs.getString("source") }
  86 +
  87 + if (existing.isEmpty()) {
  88 + throw ResponseStatusException(HttpStatus.NOT_FOUND, "Custom field '$key' not found")
  89 + }
  90 + val (id, source) = existing.first()
  91 + if (source != "user") {
  92 + throw ResponseStatusException(
  93 + HttpStatus.FORBIDDEN,
  94 + "Cannot modify custom field '$key' (source='$source')",
  95 + )
  96 + }
  97 +
  98 + val payload = body.toMutableMap().apply { put("key", key) }
  99 + val payloadJson = objectMapper.writeValueAsString(payload)
  100 + val now = Timestamp.from(Instant.now())
  101 +
  102 + jdbc.update(
  103 + "UPDATE metadata__custom_field SET payload = CAST(:payload AS jsonb), updated_at = :now WHERE id = CAST(:id AS uuid)",
  104 + MapSqlParameterSource()
  105 + .addValue("id", id)
  106 + .addValue("payload", payloadJson)
  107 + .addValue("now", now),
  108 + )
  109 +
  110 + customFieldRegistry.refresh()
  111 + return payload + mapOf("source" to "user")
  112 + }
  113 +
  114 + @DeleteMapping("/{key}")
  115 + @RequirePermission("admin.metadata.write")
  116 + @ResponseStatus(HttpStatus.NO_CONTENT)
  117 + fun delete(@PathVariable key: String) {
  118 + val existing = jdbc.query(
  119 + "SELECT id, source FROM metadata__custom_field WHERE payload->>'key' = :key",
  120 + MapSqlParameterSource("key", key),
  121 + ) { rs, _ -> rs.getString("id") to rs.getString("source") }
  122 +
  123 + if (existing.isEmpty()) {
  124 + throw ResponseStatusException(HttpStatus.NOT_FOUND, "Custom field '$key' not found")
  125 + }
  126 + val (id, source) = existing.first()
  127 + if (source != "user") {
  128 + throw ResponseStatusException(
  129 + HttpStatus.FORBIDDEN,
  130 + "Cannot delete custom field '$key' (source='$source')",
  131 + )
  132 + }
  133 +
  134 + jdbc.update(
  135 + "DELETE FROM metadata__custom_field WHERE id = CAST(:id AS uuid)",
  136 + MapSqlParameterSource("id", id),
  137 + )
  138 +
  139 + customFieldRegistry.refresh()
  140 + }
  141 +}
... ...
platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/web/FormDefinitionController.kt 0 → 100644
  1 +package org.vibeerp.platform.metadata.web
  2 +
  3 +import com.fasterxml.jackson.databind.ObjectMapper
  4 +import org.springframework.http.HttpStatus
  5 +import org.springframework.jdbc.core.namedparam.MapSqlParameterSource
  6 +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate
  7 +import org.springframework.web.bind.annotation.DeleteMapping
  8 +import org.springframework.web.bind.annotation.PathVariable
  9 +import org.springframework.web.bind.annotation.PutMapping
  10 +import org.springframework.web.bind.annotation.RequestBody
  11 +import org.springframework.web.bind.annotation.RequestMapping
  12 +import org.springframework.web.bind.annotation.ResponseStatus
  13 +import org.springframework.web.bind.annotation.RestController
  14 +import org.springframework.web.server.ResponseStatusException
  15 +import org.vibeerp.platform.security.authz.RequirePermission
  16 +import java.sql.Timestamp
  17 +import java.time.Instant
  18 +import java.util.UUID
  19 +
  20 +/**
  21 + * Write endpoints for form definitions stored in `metadata__form`.
  22 + *
  23 + * Only rows with `source = 'user'` may be created, updated, or deleted
  24 + * through this controller. Rows seeded by core YAMLs or plug-in JARs
  25 + * (`source = 'core'` or `source = 'plugin:*'`) are immutable from the
  26 + * REST surface -- they can only change by redeploying the owning module.
  27 + *
  28 + * Read endpoints live in [MetadataController].
  29 + */
  30 +@RestController
  31 +@RequestMapping("/api/v1/_meta/metadata/forms")
  32 +class FormDefinitionController(
  33 + private val jdbc: NamedParameterJdbcTemplate,
  34 + private val objectMapper: ObjectMapper,
  35 +) {
  36 +
  37 + @PutMapping("/{slug}")
  38 + @RequirePermission("admin.metadata.write")
  39 + fun upsert(@PathVariable slug: String, @RequestBody body: Map<String, Any?>): Map<String, Any?> {
  40 + val payload = body.toMutableMap().apply { put("slug", slug) }
  41 + val payloadJson = objectMapper.writeValueAsString(payload)
  42 + val now = Timestamp.from(Instant.now())
  43 +
  44 + val existing = jdbc.query(
  45 + "SELECT id, source FROM metadata__form WHERE payload->>'slug' = :slug",
  46 + MapSqlParameterSource("slug", slug),
  47 + ) { rs, _ -> rs.getString("id") to rs.getString("source") }
  48 +
  49 + if (existing.isNotEmpty()) {
  50 + val (id, source) = existing.first()
  51 + if (source != "user") {
  52 + throw ResponseStatusException(
  53 + HttpStatus.FORBIDDEN,
  54 + "Cannot modify form '$slug' (source='$source')",
  55 + )
  56 + }
  57 + jdbc.update(
  58 + "UPDATE metadata__form SET payload = CAST(:payload AS jsonb), updated_at = :now WHERE id = CAST(:id AS uuid)",
  59 + MapSqlParameterSource()
  60 + .addValue("id", id)
  61 + .addValue("payload", payloadJson)
  62 + .addValue("now", now),
  63 + )
  64 + } else {
  65 + jdbc.update(
  66 + "INSERT INTO metadata__form (id, source, payload, created_at, updated_at) VALUES (:id, 'user', CAST(:payload AS jsonb), :now, :now)",
  67 + MapSqlParameterSource()
  68 + .addValue("id", UUID.randomUUID())
  69 + .addValue("payload", payloadJson)
  70 + .addValue("now", now),
  71 + )
  72 + }
  73 + return payload + mapOf("source" to "user")
  74 + }
  75 +
  76 + @DeleteMapping("/{slug}")
  77 + @RequirePermission("admin.metadata.write")
  78 + @ResponseStatus(HttpStatus.NO_CONTENT)
  79 + fun delete(@PathVariable slug: String) {
  80 + val existing = jdbc.query(
  81 + "SELECT id, source FROM metadata__form WHERE payload->>'slug' = :slug",
  82 + MapSqlParameterSource("slug", slug),
  83 + ) { rs, _ -> rs.getString("id") to rs.getString("source") }
  84 +
  85 + if (existing.isEmpty()) {
  86 + throw ResponseStatusException(HttpStatus.NOT_FOUND, "Form '$slug' not found")
  87 + }
  88 + val (id, source) = existing.first()
  89 + if (source != "user") {
  90 + throw ResponseStatusException(
  91 + HttpStatus.FORBIDDEN,
  92 + "Cannot delete form '$slug' (source='$source')",
  93 + )
  94 + }
  95 + jdbc.update(
  96 + "DELETE FROM metadata__form WHERE id = CAST(:id AS uuid)",
  97 + MapSqlParameterSource("id", id),
  98 + )
  99 + }
  100 +}
... ...
platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/web/ListViewDefinitionController.kt 0 → 100644
  1 +package org.vibeerp.platform.metadata.web
  2 +
  3 +import com.fasterxml.jackson.databind.ObjectMapper
  4 +import org.springframework.http.HttpStatus
  5 +import org.springframework.jdbc.core.namedparam.MapSqlParameterSource
  6 +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate
  7 +import org.springframework.web.bind.annotation.DeleteMapping
  8 +import org.springframework.web.bind.annotation.PathVariable
  9 +import org.springframework.web.bind.annotation.PutMapping
  10 +import org.springframework.web.bind.annotation.RequestBody
  11 +import org.springframework.web.bind.annotation.RequestMapping
  12 +import org.springframework.web.bind.annotation.ResponseStatus
  13 +import org.springframework.web.bind.annotation.RestController
  14 +import org.springframework.web.server.ResponseStatusException
  15 +import org.vibeerp.platform.security.authz.RequirePermission
  16 +import java.sql.Timestamp
  17 +import java.time.Instant
  18 +import java.util.UUID
  19 +
  20 +/**
  21 + * Write endpoints for list view definitions stored in `metadata__list_view`.
  22 + *
  23 + * Only rows with `source = 'user'` may be created, updated, or deleted
  24 + * through this controller. Rows seeded by core YAMLs or plug-in JARs
  25 + * (`source = 'core'` or `source = 'plugin:*'`) are immutable from the
  26 + * REST surface -- they can only change by redeploying the owning module.
  27 + *
  28 + * Read endpoints live in [MetadataController].
  29 + */
  30 +@RestController
  31 +@RequestMapping("/api/v1/_meta/metadata/list-views")
  32 +class ListViewDefinitionController(
  33 + private val jdbc: NamedParameterJdbcTemplate,
  34 + private val objectMapper: ObjectMapper,
  35 +) {
  36 +
  37 + @PutMapping("/{slug}")
  38 + @RequirePermission("admin.metadata.write")
  39 + fun upsert(@PathVariable slug: String, @RequestBody body: Map<String, Any?>): Map<String, Any?> {
  40 + val payload = body.toMutableMap().apply { put("slug", slug) }
  41 + val payloadJson = objectMapper.writeValueAsString(payload)
  42 + val now = Timestamp.from(Instant.now())
  43 +
  44 + val existing = jdbc.query(
  45 + "SELECT id, source FROM metadata__list_view WHERE payload->>'slug' = :slug",
  46 + MapSqlParameterSource("slug", slug),
  47 + ) { rs, _ -> rs.getString("id") to rs.getString("source") }
  48 +
  49 + if (existing.isNotEmpty()) {
  50 + val (id, source) = existing.first()
  51 + if (source != "user") {
  52 + throw ResponseStatusException(
  53 + HttpStatus.FORBIDDEN,
  54 + "Cannot modify list view '$slug' (source='$source')",
  55 + )
  56 + }
  57 + jdbc.update(
  58 + "UPDATE metadata__list_view SET payload = CAST(:payload AS jsonb), updated_at = :now WHERE id = CAST(:id AS uuid)",
  59 + MapSqlParameterSource()
  60 + .addValue("id", id)
  61 + .addValue("payload", payloadJson)
  62 + .addValue("now", now),
  63 + )
  64 + } else {
  65 + jdbc.update(
  66 + "INSERT INTO metadata__list_view (id, source, payload, created_at, updated_at) VALUES (:id, 'user', CAST(:payload AS jsonb), :now, :now)",
  67 + MapSqlParameterSource()
  68 + .addValue("id", UUID.randomUUID())
  69 + .addValue("payload", payloadJson)
  70 + .addValue("now", now),
  71 + )
  72 + }
  73 + return payload + mapOf("source" to "user")
  74 + }
  75 +
  76 + @DeleteMapping("/{slug}")
  77 + @RequirePermission("admin.metadata.write")
  78 + @ResponseStatus(HttpStatus.NO_CONTENT)
  79 + fun delete(@PathVariable slug: String) {
  80 + val existing = jdbc.query(
  81 + "SELECT id, source FROM metadata__list_view WHERE payload->>'slug' = :slug",
  82 + MapSqlParameterSource("slug", slug),
  83 + ) { rs, _ -> rs.getString("id") to rs.getString("source") }
  84 +
  85 + if (existing.isEmpty()) {
  86 + throw ResponseStatusException(HttpStatus.NOT_FOUND, "List view '$slug' not found")
  87 + }
  88 + val (id, source) = existing.first()
  89 + if (source != "user") {
  90 + throw ResponseStatusException(
  91 + HttpStatus.FORBIDDEN,
  92 + "Cannot delete list view '$slug' (source='$source')",
  93 + )
  94 + }
  95 + jdbc.update(
  96 + "DELETE FROM metadata__list_view WHERE id = CAST(:id AS uuid)",
  97 + MapSqlParameterSource("id", id),
  98 + )
  99 + }
  100 +}
... ...
platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/web/MetadataController.kt
1 1 package org.vibeerp.platform.metadata.web
2 2  
3 3 import com.fasterxml.jackson.databind.ObjectMapper
  4 +import org.springframework.jdbc.core.namedparam.MapSqlParameterSource
4 5 import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate
  6 +import org.springframework.http.HttpStatus
5 7 import org.springframework.web.bind.annotation.GetMapping
6 8 import org.springframework.web.bind.annotation.PathVariable
7 9 import org.springframework.web.bind.annotation.RequestMapping
8 10 import org.springframework.web.bind.annotation.RestController
  11 +import org.springframework.web.server.ResponseStatusException
9 12 import org.vibeerp.api.v1.entity.CustomField
10 13 import org.vibeerp.api.v1.entity.FieldType
11 14 import org.vibeerp.platform.metadata.customfield.CustomFieldRegistry
... ... @@ -40,6 +43,8 @@ class MetadataController(
40 43 "permissions" to readPayloads("metadata__permission"),
41 44 "menus" to readPayloads("metadata__menu"),
42 45 "customFields" to readPayloads("metadata__custom_field"),
  46 + "forms" to readPayloads("metadata__form"),
  47 + "listViews" to readPayloads("metadata__list_view"),
43 48 )
44 49  
45 50 @GetMapping("/entities")
... ... @@ -51,6 +56,22 @@ class MetadataController(
51 56 @GetMapping("/menus")
52 57 fun menus(): List<Map<String, Any?>> = readPayloads("metadata__menu")
53 58  
  59 + @GetMapping("/forms")
  60 + fun forms(): List<Map<String, Any?>> = readPayloads("metadata__form")
  61 +
  62 + @GetMapping("/forms/{slug}")
  63 + fun formBySlug(@PathVariable slug: String): Map<String, Any?> =
  64 + readPayloadBySlug("metadata__form", slug)
  65 + ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Form '$slug' not found")
  66 +
  67 + @GetMapping("/list-views")
  68 + fun listViews(): List<Map<String, Any?>> = readPayloads("metadata__list_view")
  69 +
  70 + @GetMapping("/list-views/{slug}")
  71 + fun listViewBySlug(@PathVariable slug: String): Map<String, Any?> =
  72 + readPayloadBySlug("metadata__list_view", slug)
  73 + ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "List view '$slug' not found")
  74 +
54 75 /**
55 76 * Read every custom-field declaration as raw `metadata__custom_field`
56 77 * payload rows. Returns the YAML wire format unchanged so the SPA
... ... @@ -109,4 +130,18 @@ class MetadataController(
109 130 payload + mapOf("source" to source)
110 131 }
111 132 }
  133 +
  134 + private fun readPayloadBySlug(table: String, slug: String): Map<String, Any?>? {
  135 + val rows = jdbc.query(
  136 + "SELECT source, payload FROM $table WHERE payload->>'slug' = :slug",
  137 + MapSqlParameterSource("slug", slug),
  138 + ) { rs, _ ->
  139 + val source = rs.getString("source")
  140 + val payloadJson = rs.getString("payload") ?: "{}"
  141 + @Suppress("UNCHECKED_CAST")
  142 + val payload = objectMapper.readValue(payloadJson, Map::class.java) as Map<String, Any?>
  143 + payload + mapOf("source" to source)
  144 + }
  145 + return rows.firstOrNull()
  146 + }
112 147 }
... ...
platform/platform-metadata/src/main/resources/META-INF/vibe-erp/metadata/platform-metadata.yml 0 → 100644
  1 +# platform-metadata metadata.
  2 +#
  3 +# Loaded at boot by MetadataLoader, tagged source='core'.
  4 +
  5 +permissions:
  6 + - key: admin.metadata.read
  7 + description: View metadata configuration
  8 + - key: admin.metadata.write
  9 + description: Create, edit, and delete user metadata
  10 +
  11 +menus:
  12 + - path: /admin/metadata
  13 + label: Metadata Admin
  14 + icon: database-cog
  15 + section: System
  16 + order: 900
... ...
platform/platform-metadata/src/test/kotlin/org/vibeerp/platform/metadata/web/CustomFieldWriteControllerTest.kt 0 → 100644
  1 +package org.vibeerp.platform.metadata.web
  2 +
  3 +import assertk.assertThat
  4 +import assertk.assertions.isEqualTo
  5 +import com.fasterxml.jackson.databind.ObjectMapper
  6 +import com.fasterxml.jackson.module.kotlin.registerKotlinModule
  7 +import io.mockk.every
  8 +import io.mockk.justRun
  9 +import io.mockk.mockk
  10 +import io.mockk.verify
  11 +import org.junit.jupiter.api.BeforeEach
  12 +import org.junit.jupiter.api.Test
  13 +import org.junit.jupiter.api.assertThrows
  14 +import org.springframework.http.HttpStatus
  15 +import org.springframework.jdbc.core.RowMapper
  16 +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate
  17 +import org.springframework.jdbc.core.namedparam.SqlParameterSource
  18 +import org.springframework.web.server.ResponseStatusException
  19 +import org.vibeerp.platform.metadata.customfield.CustomFieldRegistry
  20 +
  21 +class CustomFieldWriteControllerTest {
  22 +
  23 + private lateinit var jdbc: NamedParameterJdbcTemplate
  24 + private lateinit var objectMapper: ObjectMapper
  25 + private lateinit var registry: CustomFieldRegistry
  26 + private lateinit var controller: CustomFieldWriteController
  27 +
  28 + @BeforeEach
  29 + fun setUp() {
  30 + jdbc = mockk(relaxed = false)
  31 + objectMapper = ObjectMapper().registerKotlinModule()
  32 + registry = mockk()
  33 + justRun { registry.refresh() }
  34 + controller = CustomFieldWriteController(jdbc, objectMapper, registry)
  35 + }
  36 +
  37 + @Test
  38 + fun `POST creates source=user row and calls registry refresh`() {
  39 + // No duplicate
  40 + every { jdbc.query(match<String> { it.contains("SELECT id FROM metadata__custom_field") }, any<SqlParameterSource>(), any<RowMapper<String>>()) } returns emptyList()
  41 + every { jdbc.update(match<String> { it.contains("INSERT INTO metadata__custom_field") }, any<SqlParameterSource>()) } returns 1
  42 +
  43 + val result = controller.create(mapOf(
  44 + "key" to "test_field",
  45 + "targetEntity" to "Item",
  46 + "type" to mapOf("kind" to "string"),
  47 + ))
  48 +
  49 + assertThat(result["key"]).isEqualTo("test_field")
  50 + assertThat(result["source"]).isEqualTo("user")
  51 + verify(exactly = 1) {
  52 + jdbc.update(match<String> { it.contains("INSERT INTO metadata__custom_field") }, any<SqlParameterSource>())
  53 + }
  54 + verify(exactly = 1) { registry.refresh() }
  55 + }
  56 +
  57 + @Test
  58 + fun `POST rejects duplicate key with 409`() {
  59 + val existingId = "550e8400-e29b-41d4-a716-446655440000"
  60 + every { jdbc.query(match<String> { it.contains("SELECT id FROM metadata__custom_field") }, any<SqlParameterSource>(), any<RowMapper<String>>()) } returns listOf(existingId)
  61 +
  62 + val ex = assertThrows<ResponseStatusException> {
  63 + controller.create(mapOf("key" to "duplicate_field", "targetEntity" to "Item"))
  64 + }
  65 + assertThat(ex.statusCode).isEqualTo(HttpStatus.CONFLICT)
  66 + }
  67 +
  68 + @Test
  69 + fun `POST rejects missing key with 400`() {
  70 + val ex = assertThrows<ResponseStatusException> {
  71 + controller.create(mapOf("targetEntity" to "Item"))
  72 + }
  73 + assertThat(ex.statusCode).isEqualTo(HttpStatus.BAD_REQUEST)
  74 + }
  75 +
  76 + @Test
  77 + fun `PUT updates existing source=user row and calls registry refresh`() {
  78 + val existingId = "550e8400-e29b-41d4-a716-446655440000"
  79 + every { jdbc.query(match<String> { it.contains("SELECT id, source FROM metadata__custom_field") }, any<SqlParameterSource>(), any<RowMapper<Pair<String, String>>>()) } returns listOf(existingId to "user")
  80 + every { jdbc.update(match<String> { it.contains("UPDATE metadata__custom_field") }, any<SqlParameterSource>()) } returns 1
  81 +
  82 + val result = controller.update("test_field", mapOf("targetEntity" to "Item", "type" to mapOf("kind" to "integer")))
  83 +
  84 + assertThat(result["key"]).isEqualTo("test_field")
  85 + assertThat(result["source"]).isEqualTo("user")
  86 + verify(exactly = 1) {
  87 + jdbc.update(match<String> { it.contains("UPDATE metadata__custom_field") }, any<SqlParameterSource>())
  88 + }
  89 + verify(exactly = 1) { registry.refresh() }
  90 + }
  91 +
  92 + @Test
  93 + fun `PUT rejects source=core row with 403`() {
  94 + val existingId = "550e8400-e29b-41d4-a716-446655440000"
  95 + every { jdbc.query(match<String> { it.contains("SELECT id, source FROM metadata__custom_field") }, any<SqlParameterSource>(), any<RowMapper<Pair<String, String>>>()) } returns listOf(existingId to "core")
  96 +
  97 + val ex = assertThrows<ResponseStatusException> {
  98 + controller.update("core_field", mapOf("targetEntity" to "Item"))
  99 + }
  100 + assertThat(ex.statusCode).isEqualTo(HttpStatus.FORBIDDEN)
  101 + }
  102 +
  103 + @Test
  104 + fun `PUT returns 404 for unknown key`() {
  105 + every { jdbc.query(match<String> { it.contains("SELECT id, source FROM metadata__custom_field") }, any<SqlParameterSource>(), any<RowMapper<Pair<String, String>>>()) } returns emptyList()
  106 +
  107 + val ex = assertThrows<ResponseStatusException> {
  108 + controller.update("nonexistent", mapOf("targetEntity" to "Item"))
  109 + }
  110 + assertThat(ex.statusCode).isEqualTo(HttpStatus.NOT_FOUND)
  111 + }
  112 +
  113 + @Test
  114 + fun `DELETE removes source=user row and calls registry refresh`() {
  115 + val existingId = "550e8400-e29b-41d4-a716-446655440000"
  116 + every { jdbc.query(match<String> { it.contains("SELECT id, source FROM metadata__custom_field") }, any<SqlParameterSource>(), any<RowMapper<Pair<String, String>>>()) } returns listOf(existingId to "user")
  117 + every { jdbc.update(match<String> { it.contains("DELETE FROM metadata__custom_field") }, any<SqlParameterSource>()) } returns 1
  118 +
  119 + controller.delete("test_field")
  120 +
  121 + verify(exactly = 1) {
  122 + jdbc.update(match<String> { it.contains("DELETE FROM metadata__custom_field") }, any<SqlParameterSource>())
  123 + }
  124 + verify(exactly = 1) { registry.refresh() }
  125 + }
  126 +
  127 + @Test
  128 + fun `DELETE rejects source=plugin row with 403`() {
  129 + val existingId = "550e8400-e29b-41d4-a716-446655440000"
  130 + every { jdbc.query(match<String> { it.contains("SELECT id, source FROM metadata__custom_field") }, any<SqlParameterSource>(), any<RowMapper<Pair<String, String>>>()) } returns listOf(existingId to "plugin:printing-shop")
  131 +
  132 + val ex = assertThrows<ResponseStatusException> {
  133 + controller.delete("plugin_field")
  134 + }
  135 + assertThat(ex.statusCode).isEqualTo(HttpStatus.FORBIDDEN)
  136 + }
  137 +
  138 + @Test
  139 + fun `DELETE returns 404 for unknown key`() {
  140 + every { jdbc.query(match<String> { it.contains("SELECT id, source FROM metadata__custom_field") }, any<SqlParameterSource>(), any<RowMapper<Pair<String, String>>>()) } returns emptyList()
  141 +
  142 + val ex = assertThrows<ResponseStatusException> {
  143 + controller.delete("nonexistent")
  144 + }
  145 + assertThat(ex.statusCode).isEqualTo(HttpStatus.NOT_FOUND)
  146 + }
  147 +}
... ...
platform/platform-metadata/src/test/kotlin/org/vibeerp/platform/metadata/web/FormDefinitionControllerTest.kt 0 → 100644
  1 +package org.vibeerp.platform.metadata.web
  2 +
  3 +import assertk.assertThat
  4 +import assertk.assertions.isEqualTo
  5 +import com.fasterxml.jackson.databind.ObjectMapper
  6 +import com.fasterxml.jackson.module.kotlin.registerKotlinModule
  7 +import io.mockk.every
  8 +import io.mockk.mockk
  9 +import io.mockk.verify
  10 +import org.junit.jupiter.api.BeforeEach
  11 +import org.junit.jupiter.api.Test
  12 +import org.junit.jupiter.api.assertThrows
  13 +import org.springframework.http.HttpStatus
  14 +import org.springframework.jdbc.core.RowMapper
  15 +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate
  16 +import org.springframework.jdbc.core.namedparam.SqlParameterSource
  17 +import org.springframework.web.server.ResponseStatusException
  18 +
  19 +class FormDefinitionControllerTest {
  20 +
  21 + private lateinit var jdbc: NamedParameterJdbcTemplate
  22 + private lateinit var objectMapper: ObjectMapper
  23 + private lateinit var controller: FormDefinitionController
  24 +
  25 + @BeforeEach
  26 + fun setUp() {
  27 + jdbc = mockk(relaxed = false)
  28 + objectMapper = ObjectMapper().registerKotlinModule()
  29 + controller = FormDefinitionController(jdbc, objectMapper)
  30 + }
  31 +
  32 + @Test
  33 + fun `PUT creates new source=user row when slug does not exist`() {
  34 + // No existing row
  35 + every { jdbc.query(match<String> { it.contains("SELECT id, source FROM metadata__form") }, any<SqlParameterSource>(), any<RowMapper<Pair<String, String>>>()) } returns emptyList()
  36 + every { jdbc.update(match<String> { it.contains("INSERT INTO metadata__form") }, any<SqlParameterSource>()) } returns 1
  37 +
  38 + val result = controller.upsert("my-form", mapOf("entityName" to "Item", "title" to "My Form"))
  39 +
  40 + assertThat(result["slug"]).isEqualTo("my-form")
  41 + assertThat(result["source"]).isEqualTo("user")
  42 + assertThat(result["entityName"]).isEqualTo("Item")
  43 + verify(exactly = 1) {
  44 + jdbc.update(match<String> { it.contains("INSERT INTO metadata__form") }, any<SqlParameterSource>())
  45 + }
  46 + }
  47 +
  48 + @Test
  49 + fun `PUT updates existing source=user row`() {
  50 + val existingId = "550e8400-e29b-41d4-a716-446655440000"
  51 + every { jdbc.query(match<String> { it.contains("SELECT id, source FROM metadata__form") }, any<SqlParameterSource>(), any<RowMapper<Pair<String, String>>>()) } returns listOf(existingId to "user")
  52 + every { jdbc.update(match<String> { it.contains("UPDATE metadata__form") }, any<SqlParameterSource>()) } returns 1
  53 +
  54 + val result = controller.upsert("my-form", mapOf("entityName" to "Item", "title" to "Updated"))
  55 +
  56 + assertThat(result["slug"]).isEqualTo("my-form")
  57 + assertThat(result["source"]).isEqualTo("user")
  58 + assertThat(result["title"]).isEqualTo("Updated")
  59 + verify(exactly = 1) {
  60 + jdbc.update(match<String> { it.contains("UPDATE metadata__form") }, any<SqlParameterSource>())
  61 + }
  62 + }
  63 +
  64 + @Test
  65 + fun `PUT rejects source=core row with 403`() {
  66 + val existingId = "550e8400-e29b-41d4-a716-446655440000"
  67 + every { jdbc.query(match<String> { it.contains("SELECT id, source FROM metadata__form") }, any<SqlParameterSource>(), any<RowMapper<Pair<String, String>>>()) } returns listOf(existingId to "core")
  68 +
  69 + val ex = assertThrows<ResponseStatusException> {
  70 + controller.upsert("core-form", mapOf("title" to "Nope"))
  71 + }
  72 + assertThat(ex.statusCode).isEqualTo(HttpStatus.FORBIDDEN)
  73 + }
  74 +
  75 + @Test
  76 + fun `DELETE removes source=user row`() {
  77 + val existingId = "550e8400-e29b-41d4-a716-446655440000"
  78 + every { jdbc.query(match<String> { it.contains("SELECT id, source FROM metadata__form") }, any<SqlParameterSource>(), any<RowMapper<Pair<String, String>>>()) } returns listOf(existingId to "user")
  79 + every { jdbc.update(match<String> { it.contains("DELETE FROM metadata__form") }, any<SqlParameterSource>()) } returns 1
  80 +
  81 + controller.delete("my-form")
  82 +
  83 + verify(exactly = 1) {
  84 + jdbc.update(match<String> { it.contains("DELETE FROM metadata__form") }, any<SqlParameterSource>())
  85 + }
  86 + }
  87 +
  88 + @Test
  89 + fun `DELETE rejects source=core row with 403`() {
  90 + val existingId = "550e8400-e29b-41d4-a716-446655440000"
  91 + every { jdbc.query(match<String> { it.contains("SELECT id, source FROM metadata__form") }, any<SqlParameterSource>(), any<RowMapper<Pair<String, String>>>()) } returns listOf(existingId to "core")
  92 +
  93 + val ex = assertThrows<ResponseStatusException> {
  94 + controller.delete("core-form")
  95 + }
  96 + assertThat(ex.statusCode).isEqualTo(HttpStatus.FORBIDDEN)
  97 + }
  98 +
  99 + @Test
  100 + fun `DELETE returns 404 for unknown slug`() {
  101 + every { jdbc.query(match<String> { it.contains("SELECT id, source FROM metadata__form") }, any<SqlParameterSource>(), any<RowMapper<Pair<String, String>>>()) } returns emptyList()
  102 +
  103 + val ex = assertThrows<ResponseStatusException> {
  104 + controller.delete("nonexistent")
  105 + }
  106 + assertThat(ex.statusCode).isEqualTo(HttpStatus.NOT_FOUND)
  107 + }
  108 +}
... ...
platform/platform-metadata/src/test/kotlin/org/vibeerp/platform/metadata/web/ListViewDefinitionControllerTest.kt 0 → 100644
  1 +package org.vibeerp.platform.metadata.web
  2 +
  3 +import assertk.assertThat
  4 +import assertk.assertions.isEqualTo
  5 +import com.fasterxml.jackson.databind.ObjectMapper
  6 +import com.fasterxml.jackson.module.kotlin.registerKotlinModule
  7 +import io.mockk.every
  8 +import io.mockk.mockk
  9 +import io.mockk.verify
  10 +import org.junit.jupiter.api.BeforeEach
  11 +import org.junit.jupiter.api.Test
  12 +import org.junit.jupiter.api.assertThrows
  13 +import org.springframework.http.HttpStatus
  14 +import org.springframework.jdbc.core.RowMapper
  15 +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate
  16 +import org.springframework.jdbc.core.namedparam.SqlParameterSource
  17 +import org.springframework.web.server.ResponseStatusException
  18 +
  19 +class ListViewDefinitionControllerTest {
  20 +
  21 + private lateinit var jdbc: NamedParameterJdbcTemplate
  22 + private lateinit var objectMapper: ObjectMapper
  23 + private lateinit var controller: ListViewDefinitionController
  24 +
  25 + @BeforeEach
  26 + fun setUp() {
  27 + jdbc = mockk(relaxed = false)
  28 + objectMapper = ObjectMapper().registerKotlinModule()
  29 + controller = ListViewDefinitionController(jdbc, objectMapper)
  30 + }
  31 +
  32 + @Test
  33 + fun `PUT creates new source=user row when slug does not exist`() {
  34 + every { jdbc.query(match<String> { it.contains("SELECT id, source FROM metadata__list_view") }, any<SqlParameterSource>(), any<RowMapper<Pair<String, String>>>()) } returns emptyList()
  35 + every { jdbc.update(match<String> { it.contains("INSERT INTO metadata__list_view") }, any<SqlParameterSource>()) } returns 1
  36 +
  37 + val result = controller.upsert("my-list", mapOf("entityName" to "Item", "title" to "Items"))
  38 +
  39 + assertThat(result["slug"]).isEqualTo("my-list")
  40 + assertThat(result["source"]).isEqualTo("user")
  41 + assertThat(result["entityName"]).isEqualTo("Item")
  42 + verify(exactly = 1) {
  43 + jdbc.update(match<String> { it.contains("INSERT INTO metadata__list_view") }, any<SqlParameterSource>())
  44 + }
  45 + }
  46 +
  47 + @Test
  48 + fun `PUT updates existing source=user row`() {
  49 + val existingId = "550e8400-e29b-41d4-a716-446655440000"
  50 + every { jdbc.query(match<String> { it.contains("SELECT id, source FROM metadata__list_view") }, any<SqlParameterSource>(), any<RowMapper<Pair<String, String>>>()) } returns listOf(existingId to "user")
  51 + every { jdbc.update(match<String> { it.contains("UPDATE metadata__list_view") }, any<SqlParameterSource>()) } returns 1
  52 +
  53 + val result = controller.upsert("my-list", mapOf("entityName" to "Item", "title" to "Updated"))
  54 +
  55 + assertThat(result["slug"]).isEqualTo("my-list")
  56 + assertThat(result["source"]).isEqualTo("user")
  57 + verify(exactly = 1) {
  58 + jdbc.update(match<String> { it.contains("UPDATE metadata__list_view") }, any<SqlParameterSource>())
  59 + }
  60 + }
  61 +
  62 + @Test
  63 + fun `PUT rejects source=core row with 403`() {
  64 + val existingId = "550e8400-e29b-41d4-a716-446655440000"
  65 + every { jdbc.query(match<String> { it.contains("SELECT id, source FROM metadata__list_view") }, any<SqlParameterSource>(), any<RowMapper<Pair<String, String>>>()) } returns listOf(existingId to "core")
  66 +
  67 + val ex = assertThrows<ResponseStatusException> {
  68 + controller.upsert("core-list", mapOf("title" to "Nope"))
  69 + }
  70 + assertThat(ex.statusCode).isEqualTo(HttpStatus.FORBIDDEN)
  71 + }
  72 +
  73 + @Test
  74 + fun `DELETE removes source=user row`() {
  75 + val existingId = "550e8400-e29b-41d4-a716-446655440000"
  76 + every { jdbc.query(match<String> { it.contains("SELECT id, source FROM metadata__list_view") }, any<SqlParameterSource>(), any<RowMapper<Pair<String, String>>>()) } returns listOf(existingId to "user")
  77 + every { jdbc.update(match<String> { it.contains("DELETE FROM metadata__list_view") }, any<SqlParameterSource>()) } returns 1
  78 +
  79 + controller.delete("my-list")
  80 +
  81 + verify(exactly = 1) {
  82 + jdbc.update(match<String> { it.contains("DELETE FROM metadata__list_view") }, any<SqlParameterSource>())
  83 + }
  84 + }
  85 +
  86 + @Test
  87 + fun `DELETE rejects source=core row with 403`() {
  88 + val existingId = "550e8400-e29b-41d4-a716-446655440000"
  89 + every { jdbc.query(match<String> { it.contains("SELECT id, source FROM metadata__list_view") }, any<SqlParameterSource>(), any<RowMapper<Pair<String, String>>>()) } returns listOf(existingId to "core")
  90 +
  91 + val ex = assertThrows<ResponseStatusException> {
  92 + controller.delete("core-list")
  93 + }
  94 + assertThat(ex.statusCode).isEqualTo(HttpStatus.FORBIDDEN)
  95 + }
  96 +
  97 + @Test
  98 + fun `DELETE returns 404 for unknown slug`() {
  99 + every { jdbc.query(match<String> { it.contains("SELECT id, source FROM metadata__list_view") }, any<SqlParameterSource>(), any<RowMapper<Pair<String, String>>>()) } returns emptyList()
  100 +
  101 + val ex = assertThrows<ResponseStatusException> {
  102 + controller.delete("nonexistent")
  103 + }
  104 + assertThat(ex.statusCode).isEqualTo(HttpStatus.NOT_FOUND)
  105 + }
  106 +}
... ...