mirror of
https://github.com/hasura/graphql-engine.git
synced 2024-12-24 16:03:37 +03:00
ccea1da1d5
### Description This is it! This PR enables the Metadata API for remote relationships from remote schemas, adds tests, ~~adds documentation~~, adds an entry to the Changelog. This is the release PR that enables the feature. ### Checklist - [ ] Tests: - [x] RS-to-Postgres (high level) - [x] RS-to-RS (high level) - [x] From RS specifically (testing for edge cases) - [x] Metadata API tests - [ ] Unit testing the actual engine? - [x] Changelog entry - [ ] Documentation? PR-URL: https://github.com/hasura/graphql-engine-mono/pull/3974 Co-authored-by: Vamshi Surabhi <6562944+0x777@users.noreply.github.com> Co-authored-by: Vishnu Bharathi <4211715+scriptnull@users.noreply.github.com> Co-authored-by: jkachmar <8461423+jkachmar@users.noreply.github.com> GitOrigin-RevId: c9aebf12e6eebef8d264ea831a327b968d4be9d2
748 lines
20 KiB
Haskell
748 lines
20 KiB
Haskell
{-# LANGUAGE DuplicateRecordFields #-}
|
|
{-# LANGUAGE QuasiQuotes #-}
|
|
|
|
-- | Tests for remote relationships from remote schemas. Unlike the "ToX"
|
|
-- modules, this module specifically cares about the remote schema on the LHS:
|
|
-- - testing the metadata API
|
|
-- - testing how we generate queries to remote schemas for the purpose of
|
|
-- making joins against them.
|
|
module Test.RemoteRelationship.FromRemoteSchemaSpec (spec) where
|
|
|
|
import Data.Functor ((<&>))
|
|
import Data.Morpheus.Document (gqlDocument)
|
|
import Data.Morpheus.Types
|
|
import Data.Text (Text)
|
|
import Harness.Backend.Postgres qualified as Postgres
|
|
import Harness.GraphqlEngine qualified as GraphqlEngine
|
|
import Harness.Quoter.Graphql (graphql)
|
|
import Harness.Quoter.Yaml (shouldReturnYaml, yaml)
|
|
import Harness.RemoteServer qualified as RemoteServer
|
|
import Harness.State (Server, State, stopServer)
|
|
import Harness.Test.Context (Context (..))
|
|
import Harness.Test.Context qualified as Context
|
|
import Harness.Test.Schema qualified as Schema
|
|
import Test.Hspec (SpecWith, describe, it)
|
|
import Prelude
|
|
|
|
--------------------------------------------------------------------------------
|
|
-- Preamble
|
|
|
|
spec :: SpecWith State
|
|
spec = Context.runWithLocalState [context] tests
|
|
where
|
|
context =
|
|
Context
|
|
{ name = Context.RemoteGraphQLServer,
|
|
-- start only one remote server
|
|
mkLocalState = \_state ->
|
|
RemoteServer.run $
|
|
RemoteServer.generateQueryInterpreter $
|
|
Query
|
|
{ object = objectResolver,
|
|
writer = writerResolver,
|
|
artist = artistResolver,
|
|
objects = objectsResolver,
|
|
articles = articlesResolver
|
|
},
|
|
-- set that remote server as both source and target, for convenience
|
|
-- start a RHS Postgres for Metadata tests only
|
|
setup = \(state, server) -> do
|
|
GraphqlEngine.clearMetadata state
|
|
addRemoteSchema state "remote" server
|
|
addRelationships state
|
|
rhsPostgresSetup state,
|
|
-- shutdown the server
|
|
teardown = \(state, server) -> do
|
|
GraphqlEngine.clearMetadata state
|
|
stopServer server
|
|
rhsPostgresTeardown,
|
|
-- no custom options
|
|
customOptions = Nothing
|
|
}
|
|
|
|
-- | Add a remote schema to the engine with the given name.
|
|
addRemoteSchema :: State -> String -> Server -> IO ()
|
|
addRemoteSchema state rsName remoteServer = do
|
|
let remoteSchemaEndpoint = RemoteServer.graphqlEndpoint remoteServer
|
|
GraphqlEngine.postMetadata_
|
|
state
|
|
[yaml|
|
|
type: add_remote_schema
|
|
args:
|
|
name: *rsName
|
|
definition:
|
|
url: *remoteSchemaEndpoint
|
|
|]
|
|
|
|
-- | Create the remote relationships.
|
|
addRelationships :: State -> IO ()
|
|
addRelationships state = do
|
|
GraphqlEngine.postMetadata_
|
|
state
|
|
[yaml|
|
|
type: bulk
|
|
args:
|
|
- type: create_remote_schema_remote_relationship
|
|
args:
|
|
remote_schema: remote
|
|
type_name: Writer
|
|
name: articles
|
|
definition:
|
|
to_remote_schema:
|
|
remote_schema: remote
|
|
lhs_fields: [wIds]
|
|
remote_field:
|
|
articles:
|
|
arguments:
|
|
ids: $wIds
|
|
- type: create_remote_schema_remote_relationship
|
|
args:
|
|
remote_schema: remote
|
|
type_name: Artist
|
|
name: articles
|
|
definition:
|
|
to_remote_schema:
|
|
remote_schema: remote
|
|
lhs_fields: [aIds]
|
|
remote_field:
|
|
articles:
|
|
arguments:
|
|
ids: $aIds
|
|
- type: create_remote_schema_remote_relationship
|
|
args:
|
|
remote_schema: remote
|
|
type_name: Article
|
|
name: artist
|
|
definition:
|
|
to_remote_schema:
|
|
remote_schema: remote
|
|
lhs_fields: [aId]
|
|
remote_field:
|
|
artist:
|
|
arguments:
|
|
id: $aId
|
|
- type: create_remote_schema_remote_relationship
|
|
args:
|
|
remote_schema: remote
|
|
type_name: Article
|
|
name: writer
|
|
definition:
|
|
to_remote_schema:
|
|
remote_schema: remote
|
|
lhs_fields: [wId]
|
|
remote_field:
|
|
writer:
|
|
arguments:
|
|
id: $wId
|
|
|]
|
|
|
|
--------------------------------------------------------------------------------
|
|
-- Remote schema
|
|
|
|
[gqlDocument|
|
|
|
|
type Query {
|
|
object(id: Int!): Object
|
|
writer(id: Int!): Writer
|
|
artist(id: Int!): Artist
|
|
objects(ids: [Int!]!): [Object]!
|
|
articles(ids: [Int!]!): [Article]!
|
|
}
|
|
|
|
union Object = Writer | Artist | Article
|
|
|
|
type Writer {
|
|
id: Int!
|
|
name: String!
|
|
wIds: [Int!]!
|
|
local_articles: [Article!]!
|
|
}
|
|
|
|
type Artist {
|
|
id: Int!
|
|
name: String!
|
|
aIds: [Int!]!
|
|
local_articles: [Article!]!
|
|
self: Artist!
|
|
}
|
|
|
|
type Article {
|
|
id: Int!
|
|
title: String!
|
|
aId: Int!
|
|
wId: Int!
|
|
}
|
|
|
|
|]
|
|
|
|
knownObjects :: Monad m => [(Int, Object m)]
|
|
knownObjects =
|
|
[ (101, ObjectWriter writer1),
|
|
(102, ObjectWriter writer2),
|
|
(201, ObjectArtist artist1),
|
|
(202, ObjectArtist artist2),
|
|
(301, ObjectArticle article1),
|
|
(302, ObjectArticle article2),
|
|
(303, ObjectArticle article3),
|
|
(304, ObjectArticle article4)
|
|
]
|
|
where
|
|
writer1 = Writer (pure 101) (pure "Writer1") (pure [301, 302]) (pure [article1, article2])
|
|
writer2 = Writer (pure 102) (pure "Writer2") (pure [303, 304]) (pure [article3, article4])
|
|
artist1 = Artist (pure 201) (pure "Artist1") (pure [301, 303]) (pure [article1, article3]) (pure artist1)
|
|
artist2 = Artist (pure 202) (pure "Artist2") (pure [302, 304]) (pure [article2, article4]) (pure artist2)
|
|
article1 = Article (pure 301) (pure "Article1") (pure 201) (pure 101)
|
|
article2 = Article (pure 302) (pure "Article2") (pure 202) (pure 101)
|
|
article3 = Article (pure 303) (pure "Article3") (pure 201) (pure 102)
|
|
article4 = Article (pure 304) (pure "Article4") (pure 202) (pure 102)
|
|
|
|
objectResolver :: Monad m => Arg "id" Int -> m (Maybe (Object m))
|
|
objectResolver (Arg objectId) = pure $ lookup objectId knownObjects
|
|
|
|
writerResolver :: Monad m => Arg "id" Int -> m (Maybe (Writer m))
|
|
writerResolver (Arg objectId) =
|
|
pure $ case lookup objectId knownObjects of
|
|
Just (ObjectWriter w) -> Just w
|
|
_ -> Nothing
|
|
|
|
artistResolver :: Monad m => Arg "id" Int -> m (Maybe (Artist m))
|
|
artistResolver (Arg objectId) =
|
|
pure $ case lookup objectId knownObjects of
|
|
Just (ObjectArtist a) -> Just a
|
|
_ -> Nothing
|
|
|
|
objectsResolver :: Monad m => Arg "ids" [Int] -> m [Maybe (Object m)]
|
|
objectsResolver (Arg objectIds) = pure [lookup objectId knownObjects | objectId <- objectIds]
|
|
|
|
articlesResolver :: Monad m => Arg "ids" [Int] -> m [Maybe (Article m)]
|
|
articlesResolver (Arg objectIds) =
|
|
pure $
|
|
objectIds <&> \objectId ->
|
|
case lookup objectId knownObjects of
|
|
Just (ObjectArticle a) -> Just a
|
|
_ -> Nothing
|
|
|
|
--------------------------------------------------------------------------------
|
|
-- RHS Postgres (for metadata only)
|
|
|
|
track :: Schema.Table
|
|
track =
|
|
Schema.Table
|
|
"track"
|
|
[ Schema.column "id" Schema.TInt,
|
|
Schema.column "title" Schema.TStr,
|
|
Schema.columnNull "album_id" Schema.TInt
|
|
]
|
|
["id"]
|
|
[]
|
|
[ [Schema.VInt 1, Schema.VStr "track1_album1", Schema.VInt 1],
|
|
[Schema.VInt 2, Schema.VStr "track2_album1", Schema.VInt 1],
|
|
[Schema.VInt 3, Schema.VStr "track3_album1", Schema.VInt 1],
|
|
[Schema.VInt 4, Schema.VStr "track1_album2", Schema.VInt 2],
|
|
[Schema.VInt 5, Schema.VStr "track2_album2", Schema.VInt 2],
|
|
[Schema.VInt 6, Schema.VStr "track1_album3", Schema.VInt 3],
|
|
[Schema.VInt 7, Schema.VStr "track2_album3", Schema.VInt 3],
|
|
[Schema.VInt 8, Schema.VStr "track_no_album", Schema.VNull]
|
|
]
|
|
|
|
rhsPostgresSetup :: State -> IO ()
|
|
rhsPostgresSetup state = do
|
|
let sourceName = "db"
|
|
sourceConfig = Postgres.defaultSourceConfiguration
|
|
GraphqlEngine.postMetadata_
|
|
state
|
|
[yaml|
|
|
type: pg_add_source
|
|
args:
|
|
name: *sourceName
|
|
configuration: *sourceConfig
|
|
|]
|
|
-- setup tables only
|
|
Postgres.createTable track
|
|
Postgres.insertTable track
|
|
Schema.trackTable Context.Postgres sourceName track state
|
|
|
|
rhsPostgresTeardown :: IO ()
|
|
rhsPostgresTeardown = Postgres.dropTable track
|
|
|
|
--------------------------------------------------------------------------------
|
|
-- Tests
|
|
|
|
tests :: Context.Options -> SpecWith (State, Server)
|
|
tests opts = do
|
|
-- tests metadata API
|
|
metadataAPITests
|
|
-- ensures setup is correct
|
|
noJoinsTests opts
|
|
simpleTests opts
|
|
-- joins on neither part of the union
|
|
joinArticleTests opts
|
|
-- joins on parts of the union
|
|
joinWriterTests opts
|
|
joinArtistTests opts
|
|
-- joins on deeply nested joins
|
|
deeplyNestedJoinTests opts
|
|
|
|
metadataAPITests :: SpecWith (State, Server)
|
|
metadataAPITests = describe "metadata API" do
|
|
it "adds a RS-RS relationship" \(state, _) ->
|
|
GraphqlEngine.postMetadata_
|
|
state
|
|
[yaml|
|
|
type: create_remote_schema_remote_relationship
|
|
args:
|
|
remote_schema: remote
|
|
type_name: Writer
|
|
name: nonsensical1
|
|
definition:
|
|
to_remote_schema:
|
|
remote_schema: remote
|
|
lhs_fields: [wIds]
|
|
remote_field:
|
|
articles:
|
|
arguments:
|
|
ids: $wIds
|
|
|]
|
|
it "adds a RS-RD relationship" \(state, _) ->
|
|
GraphqlEngine.postMetadata_
|
|
state
|
|
[yaml|
|
|
type: create_remote_schema_remote_relationship
|
|
args:
|
|
remote_schema: remote
|
|
type_name: Writer
|
|
name: nonsensical2
|
|
definition:
|
|
to_source:
|
|
source: db
|
|
table: {schema: hasura, name: track}
|
|
relationship_type: object
|
|
field_mapping:
|
|
id: id
|
|
|]
|
|
it "updates a RS-RS relationship" \(state, _) ->
|
|
GraphqlEngine.postMetadata_
|
|
state
|
|
[yaml|
|
|
type: update_remote_schema_remote_relationship
|
|
args:
|
|
remote_schema: remote
|
|
type_name: Writer
|
|
name: nonsensical1
|
|
definition:
|
|
to_remote_schema:
|
|
remote_schema: remote
|
|
lhs_fields: [id]
|
|
remote_field:
|
|
artist:
|
|
arguments:
|
|
id: $id
|
|
|]
|
|
it "updates a RS-DB relationship" \(state, _) ->
|
|
GraphqlEngine.postMetadata_
|
|
state
|
|
[yaml|
|
|
type: update_remote_schema_remote_relationship
|
|
args:
|
|
remote_schema: remote
|
|
type_name: Writer
|
|
name: nonsensical2
|
|
definition:
|
|
to_source:
|
|
source: db
|
|
table: {schema: hasura, name: track}
|
|
relationship_type: array
|
|
field_mapping:
|
|
id: id
|
|
|]
|
|
it "deletes a RS-RS relationship" \(state, _) ->
|
|
GraphqlEngine.postMetadata_
|
|
state
|
|
[yaml|
|
|
type: delete_remote_schema_remote_relationship
|
|
args:
|
|
remote_schema: remote
|
|
type_name: Writer
|
|
name: nonsensical1
|
|
|]
|
|
it "deletes a RS-DB relationship" \(state, _) ->
|
|
GraphqlEngine.postMetadata_
|
|
state
|
|
[yaml|
|
|
type: delete_remote_schema_remote_relationship
|
|
args:
|
|
remote_schema: remote
|
|
type_name: Writer
|
|
name: nonsensical2
|
|
|]
|
|
|
|
-- | Ensure we don't insert `__hasura_internal_typename` when there are no
|
|
-- joins.
|
|
noJoinsTests :: Context.Options -> SpecWith (State, Server)
|
|
noJoinsTests opts = describe "simple joins" do
|
|
it "select objects without remote joins" \(state, _) -> do
|
|
let query =
|
|
[graphql|
|
|
query {
|
|
obj101: object(id: 101) {
|
|
__typename
|
|
}
|
|
obj201: object(id: 201) {
|
|
__typename
|
|
}
|
|
obj301: object(id: 301) {
|
|
__typename
|
|
}
|
|
}
|
|
|]
|
|
expectedResponse =
|
|
[yaml|
|
|
data:
|
|
obj101:
|
|
__typename: Writer
|
|
obj201:
|
|
__typename: Artist
|
|
obj301:
|
|
__typename: Article
|
|
|
|
|]
|
|
shouldReturnYaml
|
|
opts
|
|
(GraphqlEngine.postGraphql state query)
|
|
expectedResponse
|
|
|
|
simpleTests :: Context.Options -> SpecWith (State, Server)
|
|
simpleTests opts = describe "simple joins" do
|
|
it "joins writer against articles" \(state, _) -> do
|
|
let query =
|
|
[graphql|
|
|
query {
|
|
writer(id: 101) {
|
|
name
|
|
articles {
|
|
title
|
|
}
|
|
}
|
|
}
|
|
|]
|
|
expectedResponse =
|
|
[yaml|
|
|
data:
|
|
writer:
|
|
name: "Writer1"
|
|
articles:
|
|
- title: Article1
|
|
- title: Article2
|
|
|]
|
|
shouldReturnYaml
|
|
opts
|
|
(GraphqlEngine.postGraphql state query)
|
|
expectedResponse
|
|
it "joins no writer against articles" \(state, _) -> do
|
|
let query =
|
|
[graphql|
|
|
query {
|
|
writer(id: 0) {
|
|
name
|
|
articles {
|
|
title
|
|
}
|
|
}
|
|
}
|
|
|]
|
|
expectedResponse =
|
|
[yaml|
|
|
data:
|
|
writer: null
|
|
|]
|
|
shouldReturnYaml
|
|
opts
|
|
(GraphqlEngine.postGraphql state query)
|
|
expectedResponse
|
|
it "joins artist against articles" \(state, _) -> do
|
|
let query =
|
|
[graphql|
|
|
query {
|
|
artist(id: 201) {
|
|
name
|
|
articles {
|
|
title
|
|
}
|
|
}
|
|
}
|
|
|]
|
|
expectedResponse =
|
|
[yaml|
|
|
data:
|
|
artist:
|
|
name: "Artist1"
|
|
articles:
|
|
- title: Article1
|
|
- title: Article3
|
|
|]
|
|
shouldReturnYaml
|
|
opts
|
|
(GraphqlEngine.postGraphql state query)
|
|
expectedResponse
|
|
it "joins no artist against articles" \(state, _) -> do
|
|
let query =
|
|
[graphql|
|
|
query {
|
|
artist(id: 0) {
|
|
name
|
|
articles {
|
|
title
|
|
}
|
|
}
|
|
}
|
|
|]
|
|
expectedResponse =
|
|
[yaml|
|
|
data:
|
|
artist: null
|
|
|]
|
|
shouldReturnYaml
|
|
opts
|
|
(GraphqlEngine.postGraphql state query)
|
|
expectedResponse
|
|
|
|
joinArticleTests :: Context.Options -> SpecWith (State, Server)
|
|
joinArticleTests opts = describe "join from article object" do
|
|
it "does not join" \(state, _) -> do
|
|
let query =
|
|
[graphql|
|
|
query {
|
|
object(id: 301) {
|
|
... on Article {
|
|
title
|
|
}
|
|
... on Artist {
|
|
name
|
|
articles {
|
|
title
|
|
}
|
|
}
|
|
... on Writer {
|
|
name
|
|
articles {
|
|
title
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|]
|
|
expectedResponse =
|
|
[yaml|
|
|
data:
|
|
object:
|
|
# to circumvent https://github.com/morpheusgraphql/morpheus-graphql/issues/687
|
|
__typename: Article
|
|
title: "Article1"
|
|
|]
|
|
shouldReturnYaml
|
|
opts
|
|
(GraphqlEngine.postGraphql state query)
|
|
expectedResponse
|
|
|
|
joinWriterTests :: Context.Options -> SpecWith (State, Server)
|
|
joinWriterTests opts = describe "join from writer object" do
|
|
it "joins against articles" \(state, _) -> do
|
|
let query =
|
|
[graphql|
|
|
query {
|
|
object(id: 101) {
|
|
... on Article {
|
|
title
|
|
}
|
|
... on Artist {
|
|
name
|
|
articles {
|
|
title
|
|
}
|
|
}
|
|
... on Writer {
|
|
name
|
|
articles {
|
|
title
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|]
|
|
expectedResponse =
|
|
[yaml|
|
|
data:
|
|
object:
|
|
# to circumvent https://github.com/morpheusgraphql/morpheus-graphql/issues/687
|
|
__typename: Writer
|
|
name: "Writer1"
|
|
articles:
|
|
- title: Article1
|
|
- title: Article2
|
|
|]
|
|
shouldReturnYaml
|
|
opts
|
|
(GraphqlEngine.postGraphql state query)
|
|
expectedResponse
|
|
|
|
joinArtistTests :: Context.Options -> SpecWith (State, Server)
|
|
joinArtistTests opts = describe "join from artist object" do
|
|
it "joins against articles" \(state, _) -> do
|
|
let query =
|
|
[graphql|
|
|
query {
|
|
object(id: 201) {
|
|
... on Article {
|
|
title
|
|
}
|
|
... on Artist {
|
|
name
|
|
articles {
|
|
title
|
|
}
|
|
}
|
|
... on Writer {
|
|
name
|
|
articles {
|
|
title
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|]
|
|
expectedResponse =
|
|
[yaml|
|
|
data:
|
|
object:
|
|
# to circumvent https://github.com/morpheusgraphql/morpheus-graphql/issues/687
|
|
__typename: Artist
|
|
name: "Artist1"
|
|
articles:
|
|
- title: Article1
|
|
- title: Article3
|
|
|]
|
|
shouldReturnYaml
|
|
opts
|
|
(GraphqlEngine.postGraphql state query)
|
|
expectedResponse
|
|
|
|
deeplyNestedJoinTests :: Context.Options -> SpecWith (State, Server)
|
|
deeplyNestedJoinTests opts = describe "join from artist object" do
|
|
it "joins ambiguously nested articles depending on the full path" \(state, _) -> do
|
|
let query =
|
|
[graphql|
|
|
query {
|
|
objects(ids: [101, 201]) {
|
|
... on Artist {
|
|
local_articles { # local join
|
|
title
|
|
foo: writer { # remote join
|
|
bar: wIds
|
|
baz: articles { # remote join
|
|
title
|
|
}
|
|
}
|
|
}
|
|
}
|
|
... on Writer {
|
|
local_articles { # local join
|
|
title
|
|
foo: artist { # remote join
|
|
bar: aIds
|
|
baz: articles { # remote join
|
|
title
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|]
|
|
expectedResponse =
|
|
[yaml|
|
|
data:
|
|
objects:
|
|
- local_articles:
|
|
- title: Article1
|
|
foo:
|
|
bar:
|
|
- 301
|
|
- 303
|
|
baz:
|
|
- title: Article1
|
|
- title: Article3
|
|
- title: Article2
|
|
foo:
|
|
bar:
|
|
- 302
|
|
- 304
|
|
baz:
|
|
- title: Article2
|
|
- title: Article4
|
|
# to circumvent https://github.com/morpheusgraphql/morpheus-graphql/issues/687
|
|
__typename: Writer
|
|
- local_articles:
|
|
- title: Article1
|
|
foo:
|
|
bar:
|
|
- 301
|
|
- 302
|
|
baz:
|
|
- title: Article1
|
|
- title: Article2
|
|
- title: Article3
|
|
foo:
|
|
bar:
|
|
- 303
|
|
- 304
|
|
baz:
|
|
- title: Article3
|
|
- title: Article4
|
|
# to circumvent https://github.com/morpheusgraphql/morpheus-graphql/issues/687
|
|
__typename: Artist
|
|
|]
|
|
shouldReturnYaml
|
|
opts
|
|
(GraphqlEngine.postGraphql state query)
|
|
expectedResponse
|
|
it "joins nested articles at different depths" \(state, _) -> do
|
|
let query =
|
|
[graphql|
|
|
query {
|
|
objects(ids: [102, 202]) {
|
|
... on Artist {
|
|
bar: self {
|
|
baz: articles {
|
|
title
|
|
}
|
|
}
|
|
}
|
|
... on Writer {
|
|
bar: articles {
|
|
title
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|]
|
|
expectedResponse =
|
|
[yaml|
|
|
data:
|
|
objects:
|
|
- bar:
|
|
- title: Article3
|
|
- title: Article4
|
|
# to circumvent https://github.com/morpheusgraphql/morpheus-graphql/issues/687
|
|
__typename: Writer
|
|
- bar:
|
|
baz:
|
|
- title: Article2
|
|
- title: Article4
|
|
# to circumvent https://github.com/morpheusgraphql/morpheus-graphql/issues/687
|
|
__typename: Artist
|
|
|]
|
|
shouldReturnYaml
|
|
opts
|
|
(GraphqlEngine.postGraphql state query)
|
|
expectedResponse
|