Skip to content
Open
Show file tree
Hide file tree
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
2 changes: 1 addition & 1 deletion backend/src/main/kotlin/dev/dres/DRES.kt
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ object DRES {
RunExecutor.init(store)

/* Initialize EventStreamProcessor */
EventStreamProcessor.register( /* Add handlers here */)
EventStreamProcessor.register(RunExecutor)
EventStreamProcessor.init()

/* Initialize Rest API. */
Expand Down
17 changes: 17 additions & 0 deletions backend/src/main/kotlin/dev/dres/api/rest/AccessManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -165,4 +165,21 @@ object AccessManager {
fun getRunManagerForUser(userId: UserId): Set<RunManager> = this.locks.read {
return this.usersToRunMap[userId] ?: emptySet()
}

/**
* Checks whether the user associated with the given [SessionToken] is allowed to observe
* (e.g., via WebSocket) the given [runManager], mirroring the access rules applied to the REST API.
*
* @param sessionId The [SessionToken] to check.
* @param runManager The [RunManager] the session wants to observe.
* @return True if access is permitted, false otherwise.
*/
fun canViewEvaluation(sessionId: SessionToken?, runManager: RunManager): Boolean {
val roles = rolesOfSession(sessionId)
if (roles.contains(ApiRole.ADMIN)) return true
val userId = userIdForSession(sessionId) ?: return false
return (roles.contains(ApiRole.JUDGE) && runManager.template.judges.contains(userId)) ||
(roles.contains(ApiRole.VIEWER) && runManager.template.viewers.contains(userId)) ||
(roles.contains(ApiRole.PARTICIPANT) && runManager.template.hasParticipant(userId))
}
}
3 changes: 2 additions & 1 deletion backend/src/main/kotlin/dev/dres/api/rest/RestApi.kt
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import dev.dres.api.rest.types.status.ErrorStatus
import dev.dres.api.rest.types.users.ApiRole
import dev.dres.data.model.config.Config
import dev.dres.mgmt.cache.CacheManager
import dev.dres.run.RunExecutor
import dev.dres.utilities.NamedThreadFactory
import io.javalin.Javalin
import io.javalin.apibuilder.ApiBuilder.*
Expand Down Expand Up @@ -328,7 +329,7 @@ object RestApi {
}
}
}
//ws("ws/run", runExecutor)
ws("ws/run", RunExecutor::accept)
}
}

