You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@texera.apache.org by gi...@apache.org on 2026/06/07 08:41:16 UTC

(texera) 01/01: test(amber): unit tests for web/resource pure-logic helpers (#5219)

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-5219-4b0decd9762bc98db210e63a4e997c33ec17d72a
in repository https://gitbox.apache.org/repos/asf/texera.git

commit 92f2b51b069ffd84fca5737bc06fbfb693488b6f
Author: Yicong Huang <17...@users.noreply.github.com>
AuthorDate: Sun Jun 7 01:40:54 2026 -0700

    test(amber): unit tests for web/resource pure-logic helpers (#5219)
    
    ### What changes were proposed in this PR?
    
    Add six ScalaTest specs covering a self-contained cluster of pure-logic
    helpers under `amber/src/main/scala/org/apache/texera/web/resource/`
    that ship without direct unit coverage.
    
    - `EmailTemplateSpec` — admin / user branches of
    `userRegistrationNotification`, optional-field fallbacks, role-change
    template per `UserRoleEnum`.
    - `FulltextSearchQueryUtilsSpec` — pgroonga and fallback `to_tsvector`
    predicates, `getContainsFilter` OR + set dedup, `getDateFilter`
    open-ended endpoints + end-of-day bump + `ParseException`,
    `getOperatorsFilter` case-insensitive LIKE.
    - `EntityTablesSpec` and `EntityTypeSpec` — hub dispatch tables for
    `Workflow` and `Dataset`, `CloneTable`'s asymmetric IAE on `Dataset`,
    Jackson `@JsonValue` / `@JsonCreator` round-trip.
    - `HealthCheckResourceSpec` and `SuccessExecutionResultSpec` — the
    trivial contracts that deployment manifests and the frontend
    success-handler rely on.
    
    Pins one quirky edge case in
    `FulltextSearchQueryUtils.getFullTextSearchFilter`:
    `keywords.filter(_.nonEmpty)` is applied before `map(_.trim)`, so a
    whitespace-only keyword survives and produces a degenerate
    `pgroonga_condition('', ...)` predicate. A future fix (move the trim
    into the filter step) would break that pinned test deliberately.
    
    ### Any related issues, documentation, discussions?
    
    Closes #5217.
    
    ### How was this PR tested?
    
    Added unit tests under
    `amber/src/test/scala/org/apache/texera/web/resource/`.
    
    ### Was this PR authored or co-authored using generative AI tooling?
    
    Generated-by: Claude Opus 4.7
---
 .../texera/web/resource/EmailTemplateSpec.scala    | 126 +++++++++++++
 .../web/resource/HealthCheckResourceSpec.scala     |  33 ++++
 .../web/resource/SuccessExecutionResultSpec.scala  |  42 +++++
 .../dashboard/FulltextSearchQueryUtilsSpec.scala   | 200 +++++++++++++++++++++
 .../resource/dashboard/hub/EntityTablesSpec.scala  | 112 ++++++++++++
 .../resource/dashboard/hub/EntityTypeSpec.scala    |  63 +++++++
 6 files changed, 576 insertions(+)

diff --git a/amber/src/test/scala/org/apache/texera/web/resource/EmailTemplateSpec.scala b/amber/src/test/scala/org/apache/texera/web/resource/EmailTemplateSpec.scala
new file mode 100644
index 0000000000..64e6636a36
--- /dev/null
+++ b/amber/src/test/scala/org/apache/texera/web/resource/EmailTemplateSpec.scala
@@ -0,0 +1,126 @@
+/*
+ * 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
+
+import org.apache.texera.dao.jooq.generated.enums.UserRoleEnum
+import org.scalatest.flatspec.AnyFlatSpec
+import org.scalatest.matchers.should.Matchers
+
+class EmailTemplateSpec extends AnyFlatSpec with Matchers {
+
+  // EmailTemplate captures `UserSystemConfig.appDomain` at class init. Under
+  // CI / local test runs no USER_SYS_DOMAIN is set, so `appDomain` is None
+  // and the templates render without the trailing `[$deployment]` suffix.
+  // Cases that require the suffix-present branch would need a JVM-level
+  // env override before EmailTemplate loads — out of scope here.
+
+  // -- userRegistrationNotification (admin branch) ----------------------------
+
+  "userRegistrationNotification(toAdmin = true)" should
+    "produce a subject and receiver and include the requesting user's email" in {
+    val msg = EmailTemplate.userRegistrationNotification(
+      receiverEmail = "admin@example.com",
+      userEmail = Some("alice@example.com"),
+      affiliation = Some("UC Irvine"),
+      reason = Some("research"),
+      toAdmin = true
+    )
+    msg.receiver shouldBe "admin@example.com"
+    msg.subject should startWith("New Account Request Pending Approval")
+    msg.content should include("Email: alice@example.com")
+    msg.content should include("Affiliation: UC Irvine")
+    msg.content should include("Reason: research")
+    msg.content should include("Visit the admin panel at:")
+  }
+
+  it should "fall back to 'Unknown' when userEmail is None" in {
+    val msg = EmailTemplate.userRegistrationNotification(
+      receiverEmail = "admin@example.com",
+      userEmail = None,
+      affiliation = Some("UC Irvine"),
+      reason = Some("research"),
+      toAdmin = true
+    )
+    msg.content should include("Email: Unknown")
+  }
+
+  it should "render 'Not provided' for affiliation/reason when None or whitespace-only" in {
+    val withNone = EmailTemplate.userRegistrationNotification(
+      receiverEmail = "admin@example.com",
+      userEmail = Some("alice@example.com"),
+      affiliation = None,
+      reason = None,
+      toAdmin = true
+    )
+    withNone.content should include("Affiliation: Not provided")
+    withNone.content should include("Reason: Not provided")
+
+    val withBlank = EmailTemplate.userRegistrationNotification(
+      receiverEmail = "admin@example.com",
+      userEmail = Some("alice@example.com"),
+      affiliation = Some("   "),
+      reason = Some(""),
+      toAdmin = true
+    )
+    // The `.filter(_.trim.nonEmpty)` guard treats whitespace-only and empty
+    // strings the same as None.
+    withBlank.content should include("Affiliation: Not provided")
+    withBlank.content should include("Reason: Not provided")
+  }
+
+  // -- userRegistrationNotification (user branch) -----------------------------
+
+  "userRegistrationNotification(toAdmin = false)" should
+    "produce the acknowledgement template addressed to the user" in {
+    val msg = EmailTemplate.userRegistrationNotification(
+      receiverEmail = "alice@example.com",
+      userEmail = Some("ignored@example.com"),
+      affiliation = Some("ignored"),
+      reason = Some("ignored"),
+      toAdmin = false
+    )
+    msg.receiver shouldBe "alice@example.com"
+    msg.subject should startWith("Account Request Received")
+    msg.content should include("Thank you for submitting your account request")
+    // The user-facing template intentionally does NOT echo the admin-only
+    // fields back to the requester — if a refactor accidentally surfaces
+    // them, this assertion will catch the leak.
+    msg.content should not include "Email: ignored"
+    msg.content should not include "Affiliation: ignored"
+    msg.content should not include "Reason: ignored"
+  }
+
+  // -- createRoleChangeTemplate -----------------------------------------------
+
+  "createRoleChangeTemplate" should "embed the new role name in the content" in {
+    val msg = EmailTemplate.createRoleChangeTemplate("alice@example.com", UserRoleEnum.ADMIN)
+    msg.receiver shouldBe "alice@example.com"
+    msg.subject should startWith("Your Role Has Been Updated")
+    msg.content should include("Your user role has been updated to: ADMIN")
+  }
+
+  it should "render distinct content for distinct enum values" in {
+    val admin = EmailTemplate.createRoleChangeTemplate("alice@example.com", UserRoleEnum.ADMIN)
+    val regular = EmailTemplate.createRoleChangeTemplate("alice@example.com", UserRoleEnum.REGULAR)
+    val inactive =
+      EmailTemplate.createRoleChangeTemplate("alice@example.com", UserRoleEnum.INACTIVE)
+    Set(admin.content, regular.content, inactive.content) should have size 3
+  }
+}
diff --git a/amber/src/test/scala/org/apache/texera/web/resource/HealthCheckResourceSpec.scala b/amber/src/test/scala/org/apache/texera/web/resource/HealthCheckResourceSpec.scala
new file mode 100644
index 0000000000..1dfe717f42
--- /dev/null
+++ b/amber/src/test/scala/org/apache/texera/web/resource/HealthCheckResourceSpec.scala
@@ -0,0 +1,33 @@
+/*
+ * 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
+
+import org.scalatest.flatspec.AnyFlatSpec
+import org.scalatest.matchers.should.Matchers
+
+class HealthCheckResourceSpec extends AnyFlatSpec with Matchers {
+
+  // The /healthcheck endpoint is what readiness probes hit. The contract is
+  // an exact `{"status": "ok"}` JSON body; any change here would also need
+  // a coordinated update in deployment manifests.
+  "HealthCheckResource.healthCheck" should "return the canonical status map" in {
+    new HealthCheckResource().healthCheck shouldBe Map("status" -> "ok")
+  }
+}
diff --git a/amber/src/test/scala/org/apache/texera/web/resource/SuccessExecutionResultSpec.scala b/amber/src/test/scala/org/apache/texera/web/resource/SuccessExecutionResultSpec.scala
new file mode 100644
index 0000000000..f013109e3c
--- /dev/null
+++ b/amber/src/test/scala/org/apache/texera/web/resource/SuccessExecutionResultSpec.scala
@@ -0,0 +1,42 @@
+/*
+ * 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
+
+import org.scalatest.flatspec.AnyFlatSpec
+import org.scalatest.matchers.should.Matchers
+
+class SuccessExecutionResultSpec extends AnyFlatSpec with Matchers {
+
+  "SuccessExecutionResult" should "default code to 0 and result to an empty list" in {
+    // The defaults are the success-path values that JSON consumers (the
+    // frontend execution-result handler) treat as "ok with no payload".
+    // A change to either default would silently shift contract semantics.
+    val r = SuccessExecutionResult(resultID = "abc")
+    r.resultID shouldBe "abc"
+    r.code shouldBe 0
+    r.result shouldBe List.empty
+  }
+
+  it should "carry the passed code and result list when provided" in {
+    val r = SuccessExecutionResult(resultID = "xyz", code = 1, result = List("a", "b"))
+    r.code shouldBe 1
+    r.result shouldBe List("a", "b")
+  }
+}
diff --git a/amber/src/test/scala/org/apache/texera/web/resource/dashboard/FulltextSearchQueryUtilsSpec.scala b/amber/src/test/scala/org/apache/texera/web/resource/dashboard/FulltextSearchQueryUtilsSpec.scala
new file mode 100644
index 0000000000..aec49bc396
--- /dev/null
+++ b/amber/src/test/scala/org/apache/texera/web/resource/dashboard/FulltextSearchQueryUtilsSpec.scala
@@ -0,0 +1,200 @@
+/*
+ * 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
+
+import org.jooq.impl.{DSL => JDSL}
+import org.jooq.{Condition, Field, SQLDialect}
+import org.scalatest.BeforeAndAfter
+import org.scalatest.flatspec.AnyFlatSpec
+import org.scalatest.matchers.should.Matchers
+
+import java.sql.Timestamp
+import java.text.{ParseException, SimpleDateFormat}
+import java.util.concurrent.TimeUnit
+
+class FulltextSearchQueryUtilsSpec extends AnyFlatSpec with Matchers with BeforeAndAfter {
+
+  // jOOQ render context (Postgres dialect to match production renderers).
+  private val ctx = JDSL.using(SQLDialect.POSTGRES)
+
+  private def sqlOf(c: Condition): String = ctx.renderInlined(c)
+
+  // The `usePgroonga` flag is shared mutable global state — restore it
+  // around every test so a failed assertion in the fallback branch can't
+  // leave the rest of the suite running against the wrong branch.
+  private val pgroongaDefault = FulltextSearchQueryUtils.usePgroonga
+  after { FulltextSearchQueryUtils.usePgroonga = pgroongaDefault }
+
+  // -- getFullTextSearchFilter ------------------------------------------------
+
+  "getFullTextSearchFilter" should "return noCondition when fields list is empty" in {
+    val cond = FulltextSearchQueryUtils.getFullTextSearchFilter(Seq("anything"), List.empty)
+    sqlOf(cond) shouldBe sqlOf(JDSL.noCondition())
+  }
+
+  it should "return noCondition when all keywords are empty strings" in {
+    val field: Field[String] = JDSL.field("name", classOf[String])
+    val cond = FulltextSearchQueryUtils.getFullTextSearchFilter(Seq(""), List(field))
+    sqlOf(cond) shouldBe sqlOf(JDSL.noCondition())
+  }
+
+  it should "currently still build a pgroonga predicate for whitespace-only keywords (subtle quirk)" in {
+    // `keywords.filter(_.nonEmpty)` checks for empty BEFORE trimming, so
+    // a "   " input survives the filter and is trimmed to "" — but only
+    // after the emptiness check. The resulting predicate searches for the
+    // empty string. Pin the behavior so a future fix (move the trim into
+    // the filter step) deliberately breaks this test.
+    FulltextSearchQueryUtils.usePgroonga = true
+    val field: Field[String] = JDSL.field("name", classOf[String])
+    val cond = FulltextSearchQueryUtils.getFullTextSearchFilter(Seq("   "), List(field))
+    sqlOf(cond) should include("pgroonga_condition('',")
+  }
+
+  it should "emit a pgroonga fuzzy-match expression when usePgroonga is true" in {
+    FulltextSearchQueryUtils.usePgroonga = true
+    val name: Field[String] = JDSL.field("name", classOf[String])
+    val desc: Field[String] = JDSL.field("description", classOf[String])
+    val cond = FulltextSearchQueryUtils.getFullTextSearchFilter(
+      Seq("alpha", "beta"),
+      List(name, desc)
+    )
+    val sql = sqlOf(cond)
+    sql should include("&@~")
+    sql should include("pgroonga_condition")
+    sql should include("fuzzy_max_distance_ratio")
+    // Field COALESCE chain — joined with " || ' ' || " across all fields.
+    sql should include("COALESCE")
+  }
+
+  it should "emit a to_tsvector / to_tsquery chain when usePgroonga is false" in {
+    FulltextSearchQueryUtils.usePgroonga = false
+    val name: Field[String] = JDSL.field("name", classOf[String])
+    val cond = FulltextSearchQueryUtils.getFullTextSearchFilter(
+      Seq("hello world", "goodbye"),
+      List(name)
+    )
+    val sql = sqlOf(cond)
+    sql should include("to_tsvector")
+    sql should include("to_tsquery")
+    // Multi-word keyword "hello world" should produce "hello & world" inside
+    // the tsquery; this is what makes the fallback path act like an AND
+    // across tokens of the same keyword.
+    sql should include("hello & world")
+  }
+
+  // -- getContainsFilter ------------------------------------------------------
+
+  "getContainsFilter" should "OR together field-equality checks for each unique value" in {
+    val field: Field[Integer] = JDSL.field("uid", classOf[Integer])
+    val values = new java.util.ArrayList[Integer]()
+    values.add(1)
+    values.add(2)
+    values.add(2) // duplicate — set conversion should collapse it
+    val cond = FulltextSearchQueryUtils.getContainsFilter(values, field)
+    val sql = sqlOf(cond)
+    sql should include("uid = 1")
+    sql should include("uid = 2")
+    sql should include(" or ")
+    // Duplicate dedup: only two distinct equality clauses, so exactly one
+    // ` or ` separator. (Render strips into a single chained OR.)
+    sql.split(" or ").length shouldBe 2
+  }
+
+  it should "return noCondition for an empty values list" in {
+    val field: Field[Integer] = JDSL.field("uid", classOf[Integer])
+    val empty = new java.util.ArrayList[Integer]()
+    val cond = FulltextSearchQueryUtils.getContainsFilter(empty, field)
+    sqlOf(cond) shouldBe sqlOf(JDSL.noCondition())
+  }
+
+  // -- getDateFilter ----------------------------------------------------------
+
+  "getDateFilter" should "produce a BETWEEN-shaped predicate for both endpoints" in {
+    val field: Field[Timestamp] = JDSL.field("created_at", classOf[Timestamp])
+    val cond = FulltextSearchQueryUtils.getDateFilter("2026-01-01", "2026-01-31", field)
+    val sql = sqlOf(cond)
+    sql should include("between")
+    sql should include("2026-01-01 00:00:00")
+    // The end timestamp is bumped to "+1 day - 1ms" so a range ending on a
+    // calendar date is inclusive through end-of-day. JOOQ renders the
+    // Timestamp millis as `.999`.
+    sql should include("2026-01-31 23:59:59")
+  }
+
+  it should "default to the open-ended sentinel dates when one endpoint is empty" in {
+    val field: Field[Timestamp] = JDSL.field("created_at", classOf[Timestamp])
+    val onlyEnd = FulltextSearchQueryUtils.getDateFilter("", "2026-01-31", field)
+    sqlOf(onlyEnd) should include("1970-01-01")
+
+    val onlyStart = FulltextSearchQueryUtils.getDateFilter("2026-01-01", "", field)
+    // 9999-12-31 is the "no end" sentinel; for this case the code skips the
+    // +1-day bump (rendering the date as-is).
+    sqlOf(onlyStart) should include("9999-12-31")
+  }
+
+  it should "return noCondition when both endpoints are empty" in {
+    val field: Field[Timestamp] = JDSL.field("created_at", classOf[Timestamp])
+    val cond = FulltextSearchQueryUtils.getDateFilter("", "", field)
+    sqlOf(cond) shouldBe sqlOf(JDSL.noCondition())
+  }
+
+  it should "throw ParseException for a malformed start date" in {
+    val field: Field[Timestamp] = JDSL.field("created_at", classOf[Timestamp])
+    a[ParseException] should be thrownBy
+      FulltextSearchQueryUtils.getDateFilter("not-a-date", "2026-01-31", field)
+  }
+
+  // Sanity check that the SimpleDateFormat used inside getDateFilter parses
+  // the documented format — guards against a future locale-dependent bug.
+  it should "accept the documented yyyy-MM-dd format" in {
+    val parsed = new SimpleDateFormat("yyyy-MM-dd").parse("2026-01-01")
+    val ts = new Timestamp(parsed.getTime + TimeUnit.DAYS.toMillis(0))
+    ts.getTime should be > 0L
+  }
+
+  // -- getOperatorsFilter -----------------------------------------------------
+
+  "getOperatorsFilter" should "build a case-insensitive LIKE pattern around \"operatorType\":\"$op\"" in {
+    val field: Field[String] = JDSL.field("content", classOf[String])
+    val ops = new java.util.ArrayList[String]()
+    ops.add("CSVScan")
+    ops.add("CSVScan") // duplicate
+    val cond = FulltextSearchQueryUtils.getOperatorsFilter(ops, field)
+    val sql = sqlOf(cond)
+    sql.toLowerCase should include("ilike")
+    // The pattern wraps the operator name in the literal JSON shape that
+    // appears in workflow.content blobs — including the surrounding quotes.
+    sql should include("\"operatorType\":\"CSVScan\"")
+    // De-duplication: only one ILIKE term despite two inputs.
+    sql.toLowerCase.split("ilike").length shouldBe 2
+  }
+
+  it should "OR together patterns for distinct operators" in {
+    val field: Field[String] = JDSL.field("content", classOf[String])
+    val ops = new java.util.ArrayList[String]()
+    ops.add("CSVScan")
+    ops.add("Filter")
+    val cond = FulltextSearchQueryUtils.getOperatorsFilter(ops, field)
+    val sql = sqlOf(cond)
+    sql should include("CSVScan")
+    sql should include("Filter")
+    sql.toLowerCase should include(" or ")
+  }
+}
diff --git a/amber/src/test/scala/org/apache/texera/web/resource/dashboard/hub/EntityTablesSpec.scala b/amber/src/test/scala/org/apache/texera/web/resource/dashboard/hub/EntityTablesSpec.scala
new file mode 100644
index 0000000000..5be87fdbd0
--- /dev/null
+++ b/amber/src/test/scala/org/apache/texera/web/resource/dashboard/hub/EntityTablesSpec.scala
@@ -0,0 +1,112 @@
+/*
+ * 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.scalatest.flatspec.AnyFlatSpec
+import org.scalatest.matchers.should.Matchers
+
+class EntityTablesSpec extends AnyFlatSpec with Matchers {
+
+  // -- BaseEntityTable --------------------------------------------------------
+
+  "EntityTables.BaseEntityTable.apply" should "dispatch Workflow → WorkflowTable" in {
+    EntityTables.BaseEntityTable(EntityType.Workflow) shouldBe
+      EntityTables.BaseEntityTable.WorkflowTable
+  }
+
+  it should "dispatch Dataset → DatasetTable" in {
+    EntityTables.BaseEntityTable(EntityType.Dataset) shouldBe
+      EntityTables.BaseEntityTable.DatasetTable
+  }
+
+  "BaseEntityTable.WorkflowTable" should "wire up id and isPublic columns from WORKFLOW" in {
+    val t = EntityTables.BaseEntityTable.WorkflowTable
+    t.idColumn.getName shouldBe "wid"
+    t.isPublicColumn.getName shouldBe "is_public"
+  }
+
+  "BaseEntityTable.DatasetTable" should "wire up id and isPublic columns from DATASET" in {
+    val t = EntityTables.BaseEntityTable.DatasetTable
+    t.idColumn.getName shouldBe "did"
+    t.isPublicColumn.getName shouldBe "is_public"
+  }
+
+  // -- LikeTable --------------------------------------------------------------
+
+  "EntityTables.LikeTable.apply" should "dispatch Workflow → WorkflowLikeTable" in {
+    EntityTables.LikeTable(EntityType.Workflow) shouldBe
+      EntityTables.LikeTable.WorkflowLikeTable
+  }
+
+  it should "dispatch Dataset → DatasetLikeTable" in {
+    EntityTables.LikeTable(EntityType.Dataset) shouldBe
+      EntityTables.LikeTable.DatasetLikeTable
+  }
+
+  "LikeTable variants" should "expose uid and the per-entity id column" in {
+    val w = EntityTables.LikeTable.WorkflowLikeTable
+    w.uidColumn.getName shouldBe "uid"
+    w.idColumn.getName shouldBe "wid"
+
+    val d = EntityTables.LikeTable.DatasetLikeTable
+    d.uidColumn.getName shouldBe "uid"
+    d.idColumn.getName shouldBe "did"
+  }
+
+  // -- CloneTable -------------------------------------------------------------
+
+  "EntityTables.CloneTable.apply" should "dispatch Workflow → WorkflowCloneTable" in {
+    EntityTables.CloneTable(EntityType.Workflow) shouldBe
+      EntityTables.CloneTable.WorkflowCloneTable
+  }
+
+  it should "throw IllegalArgumentException for Dataset because there is no DatasetClone table" in {
+    // The asymmetry is intentional today: dataset clones aren't a modelled
+    // entity. Pinning the exception so a future addition of DatasetCloneTable
+    // forces this spec to be updated alongside the new dispatch branch.
+    val ex = intercept[IllegalArgumentException] {
+      EntityTables.CloneTable(EntityType.Dataset)
+    }
+    ex.getMessage should include("Unsupported entity type")
+    ex.getMessage should include("clone")
+  }
+
+  // -- ViewCountTable ---------------------------------------------------------
+
+  "EntityTables.ViewCountTable.apply" should "dispatch Workflow → WorkflowViewCountTable" in {
+    EntityTables.ViewCountTable(EntityType.Workflow) shouldBe
+      EntityTables.ViewCountTable.WorkflowViewCountTable
+  }
+
+  it should "dispatch Dataset → DatasetViewCountTable" in {
+    EntityTables.ViewCountTable(EntityType.Dataset) shouldBe
+      EntityTables.ViewCountTable.DatasetViewCountTable
+  }
+
+  "ViewCountTable variants" should "expose id and view_count columns" in {
+    val w = EntityTables.ViewCountTable.WorkflowViewCountTable
+    w.idColumn.getName shouldBe "wid"
+    w.viewCountColumn.getName shouldBe "view_count"
+
+    val d = EntityTables.ViewCountTable.DatasetViewCountTable
+    d.idColumn.getName shouldBe "did"
+    d.viewCountColumn.getName shouldBe "view_count"
+  }
+}
diff --git a/amber/src/test/scala/org/apache/texera/web/resource/dashboard/hub/EntityTypeSpec.scala b/amber/src/test/scala/org/apache/texera/web/resource/dashboard/hub/EntityTypeSpec.scala
new file mode 100644
index 0000000000..2817aa75b1
--- /dev/null
+++ b/amber/src/test/scala/org/apache/texera/web/resource/dashboard/hub/EntityTypeSpec.scala
@@ -0,0 +1,63 @@
+/*
+ * 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 com.fasterxml.jackson.databind.ObjectMapper
+import org.scalatest.flatspec.AnyFlatSpec
+import org.scalatest.matchers.should.Matchers
+
+class EntityTypeSpec extends AnyFlatSpec with Matchers {
+
+  "EntityType.value" should "be the lowercase form of the case-object name" in {
+    EntityType.Workflow.value shouldBe "workflow"
+    EntityType.Dataset.value shouldBe "dataset"
+  }
+
+  "EntityType.toString" should "mirror value (used by error messages and SQL strings)" in {
+    EntityType.Workflow.toString shouldBe "workflow"
+    EntityType.Dataset.toString shouldBe "dataset"
+  }
+
+  "EntityType.fromString" should "round-trip the canonical lowercase value" in {
+    EntityType.fromString("workflow") shouldBe EntityType.Workflow
+    EntityType.fromString("dataset") shouldBe EntityType.Dataset
+  }
+
+  it should "accept mixed case (equalsIgnoreCase)" in {
+    EntityType.fromString("Workflow") shouldBe EntityType.Workflow
+    EntityType.fromString("DATASET") shouldBe EntityType.Dataset
+  }
+
+  it should "throw IllegalArgumentException for an unknown value" in {
+    val ex = intercept[IllegalArgumentException] {
+      EntityType.fromString("project")
+    }
+    ex.getMessage should include("project")
+  }
+
+  // The @JsonValue / @JsonCreator pair drives Jackson serialisation. Cover
+  // the round-trip so a future @JsonValue rename can't silently change the
+  // wire format.
+  "Jackson serialisation" should "render an EntityType as its lowercase value and parse it back" in {
+    val mapper = new ObjectMapper()
+    mapper.writeValueAsString(EntityType.Workflow: EntityType) shouldBe "\"workflow\""
+    mapper.readValue("\"dataset\"", classOf[EntityType]) shouldBe EntityType.Dataset
+  }
+}