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:45:29 UTC

(texera) branch gh-readonly-queue/main/pr-5219-65ce1e52d10b657338b41f220f2326d3fad86a3e created (now ef171b7349)

This is an automated email from the ASF dual-hosted git repository.

github-merge-queue[bot] pushed a change to branch gh-readonly-queue/main/pr-5219-65ce1e52d10b657338b41f220f2326d3fad86a3e
in repository https://gitbox.apache.org/repos/asf/texera.git


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

This branch includes the following new commits:

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

The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.



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

Posted by gi...@apache.org.
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-65ce1e52d10b657338b41f220f2326d3fad86a3e
in repository https://gitbox.apache.org/repos/asf/texera.git

commit ef171b73490115e94424d1ad0e4c7c6166e94224
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
+  }
+}