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