From 97cf8960bfbf4c49ca2cfc2ad2766d59e3701a70 Mon Sep 17 00:00:00 2001 From: mengw15 <125719918+mengw15@users.noreply.github.com> Date: Fri, 31 Jul 2026 05:32:39 -0700 Subject: [PATCH] test(amber): extend WorkflowVersionResource unit test coverage --- .../WorkflowVersionResourceSpec.scala | 282 +++++++++++++++++- 1 file changed, 272 insertions(+), 10 deletions(-) diff --git a/amber/src/test/scala/org/apache/texera/web/resource/dashboard/user/workflow/WorkflowVersionResourceSpec.scala b/amber/src/test/scala/org/apache/texera/web/resource/dashboard/user/workflow/WorkflowVersionResourceSpec.scala index 3e86f38c2ef..06d933f51d0 100644 --- a/amber/src/test/scala/org/apache/texera/web/resource/dashboard/user/workflow/WorkflowVersionResourceSpec.scala +++ b/amber/src/test/scala/org/apache/texera/web/resource/dashboard/user/workflow/WorkflowVersionResourceSpec.scala @@ -20,30 +20,56 @@ package org.apache.texera.web.resource.dashboard.user.workflow import org.apache.texera.amber.util.JSONUtils.objectMapper +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.tables.daos.{WorkflowDao, WorkflowVersionDao} -import org.apache.texera.dao.jooq.generated.tables.pojos.{Workflow, WorkflowVersion} +import org.apache.texera.dao.jooq.generated.enums.PrivilegeEnum +import org.apache.texera.dao.jooq.generated.tables.daos.{ + UserDao, + WorkflowDao, + WorkflowOfUserDao, + WorkflowUserAccessDao, + WorkflowVersionDao +} +import org.apache.texera.dao.jooq.generated.tables.pojos.{ + User, + Workflow, + WorkflowOfUser, + WorkflowUserAccess, + WorkflowVersion +} import org.jooq.impl.DSL 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 java.util.concurrent.TimeUnit +import javax.ws.rs.ForbiddenException import scala.collection.mutable.ArrayBuffer +import scala.jdk.CollectionConverters._ class WorkflowVersionResourceSpec extends AnyFlatSpec + with Matchers with BeforeAndAfterAll with BeforeAndAfterEach with MockTexeraDB { private val testWorkflowWid = 2000 + scala.util.Random.nextInt(1000) + private val ownerUid = 4000 + scala.util.Random.nextInt(1000) + private val strangerUid = 6000 + scala.util.Random.nextInt(1000) private var testWorkflow: Workflow = _ + private var owner: User = _ + private var stranger: User = _ private var workflowDao: WorkflowDao = _ private var workflowVersionDao: WorkflowVersionDao = _ + private var userDao: UserDao = _ + private var workflowOfUserDao: WorkflowOfUserDao = _ + private var workflowUserAccessDao: WorkflowUserAccessDao = _ + private var resource: WorkflowVersionResource = _ private val capturedVersions = ArrayBuffer.empty[Integer] @@ -52,17 +78,36 @@ class WorkflowVersionResourceSpec } override protected def beforeEach(): Unit = { + workflowDao = new WorkflowDao(getDSLContext.configuration()) + workflowVersionDao = new WorkflowVersionDao(getDSLContext.configuration()) + userDao = new UserDao(getDSLContext.configuration()) + workflowOfUserDao = new WorkflowOfUserDao(getDSLContext.configuration()) + workflowUserAccessDao = new WorkflowUserAccessDao(getDSLContext.configuration()) + resource = new WorkflowVersionResource() + testWorkflow = new Workflow testWorkflow.setWid(Integer.valueOf(testWorkflowWid)) testWorkflow.setName("test_workflow_" + UUID.randomUUID().toString.substring(0, 8)) testWorkflow.setContent(createWorkflowContent("initial")) testWorkflow.setDescription("test description") + testWorkflow.setIsPublic(false) + testWorkflow.setCreationTime(new Timestamp(System.currentTimeMillis())) + testWorkflow.setLastModifiedTime(new Timestamp(System.currentTimeMillis())) - workflowDao = new WorkflowDao(getDSLContext.configuration()) - workflowVersionDao = new WorkflowVersionDao(getDSLContext.configuration()) + owner = makeUser(ownerUid, "wfv_owner") + stranger = makeUser(strangerUid, "wfv_stranger") cleanupTestData() + userDao.insert(owner) + userDao.insert(stranger) workflowDao.insert(testWorkflow) + + val ownership = new WorkflowOfUser + ownership.setUid(Integer.valueOf(ownerUid)) + ownership.setWid(Integer.valueOf(testWorkflowWid)) + workflowOfUserDao.insert(ownership) + grantAccess(ownerUid, PrivilegeEnum.WRITE) + capturedVersions.clear() } @@ -71,14 +116,36 @@ class WorkflowVersionResourceSpec } private def cleanupTestData(): Unit = { - getDSLContext + val ctx = getDSLContext + // Purge the fixed test workflow plus anything our test users came to own + // (cloneVersion creates a fresh workflow), children before parents so the + // user rows can be removed without violating foreign keys. + val wids = (ctx + .select(Tables.WORKFLOW_OF_USER.WID) + .from(Tables.WORKFLOW_OF_USER) + .where( + Tables.WORKFLOW_OF_USER.UID.in(Integer.valueOf(ownerUid), Integer.valueOf(strangerUid)) + ) + .fetchInto(classOf[Integer]) + .asScala + .toSet + Integer.valueOf(testWorkflowWid)).toList + + ctx .deleteFrom(Tables.WORKFLOW_VERSION) - .where(Tables.WORKFLOW_VERSION.WID.eq(testWorkflowWid)) + .where(Tables.WORKFLOW_VERSION.WID.in(wids: _*)) .execute() - - getDSLContext - .deleteFrom(Tables.WORKFLOW) - .where(Tables.WORKFLOW.WID.eq(testWorkflowWid)) + ctx + .deleteFrom(Tables.WORKFLOW_USER_ACCESS) + .where(Tables.WORKFLOW_USER_ACCESS.WID.in(wids: _*)) + .execute() + ctx + .deleteFrom(Tables.WORKFLOW_OF_USER) + .where(Tables.WORKFLOW_OF_USER.WID.in(wids: _*)) + .execute() + ctx.deleteFrom(Tables.WORKFLOW).where(Tables.WORKFLOW.WID.in(wids: _*)).execute() + ctx + .deleteFrom(Tables.USER) + .where(Tables.USER.UID.in(Integer.valueOf(ownerUid), Integer.valueOf(strangerUid))) .execute() } @@ -86,6 +153,25 @@ class WorkflowVersionResourceSpec closeConnectionPool() } + private def makeUser(uid: Int, name: String): User = { + val user = new User + user.setUid(Integer.valueOf(uid)) + user.setName(name) + user.setEmail(s"$name@test.com") + user.setPassword("password") + user + } + + private def grantAccess(uid: Int, privilege: PrivilegeEnum): Unit = { + val access = new WorkflowUserAccess + access.setUid(Integer.valueOf(uid)) + access.setWid(Integer.valueOf(testWorkflowWid)) + access.setPrivilege(privilege) + workflowUserAccessDao.insert(access) + } + + private def session(user: User): SessionUser = new SessionUser(user) + private def createWorkflowContent(value: String): String = { val jsonNode = objectMapper.createObjectNode() jsonNode.put("value", value) @@ -106,6 +192,38 @@ class WorkflowVersionResourceSpec patch.toString } + /** A patch whose only op is an `add`, i.e. a semantically important change. */ + private def createAddDiff(): String = { + val before = objectMapper.createObjectNode() + val after = objectMapper.createObjectNode() + after.put("addedKey", "x") + com.flipkart.zjsonpatch.JsonDiff.asJson(before, after).toString + } + + private def seedVersion(content: String, minutesAgo: Int): Unit = { + val version = new WorkflowVersion + version.setWid(Integer.valueOf(testWorkflowWid)) + version.setContent(content) + version.setCreationTime( + new Timestamp(System.currentTimeMillis() - TimeUnit.MINUTES.toMillis(minutesAgo)) + ) + workflowVersionDao.insert(version) + } + + /** The vids of the test workflow's versions, ascending (creation order). */ + private def versionVids(): List[Integer] = { + getDSLContext + .select(Tables.WORKFLOW_VERSION.VID) + .from(Tables.WORKFLOW_VERSION) + .where(Tables.WORKFLOW_VERSION.WID.eq(Integer.valueOf(testWorkflowWid))) + .orderBy(Tables.WORKFLOW_VERSION.VID.asc()) + .fetchInto(classOf[Integer]) + .asScala + .toList + } + + private def versionCount(): Int = versionVids().size + "WorkflowVersionResource" should "return versions in descending order from fetchSubsequentVersions and apply patches correctly" in { var currentContent = "initial" for (i <- 1 to 10) { @@ -192,4 +310,148 @@ class WorkflowVersionResourceSpec s"Workflow content should be 'version_$midVersionId' but was '$midVersionContent'" ) } + + it should "reconstruct an older content by applying a stored delta (applyPatch, no DB)" in { + val base = new Workflow + base.setWid(Integer.valueOf(testWorkflowWid)) + base.setContent(createWorkflowContent("new")) + + val delta = new WorkflowVersion + // a delta that turns "new" back into "old" + delta.setContent(createVersionDiff("new", "old")) + delta.setCreationTime(new Timestamp(System.currentTimeMillis())) + + val result = WorkflowVersionResource.applyPatch(List(delta), base) + + objectMapper.readTree(result.getContent).get("value").asText() shouldBe "old" + } + + "isSnapshotInRangeUnimportant" should "return true when the bounds are equal" in { + WorkflowVersionResource.isSnapshotInRangeUnimportant(7, 7, testWorkflowWid) shouldBe true + } + + it should "return true when every delta in range is positional-only (replace)" in { + seedVersion(createVersionDiff("a", "b"), minutesAgo = 2) + seedVersion(createVersionDiff("b", "c"), minutesAgo = 1) + val vids = versionVids() + + WorkflowVersionResource.isSnapshotInRangeUnimportant( + vids.head, + vids.last, + testWorkflowWid + ) shouldBe true + } + + it should "return false when a delta in range is an important (non-replace) change" in { + seedVersion(createVersionDiff("a", "b"), minutesAgo = 2) + seedVersion(createAddDiff(), minutesAgo = 1) + val vids = versionVids() + + WorkflowVersionResource.isSnapshotInRangeUnimportant( + vids.head, + vids.last, + testWorkflowWid + ) shouldBe false + } + + "insertNewVersion" should "persist a retrievable version with the given content" in { + val inserted = WorkflowVersionResource.insertNewVersion(testWorkflowWid, "[]") + + val fetched = workflowVersionDao.fetchOneByVid(inserted.getVid) + fetched should not be null + fetched.getWid shouldBe Integer.valueOf(testWorkflowWid) + fetched.getContent shouldBe "[]" + } + + "getLatestVersion" should "return the highest vid when versions exist" in { + seedVersion(createVersionDiff("a", "b"), minutesAgo = 2) + seedVersion(createVersionDiff("b", "c"), minutesAgo = 1) + val vids = versionVids() + + WorkflowVersionResource.getLatestVersion(testWorkflowWid) shouldBe vids.max + } + + it should "create and return a version when the workflow has none yet" in { + versionCount() shouldBe 0 + + val vid = WorkflowVersionResource.getLatestVersion(testWorkflowWid) + + versionCount() shouldBe 1 + workflowVersionDao.fetchOneByVid(vid) should not be null + } + + "insertVersion" should "always create a version for a new workflow" in { + WorkflowVersionResource.insertVersion(testWorkflow, insertingNewWorkflow = true) + versionCount() shouldBe 1 + } + + it should "create a version only when the content actually changed" in { + // unchanged: the passed content equals what is already stored -> empty patch -> no version + WorkflowVersionResource.insertVersion(testWorkflow, insertingNewWorkflow = false) + versionCount() shouldBe 0 + + // changed: a different content produces a non-empty patch -> one version + val edited = new Workflow + edited.setWid(Integer.valueOf(testWorkflowWid)) + edited.setContent(createWorkflowContent("changed")) + WorkflowVersionResource.insertVersion(edited, insertingNewWorkflow = false) + versionCount() shouldBe 1 + } + + "retrieveVersionsOfWorkflow" should "return the importance-encoded versions for a user with access" in { + seedVersion(createVersionDiff("a", "b"), minutesAgo = 2) + seedVersion(createVersionDiff("b", "c"), minutesAgo = 1) + + val versions = resource.retrieveVersionsOfWorkflow(testWorkflowWid, session(owner)) + + versions should have size 2 + // the latest version is always marked important + versions.head.importance shouldBe true + } + + it should "return an empty list for a user without read access" in { + seedVersion(createVersionDiff("a", "b"), minutesAgo = 1) + + resource.retrieveVersionsOfWorkflow(testWorkflowWid, session(stranger)) shouldBe empty + } + + "retrieveWorkflowVersion" should "reconstruct the workflow at a version for a user with access" in { + // an empty delta leaves the content unchanged, so the reconstructed content + // is exactly the workflow's current content + val version = WorkflowVersionResource.insertNewVersion(testWorkflowWid, "[]") + + val result = resource.retrieveWorkflowVersion(testWorkflowWid, version.getVid, session(owner)) + + result.getContent shouldBe testWorkflow.getContent + } + + it should "throw ForbiddenException for a user without read access" in { + val version = WorkflowVersionResource.insertNewVersion(testWorkflowWid, "[]") + + assertThrows[ForbiddenException] { + resource.retrieveWorkflowVersion(testWorkflowWid, version.getVid, session(stranger)) + } + } + + "cloneVersion" should "create a new workflow from an existing version" in { + // cloneVersion re-ids operators, so the version must reconstruct to a real + // workflow document; an empty delta keeps the workflow's own content. + val workflowContent = + """{"operators":[{"operatorID":"CSVFileScan-operator-a","operatorType":"CSVFileScan"}],"links":[]}""" + testWorkflow.setContent(workflowContent) + workflowDao.update(testWorkflow) + val version = WorkflowVersionResource.insertNewVersion(testWorkflowWid, "[]") + + val newWid = resource.cloneVersion( + version.getVid, + session(owner), + Map("displayedVersionId" -> 1).asJava + ) + + newWid should not be null + newWid should not be Integer.valueOf(testWorkflowWid) + val cloned = workflowDao.fetchOneByWid(newWid) + cloned should not be null + cloned.getName should include("_copy") + } }