diff --git a/jvm/src/main/scala/cs214/webapp/server/web/WebServerRoutes.scala b/jvm/src/main/scala/cs214/webapp/server/web/WebServerRoutes.scala index a98eb3caf6fba0f730324b947bd5460660167506..24ab94798bf936515c377d53553b1ed79a1624fa 100644 --- a/jvm/src/main/scala/cs214/webapp/server/web/WebServerRoutes.scala +++ b/jvm/src/main/scala/cs214/webapp/server/web/WebServerRoutes.scala @@ -82,3 +82,6 @@ private[server] final case class WebServerRoutes()(using cc: castor.Context, log case None => cask.Response(f"Unknown instance '$instanceId'", 400) initialize() + +private[webapp] def allRoutes(using cc: castor.Context, log: cask.Logger): Seq[cask.Routes] = + Seq(cs214.webapp.server.web.WebServerRoutes()) diff --git a/jvm/src/test/scala/cs214/webapp/AppSuite.scala b/jvm/src/test/scala/cs214/webapp/AppSuite.scala new file mode 100644 index 0000000000000000000000000000000000000000..7e16f45eb23bc15abbb13f453da5cc450530ee0c --- /dev/null +++ b/jvm/src/test/scala/cs214/webapp/AppSuite.scala @@ -0,0 +1,62 @@ +package cs214.webapp +package server +package web + +import scala.util.{Try, Success, Failure} +import scala.concurrent.duration.Duration +import scala.concurrent.{Await, Future} +import scala.concurrent.ExecutionContext.Implicits.global + +import ujson.Value +import sttp.ws.WebSocket +import sttp.client4.* +import sttp.client4.ws.sync.* + +import cs214.webapp.utils.{*, given} +import cs214.webapp.server.StateMachine +import sttp.client4.ws.SyncWebSocket + +object PingPongSuite: + type Ping = String + type Pong = String + type State = String + + object PingPong extends StateMachine[Ping, State, Pong]: + override def appInfo: AppInfo = AppInfo( + id = "stress", name = "Stress test", description = "", year = 2024 + ) + + override object wire extends AppWire[Ping, Pong]: + override val eventFormat = StringWire + override val viewFormat = StringWire + + override def init(clients: Seq[UserId]): String = "" + override def project(state: String)(userId: UserId): Pong = state + override def transition(state: String)(userId: UserId, event: Ping): Try[Seq[Action[State]]] = Try: + Seq(Action.Render(state)) + + WebServer.register(PingPong) + +abstract class PingPongSuite extends ClientSuite[PingPongSuite.Ping, PingPongSuite.State, PingPongSuite.Pong]: + import PingPongSuite.* + + override def app = PingPong + +class PagesSuite extends PingPongSuite: + import PingPongSuite.* + import ClientSuite.* + + test("The main page responds to requests"): + withServer: server ?=> + assert(quickRequest.get(server.uri).send(backend).code.isSuccess) + + test("The app-selection page responds to requests"): + withServer: server ?=> + assert(quickRequest.get(uri"$server/app/${PingPong.appInfo.id}").send(backend).code.isSuccess) + + test("Multiple instances can be created at the same time"): + withServer: + time: + for _ <- 1 to 10 do + val inst = createInstance(USER_IDS) + instanceInfo(inst.instanceId) diff --git a/jvm/src/test/scala/cs214/webapp/utils/ClientSuite.scala b/jvm/src/test/scala/cs214/webapp/utils/ClientSuite.scala index e45113107245d15ac1604157759a1d7af5e8a95f..82128e8ab40e3f868baf6decded091e5dacf614f 100644 --- a/jvm/src/test/scala/cs214/webapp/utils/ClientSuite.scala +++ b/jvm/src/test/scala/cs214/webapp/utils/ClientSuite.scala @@ -1,58 +1,49 @@ package cs214.webapp -package server -package web +package utils import scala.util.Try import ujson.Value import io.undertow.Undertow -import sttp.client4.quick.* + import sttp.model.Uri +import sttp.client4.DefaultSyncBackend import cs214.webapp.server.StateMachine import cs214.webapp.server.web.WebServer -import scala.language.implicitConversions -given unsafeURI: Conversion[String, Uri] with - def apply(uri: String): Uri = Uri.unsafeParse(uri) - -case class WebServerInfo(uri: String): - override def toString = uri - -object ServerSuite: - type Ping = String - type Pong = String - type State = String +extension (sc: StringContext) + def uri(args: Any*): Uri = + Uri.unsafeParse(sc.s(args*)) - object PingPong extends StateMachine[Ping, State, Pong]: - override def appInfo: AppInfo = AppInfo( - id = "stress", name = "Stress test", description = "", year = 2024 - ) +case class WebServerInfo(host: String, port: Int): + val uri = uri"http://$host:$port" + def authority = f"$host:$port" + override def toString = uri.toString - override object wire extends AppWire[Ping, Pong]: - override val eventFormat = StringWire - override val viewFormat = StringWire +object ClientSuite: + val HOST = "localhost" + val PORT = 8091 + private val lock = Object() // Run with a single server at a time - override def init(clients: Seq[UserId]): String = "" - override def project(state: String)(userId: UserId): Pong = state - override def transition(state: String)(userId: UserId, event: Ping): Try[Seq[Action[State]]] = Try: - Seq(Action.Render(state)) + val backend = DefaultSyncBackend() + cask.main.Main.silenceJboss() // Reduce logging - WebServer.register(PingPong) - - def withServer[T](body: WebServerInfo ?=> T): T = + def withServer[T](body: WebServerInfo ?=> T): T = lock.synchronized: object caskServer extends cask.Main: - def allRoutes = Seq(WebServerRoutes()) + def allRoutes = cs214.webapp.server.web.allRoutes - val (address, port) = ("localhost", 8091) val server = Undertow.builder - .addHttpListener(port, address) + .addHttpListener(PORT, HOST) .setHandler(caskServer.defaultHandler) .build + // println(f"[test] Starting server on $HOST:$PORT") server.start() val t = - try body(using WebServerInfo(f"http://${address}:${port}")) - finally server.stop() + try body(using WebServerInfo(HOST, PORT)) + finally + // println(f"[test] Stopping server on $HOST:$PORT") + server.stop() t def time[T](t: => T) = @@ -62,36 +53,49 @@ object ServerSuite: println(f"Elapsed: ${(end - start) / 1000000} ms") result -class ServerSuite extends munit.FunSuite: + def substituteInstanceInfo( + endpointTemplate: String, + protocolInfo: ProtocolInfo, userId: UserId + )(using server: WebServerInfo): String = + endpointTemplate + .replace("{{protocol}}", protocolInfo.proto(tls = false)) + .replace("{{authority}}", f"${server.authority}") + .replace("{{userId}}", java.net.URLEncoder.encode(userId, "UTF-8")) + +abstract class ClientSuite[Event, State, View] extends munit.FunSuite: import sttp.client4.quick.* - import ServerSuite.* - test("The main and app-selection pages respond to requests"): - withServer: ws ?=> - assert(quickRequest.get(ws.uri).send().code.isSuccess) - assert(quickRequest.get(f"$ws/app/${PingPong.appInfo.id}").send().code.isSuccess) + /** System under test. **/ + def app: StateMachine[Event, State, View] + + protected val UID0: String = "yak" + protected val UID1: String = "hut" + protected val UID2: String = "kik" - def createInstance(using ws: WebServerInfo) = - val users = Seq("yak", "hut", "kik") + /** Mock user IDs that can be used in tests */ + protected val USER_IDS = Seq(UID0, UID1, UID2) + + extension[T] (r: sttp.client4.Response[T]) + def assertSuccess() = + assert(r.code.isSuccess, f"Request failed: ${r.body}") + r + + def createInstance(userIds: Seq[UserId])(using ws: WebServerInfo) = val js = CreateInstanceRequest.Wire.encode: - CreateInstanceRequest(PingPong.appInfo.id, users) + CreateInstanceRequest(app.appInfo.id, userIds) val jsResponse = quickRequest - .post(f"$ws${Endpoints.Api.createInstance}") + .post(uri"$ws${Endpoints.Api.createInstance}") .header("Content-Type", "application/json") .body(ujson.write(js)) .send() + .assertSuccess() .body CreateInstanceResponse.Wire.decode(ujson.read(jsResponse)).get def instanceInfo(instanceId: String)(using ws: WebServerInfo) = - val resp = InstanceInfoResponse.Wire.decode: - ujson.read: - quickRequest.get(f"$ws${Endpoints.Api.instanceInfo}?instanceId=$instanceId").send().body - resp.get - - test("Multiple instances can be created at the same time"): - withServer: - time: - for _ <- 1 to 10 do - val inst = createInstance - instanceInfo(inst.instanceId) + val jsResponse = quickRequest + .get(uri"$ws${Endpoints.Api.instanceInfo}?instanceId=$instanceId") + .send() + .assertSuccess() + .body + InstanceInfoResponse.Wire.decode(ujson.read(jsResponse)).get