This is an automated email from the ASF dual-hosted git repository. github-merge-queue[bot] pushed a commit to branch gh-readonly-queue/main/pr-5402-09aac02a55d55c6ed026e1e372355aaaf33b63d3 in repository https://gitbox.apache.org/repos/asf/texera.git
commit 6849c8ca0dfec882e68d174707cc05a8bce0f523 Author: Xinyuan Lin <[email protected]> AuthorDate: Sat Jun 6 18:53:59 2026 -0700 test(amber): add unit test coverage for dashboard/hub entity model (#5402) ### What changes were proposed in this PR? Adds `HubEntityModelSpec` covering all three files in `amber/.../web/resource/dashboard/hub/` — the entity-kind dispatch types the Hub dashboard uses and the jOOQ table lookups they drive. Bundled because the three files are a tight `apply(EntityType)` dispatch family. | File | Behavior pinned | | --- | --- | | `ActionType.scala` | Four subtypes (`View` / `Like` / `Clone` / `Unlike`) expose lowercase `value`; `toString = value` override (what reaches log lines, not the case-object name); `fromString` exact-match + case-insensitive; throws `IllegalArgumentException` with input in message on unknown / empty; `@JsonValue` + `@JsonCreator` Jackson round-trip via `JSONUtils.objectMapper` (emits lowercase string, accepts case-insensitive). | | `EntityType.scala` | Same shape — `Workflow` / `Dataset` carry `"workflow"` / `"dataset"`; toString-equals-value override; case-insensitive `fromString`; unknown-input error; Jackson round-trip. | | `EntityTables.scala` dispatch | `BaseEntityTable.apply`, `LikeTable.apply`, `CloneTable.apply`, `ViewCountTable.apply` each dispatched against both `EntityType` subtypes. Each `case object` exposes its `table` / `idColumn` / `isPublicColumn` / `uidColumn` / `viewCountColumn` from the production jOOQ generated tables — a regression that wired a column to the wrong jOOQ field would fail here. `CloneTable(Dataset)` throws `IllegalArgumentException` since clone is workflow-only today; a future `DatasetCloneTable` should remove that assertion deliberately. | No production code changed; this is test-only. ### Any related issues, documentation, discussions? Closes #5397 ### How was this PR tested? ``` sbt "WorkflowExecutionService/Test/testOnly org.apache.texera.web.resource.dashboard.hub.HubEntityModelSpec" # → 24 tests, all pass sbt "WorkflowExecutionService/Test/scalafmtCheck" # → clean ``` ### Was this PR authored or co-authored using generative AI tooling? Generated-by: Claude Code (Claude Opus 4.7) --- .../dashboard/hub/HubEntityModelSpec.scala | 235 +++++++++++++++++++++ 1 file changed, 235 insertions(+) diff --git a/amber/src/test/scala/org/apache/texera/web/resource/dashboard/hub/HubEntityModelSpec.scala b/amber/src/test/scala/org/apache/texera/web/resource/dashboard/hub/HubEntityModelSpec.scala new file mode 100644 index 0000000000..2924cf54b4 --- /dev/null +++ b/amber/src/test/scala/org/apache/texera/web/resource/dashboard/hub/HubEntityModelSpec.scala @@ -0,0 +1,235 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.texera.web.resource.dashboard.hub + +import org.apache.texera.amber.util.JSONUtils.objectMapper +import org.apache.texera.dao.jooq.generated.Tables._ +import org.scalatest.flatspec.AnyFlatSpec + +class HubEntityModelSpec extends AnyFlatSpec { + + // --------------------------------------------------------------------------- + // ActionType + // --------------------------------------------------------------------------- + + "ActionType subtypes" should "expose their lowercase string value" in { + assert(ActionType.View.value == "view") + assert(ActionType.Like.value == "like") + assert(ActionType.Clone.value == "clone") + assert(ActionType.Unlike.value == "unlike") + } + + it should "have toString equal to value (override pin)" in { + // The trait override `toString = value` is what reaches log lines and + // tracking pipelines. Pin so a regression that returns the case-object + // name (e.g. "View" instead of "view") breaks here. Use the class + // simpleName in the failure message — not toString, which is what + // we are pinning — so a regression produces a readable diagnostic. + val all: List[ActionType] = + List(ActionType.View, ActionType.Like, ActionType.Clone, ActionType.Unlike) + all.foreach { a => + val name = a.getClass.getSimpleName + assert(a.toString == a.value, s"$name.toString = '${a.toString}' but value = '${a.value}'") + } + } + + "ActionType.fromString" should "match each subtype exactly" in { + assert(ActionType.fromString("view") == ActionType.View) + assert(ActionType.fromString("like") == ActionType.Like) + assert(ActionType.fromString("clone") == ActionType.Clone) + assert(ActionType.fromString("unlike") == ActionType.Unlike) + } + + it should "match case-insensitively" in { + assert(ActionType.fromString("VIEW") == ActionType.View) + assert(ActionType.fromString("Like") == ActionType.Like) + assert(ActionType.fromString("CLONE") == ActionType.Clone) + assert(ActionType.fromString("uNlIkE") == ActionType.Unlike) + } + + it should "throw IllegalArgumentException for an unknown action, naming the input" in { + val ex = intercept[IllegalArgumentException] { + ActionType.fromString("delete") + } + assert(ex.getMessage.contains("delete"), s"unexpected message: ${ex.getMessage}") + } + + it should "throw IllegalArgumentException for an empty string, naming the empty input" in { + // Pin the concrete `''` representation in the message, not just that + // an exception is thrown (`"".contains("")` is trivially true). + val ex = intercept[IllegalArgumentException] { + ActionType.fromString("") + } + assert(ex.getMessage.contains("''"), s"unexpected message: ${ex.getMessage}") + } + + "ActionType Jackson round-trip" should "serialize each subtype as its lowercase string value" in { + // `@JsonValue` on `value` instructs Jackson to emit the field's value + // string instead of a wrapping object form. + assert(objectMapper.writeValueAsString(ActionType.View: ActionType) == "\"view\"") + assert(objectMapper.writeValueAsString(ActionType.Like: ActionType) == "\"like\"") + assert(objectMapper.writeValueAsString(ActionType.Clone: ActionType) == "\"clone\"") + assert(objectMapper.writeValueAsString(ActionType.Unlike: ActionType) == "\"unlike\"") + } + + it should "deserialize from the lowercase string back to the canonical subtype" in { + // `@JsonCreator` on `fromString` lets Jackson reconstruct the subtype + // from a raw string field. + assert(objectMapper.readValue("\"view\"", classOf[ActionType]) == ActionType.View) + assert(objectMapper.readValue("\"like\"", classOf[ActionType]) == ActionType.Like) + assert(objectMapper.readValue("\"clone\"", classOf[ActionType]) == ActionType.Clone) + assert(objectMapper.readValue("\"unlike\"", classOf[ActionType]) == ActionType.Unlike) + } + + it should "honor the case-insensitive deserialization via @JsonCreator" in { + assert(objectMapper.readValue("\"VIEW\"", classOf[ActionType]) == ActionType.View) + } + + // --------------------------------------------------------------------------- + // EntityType + // --------------------------------------------------------------------------- + + "EntityType subtypes" should "expose their lowercase string value" in { + assert(EntityType.Workflow.value == "workflow") + assert(EntityType.Dataset.value == "dataset") + } + + it should "have toString equal to value (override pin)" in { + // Same stable-name pattern as ActionType — don't use the SUT + // (toString) in the failure message. + val all: List[EntityType] = List(EntityType.Workflow, EntityType.Dataset) + all.foreach { e => + val name = e.getClass.getSimpleName + assert(e.toString == e.value, s"$name.toString = '${e.toString}' but value = '${e.value}'") + } + } + + "EntityType.fromString" should "match each subtype exactly" in { + assert(EntityType.fromString("workflow") == EntityType.Workflow) + assert(EntityType.fromString("dataset") == EntityType.Dataset) + } + + it should "match case-insensitively" in { + assert(EntityType.fromString("WORKFLOW") == EntityType.Workflow) + assert(EntityType.fromString("Dataset") == EntityType.Dataset) + } + + it should "throw IllegalArgumentException for an unknown kind, naming the input" in { + val ex = intercept[IllegalArgumentException] { + EntityType.fromString("project") + } + assert(ex.getMessage.contains("project")) + } + + it should "throw IllegalArgumentException for an empty string, naming the empty input" in { + val ex = intercept[IllegalArgumentException] { + EntityType.fromString("") + } + assert(ex.getMessage.contains("''"), s"unexpected message: ${ex.getMessage}") + } + + "EntityType Jackson round-trip" should "serialize / deserialize each subtype as its lowercase string value" in { + assert(objectMapper.writeValueAsString(EntityType.Workflow: EntityType) == "\"workflow\"") + assert(objectMapper.writeValueAsString(EntityType.Dataset: EntityType) == "\"dataset\"") + assert(objectMapper.readValue("\"workflow\"", classOf[EntityType]) == EntityType.Workflow) + assert(objectMapper.readValue("\"dataset\"", classOf[EntityType]) == EntityType.Dataset) + } + + // --------------------------------------------------------------------------- + // EntityTables.BaseEntityTable + // --------------------------------------------------------------------------- + + "EntityTables.BaseEntityTable.apply" should "dispatch Workflow → WorkflowTable" in { + val t = EntityTables.BaseEntityTable(EntityType.Workflow) + assert(t == EntityTables.BaseEntityTable.WorkflowTable) + assert(t.table == WORKFLOW) + assert(t.isPublicColumn == WORKFLOW.IS_PUBLIC) + assert(t.idColumn == WORKFLOW.WID) + } + + it should "dispatch Dataset → DatasetTable" in { + val t = EntityTables.BaseEntityTable(EntityType.Dataset) + assert(t == EntityTables.BaseEntityTable.DatasetTable) + assert(t.table == DATASET) + assert(t.isPublicColumn == DATASET.IS_PUBLIC) + assert(t.idColumn == DATASET.DID) + } + + // --------------------------------------------------------------------------- + // EntityTables.LikeTable + // --------------------------------------------------------------------------- + + "EntityTables.LikeTable.apply" should "dispatch Workflow → WorkflowLikeTable" in { + val t = EntityTables.LikeTable(EntityType.Workflow) + assert(t == EntityTables.LikeTable.WorkflowLikeTable) + assert(t.table == WORKFLOW_USER_LIKES) + assert(t.uidColumn == WORKFLOW_USER_LIKES.UID) + assert(t.idColumn == WORKFLOW_USER_LIKES.WID) + } + + it should "dispatch Dataset → DatasetLikeTable" in { + val t = EntityTables.LikeTable(EntityType.Dataset) + assert(t == EntityTables.LikeTable.DatasetLikeTable) + assert(t.table == DATASET_USER_LIKES) + assert(t.uidColumn == DATASET_USER_LIKES.UID) + assert(t.idColumn == DATASET_USER_LIKES.DID) + } + + // --------------------------------------------------------------------------- + // EntityTables.CloneTable (workflow-only today) + // --------------------------------------------------------------------------- + + "EntityTables.CloneTable.apply" should "dispatch Workflow → WorkflowCloneTable" in { + val t = EntityTables.CloneTable(EntityType.Workflow) + assert(t == EntityTables.CloneTable.WorkflowCloneTable) + assert(t.table == WORKFLOW_USER_CLONES) + assert(t.uidColumn == WORKFLOW_USER_CLONES.UID) + assert(t.idColumn == WORKFLOW_USER_CLONES.WID) + } + + it should "throw IllegalArgumentException for Dataset (clone is workflow-only)" in { + // Pin: clone is implemented for workflow only today. A future addition + // of DatasetCloneTable should remove this assertion deliberately. + val ex = intercept[IllegalArgumentException] { + EntityTables.CloneTable(EntityType.Dataset) + } + assert(ex.getMessage.contains("clone")) + } + + // --------------------------------------------------------------------------- + // EntityTables.ViewCountTable + // --------------------------------------------------------------------------- + + "EntityTables.ViewCountTable.apply" should "dispatch Workflow → WorkflowViewCountTable" in { + val t = EntityTables.ViewCountTable(EntityType.Workflow) + assert(t == EntityTables.ViewCountTable.WorkflowViewCountTable) + assert(t.table == WORKFLOW_VIEW_COUNT) + assert(t.idColumn == WORKFLOW_VIEW_COUNT.WID) + assert(t.viewCountColumn == WORKFLOW_VIEW_COUNT.VIEW_COUNT) + } + + it should "dispatch Dataset → DatasetViewCountTable" in { + val t = EntityTables.ViewCountTable(EntityType.Dataset) + assert(t == EntityTables.ViewCountTable.DatasetViewCountTable) + assert(t.table == DATASET_VIEW_COUNT) + assert(t.idColumn == DATASET_VIEW_COUNT.DID) + assert(t.viewCountColumn == DATASET_VIEW_COUNT.VIEW_COUNT) + } +}
