This is an automated email from the ASF dual-hosted git repository.

github-merge-queue[bot] pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/texera.git


The following commit(s) were added to refs/heads/main by this push:
     new 49f9e2c414 test(workflow-execution-service): add CollaborationResource 
unit tests (#6904)
49f9e2c414 is described below

commit 49f9e2c414b116bf328bdb9154cc97dc4d1e7e9e
Author: Meng Wang <[email protected]>
AuthorDate: Sun Jul 26 20:47:43 2026 -0700

    test(workflow-execution-service): add CollaborationResource unit tests 
(#6904)
    
    ### What changes were proposed in this PR?
    
    `CollaborationResource` had no spec and sat at 0% — all 87 tracked lines
    unhit. It reads as websocket-bound but the only collaborator is the
    `javax.websocket.Session` interface, which mocks cleanly with the
    ScalaMock already on amber's test classpath, so the session bookkeeping
    and message fan-out are ordinary unit-testable logic.
    
    Adds `CollaborationResourceSpec` with 13 tests covering the session
    lifecycle (`myOnOpen`, `myOnClose`), `WIdRequest` bookkeeping on both
    the authenticated and anonymous paths, `CommandRequest` and
    `RestoreVersionRequest` fan-out, `HeartBeatRequest`, and the locking
    branches that do not touch the database. A `mockSession` helper returns
    a `Session` with a fixed id whose outgoing messages are collected into a
    buffer; requests are built by serializing the real request case classes,
    so the `"type"` discriminator cannot drift out of sync with the
    production `@JsonTypeInfo` config.
    
    Three behaviors are worth calling out because they are subtle rather
    than obvious:
    
    - The multi-session `WIdRequest` test pins the
    `set.union(Set(senderSessId))` line, which returns a fresh set instead
    of mutating and only works because the result is reassigned into the
    map.
    - The fan-out tests assert the sender receives nothing, the
    same-workflow peer receives exactly one message, and a session on
    another workflow is untouched.
    - `wIdLockHolderSessionIdMap` stores a `null` sentinel for "no holder",
    which is a distinct state from an absent key. With the sentinel in
    place, `AcquireLockRequest` cannot resolve the holder session and
    rethrows — the spec pins that current behavior.
    
    The five bookkeeping maps live on the companion object and are therefore
    JVM-wide mutable state, so `beforeEach` clears all five; that is what
    keeps the suite order-independent. There are no clocks, threads, temp
    files or network calls in the spec.
    
    ### Any related issues, documentation, discussions?
    
    Closes #6900.
    
    The read-only `TryLockRequest` rejection and the lock hand-off inside
    `myOnClose` both reach `WorkflowAccessResource.hasWriteAccess` and
    therefore `SqlServer`; they are left uncovered rather than pulling
    `MockTexeraDB` (and an embedded Postgres process) in for two branches.
    
    ### How was this PR tested?
    
    `sbt "WorkflowExecutionService/testOnly *CollaborationResourceSpec"`
    passes with 13 tests; `Test/scalafmtCheck` and `Test/scalafix --check`
    are clean. For the failure path, the self-exclusion guard in the
    `CommandRequest` fan-out was temporarily removed from the production
    code, which reddened the fan-out test and exited non-zero, and the guard
    was then restored — so the assertions genuinely constrain the behavior.
    
    ### Was this PR authored or co-authored using generative AI tooling?
    
    Generated-by: Claude Code (claude-opus-5)
    
    ---------
    
    Signed-off-by: Meng Wang <[email protected]>
    Co-authored-by: Copilot Autofix powered by AI 
<[email protected]>
---
 .../web/resource/CollaborationResourceSpec.scala   | 297 +++++++++++++++++++++
 1 file changed, 297 insertions(+)

diff --git 
a/amber/src/test/scala/org/apache/texera/web/resource/CollaborationResourceSpec.scala
 
b/amber/src/test/scala/org/apache/texera/web/resource/CollaborationResourceSpec.scala
new file mode 100644
index 0000000000..3c604578ad
--- /dev/null
+++ 
b/amber/src/test/scala/org/apache/texera/web/resource/CollaborationResourceSpec.scala
@@ -0,0 +1,297 @@
+/*
+ * 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.amber.util.JSONUtils
+import org.apache.texera.dao.jooq.generated.tables.pojos.User
+import org.apache.texera.web.model.collab.request._
+import org.apache.texera.web.resource.CollaborationResource._
+import org.scalamock.scalatest.MockFactory
+import org.scalatest.BeforeAndAfterEach
+import org.scalatest.flatspec.AnyFlatSpec
+import org.scalatest.matchers.should.Matchers
+
+import java.util.concurrent.{Future => JFuture}
+import javax.websocket.{RemoteEndpoint, Session}
+import scala.collection.mutable.ArrayBuffer
+
+// Unit tests for CollaborationResource's session bookkeeping and message
+// fan-out. The only collaborator is the javax.websocket.Session interface, so
+// everything here runs in-process against mocks — no database, filesystem or
+// network. The two branches that reach WorkflowAccessResource.hasWriteAccess
+// (the read-only TryLockRequest rejection and the lock hand-off inside
+// myOnClose) need SqlServer and are deliberately left uncovered.
+class CollaborationResourceSpec
+    extends AnyFlatSpec
+    with Matchers
+    with MockFactory
+    with BeforeAndAfterEach {
+
+  private var resource: CollaborationResource = _
+
+  // The five maps live on the companion object, i.e. they are JVM-wide mutable
+  // state shared by every test in the suite. Clearing them here is what keeps
+  // this spec order-independent.
+  override def beforeEach(): Unit = {
+    sessionIdSessionMap.clear()
+    sessionIdWIdMap.clear()
+    sessionIdUIdMap.clear()
+    wIdSessionIdsMap.clear()
+    wIdLockHolderSessionIdMap.clear()
+    resource = new CollaborationResource()
+  }
+
+  /**
+    * A mocked Session whose getId is fixed and whose outgoing messages are
+    * collected into the returned buffer. `uId` seeds the authenticated user in
+    * the session's user properties; None models an anonymous session.
+    */
+  private def mockSession(id: String, uId: Option[Int] = None): (Session, 
ArrayBuffer[String]) = {
+    val sent = ArrayBuffer[String]()
+
+    val async = mock[RemoteEndpoint.Async]
+    (async
+      .sendText(_: String))
+      .expects(*)
+      .onCall { (text: String) =>
+        sent += text
+        null.asInstanceOf[JFuture[Void]]
+      }
+      .anyNumberOfTimes()
+
+    val properties = new java.util.HashMap[String, Object]()
+    uId.foreach { uid =>
+      val user = new User()
+      user.setUid(Integer.valueOf(uid))
+      properties.put(classOf[User].getName, user)
+    }
+
+    val session = mock[Session]
+    (() => session.getId).expects().returning(id).anyNumberOfTimes()
+    (() => 
session.getAsyncRemote).expects().returning(async).anyNumberOfTimes()
+    (() => 
session.getUserProperties).expects().returning(properties).anyNumberOfTimes()
+
+    (session, sent)
+  }
+
+  private def send(request: CollabWebSocketRequest): String =
+    JSONUtils.objectMapper.writeValueAsString(request)
+
+  // -- session lifecycle 
------------------------------------------------------
+
+  "myOnOpen" should "register the session" in {
+    val (session, _) = mockSession("s1")
+
+    resource.myOnOpen(session)
+
+    sessionIdSessionMap should contain key "s1"
+    sessionIdSessionMap("s1") shouldBe session
+  }
+
+  "myOnClose" should "drop the session and its workflow bookkeeping" in {
+    val (session, _) = mockSession("s1")
+    resource.myOnOpen(session)
+    resource.myOnMsg(session, send(WIdRequest(7)))
+    sessionIdWIdMap should contain key "s1"
+
+    resource.myOnClose(session)
+
+    sessionIdSessionMap should not contain key("s1")
+    sessionIdWIdMap should not contain key("s1")
+    wIdSessionIdsMap(DUMMY_WID) shouldBe empty
+  }
+
+  it should "leave the maps alone for a session that never sent a WIdRequest" 
in {
+    val (session, _) = mockSession("s1")
+    resource.myOnOpen(session)
+
+    resource.myOnClose(session)
+
+    sessionIdSessionMap shouldBe empty
+    sessionIdWIdMap shouldBe empty
+    wIdSessionIdsMap shouldBe empty
+  }
+
+  // -- WIdRequest 
-------------------------------------------------------------
+
+  "WIdRequest" should "record the uid and the requested wid for an 
authenticated session" in {
+    val (session, _) = mockSession("s1", uId = Some(42))
+    resource.myOnOpen(session)
+
+    resource.myOnMsg(session, send(WIdRequest(7)))
+
+    sessionIdUIdMap("s1") shouldBe 42
+    sessionIdWIdMap("s1") shouldBe 7
+    wIdSessionIdsMap(7) should contain only "s1"
+  }
+
+  it should "fall back to DUMMY_WID for an anonymous session" in {
+    val (session, _) = mockSession("s1")
+    resource.myOnOpen(session)
+
+    resource.myOnMsg(session, send(WIdRequest(7)))
+
+    sessionIdWIdMap("s1") shouldBe DUMMY_WID
+    sessionIdUIdMap should not contain key("s1")
+    wIdSessionIdsMap(DUMMY_WID) should contain only "s1"
+  }
+
+  it should "accumulate every session that joins the same wid" in {
+    val (first, _) = mockSession("s1", uId = Some(1))
+    val (second, _) = mockSession("s2", uId = Some(2))
+    resource.myOnOpen(first)
+    resource.myOnOpen(second)
+
+    resource.myOnMsg(first, send(WIdRequest(7)))
+    resource.myOnMsg(second, send(WIdRequest(7)))
+
+    // The union() call returns a fresh set rather than mutating in place, so
+    // this only holds because the result is reassigned into the map.
+    wIdSessionIdsMap(7) should contain theSameElementsAs Set("s1", "s2")
+  }
+
+  // -- fan-out 
----------------------------------------------------------------
+
+  /**
+    * Three open sessions: s1 and s2 share wid 1, s3 sits on wid 2.
+    */
+  private def threeSessions(): (
+      (Session, ArrayBuffer[String]),
+      (Session, ArrayBuffer[String]),
+      (Session, ArrayBuffer[String])
+  ) = {
+    val first = mockSession("s1", uId = Some(1))
+    val second = mockSession("s2", uId = Some(2))
+    val third = mockSession("s3", uId = Some(3))
+    List(first, second, third).foreach { case (session, _) => 
resource.myOnOpen(session) }
+    resource.myOnMsg(first._1, send(WIdRequest(1)))
+    resource.myOnMsg(second._1, send(WIdRequest(1)))
+    resource.myOnMsg(third._1, send(WIdRequest(2)))
+    (first, second, third)
+  }
+
+  "CommandRequest" should "reach only the peers on the same workflow" in {
+    val ((sender, senderSent), (peer, peerSent), (other, otherSent)) = 
threeSessions()
+    senderSent.clear()
+    peerSent.clear()
+    otherSent.clear()
+
+    resource.myOnMsg(sender, send(CommandRequest("do-something")))
+
+    peerSent should have size 1
+    peerSent.head should include("CommandEvent")
+    peerSent.head should include("do-something")
+    senderSent shouldBe empty
+    otherSent shouldBe empty
+    peer.getId shouldBe "s2"
+    other.getId shouldBe "s3"
+  }
+
+  "RestoreVersionRequest" should "reach only the peers on the same workflow" 
in {
+    val ((sender, senderSent), (_, peerSent), (_, otherSent)) = threeSessions()
+    senderSent.clear()
+    peerSent.clear()
+    otherSent.clear()
+
+    resource.myOnMsg(sender, send(RestoreVersionRequest()))
+
+    peerSent should have size 1
+    peerSent.head should include("RestoreVersionEvent")
+    senderSent shouldBe empty
+    otherSent shouldBe empty
+  }
+
+  // -- heartbeat 
--------------------------------------------------------------
+
+  "HeartBeatRequest" should "answer the sender only" in {
+    val ((sender, senderSent), (_, peerSent), _) = threeSessions()
+    senderSent.clear()
+    peerSent.clear()
+
+    resource.myOnMsg(sender, send(HeartBeatRequest()))
+
+    senderSent should have size 1
+    senderSent.head should include("HeartBeatResponse")
+    peerSent shouldBe empty
+  }
+
+  // -- locking 
----------------------------------------------------------------
+
+  "TryLockRequest" should "grant the lock unconditionally on the DUMMY_WID 
workflow" in {
+    val (session, sent) = mockSession("s1")
+    resource.myOnOpen(session)
+    resource.myOnMsg(session, send(WIdRequest(7)))
+    sessionIdWIdMap("s1") shouldBe DUMMY_WID
+    sent.clear()
+
+    resource.myOnMsg(session, send(TryLockRequest()))
+
+    sent should have size 2
+    sent.head should include("WorkflowAccessEvent")
+    sent.head should include("\"workflowReadonly\":false")
+    sent(1) should include("LockGrantedEvent")
+  }
+
+  "AcquireLockRequest" should "hand the lock over from the previous holder" in 
{
+    val (holder, holderSent) = mockSession("s1", uId = Some(1))
+    val (requester, requesterSent) = mockSession("s2", uId = Some(2))
+    resource.myOnOpen(holder)
+    resource.myOnOpen(requester)
+    resource.myOnMsg(holder, send(WIdRequest(1)))
+    resource.myOnMsg(requester, send(WIdRequest(1)))
+    wIdLockHolderSessionIdMap(1) = "s1"
+    holderSent.clear()
+    requesterSent.clear()
+
+    resource.myOnMsg(requester, send(AcquireLockRequest()))
+
+    holderSent should have size 1
+    holderSent.head should include("ReleaseLockEvent")
+    requesterSent should have size 1
+    requesterSent.head should include("LockGrantedEvent")
+    wIdLockHolderSessionIdMap(1) shouldBe "s2"
+  }
+
+  it should "re-grant the lock to the session that already holds it" in {
+    val (session, sent) = mockSession("s1", uId = Some(1))
+    resource.myOnOpen(session)
+    resource.myOnMsg(session, send(WIdRequest(1)))
+    wIdLockHolderSessionIdMap(1) = "s1"
+    sent.clear()
+
+    resource.myOnMsg(session, send(AcquireLockRequest()))
+
+    sent should have size 1
+    sent.head should include("LockGrantedEvent")
+    wIdLockHolderSessionIdMap(1) shouldBe "s1"
+  }
+
+  it should "rethrow when the holder slot holds the null sentinel" in {
+    val (session, _) = mockSession("s1", uId = Some(1))
+    resource.myOnOpen(session)
+    resource.myOnMsg(session, send(WIdRequest(1)))
+    // `null` means "no holder"; it is a distinct state from an absent key and
+    // the hand-off branch cannot look a null session id up.
+    wIdLockHolderSessionIdMap(1) = null
+
+    a[NoSuchElementException] should be thrownBy
+      resource.myOnMsg(session, send(AcquireLockRequest()))
+  }
+}

Reply via email to