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-7182-64e978be9ae7983d402fd0ddf9034497f6a52a31 in repository https://gitbox.apache.org/repos/asf/texera.git
commit 4de3605d0784abb6ec9e99187d0a181acc0aee71 Author: Meng Wang <[email protected]> AuthorDate: Fri Jul 31 17:21:38 2026 -0700 test(amber): add unit test coverage for ProjectResource (#7182) ### What changes were proposed in this PR? Adds a `MockTexeraDB`-backed spec for `ProjectResource` (previously ~0% coverage), following the sibling `WorkflowAccessResourceSpec` / `WorkflowVersionResourceSpec` pattern (embedded Postgres, DAOs built in `beforeEach`). 10 tests: - `createProject` — persists a project owned by the user, retrievable via `getProject`, with a `WRITE` access row. - `getProjectList` — empty for a user who owns none; the owner's projects once created. - `updateProjectName` — renames via a re-read and rejects a blank name with `BadRequestException`. - `updateProjectDescription` — the description round-trips. - `addWorkflowToProject` — creates the mapping, stays idempotent on a repeat add, and throws `ForbiddenException` for a user without access to the workflow. - `deleteWorkflowFromProject` / `deleteProject` — the mapping / project is removed. - `addExportedFileToProject` — returns `""` when the workflow is in no project, `"and added to project: <name>"` for one, and the comma-joined list for several. **Note on observed behavior:** despite its name, `addExportedFileToProject` does not insert a file row — it reads the workflow's existing project mappings and builds the status string returned to `ResultExportService`; the tests assert that string. `listProjectWorkflows` is not exercised here — it is a thin delegate to `DashboardResource.searchAllResources` (the full-text dashboard-search tier, which has its own specs), and re-testing it through this resource would only couple the spec to that search setup without covering `ProjectResource` lines. No production code was changed. ### Any related issues, documentation, discussions? Closes #7175. ### How was this PR tested? `sbt "WorkflowExecutionService/testOnly *ProjectResourceSpec"` — 10 passed, run twice for determinism against the embedded Postgres. The failure path was verified by breaking an assertion (1 failed, non-zero exit), and `Test/scalafmtCheck` + `Test/scalafix --check` are clean. ### Was this PR authored or co-authored using generative AI tooling? Generated-by: Claude Code (Opus 4.8 [1M context]) --- .../user/project/ProjectResourceSpec.scala | 298 +++++++++++++++++++++ 1 file changed, 298 insertions(+) diff --git a/amber/src/test/scala/org/apache/texera/web/resource/dashboard/user/project/ProjectResourceSpec.scala b/amber/src/test/scala/org/apache/texera/web/resource/dashboard/user/project/ProjectResourceSpec.scala new file mode 100644 index 0000000000..e05a6fb63d --- /dev/null +++ b/amber/src/test/scala/org/apache/texera/web/resource/dashboard/user/project/ProjectResourceSpec.scala @@ -0,0 +1,298 @@ +/* + * 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.user.project + +import org.apache.texera.auth.SessionUser +import org.apache.texera.dao.MockTexeraDB +import org.apache.texera.dao.jooq.generated.Tables._ +import org.apache.texera.dao.jooq.generated.enums.PrivilegeEnum +import org.apache.texera.dao.jooq.generated.tables.daos.{ + UserDao, + WorkflowDao, + WorkflowOfProjectDao, + WorkflowOfUserDao, + WorkflowUserAccessDao +} +import org.apache.texera.dao.jooq.generated.tables.pojos.{ + User, + Workflow, + WorkflowOfProject, + WorkflowOfUser, + WorkflowUserAccess +} +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers +import org.scalatest.{BeforeAndAfterAll, BeforeAndAfterEach} + +import java.sql.Timestamp +import java.util.UUID +import javax.ws.rs.{BadRequestException, ForbiddenException} +import scala.jdk.CollectionConverters._ + +class ProjectResourceSpec + extends AnyFlatSpec + with Matchers + with BeforeAndAfterAll + with BeforeAndAfterEach + with MockTexeraDB { + + private val ownerUid = 12000 + scala.util.Random.nextInt(1000) + private val strangerUid = 14000 + scala.util.Random.nextInt(1000) + private val testUids = Seq(ownerUid, strangerUid) + + private var owner: User = _ + private var stranger: User = _ + private var workflowOfProjectDao: WorkflowOfProjectDao = _ + private var userDao: UserDao = _ + private var workflowDao: WorkflowDao = _ + private var workflowOfUserDao: WorkflowOfUserDao = _ + private var workflowUserAccessDao: WorkflowUserAccessDao = _ + private var resource: ProjectResource = _ + + override protected def beforeAll(): Unit = { + initializeDBAndReplaceDSLContext() + } + + override protected def beforeEach(): Unit = { + workflowOfProjectDao = new WorkflowOfProjectDao(getDSLContext.configuration()) + userDao = new UserDao(getDSLContext.configuration()) + workflowDao = new WorkflowDao(getDSLContext.configuration()) + workflowOfUserDao = new WorkflowOfUserDao(getDSLContext.configuration()) + workflowUserAccessDao = new WorkflowUserAccessDao(getDSLContext.configuration()) + resource = new ProjectResource() + + owner = makeUser(ownerUid, "proj_owner") + stranger = makeUser(strangerUid, "proj_stranger") + + cleanupTestData() + userDao.insert(owner) + userDao.insert(stranger) + } + + override protected def afterEach(): Unit = { + cleanupTestData() + } + + override protected def afterAll(): Unit = { + closeConnectionPool() + } + + private def cleanupTestData(): Unit = { + val ctx = getDSLContext + // projects owned by our test users (pids are DB-generated) + val pids = ctx + .select(PROJECT.PID) + .from(PROJECT) + .where(PROJECT.OWNER_ID.in(testUids.map(Integer.valueOf): _*)) + .fetchInto(classOf[Integer]) + .asScala + .toList + // workflows our test users came to own + val wids = ctx + .select(WORKFLOW_OF_USER.WID) + .from(WORKFLOW_OF_USER) + .where(WORKFLOW_OF_USER.UID.in(testUids.map(Integer.valueOf): _*)) + .fetchInto(classOf[Integer]) + .asScala + .toList + + if (pids.nonEmpty) { + ctx.deleteFrom(WORKFLOW_OF_PROJECT).where(WORKFLOW_OF_PROJECT.PID.in(pids: _*)).execute() + ctx.deleteFrom(PROJECT_USER_ACCESS).where(PROJECT_USER_ACCESS.PID.in(pids: _*)).execute() + } + ctx + .deleteFrom(PROJECT_USER_ACCESS) + .where(PROJECT_USER_ACCESS.UID.in(testUids.map(Integer.valueOf): _*)) + .execute() + ctx.deleteFrom(PROJECT).where(PROJECT.OWNER_ID.in(testUids.map(Integer.valueOf): _*)).execute() + + if (wids.nonEmpty) { + ctx.deleteFrom(WORKFLOW_OF_PROJECT).where(WORKFLOW_OF_PROJECT.WID.in(wids: _*)).execute() + ctx.deleteFrom(WORKFLOW_USER_ACCESS).where(WORKFLOW_USER_ACCESS.WID.in(wids: _*)).execute() + ctx.deleteFrom(WORKFLOW_OF_USER).where(WORKFLOW_OF_USER.WID.in(wids: _*)).execute() + ctx.deleteFrom(WORKFLOW).where(WORKFLOW.WID.in(wids: _*)).execute() + } + ctx.deleteFrom(USER).where(USER.UID.in(testUids.map(Integer.valueOf): _*)).execute() + } + + private def makeUser(uid: Int, name: String): User = { + val user = new User + user.setUid(Integer.valueOf(uid)) + user.setName(name) + user.setEmail(s"[email protected]") + user.setPassword("password") + user + } + + private def session(user: User): SessionUser = new SessionUser(user) + + /** Seeds a workflow owned by the given user with WRITE access (so hasReadAccess passes). */ + private def seedWorkflow(uid: Int): Integer = { + val wid = Integer.valueOf(16000 + scala.util.Random.nextInt(100000)) + val workflow = new Workflow + workflow.setWid(wid) + workflow.setName("wf_" + UUID.randomUUID().toString.substring(0, 8)) + workflow.setContent("""{"operators":[],"links":[]}""") + workflow.setDescription("") + workflow.setIsPublic(false) + workflow.setCreationTime(new Timestamp(System.currentTimeMillis())) + workflow.setLastModifiedTime(new Timestamp(System.currentTimeMillis())) + workflowDao.insert(workflow) + + val ownership = new WorkflowOfUser + ownership.setUid(Integer.valueOf(uid)) + ownership.setWid(wid) + workflowOfUserDao.insert(ownership) + + val access = new WorkflowUserAccess + access.setUid(Integer.valueOf(uid)) + access.setWid(wid) + access.setPrivilege(PrivilegeEnum.WRITE) + workflowUserAccessDao.insert(access) + wid + } + + private def workflowOfProjectCount(wid: Integer, pid: Integer): Int = + getDSLContext.fetchCount( + WORKFLOW_OF_PROJECT, + WORKFLOW_OF_PROJECT.WID.eq(wid).and(WORKFLOW_OF_PROJECT.PID.eq(pid)) + ) + + behavior of "ProjectResource" + + it should "create a project owned by the user and make it retrievable with a WRITE access row" in { + val created = resource.createProject(session(owner), "my_project") + + created.getName shouldBe "my_project" + created.getOwnerId shouldBe Integer.valueOf(ownerUid) + resource.getProject(created.getPid).getName shouldBe "my_project" + + val privilege = getDSLContext + .select(PROJECT_USER_ACCESS.PRIVILEGE) + .from(PROJECT_USER_ACCESS) + .where( + PROJECT_USER_ACCESS.PID + .eq(created.getPid) + .and(PROJECT_USER_ACCESS.UID.eq(Integer.valueOf(ownerUid))) + ) + .fetchOne(0, classOf[PrivilegeEnum]) + privilege shouldBe PrivilegeEnum.WRITE + } + + it should "list no projects for a user who owns none and all projects once created" in { + resource.getProjectList(session(stranger)).asScala shouldBe empty + + resource.createProject(session(owner), "p1") + resource.createProject(session(owner), "p2") + + resource.getProjectList(session(owner)).asScala.map(_.name).toSet shouldBe Set("p1", "p2") + } + + it should "rename a project and reject a blank name" in { + val pid = resource.createProject(session(owner), "before").getPid + + resource.updateProjectName(pid, "after") + resource.getProject(pid).getName shouldBe "after" + + assertThrows[BadRequestException] { + resource.updateProjectName(pid, " ") + } + // the rejected rename left the previous value intact + resource.getProject(pid).getName shouldBe "after" + } + + it should "update a project description via a re-read" in { + val pid = resource.createProject(session(owner), "p").getPid + + resource.updateProjectDescription(pid, "a new description") + + resource.getProject(pid).getDescription shouldBe "a new description" + } + + it should "add a workflow to a project, stay idempotent, and reject a user without access" in { + val pid = resource.createProject(session(owner), "p").getPid + val wid = seedWorkflow(ownerUid) + + resource.addWorkflowToProject(pid, wid, session(owner)) + workflowOfProjectCount(wid, pid) shouldBe 1 + + // a second add for the same pair must not create a duplicate mapping + resource.addWorkflowToProject(pid, wid, session(owner)) + workflowOfProjectCount(wid, pid) shouldBe 1 + + // the stranger has no access to this workflow + assertThrows[ForbiddenException] { + resource.addWorkflowToProject(pid, wid, session(stranger)) + } + } + + it should "remove a workflow-to-project mapping" in { + val pid = resource.createProject(session(owner), "p").getPid + val wid = seedWorkflow(ownerUid) + resource.addWorkflowToProject(pid, wid, session(owner)) + workflowOfProjectCount(wid, pid) shouldBe 1 + + resource.deleteWorkflowFromProject(pid, wid) + + workflowOfProjectCount(wid, pid) shouldBe 0 + } + + it should "delete a project" in { + val pid = resource.createProject(session(owner), "doomed").getPid + resource.getProject(pid) should not be null + + resource.deleteProject(pid) + + resource.getProject(pid) shouldBe null + } + + behavior of "ProjectResource.addExportedFileToProject" + + it should "return an empty status when the workflow belongs to no project" in { + val wid = seedWorkflow(ownerUid) + ProjectResource.addExportedFileToProject(Integer.valueOf(ownerUid), wid, "out.csv") shouldBe "" + } + + it should "name the single project the workflow belongs to" in { + val wid = seedWorkflow(ownerUid) + val pid = resource.createProject(session(owner), "only_project").getPid + workflowOfProjectDao.insert(new WorkflowOfProject(wid, pid)) + + ProjectResource.addExportedFileToProject( + Integer.valueOf(ownerUid), + wid, + "out.csv" + ) shouldBe "and added to project: only_project" + } + + it should "list every project the workflow belongs to when there are several" in { + val wid = seedWorkflow(ownerUid) + val pid1 = resource.createProject(session(owner), "alpha").getPid + val pid2 = resource.createProject(session(owner), "beta").getPid + workflowOfProjectDao.insert(new WorkflowOfProject(wid, pid1)) + workflowOfProjectDao.insert(new WorkflowOfProject(wid, pid2)) + + val status = ProjectResource.addExportedFileToProject(Integer.valueOf(ownerUid), wid, "out.csv") + + status should startWith("and added to projects: ") + status should include("alpha") + status should include("beta") + } +}
