From 22d0b4fa86f3ba7745f49cc98824b17e326523ce Mon Sep 17 00:00:00 2001 From: Xinyuan Lin Date: Wed, 29 Jul 2026 21:26:31 -0700 Subject: [PATCH] test(amber): add no-DB specs for the dashboard search query tier FulltextSearchQueryUtilsSpec was the only spec in the dashboard package. It also proves the approach: a dialect-only `DSL.using(SQLDialect.POSTGRES)` context renders jOOQ Conditions with no connection at all. These three specs extend that to the parts of the tier that are genuinely connection-free. - DashboardResourceSpec: getOrderFields is public and pure. Existing coverage (incidental, via WorkflowResourceSpec) only reached NameAsc/Desc, ExecutionTimeAsc/Desc and EditTime-via-default; this adds CreateTime both ways and the non-matching-orderBy fall-through. It also pins the asc/desc asymmetry -- Desc emits `desc nulls last` while Asc emits a bare `asc` -- which is what makes the behaviour symmetric, since Postgres defaults to NULLS LAST for ASC and NULLS FIRST for DESC. Plus the unknown-resourceType IllegalArgumentException, which is raised before any query is built and so needs no database. - WorkflowSearchQueryBuilderSpec: toEntryImpl and mappedResourceSchema are callable because this object widens the trait's `protected` to public. Covers the projects-aggregate lookup (which rides on jOOQ structural Field equality), both branches of the comma-split, the NULL-privilege fallback to NONE, and the ownership flag both ways. - UnifiedResourceSchemaSpec: the 24-slot projection and the keep-first de-duplication behind allFields, whose counts were measured rather than assumed (24 aliases, collapsing to 6 for the all-defaults schema and 17 for the workflow schema). Assertion strength was checked by mutation: reverting `nulls last`, swapping an order field, inverting the isOwner comparison, changing the privilege fallback, and disabling the de-dup guard each turn tests red. No production file is touched. --- .../dashboard/DashboardResourceSpec.scala | 147 ++++++++++ .../dashboard/UnifiedResourceSchemaSpec.scala | 252 ++++++++++++++++++ .../WorkflowSearchQueryBuilderSpec.scala | 228 ++++++++++++++++ 3 files changed, 627 insertions(+) create mode 100644 amber/src/test/scala/org/apache/texera/web/resource/dashboard/DashboardResourceSpec.scala create mode 100644 amber/src/test/scala/org/apache/texera/web/resource/dashboard/UnifiedResourceSchemaSpec.scala create mode 100644 amber/src/test/scala/org/apache/texera/web/resource/dashboard/WorkflowSearchQueryBuilderSpec.scala diff --git a/amber/src/test/scala/org/apache/texera/web/resource/dashboard/DashboardResourceSpec.scala b/amber/src/test/scala/org/apache/texera/web/resource/dashboard/DashboardResourceSpec.scala new file mode 100644 index 00000000000..279e04ed585 --- /dev/null +++ b/amber/src/test/scala/org/apache/texera/web/resource/dashboard/DashboardResourceSpec.scala @@ -0,0 +1,147 @@ +/* + * 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.apache.texera.auth.SessionUser +import org.apache.texera.dao.jooq.generated.tables.pojos.User +import org.apache.texera.web.resource.dashboard.DashboardResource.SearchQueryParams +import org.jooq.impl.{DSL => JDSL} +import org.jooq.{OrderField, SQLDialect} + +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers + +/** + * Covers the connection-free surface of [[DashboardResource]]: the `orderBy` + * translation and the resource-type dispatch guard. + * + * Breakage this catches: + * - a column of the `orderBy` grammar wired to the wrong unified-schema + * alias (e.g. `EditTime` sorting by creation time), which silently + * re-orders the dashboard instead of failing; + * - the `nullsLast()` on the descending branch being dropped or copied onto + * the ascending branch — under Postgres that flips where rows with a NULL + * execution time land; + * - the regex losing its implicit anchoring (`Regex.unapplySeq` requires a + * full match), which would start accepting junk like `SortByNameAsc`; + * - an unrecognised `orderBy` no longer degrading to "no ORDER BY", or the + * unknown-resourceType guard no longer throwing before the query is built. + * + * Note on the shared global `FulltextSearchQueryUtils.usePgroonga`: nothing + * exercised here reads or writes it, so this spec neither depends on its value + * nor mutates it (a "restore" would itself be a write, and amber's suites + * share one JVM). + */ +class DashboardResourceSpec extends AnyFlatSpec with Matchers { + + // jOOQ render context (Postgres dialect to match production renderers). + // Dialect-only DSLContext — it never opens a connection. + private val ctx = JDSL.using(SQLDialect.POSTGRES) + + private def sqlOf(o: OrderField[_]): String = ctx.renderInlined(o) + + private def orderSql(orderBy: String): List[String] = + DashboardResource.getOrderFields(SearchQueryParams(orderBy = orderBy)).map(sqlOf) + + // -- getOrderFields: the recognised grammar --------------------------------- + + "getOrderFields" should "map each recognised column onto its unified-schema alias, ascending" in { + // The rendered identifier is the alias of the UNION-ALL projection, not a + // physical column: sorting happens after the three sub-queries are merged. + orderSql("NameAsc") shouldBe List("\"resourceName\" asc") + orderSql("CreateTimeAsc") shouldBe List("\"resourceCreationTime\" asc") + orderSql("EditTimeAsc") shouldBe List("\"resourceLastModifiedTime\" asc") + orderSql("ExecutionTimeAsc") shouldBe List("\"resourceExecutionTime\" asc") + } + + it should "map each recognised column onto its unified-schema alias, descending" in { + orderSql("NameDesc") shouldBe List("\"resourceName\" desc nulls last") + orderSql("CreateTimeDesc") shouldBe List("\"resourceCreationTime\" desc nulls last") + orderSql("EditTimeDesc") shouldBe List("\"resourceLastModifiedTime\" desc nulls last") + orderSql("ExecutionTimeDesc") shouldBe List("\"resourceExecutionTime\" desc nulls last") + } + + it should "spell out nulls-last only on the descending branch" in { + // `Desc` goes through `value.desc().nullsLast()` while `Asc` is a bare + // `value.asc()`. The asymmetry in the code is what makes the *semantics* + // symmetric under Postgres, whose defaults are NULLS LAST for ASC and + // NULLS FIRST for DESC: workflows that never ran (NULL executionTime) + // therefore sort to the end in both directions. Removing `nullsLast()` + // would quietly float them to the top of the descending listing. + val asc = orderSql("ExecutionTimeAsc").head + val desc = orderSql("ExecutionTimeDesc").head + asc should not include "nulls" + desc should include("nulls last") + } + + it should "emit exactly one ORDER BY term" in { + // A second term would change pagination stability; the contract is one + // field per request. + DashboardResource.getOrderFields( + SearchQueryParams(orderBy = "CreateTimeDesc") + ) should have size 1 + } + + // -- getOrderFields: everything the grammar rejects ------------------------- + + it should "fall back to no ordering at all when orderBy does not match the pattern" in { + // Consequence of the empty list: the query runs unordered while + // offset/limit still apply, so pagination silently becomes + // non-deterministic. Pin the inputs that take this branch. + orderSql("") shouldBe empty // the frontend can send an empty query param + orderSql("Bogus") shouldBe empty + orderSql("NameSideways") shouldBe empty // known column, junk direction + orderSql("Name") shouldBe empty // direction missing entirely + orderSql("nameAsc") shouldBe empty // the regex is case-sensitive + orderSql("SortByNameAsc") shouldBe empty // Regex.unapplySeq anchors the + orderSql("NameAscending") shouldBe empty // whole string, both ends + } + + // Two branches in this area are unreachable and are therefore deliberately + // NOT tested: `case None => List()` inside getOrderFields and + // `case _ => null` inside the private getColumnField. The regex can only + // yield the four column names Name/CreateTime/EditTime/ExecutionTime, and + // every one of them maps to a non-null Field, so getColumnField never + // returns None. + + // -- searchAllResources: the dispatch guard -------------------------------- + + "searchAllResources" should "reject an unknown resourceType before it builds any query" in { + // The `case _ => throw` arm sits in the dispatch match, ahead of + // constructQuery / SqlServer, so this is reachable with no database. The + // exception *type* is the real assertion: if the guard were moved below the + // query construction, this would surface as a SqlServer failure instead. + val thrown = the[IllegalArgumentException] thrownBy DashboardResource.searchAllResources( + new SessionUser(new User()), // uid stays null; unused on this path + SearchQueryParams(resourceType = "notAResourceType") + ) + thrown.getMessage should include("notAResourceType") + } + + it should "reject a resourceType that only differs from a valid one by case" in { + // Guards against someone relaxing the match to be case-insensitive on one + // side only: "Workflow" is not "workflow" today. + val thrown = the[IllegalArgumentException] thrownBy DashboardResource.searchAllResources( + new SessionUser(new User()), + SearchQueryParams(resourceType = "Workflow") + ) + thrown.getMessage should include("Workflow") + } +} diff --git a/amber/src/test/scala/org/apache/texera/web/resource/dashboard/UnifiedResourceSchemaSpec.scala b/amber/src/test/scala/org/apache/texera/web/resource/dashboard/UnifiedResourceSchemaSpec.scala new file mode 100644 index 00000000000..9d5f1e0b0bd --- /dev/null +++ b/amber/src/test/scala/org/apache/texera/web/resource/dashboard/UnifiedResourceSchemaSpec.scala @@ -0,0 +1,252 @@ +/* + * 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.apache.texera.dao.jooq.generated.Tables._ +import org.apache.texera.dao.jooq.generated.enums.PrivilegeEnum +import org.jooq.impl.{DSL => JDSL} +import org.jooq.{Field, SQLDialect} + +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers + +import java.sql.Timestamp + +/** + * Covers `UnifiedResourceSchema.apply` — the projection every search builder + * unions into — and the de-duplication it performs behind `allFields`. + * + * Breakage this catches: + * - a projection slot wired to the wrong alias. Note the builders cannot drift + * from each other: the class constructor is private and every builder goes + * through this one `apply`, which pairs each expression with its own alias in + * the same tuple. So the slot-order assertion here is a lock on a centrally + * fixed projection shape (which `searchAllResources` unions positionally), + * not protection against one builder reordering columns — that is impossible; + * - the de-dup in `translatedFieldSet` changing from "keep the first alias + * per distinct original Field" to keep-last (or disappearing), which + * changes which alias `translateRecord` reads values out of; + * - jOOQ no longer comparing Fields structurally, which is the sole reason + * the de-dup collapses anything at all. + * + * `translateRecord` itself is out of scope: it calls `context.newRecord`, and + * `context` is `SqlServer.getInstance().createDSLContext()`, so exercising it + * would need a live database. Nothing here reads or writes the shared global + * `FulltextSearchQueryUtils.usePgroonga`. + */ +class UnifiedResourceSchemaSpec extends AnyFlatSpec with Matchers { + + // jOOQ render context (Postgres dialect to match production renderers). + private val ctx = JDSL.using(SQLDialect.POSTGRES) + + // `translatedFieldSet` is a `private val` whose only public consumer + // (translateRecord) needs a database, so read it reflectively rather than + // standing one up. Keeping this observable is the point: the de-dup is what + // decides which aliases ever reach a translated record. + private val translatedFieldSetAccessor = { + val field = classOf[UnifiedResourceSchema].getDeclaredField("translatedFieldSet") + field.setAccessible(true) + field + } + + private def translatedPairs(schema: UnifiedResourceSchema): Seq[(Field[_], Field[_])] = + translatedFieldSetAccessor + .get(schema) + .asInstanceOf[Seq[(Field[_], Field[_])]] + + private def translatedAliases(schema: UnifiedResourceSchema): Seq[String] = + translatedPairs(schema).map(_._2.getName) + + private def translatedOriginals(schema: UnifiedResourceSchema): Seq[Field[_]] = + translatedPairs(schema).map(_._1) + + // Sentinels for the three slots that have no convenient distinct table + // column of the right type; every other slot uses a real generated column so + // that all 24 originals render differently from one another. + private val sentinelResourceType: Field[String] = JDSL.inline("s-resource-type") + private val sentinelProjects: Field[String] = JDSL.inline("s-projects") + private val sentinelStoragePath: Field[String] = JDSL.inline("s-storage-path") + + private val sentinelSchema: UnifiedResourceSchema = UnifiedResourceSchema( + resourceType = sentinelResourceType, + name = WORKFLOW.NAME, + description = DATASET.DESCRIPTION, + creationTime = WORKFLOW.CREATION_TIME, + lastModifiedTime = WORKFLOW.LAST_MODIFIED_TIME, + executionTime = WORKFLOW_EXECUTIONS.STARTING_TIME, + ownerId = WORKFLOW_OF_USER.UID, + wid = WORKFLOW.WID, + workflowUserAccess = WORKFLOW_USER_ACCESS.PRIVILEGE, + projectsOfWorkflow = sentinelProjects, + uid = USER.UID, + userName = USER.NAME, + userEmail = USER.EMAIL, + pid = PROJECT.PID, + projectOwnerId = PROJECT.OWNER_ID, + projectColor = PROJECT.COLOR, + did = DATASET.DID, + datasetStoragePath = sentinelStoragePath, + repositoryName = DATASET.REPOSITORY_NAME, + isDatasetPublic = DATASET.IS_PUBLIC, + isDatasetDownloadable = DATASET.IS_DOWNLOADABLE, + datasetUserAccess = DATASET_USER_ACCESS.PRIVILEGE, + datasetCoverImage = DATASET.COVER_IMAGE, + workflowCoverImage = WORKFLOW_COVER_IMAGE.IMAGE + ) + + // Expected projection, in order: alias -> the original it must be built from. + private val expectedProjection: Seq[(String, Field[_])] = Seq( + "resourceType" -> sentinelResourceType, + "resourceName" -> WORKFLOW.NAME, + "resourceDescription" -> DATASET.DESCRIPTION, + "resourceCreationTime" -> WORKFLOW.CREATION_TIME, + "resourceLastModifiedTime" -> WORKFLOW.LAST_MODIFIED_TIME, + "resourceExecutionTime" -> WORKFLOW_EXECUTIONS.STARTING_TIME, + "resourceOwnerId" -> WORKFLOW_OF_USER.UID, + "wid" -> WORKFLOW.WID, + "workflow_privilege" -> WORKFLOW_USER_ACCESS.PRIVILEGE, + "projects" -> sentinelProjects, + "uid" -> USER.UID, + "userName" -> USER.NAME, + "email" -> USER.EMAIL, + "pid" -> PROJECT.PID, + "owner_uid" -> PROJECT.OWNER_ID, + "color" -> PROJECT.COLOR, + "did" -> DATASET.DID, + "dataset_storage_path" -> sentinelStoragePath, + "repository_name" -> DATASET.REPOSITORY_NAME, + "is_dataset_public" -> DATASET.IS_PUBLIC, + "is_dataset_downloadable" -> DATASET.IS_DOWNLOADABLE, + "user_dataset_access" -> DATASET_USER_ACCESS.PRIVILEGE, + "cover_image" -> DATASET.COVER_IMAGE, + "workflow_cover_image" -> WORKFLOW_COVER_IMAGE.IMAGE + ) + + // -- apply(): the projection ------------------------------------------------ + + "apply" should "expose all 24 slots as aliases, in the order the UNION ALL depends on" in { + sentinelSchema.allFields should have size 24 + sentinelSchema.allFields.map(_.getName) shouldBe expectedProjection.map(_._1) + } + + it should "alias each original to its own slot" in { + // Rendered as a SELECT because that is how constructQuery consumes + // allFields; a swapped pair (`name -> description.as(resourceNameAlias)`) + // shows up here even though the alias order stays intact. + val rendered = ctx.renderInlined(JDSL.select(sentinelSchema.allFields: _*)) + expectedProjection.foreach { + case (alias, original) => + rendered should include(s"""${ctx.renderInlined(original)} as "$alias"""") + } + } + + it should "default every slot to an inline literal or a typed NULL cast" in { + // The all-defaults projection is what lets a builder that knows nothing + // about datasets still union with one that does: the column count and + // types have to line up. + val defaults = UnifiedResourceSchema() + defaults.allFields should have size 24 + val rendered = ctx.renderInlined(JDSL.select(defaults.allFields: _*)) + rendered should include("'' as \"resourceType\"") + rendered should include("cast(null as timestamp) as \"resourceCreationTime\"") + rendered should include("cast(null as int) as \"resourceOwnerId\"") + rendered should include("cast(null as boolean) as \"is_dataset_public\"") + } + + // -- the de-dup behind translatedFieldSet ----------------------------------- + + "translatedFieldSet" should "keep the first alias when the same Field feeds two slots" in { + // WorkflowSearchQueryBuilder really does this: `ownerId` and `uid` are both + // WORKFLOW_OF_USER.UID. allFields keeps both aliases (the SELECT projects the + // column twice) while the translation map keeps only the earlier one. + // + // What that does and does not mean: `translateRecord` builds its output with + // `context.newRecord(translatedFieldSet.map(_._1): _*)`, i.e. keyed by the + // ORIGINAL fields, so no `uid`-named slot exists on a translated record either + // way. And since both slots project the *same* expression, the dropped alias + // would have carried the same value — no data is lost. The only consequence is + // that the source record's `uid` alias column is never read back. So this test + // pins the keep-first rule itself, not a data loss. + val shared: Field[Integer] = JDSL.field(JDSL.name("shared_uid"), classOf[Integer]) + val schema = UnifiedResourceSchema(ownerId = shared, uid = shared) + + val aliases = translatedAliases(schema) + aliases should contain("resourceOwnerId") // first occurrence wins + aliases should not contain "uid" // second occurrence is dropped + // The de-dup rule is about the ORIGINALS: one entry per distinct original Field. + val originals = translatedOriginals(schema) + originals.distinct.size shouldBe originals.size + originals.count(_ == shared) shouldBe 1 + } + + it should "collapse the all-defaults projection down to one alias per distinct default" in { + // 24 slots, but only six structurally distinct default expressions, so the + // de-dup collapses the map to six entries. Worth pinning because it is + // surprising, and because it is what makes the keep-first rule observable at + // all: allFields stays at 24 while the translation map does not. + val defaults = UnifiedResourceSchema() + defaults.allFields should have size 24 + translatedAliases(defaults) shouldBe Seq( + "resourceType", // DSL.inline("") + "resourceCreationTime", // cast(null as timestamp) + "resourceOwnerId", // cast(null as int) + "workflow_privilege", // cast(null as privilege_enum) + "dataset_storage_path", // cast(null as varchar) + "is_dataset_public" // cast(null as boolean) + ) + } + + it should "keep every distinct original when the caller supplies 24 distinct Fields" in { + // Nothing to collapse here, which is the control case for the two tests + // above: the shrinkage they observe comes from duplicate originals only. + translatedAliases(sentinelSchema) shouldBe expectedProjection.map(_._1) + } + + it should "drop exactly the duplicated slots of the production workflow projection" in { + val workflowSchema = WorkflowSearchQueryBuilder.mappedResourceSchema + workflowSchema.allFields should have size 24 + val aliases = translatedAliases(workflowSchema) + // `uid` duplicates ownerId (WORKFLOW_OF_USER.UID); the rest are slots the + // builder left at their default, and the defaults collide by type. + workflowSchema.allFields.map(_.getName).diff(aliases) shouldBe Seq( + "uid", + "owner_uid", + "color", + "did", + "repository_name", + "is_dataset_downloadable", + "cover_image" + ) + aliases should contain("resourceOwnerId") + } + + // -- the jOOQ assumption the de-dup rests on ------------------------------- + + "jOOQ Field equality" should "be structural, which is what makes the de-dup collapse anything" in { + // If jOOQ ever switched to identity equality, translatedFieldSet would keep + // all 24 slots and translateRecord would start reading duplicated columns — + // the tests above would flip, and this one says why. + JDSL.cast(null, classOf[Integer]) shouldBe JDSL.cast(null, classOf[Integer]) + JDSL.inline("") shouldBe JDSL.inline("") + // ...but only within a type: the six default expressions stay distinct. + JDSL.cast(null, classOf[Integer]) should not be JDSL.cast(null, classOf[Timestamp]) + JDSL.cast(null, classOf[String]) should not be JDSL.castNull(classOf[PrivilegeEnum]) + } +} diff --git a/amber/src/test/scala/org/apache/texera/web/resource/dashboard/WorkflowSearchQueryBuilderSpec.scala b/amber/src/test/scala/org/apache/texera/web/resource/dashboard/WorkflowSearchQueryBuilderSpec.scala new file mode 100644 index 00000000000..59dd8d9e19f --- /dev/null +++ b/amber/src/test/scala/org/apache/texera/web/resource/dashboard/WorkflowSearchQueryBuilderSpec.scala @@ -0,0 +1,228 @@ +/* + * 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.apache.texera.dao.jooq.generated.Tables._ +import org.apache.texera.dao.jooq.generated.enums.PrivilegeEnum +import org.apache.texera.web.resource.dashboard.user.workflow.WorkflowResource.DashboardWorkflow +import org.jooq.impl.{DSL => JDSL} +import org.jooq.{Record, SQLDialect} + +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers + +/** + * Covers `WorkflowSearchQueryBuilder.toEntryImpl`, the pure record-to-DTO + * mapping, plus the one literal in `mappedResourceSchema` that the dashboard + * dispatch depends on. Both members widen the trait's `protected` to public, + * which is what makes them reachable from a spec at all. + * + * Breakage this catches: + * - the `projects` aggregate lookup breaking (a Field looked up by structural + * equality): `record.get(pidField)` would start returning null and every + * workflow would silently report zero projects instead of failing; + * - the comma-split branch losing/reordering ids, or the NULL branch no + * longer yielding an empty list; + * - a NULL `workflow_user_access.privilege` (a left join miss for public + * workflows) no longer degrading to NONE — an NPE or a wrong grant; + * - the ownership flag comparing the wrong uid, or owner id/name being read off + * the wrong column: two record columns are called `name` (WORKFLOW.NAME and + * USER.NAME), and `ownerId` resolves only because the unqualified name `uid` + * matches `workflow_of_user.uid` — the projection never selects USER.UID; + * - the inline `'workflow'` tag changing, which turns every "all resources" + * search into a MatchError in DashboardResource.searchAllResources. + * + * `constructFromClause`, `constructWhereClause` and `getGroupByFields` stay + * `override protected`, and Scala `protected` grants no same-package access, so + * they are not callable from here. (They are otherwise pure jOOQ DSL builders — + * the access modifier is the only thing keeping them out of scope.) + * + * Nothing here reads or writes the shared global + * `FulltextSearchQueryUtils.usePgroonga`, so this spec neither depends on it + * nor mutates it. + */ +class WorkflowSearchQueryBuilderSpec extends AnyFlatSpec with Matchers { + + // jOOQ render context (Postgres dialect to match production renderers). + private val ctx = JDSL.using(SQLDialect.POSTGRES) + + // toEntryImpl re-creates this aggregate locally and then looks it up with + // `record.get(pidField)`; the lookup only resolves because jOOQ compares + // QueryParts structurally. The first test pins that assumption. + private val pidField = JDSL.groupConcatDistinct(WORKFLOW_OF_PROJECT.PID) + private val coverField = JDSL.max(WORKFLOW_COVER_IMAGE.IMAGE).as("workflow_cover_image") + + private val ownerUid: Integer = Integer.valueOf(42) + private val viewerUid: Integer = Integer.valueOf(43) + private val wid: Integer = Integer.valueOf(7) + + /** + * Builds the subset of the *translated* record (see + * `UnifiedResourceSchema.translateRecord`, whose output is keyed by the + * original fields) that toEntryImpl actually reads. `newRecord` is pure + * in-memory jOOQ — no connection is involved. + * + * Every value is distinct so that reading the wrong column cannot pass. + */ + private def translatedRecord( + uidValue: Integer = ownerUid, + privilege: PrivilegeEnum = PrivilegeEnum.WRITE, + projects: String = "3,1,2", + cover: String = "cover-b64" + ): Record = { + val record = ctx.newRecord( + WORKFLOW.WID, + WORKFLOW.NAME, + WORKFLOW.DESCRIPTION, + WORKFLOW_OF_USER.UID, + WORKFLOW_USER_ACCESS.PRIVILEGE, + USER.NAME, + pidField, + coverField + ) + record.set(WORKFLOW.WID, wid) + record.set(WORKFLOW.NAME, "wf-name") + record.set(WORKFLOW.DESCRIPTION, "wf-description") + record.set(WORKFLOW_OF_USER.UID, uidValue) + record.set(WORKFLOW_USER_ACCESS.PRIVILEGE, privilege) + record.set(USER.NAME, "owner-name") + record.set(pidField, projects) + record.set(coverField, cover) + record + } + + private def workflowOf(record: Record, uid: Integer): DashboardWorkflow = + WorkflowSearchQueryBuilder.toEntryImpl(uid, record).workflow.get + + // -- the aggregate-field lookup -------------------------------------------- + + "toEntryImpl" should "resolve the project aggregate through structural Field equality" in { + // toEntryImpl builds a *fresh* groupConcatDistinct instance rather than + // reusing the one in mappedResourceSchema, so the whole projects feature + // rides on jOOQ treating two structurally identical aggregates as equal. + val record = translatedRecord(projects = "5,6") + JDSL.groupConcatDistinct(WORKFLOW_OF_PROJECT.PID) shouldBe pidField + record.get(JDSL.groupConcatDistinct(WORKFLOW_OF_PROJECT.PID)) shouldBe "5,6" + } + + // -- projectsOfWorkflow: both branches ------------------------------------- + + it should "split the comma-joined aggregate into Integers, preserving the aggregate's order" in { + // Deliberately unsorted so an accidental `.sorted` / `.reverse` fails. + workflowOf(translatedRecord(projects = "3,1,2"), ownerUid).projectIDs shouldBe + List(Integer.valueOf(3), Integer.valueOf(1), Integer.valueOf(2)) + } + + it should "handle a single-project aggregate (no separator present)" in { + workflowOf(translatedRecord(projects = "8"), ownerUid).projectIDs shouldBe + List(Integer.valueOf(8)) + } + + it should "return an empty project list when the aggregate is NULL" in { + // A workflow that belongs to no project left-joins to a NULL aggregate. + workflowOf(translatedRecord(projects = null), ownerUid).projectIDs shouldBe empty + } + + // Deliberately NOT asserted: that a padded separator ("1, 2") or a leading comma + // (",1") throws NumberFormatException. It does today — `Integer.valueOf` is applied + // straight to the raw `split(',')` output, so such input aborts the whole search + // request with a 500 instead of degrading. But production cannot produce it + // (Postgres' string_agg is rendered with a bare ',' separator), and pinning the + // throw would turn this suite red the moment someone hardens the parser with + // `.map(_.trim).filter(_.nonEmpty)` — i.e. it would punish an improvement. + + // -- privilege fallback ----------------------------------------------------- + + it should "fall back to NONE when the workflow privilege is NULL" in { + // Public workflows the caller has no explicit grant on left-join to a NULL + // privilege; the DTO must still carry a usable access level. + workflowOf(translatedRecord(privilege = null), ownerUid).accessLevel shouldBe + PrivilegeEnum.NONE.toString + } + + it should "pass a non-NULL privilege through unchanged" in { + // READ (not the WRITE default) so the fallback cannot be mistaken for a + // pass-through of the fixture value. + workflowOf( + translatedRecord(privilege = PrivilegeEnum.READ), + ownerUid + ).accessLevel shouldBe "READ" + } + + // -- ownership -------------------------------------------------------------- + + it should "flag ownership by comparing workflow_of_user.uid against the caller" in { + workflowOf(translatedRecord(), ownerUid).isOwner shouldBe true + workflowOf(translatedRecord(), viewerUid).isOwner shouldBe false + } + + it should "report the owner's id and name even when the caller is not the owner" in { + val dw = workflowOf(translatedRecord(), viewerUid) + dw.isOwner shouldBe false + // `ownerId` is read as `record.into(USER).getUid`, but the unified schema + // never selects USER.UID — jOOQ resolves the unqualified column name "uid" + // against workflow_of_user.uid instead. So this must be the owner's 42, + // neither the caller's 43 nor the workflow's wid of 7. + dw.ownerId shouldBe ownerUid + dw.ownerName shouldBe "owner-name" + } + + // -- workflow payload + cover image ---------------------------------------- + + it should "copy the workflow columns into the POJO without confusing them with the owner's" in { + // The record carries two columns named "name" (workflow.name and + // user.name); qualified resolution has to keep them apart. + val dw = workflowOf(translatedRecord(), ownerUid) + dw.workflow.getWid shouldBe wid + dw.workflow.getName shouldBe "wf-name" + dw.workflow.getDescription shouldBe "wf-description" + dw.ownerName shouldBe "owner-name" + } + + it should "wrap the cover image in an Option" in { + workflowOf(translatedRecord(), ownerUid).coverImage shouldBe Some("cover-b64") + workflowOf(translatedRecord(cover = null), ownerUid).coverImage shouldBe None + } + + it should "tag the entry as a workflow and leave the other payload slots empty" in { + val entry = WorkflowSearchQueryBuilder.toEntryImpl(ownerUid, translatedRecord()) + entry.resourceType shouldBe "workflow" + entry.project shouldBe None + entry.dataset shouldBe None + entry.workflow should not be None + } + + // -- the projection literal the dispatch depends on ------------------------ + + "mappedResourceSchema" should "project the literal 'workflow' as the resourceType column" in { + // searchAllResources dispatches on the *value* of this column with no + // default branch, so changing the literal turns an "all resources" search + // into a MatchError at fetch time rather than a compile error. + // Rendered as a SELECT because an aliased Field on its own renders as the + // bare alias reference, not as its underlying expression. + val rendered = ctx.renderInlined( + JDSL.select(WorkflowSearchQueryBuilder.mappedResourceSchema.allFields: _*) + ) + rendered should include("'workflow' as \"resourceType\"") + // The projects column is the aggregate toEntryImpl re-creates locally; a + // separator other than a bare ',' would break the split above. + rendered should include(s"""${ctx.renderInlined(pidField)} as "projects"""") + } +}