Commit dc8ee0ff3153ac50e3216bcbb65e6b9180c3793a
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.
Showing
9 changed files
with
754 additions
and
0 deletions
platform/platform-metadata/build.gradle.kts
| @@ -22,6 +22,7 @@ kotlin { | @@ -22,6 +22,7 @@ kotlin { | ||
| 22 | dependencies { | 22 | dependencies { |
| 23 | api(project(":api:api-v1")) | 23 | api(project(":api:api-v1")) |
| 24 | api(project(":platform:platform-persistence")) | 24 | api(project(":platform:platform-persistence")) |
| 25 | + implementation(project(":platform:platform-security")) // @RequirePermission on the write controllers | ||
| 25 | 26 | ||
| 26 | implementation(libs.kotlin.stdlib) | 27 | implementation(libs.kotlin.stdlib) |
| 27 | implementation(libs.kotlin.reflect) | 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 | package org.vibeerp.platform.metadata.web | 1 | package org.vibeerp.platform.metadata.web |
| 2 | 2 | ||
| 3 | import com.fasterxml.jackson.databind.ObjectMapper | 3 | import com.fasterxml.jackson.databind.ObjectMapper |
| 4 | +import org.springframework.jdbc.core.namedparam.MapSqlParameterSource | ||
| 4 | import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate | 5 | import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate |
| 6 | +import org.springframework.http.HttpStatus | ||
| 5 | import org.springframework.web.bind.annotation.GetMapping | 7 | import org.springframework.web.bind.annotation.GetMapping |
| 6 | import org.springframework.web.bind.annotation.PathVariable | 8 | import org.springframework.web.bind.annotation.PathVariable |
| 7 | import org.springframework.web.bind.annotation.RequestMapping | 9 | import org.springframework.web.bind.annotation.RequestMapping |
| 8 | import org.springframework.web.bind.annotation.RestController | 10 | import org.springframework.web.bind.annotation.RestController |
| 11 | +import org.springframework.web.server.ResponseStatusException | ||
| 9 | import org.vibeerp.api.v1.entity.CustomField | 12 | import org.vibeerp.api.v1.entity.CustomField |
| 10 | import org.vibeerp.api.v1.entity.FieldType | 13 | import org.vibeerp.api.v1.entity.FieldType |
| 11 | import org.vibeerp.platform.metadata.customfield.CustomFieldRegistry | 14 | import org.vibeerp.platform.metadata.customfield.CustomFieldRegistry |
| @@ -40,6 +43,8 @@ class MetadataController( | @@ -40,6 +43,8 @@ class MetadataController( | ||
| 40 | "permissions" to readPayloads("metadata__permission"), | 43 | "permissions" to readPayloads("metadata__permission"), |
| 41 | "menus" to readPayloads("metadata__menu"), | 44 | "menus" to readPayloads("metadata__menu"), |
| 42 | "customFields" to readPayloads("metadata__custom_field"), | 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 | @GetMapping("/entities") | 50 | @GetMapping("/entities") |
| @@ -51,6 +56,22 @@ class MetadataController( | @@ -51,6 +56,22 @@ class MetadataController( | ||
| 51 | @GetMapping("/menus") | 56 | @GetMapping("/menus") |
| 52 | fun menus(): List<Map<String, Any?>> = readPayloads("metadata__menu") | 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 | * Read every custom-field declaration as raw `metadata__custom_field` | 76 | * Read every custom-field declaration as raw `metadata__custom_field` |
| 56 | * payload rows. Returns the YAML wire format unchanged so the SPA | 77 | * payload rows. Returns the YAML wire format unchanged so the SPA |
| @@ -109,4 +130,18 @@ class MetadataController( | @@ -109,4 +130,18 @@ class MetadataController( | ||
| 109 | payload + mapOf("source" to source) | 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 | +} |