Expand Down
265 changes: 133 additions & 132 deletions backend/src/main/kotlin/dev/dres/run/RunExecutor.kt

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import dev.dres.run.RunExecutor
import dev.dres.run.RunManager
import dev.dres.run.RunManagerStatus
import io.javalin.http.Context
import io.javalin.websocket.WsContext
import kotlinx.dnq.query.filter
import kotlinx.dnq.query.flatMapDistinct
import kotlinx.dnq.query.isNotEmpty
Expand Down Expand Up @@ -72,6 +73,12 @@ fun Context.sendFile(file: File) {

fun Context.sessionToken(): String? = this.attribute<String>("session")

/**
* Returns the session token for this [WsContext], as set by the `before` handler on the
* underlying HTTP upgrade request.
*/
fun WsContext.sessionToken(): String? = this.attribute<String>("session")

fun Context.getOrCreateSessionToken(): String {
val attributeId = this.attribute<String>("session")
if (attributeId != null) {
Expand Down
206 changes: 206 additions & 0 deletions backend/src/test/kotlin/dres/run/RunExecutorWebSocketTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
package dres.run

import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import dev.dres.api.rest.types.evaluation.submission.ApiClientSubmission
import dev.dres.api.rest.types.evaluation.websocket.ServerMessage
import dev.dres.api.rest.types.evaluation.websocket.ServerMessageType
import dev.dres.api.rest.types.template.ApiEvaluationTemplate
import dev.dres.api.rest.types.template.tasks.ApiTaskTemplate
import dev.dres.run.RunExecutor
import dev.dres.run.eventstream.*
import org.junit.jupiter.api.Assertions.*
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertDoesNotThrow

/**
* Unit tests for the WebSocket event handling in [RunExecutor].
*
* Covers:
* 1. [RunExecutor.eventToMessage] — asserts the correct [ServerMessageType] and
* field values are produced for every handled [StreamEvent] subtype.
* 2. [ServerMessage] JSON serialisation — round-trip and field correctness.
* 3. Smoke tests — [RunExecutor.handleStreamEvent] does not throw for any event.
*/
class RunExecutorWebSocketTest {

private val mapper = jacksonObjectMapper()

private val runId = "eval-001"
private val taskId = "task-001"

// ── eventToMessage mapping ────────────────────────────────────────────────

@Test
fun `RunStartEvent maps to COMPETITION_START`() {
val msg = RunExecutor.eventToMessage(RunStartEvent(runId, minimalTemplate()))
assertNotNull(msg)
assertEquals(ServerMessageType.COMPETITION_START, msg!!.type)
assertEquals(runId, msg.evaluationId)
assertNull(msg.taskId)
}

@Test
fun `RunEndEvent maps to COMPETITION_END`() {
val msg = RunExecutor.eventToMessage(RunEndEvent(runId))
assertNotNull(msg)
assertEquals(ServerMessageType.COMPETITION_END, msg!!.type)
assertEquals(runId, msg.evaluationId)
assertNull(msg.taskId)
}

@Test
fun `TaskStartEvent maps to TASK_START with correct taskId`() {
val msg = RunExecutor.eventToMessage(TaskStartEvent(runId, taskId, minimalTaskTemplate()))
assertNotNull(msg)
assertEquals(ServerMessageType.TASK_START, msg!!.type)
assertEquals(runId, msg.evaluationId)
assertEquals(taskId, msg.taskId)
}

@Test
fun `TaskEndEvent maps to TASK_END with correct taskId`() {
val msg = RunExecutor.eventToMessage(TaskEndEvent(runId, taskId))
assertNotNull(msg)
assertEquals(ServerMessageType.TASK_END, msg!!.type)
assertEquals(runId, msg.evaluationId)
assertEquals(taskId, msg.taskId)
}

@Test
fun `ScoreUpdateEvent maps to COMPETITION_UPDATE`() {
val msg = RunExecutor.eventToMessage(ScoreUpdateEvent(runId, "scoreboard", emptyMap()))
assertNotNull(msg)
assertEquals(ServerMessageType.COMPETITION_UPDATE, msg!!.type)
assertEquals(runId, msg.evaluationId)
}

@Test
fun `SubmissionEvent maps to TASK_UPDATED`() {
val msg = RunExecutor.eventToMessage(
SubmissionEvent("session-1", runId, ApiClientSubmission(answerSets = emptyList()))
)
assertNotNull(msg)
assertEquals(ServerMessageType.TASK_UPDATED, msg!!.type)
assertEquals(runId, msg.evaluationId)
}

@Test
fun `unhandled event type maps to null`() {
val msg = RunExecutor.eventToMessage(InvalidRequestEvent("session-1", runId, "bad data"))
assertNull(msg)
}

@Test
fun `every handled event type carries the correct evaluationId`() {
val events: List<StreamEvent> = listOf(
RunStartEvent(runId, minimalTemplate()),
RunEndEvent(runId),
TaskStartEvent(runId, taskId, minimalTaskTemplate()),
TaskEndEvent(runId, taskId),
ScoreUpdateEvent(runId, "board", emptyMap()),
SubmissionEvent("s", runId, ApiClientSubmission(answerSets = emptyList()))
)
events.forEach { event ->
val msg = RunExecutor.eventToMessage(event)
assertNotNull(msg, "Expected non-null message for ${event::class.simpleName}")
assertEquals(runId, msg!!.evaluationId, "Wrong evaluationId for ${event::class.simpleName}")
}
}

// ── ServerMessage JSON serialisation ──────────────────────────────────────

@Test
fun `ServerMessage round-trips through JSON`() {
val original = ServerMessage(runId, ServerMessageType.TASK_START, taskId)
val decoded: ServerMessage = mapper.readValue(mapper.writeValueAsString(original))

assertEquals(original.evaluationId, decoded.evaluationId)
assertEquals(original.type, decoded.type)
assertEquals(original.taskId, decoded.taskId)
}

@Test
fun `ServerMessage JSON contains evaluationId, type and timestamp`() {
val tree = mapper.readTree(
mapper.writeValueAsString(ServerMessage(runId, ServerMessageType.COMPETITION_END))
)
assertEquals(runId, tree["evaluationId"].asText())
assertEquals("COMPETITION_END", tree["type"].asText())
assertNotNull(tree["timestamp"])
}

@Test
fun `ServerMessage without taskId serialises taskId as null`() {
val tree = mapper.readTree(
mapper.writeValueAsString(ServerMessage(runId, ServerMessageType.COMPETITION_START))
)
assertTrue(tree["taskId"].isNull)
}

@Test
fun `ServerMessage with taskId serialises taskId correctly`() {
val tree = mapper.readTree(
mapper.writeValueAsString(ServerMessage(runId, ServerMessageType.TASK_START, taskId))
)
assertEquals(taskId, tree["taskId"].asText())
}

@Test
fun `all ServerMessageTypes serialise without error`() {
ServerMessageType.entries.forEach { type ->
assertDoesNotThrow("Failed for type $type") {
mapper.writeValueAsString(ServerMessage(runId, type))
}
}
}

// ── handleStreamEvent smoke tests ─────────────────────────────────────────

@Test
fun `handleStreamEvent does not throw for any event type`() {
val events: List<StreamEvent> = listOf(
RunStartEvent(runId, minimalTemplate()),
RunEndEvent(runId),
TaskStartEvent(runId, taskId, minimalTaskTemplate()),
TaskEndEvent(runId, taskId),
ScoreUpdateEvent(runId, "board", emptyMap()),
SubmissionEvent("s", runId, ApiClientSubmission(answerSets = emptyList())),
InvalidRequestEvent("s", runId, "bad data")
)
events.forEach { event ->
assertDoesNotThrow("handleStreamEvent threw for ${event::class.simpleName}") {
RunExecutor.handleStreamEvent(event)
}
}
}

// ── Fixture helpers ───────────────────────────────────────────────────────

private fun minimalTemplate() = ApiEvaluationTemplate(
id = runId,
name = "Test Evaluation",
description = null,
created = null,
modified = null,
taskTypes = emptyList(),
taskGroups = emptyList(),
tasks = emptyList(),
teams = emptyList(),
teamGroups = emptyList(),
judges = emptyList(),
viewers = emptyList()
)

private fun minimalTaskTemplate() = ApiTaskTemplate(
id = taskId,
name = "Task 1",
taskGroup = "group-1",
taskType = "KIS",
duration = 300L,
collectionId = "col-1",
targets = emptyList(),
hints = emptyList(),
comment = null
)
}
Loading