@@ -33,8 +33,6 @@ import kotlinx.coroutines.test.runTest
3333import org.assertj.core.api.Assertions.assertThat
3434import org.junit.jupiter.api.Test
3535
36- // --- Test service definitions (interfaces so the JDK proxy can be used, no bytebuddy needed) ---
37-
3836@Service
3937@Name(" Greeter" )
4038private interface Greeter {
@@ -45,6 +43,8 @@ private interface Greeter {
4543@Name(" Counter" )
4644private interface Counter {
4745 @Exclusive suspend fun add (value : Int ): Int
46+
47+ @Exclusive suspend fun label (name : String ): String
4848}
4949
5050@Workflow
@@ -53,22 +53,24 @@ private interface SignupWorkflow {
5353 @Workflow suspend fun run (email : String ): String
5454}
5555
56- /* *
57- * A [Client] test double built on top of [BaseClient] (so all the handle factory methods come for
58- * free). It records the last [Request] that the API produced and returns canned responses without
59- * hitting the network.
60- */
61- private class RecordingClient :
56+ private object NoHeaders : ResponseHead.Headers {
57+ override fun get (key : String ): String? = null
58+
59+ override fun keys (): Set <String > = emptySet()
60+
61+ override fun toLowercaseMap (): Map <String , String > = emptyMap()
62+ }
63+
64+ private class RequestRecordingClient (private val mockResponse : Any? = null ) :
6265 BaseClient (URI .create(" http://localhost:8080" ), KotlinSerializationSerdeFactory (), null ) {
6366
6467 var lastRequest: Request <* , * >? = null
6568 var lastDelay: Duration ? = null
66- var cannedResponse: Any? = null
6769
6870 override fun <Req , Res > callAsync (request : Request <Req , Res >): CompletableFuture <Response <Res >> {
6971 lastRequest = request
7072 @Suppress(" UNCHECKED_CAST" )
71- return CompletableFuture .completedFuture(Response (200 , NoHeaders , cannedResponse as Res ))
73+ return CompletableFuture .completedFuture(Response (200 , NoHeaders , mockResponse as Res ))
7274 }
7375
7476 override fun <Req , Res > sendAsync (
@@ -83,9 +85,7 @@ private class RecordingClient :
8385
8486 override fun attachAsync (options : RequestOptions ): CompletableFuture <Response <Res >> {
8587 @Suppress(" UNCHECKED_CAST" )
86- return CompletableFuture .completedFuture(
87- Response (200 , NoHeaders , cannedResponse as Res )
88- )
88+ return CompletableFuture .completedFuture(Response (200 , NoHeaders , mockResponse as Res ))
8989 }
9090
9191 override fun getOutputAsync (
@@ -97,7 +97,6 @@ private class RecordingClient :
9797 )
9898 }
9999
100- // Never reached: callAsync/sendAsync are overridden to short-circuit the network.
101100 override fun <Res > doPostRequest (
102101 target : URI ,
103102 headers : Stream <Map .Entry <String , String >>,
@@ -110,29 +109,47 @@ private class RecordingClient :
110109 headers : Stream <Map .Entry <String , String >>,
111110 responseMapper : ResponseMapper <Res >,
112111 ): CompletableFuture <Res > = throw UnsupportedOperationException ()
112+ }
113113
114- private object NoHeaders : ResponseHead.Headers {
115- override fun get ( key : String ): String? = null
114+ private class UriRecordingClient :
115+ BaseClient ( URI .create( " http://localhost:8080 " ), KotlinSerializationSerdeFactory (), null ) {
116116
117- override fun keys (): Set < String > = emptySet()
117+ var lastUri : URI ? = null
118118
119- override fun toLowercaseMap (): Map <String , String > = emptyMap()
119+ override fun <Res > doPostRequest (
120+ target : URI ,
121+ headers : Stream <Map .Entry <String , String >>,
122+ payload : Slice ,
123+ responseMapper : ResponseMapper <Res >,
124+ ): CompletableFuture <Res > {
125+ lastUri = target
126+ val path = target.path
127+ val isSend = path.endsWith(" /send" ) || path.contains(" /send/" )
128+ val body =
129+ if (isSend) Slice .wrap(""" {"invocationId":"inv-123","status":"Accepted"}""" )
130+ else Slice .wrap(" \" ok\" " )
131+ return CompletableFuture .completedFuture(responseMapper.mapResponse(200 , NoHeaders , body))
120132 }
133+
134+ override fun <Res > doGetRequest (
135+ target : URI ,
136+ headers : Stream <Map .Entry <String , String >>,
137+ responseMapper : ResponseMapper <Res >,
138+ ): CompletableFuture <Res > = throw UnsupportedOperationException ()
121139}
122140
123141class ScopedClientTest {
124142
125143 @Test
126144 fun `scope returns the platform ScopedClient, not a Kotlin-specific type` () {
127145 // Guards against the regression where a Kotlin Client.scope extension shadowed the Java member.
128- val client = RecordingClient ()
146+ val client = RequestRecordingClient ()
129147 assertThat(client.scope(" tenant-1" )).isInstanceOf(ScopedClient ::class .java)
130148 }
131149
132150 @Test
133151 fun `scoped service proxy routes the call within the scope` () = runTest {
134- val client = RecordingClient ()
135- client.cannedResponse = " Hello Alice"
152+ val client = RequestRecordingClient (" Hello Alice" )
136153
137154 val response = client.scope(" tenant-1" ).service<Greeter >().greet(" Alice" )
138155
@@ -147,8 +164,7 @@ class ScopedClientTest {
147164
148165 @Test
149166 fun `scoped virtualObject proxy carries scope and key` () = runTest {
150- val client = RecordingClient ()
151- client.cannedResponse = 42
167+ val client = RequestRecordingClient (42 )
152168
153169 val response = client.scope(" tenant-1" ).virtualObject<Counter >(" my-key" ).add(1 )
154170
@@ -163,8 +179,7 @@ class ScopedClientTest {
163179
164180 @Test
165181 fun `scoped toService builder forwards idempotencyKey and limitKey` () = runTest {
166- val client = RecordingClient ()
167- client.cannedResponse = " Hi Bob"
182+ val client = RequestRecordingClient (" Hi Bob" )
168183
169184 val response =
170185 client
@@ -189,8 +204,7 @@ class ScopedClientTest {
189204
190205 @Test
191206 fun `toService builder forwards limitKey even without a scope` () = runTest {
192- val client = RecordingClient ()
193- client.cannedResponse = " Hi Carol"
207+ val client = RequestRecordingClient (" Hi Carol" )
194208
195209 client.toService<Greeter >().request { greet(" Carol" ) }.options { limitKey = " limit-2" }.call()
196210
@@ -201,8 +215,7 @@ class ScopedClientTest {
201215
202216 @Test
203217 fun `scoped toWorkflow builder submits within the scope` () = runTest {
204- val client = RecordingClient ()
205- client.cannedResponse = " queued"
218+ val client = RequestRecordingClient (" queued" )
206219
207220 val sendResponse =
208221 client
@@ -219,4 +232,58 @@ class ScopedClientTest {
219232 assertThat(request.target.handler).isEqualTo(" run" )
220233 assertThat(request.request).isEqualTo(" a@b.com" )
221234 }
235+
236+ @Test
237+ fun `scoped call encodes scope in the path` () = runTest {
238+ val client = UriRecordingClient ()
239+
240+ client.scope(" tenant-1" ).toService<Greeter >().request { greet(" A" ) }.call()
241+
242+ assertThat(client.lastUri!! .path).isEqualTo(" /restate/scope/tenant-1/call/Greeter/greet" )
243+ assertThat(client.lastUri!! .query).isNull()
244+ }
245+
246+ @Test
247+ fun `scoped keyed send encodes scope, key and send verb in the path` () = runTest {
248+ val client = UriRecordingClient ()
249+
250+ client.scope(" tenant-1" ).toVirtualObject<Counter >(" my-key" ).request { label(" x" ) }.send()
251+
252+ assertThat(client.lastUri!! .path).isEqualTo(" /restate/scope/tenant-1/send/Counter/my-key/label" )
253+ }
254+
255+ @Test
256+ fun `scoped send with limit key uses the limit-key query param` () = runTest {
257+ val client = UriRecordingClient ()
258+
259+ client
260+ .scope(" tenant-1" )
261+ .toService<Greeter >()
262+ .request { greet(" A" ) }
263+ .options { limitKey = " api-key/user42" }
264+ .send()
265+
266+ assertThat(client.lastUri!! .path).isEqualTo(" /restate/scope/tenant-1/send/Greeter/greet" )
267+ // The runtime reads "limit-key" (kebab-case), not "limitKey".
268+ assertThat(client.lastUri!! .query).isEqualTo(" limit-key=api-key/user42" )
269+ }
270+
271+ @Test
272+ fun `unscoped call keeps the unversioned path` () = runTest {
273+ val client = UriRecordingClient ()
274+
275+ client.toService<Greeter >().request { greet(" A" ) }.call()
276+
277+ assertThat(client.lastUri!! .path).isEqualTo(" /Greeter/greet" )
278+ assertThat(client.lastUri!! .query).isNull()
279+ }
280+
281+ @Test
282+ fun `unscoped send keeps the unversioned path` () = runTest {
283+ val client = UriRecordingClient ()
284+
285+ client.toService<Greeter >().request { greet(" A" ) }.send()
286+
287+ assertThat(client.lastUri!! .path).isEqualTo(" /Greeter/greet/send" )
288+ }
222289}
0 commit comments