From 6e5dac10d11f133525910bce8e0d091930cf9676 Mon Sep 17 00:00:00 2001 From: Yicong Huang <17627829+Yicong-Huang@users.noreply.github.com> Date: Mon, 25 May 2026 22:49:56 -0700 Subject: [PATCH] test(amber): unit tests for web/resource pure-logic helpers Covers a self-contained cluster of pure-logic helpers under org.apache.texera.web.resource that ship without direct unit coverage: EmailTemplate (admin/user registration branches + role change), FulltextSearchQueryUtils (pgroonga and fallback search predicates, contains / date / operators filters), EntityTables + EntityType (hub dispatch and Jackson round-trip), HealthCheckResource, and SuccessExecutionResult. Pins one whitespace-only-keyword edge case in FulltextSearchQueryUtils whose filter-before-trim ordering produces a degenerate empty-string pgroonga predicate; a future tightening should break that pinned test deliberately. --- .../web/resource/EmailTemplateSpec.scala | 126 +++++++++++ .../resource/HealthCheckResourceSpec.scala | 33 +++ .../resource/SuccessExecutionResultSpec.scala | 42 ++++ .../FulltextSearchQueryUtilsSpec.scala | 200 ++++++++++++++++++ .../dashboard/hub/EntityTablesSpec.scala | 112 ++++++++++ .../dashboard/hub/EntityTypeSpec.scala | 63 ++++++ 6 files changed, 576 insertions(+) create mode 100644 amber/src/test/scala/org/apache/texera/web/resource/EmailTemplateSpec.scala create mode 100644 amber/src/test/scala/org/apache/texera/web/resource/HealthCheckResourceSpec.scala create mode 100644 amber/src/test/scala/org/apache/texera/web/resource/SuccessExecutionResultSpec.scala create mode 100644 amber/src/test/scala/org/apache/texera/web/resource/dashboard/FulltextSearchQueryUtilsSpec.scala create mode 100644 amber/src/test/scala/org/apache/texera/web/resource/dashboard/hub/EntityTablesSpec.scala create mode 100644 amber/src/test/scala/org/apache/texera/web/resource/dashboard/hub/EntityTypeSpec.scala 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 00000000000..64e6636a367 --- /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 00000000000..1dfe717f42d --- /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 00000000000..f013109e3c4 --- /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 00000000000..aec49bc396c --- /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 00000000000..5be87fdbd0e --- /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 00000000000..2817aa75b1f --- /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 + } +}