Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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]

Expand All @@ -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()
}

Expand All @@ -71,21 +116,62 @@ 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()
}

override protected def afterAll(): Unit = {
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)
Expand All @@ -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) {
Expand Down Expand Up @@ -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")
}
}
Loading