test: Reorganize test/ folder into one subdirectory for each test type
This commit is contained in:
committed by
Wolfgang Walther
parent
8060fe3559
commit
8b63d928ae
@@ -0,0 +1,276 @@
|
||||
module Feature.AndOrParamsSpec where
|
||||
|
||||
import Network.Wai (Application)
|
||||
|
||||
import Network.HTTP.Types
|
||||
import Test.Hspec
|
||||
import Test.Hspec.Wai
|
||||
import Test.Hspec.Wai.JSON
|
||||
|
||||
import PostgREST.Config.PgVersion (PgVersion, pgVersion112)
|
||||
|
||||
import Protolude hiding (get)
|
||||
import SpecHelper
|
||||
|
||||
spec :: PgVersion -> SpecWith ((), Application)
|
||||
spec actualPgVersion =
|
||||
describe "and/or params used for complex boolean logic" $ do
|
||||
context "used with GET" $ do
|
||||
context "or param" $ do
|
||||
it "can do simple logic" $
|
||||
get "/entities?or=(id.eq.1,id.eq.2)&select=id" `shouldRespondWith`
|
||||
[json|[{ "id": 1 }, { "id": 2 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||
it "can negate simple logic" $
|
||||
get "/entities?not.or=(id.eq.1,id.eq.2)&select=id" `shouldRespondWith`
|
||||
[json|[{ "id": 3 }, { "id": 4 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||
it "can be combined with traditional filters" $
|
||||
get "/entities?or=(id.eq.1,id.eq.2)&name=eq.entity 1&select=id" `shouldRespondWith`
|
||||
[json|[{ "id": 1 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
context "embedded levels" $ do
|
||||
it "can do logic on the second level" $
|
||||
get "/entities?child_entities.or=(id.eq.1,name.eq.child entity 2)&select=id,child_entities(id)" `shouldRespondWith`
|
||||
[json|[
|
||||
{"id": 1, "child_entities": [ { "id": 1 }, { "id": 2 } ] }, { "id": 2, "child_entities": []},
|
||||
{"id": 3, "child_entities": []}, {"id": 4, "child_entities": []}
|
||||
]|] { matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "can do logic on the third level" $
|
||||
get "/entities?child_entities.grandchild_entities.or=(id.eq.1,id.eq.2)&select=id,child_entities(id,grandchild_entities(id))"
|
||||
`shouldRespondWith`
|
||||
[json|[
|
||||
{"id": 1, "child_entities": [
|
||||
{ "id": 1, "grandchild_entities": [ { "id": 1 }, { "id": 2 } ]},
|
||||
{ "id": 2, "grandchild_entities": []},
|
||||
{ "id": 4, "grandchild_entities": []},
|
||||
{ "id": 5, "grandchild_entities": []}
|
||||
]},
|
||||
{"id": 2, "child_entities": [
|
||||
{ "id": 3, "grandchild_entities": []},
|
||||
{ "id": 6, "grandchild_entities": []}
|
||||
]},
|
||||
{"id": 3, "child_entities": []},
|
||||
{"id": 4, "child_entities": []}
|
||||
]|]
|
||||
|
||||
context "and/or params combined" $ do
|
||||
it "can be nested inside the same expression" $
|
||||
get "/entities?or=(and(name.eq.entity 2,id.eq.2),and(name.eq.entity 1,id.eq.1))&select=id" `shouldRespondWith`
|
||||
[json|[{ "id": 1 }, { "id": 2 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||
it "can be negated while nested" $
|
||||
get "/entities?or=(not.and(name.eq.entity 2,id.eq.2),not.and(name.eq.entity 1,id.eq.1))&select=id" `shouldRespondWith`
|
||||
[json|[{ "id": 1 }, { "id": 2 }, { "id": 3 }, { "id": 4 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||
it "can be combined unnested" $
|
||||
get "/entities?and=(id.eq.1,name.eq.entity 1)&or=(id.eq.1,id.eq.2)&select=id" `shouldRespondWith`
|
||||
[json|[{ "id": 1 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
context "operators inside and/or" $ do
|
||||
it "can handle eq and neq" $
|
||||
get "/entities?and=(id.eq.1,id.neq.2))&select=id" `shouldRespondWith`
|
||||
[json|[{ "id": 1 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||
it "can handle lt and gt" $
|
||||
get "/entities?or=(id.lt.2,id.gt.3)&select=id" `shouldRespondWith`
|
||||
[json|[{ "id": 1 }, { "id": 4 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||
it "can handle lte and gte" $
|
||||
get "/entities?or=(id.lte.2,id.gte.3)&select=id" `shouldRespondWith`
|
||||
[json|[{ "id": 1 }, { "id": 2 }, { "id": 3 }, { "id": 4 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||
it "can handle like and ilike" $
|
||||
get "/entities?or=(name.like.*1,name.ilike.*ENTITY 2)&select=id" `shouldRespondWith`
|
||||
[json|[{ "id": 1 }, { "id": 2 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||
it "can handle in" $
|
||||
get "/entities?or=(id.in.(1,2),id.in.(3,4))&select=id" `shouldRespondWith`
|
||||
[json|[{ "id": 1 }, { "id": 2 }, { "id": 3 }, { "id": 4 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||
it "can handle is" $
|
||||
get "/entities?and=(name.is.null,arr.is.null)&select=id" `shouldRespondWith`
|
||||
[json|[{ "id": 4 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||
it "can handle fts" $ do
|
||||
get "/entities?or=(text_search_vector.fts.bar,text_search_vector.fts.baz)&select=id" `shouldRespondWith`
|
||||
[json|[{ "id": 1 }, { "id": 2 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||
get "/tsearch?or=(text_search_vector.plfts(german).Art%20Spass, text_search_vector.plfts(french).amusant%20impossible, text_search_vector.fts(english).impossible)" `shouldRespondWith`
|
||||
[json|[
|
||||
{"text_search_vector": "'fun':5 'imposs':9 'kind':3" },
|
||||
{"text_search_vector": "'amus':5 'fair':7 'impossibl':9 'peu':4" },
|
||||
{"text_search_vector": "'art':4 'spass':5 'unmog':7"}
|
||||
]|] { matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
when (actualPgVersion >= pgVersion112) $
|
||||
it "can handle wfts (websearch_to_tsquery)" $
|
||||
get "/tsearch?or=(text_search_vector.plfts(german).Art,text_search_vector.plfts(french).amusant,text_search_vector.not.wfts(english).impossible)"
|
||||
`shouldRespondWith`
|
||||
[json|[
|
||||
{"text_search_vector": "'also':2 'fun':3 'possibl':8" },
|
||||
{"text_search_vector": "'ate':3 'cat':2 'fat':1 'rat':4" },
|
||||
{"text_search_vector": "'amus':5 'fair':7 'impossibl':9 'peu':4" },
|
||||
{"text_search_vector": "'art':4 'spass':5 'unmog':7" }
|
||||
]|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "can handle cs and cd" $
|
||||
get "/entities?or=(arr.cs.{1,2,3},arr.cd.{1})&select=id" `shouldRespondWith`
|
||||
[json|[{ "id": 1 },{ "id": 3 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "can handle range operators" $ do
|
||||
get "/ranges?range=eq.[1,3]&select=id" `shouldRespondWith`
|
||||
[json|[{ "id": 1 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||
get "/ranges?range=neq.[1,3]&select=id" `shouldRespondWith`
|
||||
[json|[{ "id": 2 }, { "id": 3 }, { "id": 4 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||
get "/ranges?range=lt.[1,10]&select=id" `shouldRespondWith`
|
||||
[json|[{ "id": 1 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||
get "/ranges?range=gt.[8,11]&select=id" `shouldRespondWith`
|
||||
[json|[{ "id": 4 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||
get "/ranges?range=lte.[1,3]&select=id" `shouldRespondWith`
|
||||
[json|[{ "id": 1 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||
get "/ranges?range=gte.[2,3]&select=id" `shouldRespondWith`
|
||||
[json|[{ "id": 2 }, { "id": 3 }, { "id": 4 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||
get "/ranges?range=cs.[1,2]&select=id" `shouldRespondWith`
|
||||
[json|[{ "id": 1 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||
get "/ranges?range=cd.[1,6]&select=id" `shouldRespondWith`
|
||||
[json|[{ "id": 1 }, { "id": 2 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||
get "/ranges?range=ov.[0,4]&select=id" `shouldRespondWith`
|
||||
[json|[{ "id": 1 }, { "id": 2 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||
get "/ranges?range=sl.[9,10]&select=id" `shouldRespondWith`
|
||||
[json|[{ "id": 1 }, { "id": 2 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||
get "/ranges?range=sr.[3,4]&select=id" `shouldRespondWith`
|
||||
[json|[{ "id": 3 }, { "id": 4 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||
get "/ranges?range=nxr.[4,7]&select=id" `shouldRespondWith`
|
||||
[json|[{ "id": 1 }, { "id": 2 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||
get "/ranges?range=nxl.[4,7]&select=id" `shouldRespondWith`
|
||||
[json|[{ "id": 3 }, { "id": 4 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||
get "/ranges?range=adj.(3,10]&select=id" `shouldRespondWith`
|
||||
[json|[{ "id": 1 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "can handle array operators" $ do
|
||||
get "/entities?arr=eq.{1,2,3}&select=id" `shouldRespondWith`
|
||||
[json|[{ "id": 3 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||
get "/entities?arr=neq.{1,2}&select=id" `shouldRespondWith`
|
||||
[json|[{ "id": 1 }, { "id": 3 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||
get "/entities?arr=lt.{2,3}&select=id" `shouldRespondWith`
|
||||
[json|[{ "id": 1 }, { "id": 2 }, { "id": 3 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||
get "/entities?arr=lt.{2,0}&select=id" `shouldRespondWith`
|
||||
[json|[{ "id": 1 }, { "id": 2 }, { "id": 3 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||
get "/entities?arr=gt.{1,1}&select=id" `shouldRespondWith`
|
||||
[json|[{ "id": 2 }, { "id": 3 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||
get "/entities?arr=gt.{3}&select=id" `shouldRespondWith`
|
||||
[json|[]|] { matchHeaders = [matchContentTypeJson] }
|
||||
get "/entities?arr=lte.{2,1}&select=id" `shouldRespondWith`
|
||||
[json|[{ "id": 1 }, { "id": 2 }, { "id": 3 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||
get "/entities?arr=lte.{1,2,3}&select=id" `shouldRespondWith`
|
||||
[json|[{ "id": 1 }, { "id": 2 }, { "id": 3 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||
get "/entities?arr=lte.{1,2}&select=id" `shouldRespondWith`
|
||||
[json|[{ "id": 1 }, { "id": 2 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||
get "/entities?arr=cs.{1,2}&select=id" `shouldRespondWith`
|
||||
[json|[{ "id": 2 }, { "id": 3 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||
get "/entities?arr=cd.{1,2,6}&select=id" `shouldRespondWith`
|
||||
[json|[{ "id": 1 }, { "id": 2 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||
get "/entities?arr=ov.{3}&select=id" `shouldRespondWith`
|
||||
[json|[{ "id": 3 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||
get "/entities?arr=ov.{2,3}&select=id" `shouldRespondWith`
|
||||
[json|[{ "id": 2 }, { "id": 3 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
context "operators with not" $ do
|
||||
it "eq, cs, like can be negated" $
|
||||
get "/entities?and=(arr.not.cs.{1,2,3},and(id.not.eq.2,name.not.like.*3))&select=id" `shouldRespondWith`
|
||||
[json|[{ "id": 1}]|] { matchHeaders = [matchContentTypeJson] }
|
||||
it "in, is, fts can be negated" $
|
||||
get "/entities?and=(id.not.in.(1,3),and(name.not.is.null,text_search_vector.not.fts.foo))&select=id" `shouldRespondWith`
|
||||
[json|[{ "id": 2}]|] { matchHeaders = [matchContentTypeJson] }
|
||||
it "lt, gte, cd can be negated" $
|
||||
get "/entities?and=(arr.not.cd.{1},or(id.not.lt.1,id.not.gte.3))&select=id" `shouldRespondWith`
|
||||
[json|[{"id": 2}, {"id": 3}]|] { matchHeaders = [matchContentTypeJson] }
|
||||
it "gt, lte, ilike can be negated" $
|
||||
get "/entities?and=(name.not.ilike.*ITY2,or(id.not.gt.4,id.not.lte.1))&select=id" `shouldRespondWith`
|
||||
[json|[{"id": 1}, {"id": 2}, {"id": 3}]|] { matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
context "and/or params with quotes" $ do
|
||||
it "eq can have quotes" $
|
||||
get "/grandchild_entities?or=(name.eq.\"(grandchild,entity,4)\",name.eq.\"(grandchild,entity,5)\")&select=id" `shouldRespondWith`
|
||||
[json|[{ "id": 4 }, { "id": 5 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||
it "like and ilike can have quotes" $
|
||||
get "/grandchild_entities?or=(name.like.\"*ity,4*\",name.ilike.\"*ITY,5)\")&select=id" `shouldRespondWith`
|
||||
[json|[{ "id": 4 }, { "id": 5 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||
it "in can have quotes" $
|
||||
get "/grandchild_entities?or=(id.in.(\"1\",\"2\"),id.in.(\"3\",\"4\"))&select=id" `shouldRespondWith`
|
||||
[json|[{ "id": 1 }, { "id": 2 }, { "id": 3 }, { "id": 4 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "allows whitespace" $
|
||||
get "/entities?and=( and ( id.in.( 1, 2, 3 ) , id.eq.3 ) , or ( id.eq.2 , id.eq.3 ) )&select=id" `shouldRespondWith`
|
||||
[json|[{ "id": 3 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
context "multiple and/or conditions" $ do
|
||||
it "cannot have zero conditions" $
|
||||
get "/entities?or=()" `shouldRespondWith`
|
||||
[json|{
|
||||
"details": "unexpected \")\" expecting field name (* or [a..z0..9_]), negation operator (not) or logic operator (and, or)",
|
||||
"message": "\"failed to parse logic tree (())\" (line 1, column 4)"
|
||||
}|] { matchStatus = 400, matchHeaders = [matchContentTypeJson] }
|
||||
it "can have a single condition" $ do
|
||||
get "/entities?or=(id.eq.1)&select=id" `shouldRespondWith`
|
||||
[json|[{"id":1}]|] { matchHeaders = [matchContentTypeJson] }
|
||||
get "/entities?and=(id.eq.1)&select=id" `shouldRespondWith`
|
||||
[json|[{"id":1}]|] { matchHeaders = [matchContentTypeJson] }
|
||||
it "can have three conditions" $ do
|
||||
get "/grandchild_entities?or=(id.eq.1, id.eq.2, id.eq.3)&select=id" `shouldRespondWith`
|
||||
[json|[{"id":1}, {"id":2}, {"id":3}]|] { matchHeaders = [matchContentTypeJson] }
|
||||
get "/grandchild_entities?and=(id.in.(1,2), id.in.(3,1), id.in.(1,4))&select=id" `shouldRespondWith`
|
||||
[json|[{"id":1}]|] { matchHeaders = [matchContentTypeJson] }
|
||||
it "can have four conditions combining and/or" $ do
|
||||
get "/grandchild_entities?or=( id.eq.1, id.eq.2, and(id.in.(1,3), id.in.(2,3)), id.eq.4 )&select=id" `shouldRespondWith`
|
||||
[json|[{"id":1}, {"id":2}, {"id":3}, {"id":4}]|] { matchHeaders = [matchContentTypeJson] }
|
||||
get "/grandchild_entities?and=( id.eq.1, not.or(id.eq.2, id.eq.3), id.in.(1,4), or(id.eq.1, id.eq.4) )&select=id" `shouldRespondWith`
|
||||
[json|[{"id":1}]|] { matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
context "used with POST" $
|
||||
it "includes related data with filters" $
|
||||
request methodPost "/child_entities?select=id,entities(id)&entities.or=(id.eq.2,id.eq.3)&entities.order=id"
|
||||
[("Prefer", "return=representation")]
|
||||
[json|[
|
||||
{"id":7,"name":"entity 4","parent_id":1},
|
||||
{"id":8,"name":"entity 5","parent_id":2},
|
||||
{"id":9,"name":"entity 6","parent_id":3}
|
||||
]|]
|
||||
`shouldRespondWith`
|
||||
[json|[{"id": 7, "entities":null}, {"id": 8, "entities": {"id": 2}}, {"id": 9, "entities": {"id": 3}}]|]
|
||||
{ matchStatus = 201 }
|
||||
|
||||
context "used with PATCH" $
|
||||
it "succeeds when using and/or params" $
|
||||
request methodPatch "/grandchild_entities?or=(id.eq.1,id.eq.2)&select=id,name"
|
||||
[("Prefer", "return=representation")]
|
||||
[json|{ name : "updated grandchild entity"}|] `shouldRespondWith`
|
||||
[json|[{ "id": 1, "name" : "updated grandchild entity"},{ "id": 2, "name" : "updated grandchild entity"}]|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
context "used with DELETE" $
|
||||
it "succeeds when using and/or params" $
|
||||
request methodDelete "/grandchild_entities?or=(id.eq.1,id.eq.2)&select=id,name"
|
||||
[("Prefer", "return=representation")]
|
||||
""
|
||||
`shouldRespondWith`
|
||||
[json|[{ "id": 1, "name" : "grandchild entity 1" },{ "id": 2, "name" : "grandchild entity 2" }]|]
|
||||
|
||||
it "can query columns that begin with and/or reserved words" $
|
||||
get "/grandchild_entities?or=(and_starting_col.eq.smth, or_starting_col.eq.smth)" `shouldRespondWith` 200
|
||||
|
||||
it "fails when using IN without () and provides meaningful error message" $
|
||||
get "/entities?or=(id.in.1,2,id.eq.3)" `shouldRespondWith`
|
||||
[json|{
|
||||
"details": "unexpected \"1\" expecting \"(\"",
|
||||
"message": "\"failed to parse logic tree ((id.in.1,2,id.eq.3))\" (line 1, column 10)"
|
||||
}|] { matchStatus = 400, matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "fails on malformed query params and provides meaningful error message" $ do
|
||||
get "/entities?or=)(" `shouldRespondWith`
|
||||
[json|{
|
||||
"details": "unexpected \")\" expecting \"(\"",
|
||||
"message": "\"failed to parse logic tree ()()\" (line 1, column 3)"
|
||||
}|] { matchStatus = 400, matchHeaders = [matchContentTypeJson] }
|
||||
get "/entities?and=(ord(id.eq.1,id.eq.1),id.eq.2)" `shouldRespondWith`
|
||||
[json|{
|
||||
"details": "unexpected \"d\" expecting \"(\"",
|
||||
"message": "\"failed to parse logic tree ((ord(id.eq.1,id.eq.1),id.eq.2))\" (line 1, column 7)"
|
||||
}|] { matchStatus = 400, matchHeaders = [matchContentTypeJson] }
|
||||
get "/entities?or=(id.eq.1,not.xor(id.eq.2,id.eq.3))" `shouldRespondWith`
|
||||
[json|{
|
||||
"details": "unexpected \"x\" expecting logic operator (and, or)",
|
||||
"message": "\"failed to parse logic tree ((id.eq.1,not.xor(id.eq.2,id.eq.3)))\" (line 1, column 16)"
|
||||
}|] { matchStatus = 400, matchHeaders = [matchContentTypeJson] }
|
||||
@@ -0,0 +1,21 @@
|
||||
module Feature.AsymmetricJwtSpec where
|
||||
|
||||
-- {{{ Imports
|
||||
import Network.Wai (Application)
|
||||
|
||||
import Network.HTTP.Types
|
||||
import Test.Hspec
|
||||
import Test.Hspec.Wai
|
||||
|
||||
import Protolude
|
||||
import SpecHelper
|
||||
-- }}}
|
||||
|
||||
spec :: SpecWith ((), Application)
|
||||
spec = describe "server started with asymmetric JWK" $
|
||||
|
||||
-- this test will stop working 9999999999s after the UNIX EPOCH
|
||||
it "succeeds with jwt token signed with an asymmetric key" $ do
|
||||
let auth = authHeaderJWT "eyJhbGciOiJSUzI1NiJ9.eyJyb2xlIjogInBvc3RncmVzdF90ZXN0X2F1dGhvciJ9Cg.CBOYWDvqgAR0YYnZnyDGTQi6AJLc2Pds6_eV3YuBG6I36mj_h05eLhkEKNEDA5ZteMzCiY83P60rC_xtxVd7B6vo3BeF5uoanPS3rrbuHzKPwzsrgrD_CqvEuJ4n7Q9epkQiLsNkcexneENZDRqFjbwZx3DrXiCWwlK3Ytr5NAIGxmy0od-0xNpb2U1nXQyO_Q3mumWFViRt4tmFn_3goDHNKG3Ha_AzImfUNvHnWL78kAc4rbn15vLtWXD8PwtSnZaB4lY4V6RfsaW937srQsmRetvytM1i_bHBnjkjQLAqGbXPyItjtlXPs0uGNBadE8-wgkLtfmSCC4v2DjUthw"
|
||||
request methodGet "/authors_only" [auth] ""
|
||||
`shouldRespondWith` 200
|
||||
@@ -0,0 +1,47 @@
|
||||
module Feature.AudienceJwtSecretSpec where
|
||||
|
||||
-- {{{ Imports
|
||||
import Network.Wai (Application)
|
||||
|
||||
import Network.HTTP.Types
|
||||
import Test.Hspec
|
||||
import Test.Hspec.Wai
|
||||
|
||||
import Protolude hiding (get)
|
||||
import SpecHelper
|
||||
-- }}}
|
||||
|
||||
spec :: SpecWith ((), Application)
|
||||
spec = describe "test handling of aud claims in JWT" $ do
|
||||
|
||||
-- this test will stop working 9999999999s after the UNIX EPOCH
|
||||
it "succeeds with jwt token containing with an audience claim" $ do
|
||||
{- This is the decoded contents of authHeaderJWT
|
||||
|
||||
{
|
||||
"exp": 9999999999,
|
||||
"role": "postgrest_test_author",
|
||||
"id": "jdoe",
|
||||
"aud": "youraudience"
|
||||
}
|
||||
|
||||
-}
|
||||
let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjk5OTk5OTk5OTksInJvbGUiOiJwb3N0Z3Jlc3RfdGVzdF9hdXRob3IiLCJpZCI6Impkb2UiLCJhdWQiOiJ5b3VyYXVkaWVuY2UifQ.fJ4tLKSmolWGWehWN20qiU9dMO-WY0RI2VvacL7-ZGo"
|
||||
request methodGet "/authors_only" [auth] ""
|
||||
`shouldRespondWith` 200
|
||||
|
||||
it "succeeds with jwt token that does not contain an audience claim" $ do
|
||||
{- This is the decoded contents of authHeaderJWT
|
||||
|
||||
{
|
||||
"exp": 9999999999,
|
||||
"role": "postgrest_test_author",
|
||||
"id": "jdoe"
|
||||
}
|
||||
-}
|
||||
let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjk5OTk5OTk5OTksInJvbGUiOiJwb3N0Z3Jlc3RfdGVzdF9hdXRob3IiLCJpZCI6Impkb2UifQ.Dpss-QoLYjec5OTsOaAc3FNVsSjA89wACoV-0ra3ClA"
|
||||
request methodGet "/authors_only" [auth] ""
|
||||
`shouldRespondWith` 200
|
||||
|
||||
it "requests without JWT token should work" $
|
||||
get "/has_count_column" `shouldRespondWith` 200
|
||||
@@ -0,0 +1,179 @@
|
||||
module Feature.AuthSpec where
|
||||
|
||||
import Network.Wai (Application)
|
||||
|
||||
import Network.HTTP.Types
|
||||
import Test.Hspec
|
||||
import Test.Hspec.Wai
|
||||
import Test.Hspec.Wai.JSON
|
||||
|
||||
import PostgREST.Config.PgVersion (PgVersion, pgVersion112)
|
||||
|
||||
import Protolude hiding (get)
|
||||
import SpecHelper
|
||||
|
||||
spec :: PgVersion -> SpecWith ((), Application)
|
||||
spec actualPgVersion = describe "authorization" $ do
|
||||
let single = ("Accept","application/vnd.pgrst.object+json")
|
||||
|
||||
it "denies access to tables that anonymous does not own" $
|
||||
get "/authors_only" `shouldRespondWith` (
|
||||
if actualPgVersion >= pgVersion112 then
|
||||
[json| {
|
||||
"hint":null,
|
||||
"details":null,
|
||||
"code":"42501",
|
||||
"message":"permission denied for table authors_only"} |]
|
||||
else
|
||||
[json| {
|
||||
"hint":null,
|
||||
"details":null,
|
||||
"code":"42501",
|
||||
"message":"permission denied for relation authors_only"} |]
|
||||
)
|
||||
{ matchStatus = 401
|
||||
, matchHeaders = ["WWW-Authenticate" <:> "Bearer"]
|
||||
}
|
||||
|
||||
it "denies access to tables that postgrest_test_author does not own" $
|
||||
let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIn0.Xod-F15qsGL0WhdOCr2j3DdKuTw9QJERVgoFD3vGaWA" in
|
||||
request methodGet "/private_table" [auth] ""
|
||||
`shouldRespondWith` (
|
||||
if actualPgVersion >= pgVersion112 then
|
||||
[json| {
|
||||
"hint":null,
|
||||
"details":null,
|
||||
"code":"42501",
|
||||
"message":"permission denied for table private_table"} |]
|
||||
else
|
||||
[json| {
|
||||
"hint":null,
|
||||
"details":null,
|
||||
"code":"42501",
|
||||
"message":"permission denied for relation private_table"} |]
|
||||
)
|
||||
{ matchStatus = 403
|
||||
, matchHeaders = []
|
||||
}
|
||||
|
||||
it "denies execution on functions that anonymous does not own" $
|
||||
post "/rpc/privileged_hello" [json|{"name": "anonymous"}|] `shouldRespondWith` 401
|
||||
|
||||
it "allows execution on a function that postgrest_test_author owns" $
|
||||
let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIn0.Xod-F15qsGL0WhdOCr2j3DdKuTw9QJERVgoFD3vGaWA" in
|
||||
request methodPost "/rpc/privileged_hello" [auth] [json|{"name": "jdoe"}|]
|
||||
`shouldRespondWith` [json|"Privileged hello to jdoe"|]
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = [matchContentTypeJson]
|
||||
}
|
||||
|
||||
it "returns jwt functions as jwt tokens" $
|
||||
request methodPost "/rpc/login" [single]
|
||||
[json| { "id": "jdoe", "pass": "1234" } |]
|
||||
`shouldRespondWith` [json| {"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xuYW1lIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWQiOiJqZG9lIn0.KO-0PGp_rU-utcDBP6qwdd-Th2Fk-ICVt01I7QtTDWs"} |]
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = [matchContentTypeSingular]
|
||||
}
|
||||
|
||||
it "sql functions can encode custom and standard claims" $
|
||||
request methodPost "/rpc/jwt_test" [single] "{}"
|
||||
`shouldRespondWith` [json| {"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJqb2UiLCJzdWIiOiJmdW4iLCJhdWQiOiJldmVyeW9uZSIsImV4cCI6MTMwMDgxOTM4MCwibmJmIjoxMzAwODE5MzgwLCJpYXQiOjEzMDA4MTkzODAsImp0aSI6ImZvbyIsInJvbGUiOiJwb3N0Z3Jlc3RfdGVzdCIsImh0dHA6Ly9wb3N0Z3Jlc3QuY29tL2ZvbyI6dHJ1ZX0.G2REtPnOQMUrVRDA9OnkPJTd8R0tf4wdYOlauh1E2Ek"} |]
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = [matchContentTypeSingular]
|
||||
}
|
||||
|
||||
it "sql functions can read custom and standard claims variables" $ do
|
||||
let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJmdW4iLCJqdGkiOiJmb28iLCJuYmYiOjEzMDA4MTkzODAsImV4cCI6OTk5OTk5OTk5OSwiaHR0cDovL3Bvc3RncmVzdC5jb20vZm9vIjp0cnVlLCJpc3MiOiJqb2UiLCJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWF0IjoxMzAwODE5MzgwfQ.V5fEpXfpb7feqwVqlcDleFdKu86bdwU2cBRT4fcMhXg"
|
||||
request methodPost "/rpc/reveal_big_jwt" [auth] "{}"
|
||||
`shouldRespondWith` [json|[{"iss":"joe","sub":"fun","exp":9999999999,"nbf":1300819380,"iat":1300819380,"jti":"foo","http://postgrest.com/foo":true}]|]
|
||||
|
||||
it "allows users with permissions to see their tables" $ do
|
||||
let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWQiOiJqZG9lIn0.B-lReuGNDwAlU1GOC476MlO0vAt9JNoHIlxg2vwMaO0"
|
||||
request methodGet "/authors_only" [auth] ""
|
||||
`shouldRespondWith` 200
|
||||
|
||||
it "works with tokens which have extra fields" $ do
|
||||
let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWQiOiJqZG9lIiwia2V5MSI6InZhbHVlMSIsImtleTIiOiJ2YWx1ZTIiLCJrZXkzIjoidmFsdWUzIiwiYSI6MSwiYiI6MiwiYyI6M30.b0eglDKYEmGi-hCvD-ddSqFl7vnDO5qkUaviaHXm3es"
|
||||
request methodGet "/authors_only" [auth] ""
|
||||
`shouldRespondWith` 200
|
||||
|
||||
-- this test will stop working 9999999999s after the UNIX EPOCH
|
||||
it "succeeds with an unexpired token" $ do
|
||||
let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjk5OTk5OTk5OTksInJvbGUiOiJwb3N0Z3Jlc3RfdGVzdF9hdXRob3IiLCJpZCI6Impkb2UifQ.Dpss-QoLYjec5OTsOaAc3FNVsSjA89wACoV-0ra3ClA"
|
||||
request methodGet "/authors_only" [auth] ""
|
||||
`shouldRespondWith` 200
|
||||
|
||||
it "fails with an expired token" $ do
|
||||
let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE0NDY2NzgxNDksInJvbGUiOiJwb3N0Z3Jlc3RfdGVzdF9hdXRob3IiLCJpZCI6Impkb2UifQ.f8__E6VQwYcDqwHmr9PG03uaZn8Zh1b0vbJ9DYS0AdM"
|
||||
request methodGet "/authors_only" [auth] ""
|
||||
`shouldRespondWith` [json| {"message":"JWT expired"} |]
|
||||
{ matchStatus = 401
|
||||
, matchHeaders = [
|
||||
"WWW-Authenticate" <:>
|
||||
"Bearer error=\"invalid_token\", error_description=\"JWT expired\""
|
||||
]
|
||||
}
|
||||
|
||||
it "hides tables from users with invalid JWT" $ do
|
||||
let auth = authHeaderJWT "ey9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWQiOiJqZG9lIn0.y4vZuu1dDdwAl0-S00MCRWRYMlJ5YAMSir6Es6WtWx0"
|
||||
request methodGet "/authors_only" [auth] ""
|
||||
`shouldRespondWith` [json| {"message":"JWSError (CompactDecodeError Invalid number of parts: Expected 3 parts; got 2)"} |]
|
||||
{ matchStatus = 401
|
||||
, matchHeaders = [
|
||||
"WWW-Authenticate" <:>
|
||||
"Bearer error=\"invalid_token\", error_description=\"JWSError (CompactDecodeError Invalid number of parts: Expected 3 parts; got 2)\""
|
||||
]
|
||||
}
|
||||
|
||||
it "should fail when jwt contains no claims" $ do
|
||||
let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.e30.CUIP5V9thWsGGFsFyGijSZf1fJMfarLHI9CEJL-TGNk"
|
||||
request methodGet "/authors_only" [auth] ""
|
||||
`shouldRespondWith` 401
|
||||
|
||||
it "hides tables from users with JWT that contain no claims about role" $ do
|
||||
let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6Impkb2UifQ.RVlZDaSyKbFPvxUf3V_NQXybfRB4dlBIkAUQXVXLUAI"
|
||||
request methodGet "/authors_only" [auth] ""
|
||||
`shouldRespondWith` 401
|
||||
|
||||
it "recovers after 401 error with logged in user" $ do
|
||||
_ <- post "/authors_only" [json| { "owner": "jdoe", "secret": "test content" } |]
|
||||
let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWQiOiJqZG9lIn0.B-lReuGNDwAlU1GOC476MlO0vAt9JNoHIlxg2vwMaO0"
|
||||
_ <- request methodPost "/rpc/problem" [auth] ""
|
||||
request methodGet "/authors_only" [auth] ""
|
||||
`shouldRespondWith` 200
|
||||
|
||||
describe "custom pre-request proc acting on id claim" $ do
|
||||
|
||||
it "able to switch to postgrest_test_author role (id=1)" $
|
||||
let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MX0.gKw7qI50i9hMrSJW8BlTpdMEVmMXJYxlAqueGqpa_mE" in
|
||||
request methodPost "/rpc/get_current_user" [auth]
|
||||
[json| {} |]
|
||||
`shouldRespondWith` [json|"postgrest_test_author"|]
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = []
|
||||
}
|
||||
|
||||
it "able to switch to postgrest_test_default_role (id=2)" $
|
||||
let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6Mn0.nwzjMI0YLvVGJQTeoCPEBsK983b__gxdpLXisBNaO2A" in
|
||||
request methodPost "/rpc/get_current_user" [auth]
|
||||
[json| {} |]
|
||||
`shouldRespondWith` [json|"postgrest_test_default_role"|]
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = []
|
||||
}
|
||||
|
||||
it "raises error (id=3)" $
|
||||
let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6M30.OGxEJAf60NKZiTn-tIb2jy4rqKs_ZruLGWZ40TjrJsM" in
|
||||
request methodPost "/rpc/get_current_user" [auth]
|
||||
[json| {} |]
|
||||
`shouldRespondWith` [json|{"hint":"Please contact administrator","details":null,"code":"P0001","message":"Disabled ID --> 3"}|]
|
||||
{ matchStatus = 400
|
||||
, matchHeaders = []
|
||||
}
|
||||
|
||||
it "allows 'Bearer' and 'bearer' as authentication schemes" $ do
|
||||
let token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWQiOiJqZG9lIn0.B-lReuGNDwAlU1GOC476MlO0vAt9JNoHIlxg2vwMaO0"
|
||||
request methodGet "/authors_only" [authHeader "Bearer" token] ""
|
||||
`shouldRespondWith` 200
|
||||
request methodGet "/authors_only" [authHeader "bearer" token] ""
|
||||
`shouldRespondWith` 200
|
||||
@@ -0,0 +1,21 @@
|
||||
module Feature.BinaryJwtSecretSpec where
|
||||
|
||||
-- {{{ Imports
|
||||
import Network.Wai (Application)
|
||||
|
||||
import Network.HTTP.Types
|
||||
import Test.Hspec
|
||||
import Test.Hspec.Wai
|
||||
|
||||
import Protolude
|
||||
import SpecHelper
|
||||
-- }}}
|
||||
|
||||
spec :: SpecWith ((), Application)
|
||||
spec = describe "server started with binary JWT secret" $
|
||||
|
||||
-- this test will stop working 9999999999s after the UNIX EPOCH
|
||||
it "succeeds with jwt token encoded with a binary secret" $ do
|
||||
let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjk5OTk5OTk5OTksInJvbGUiOiJwb3N0Z3Jlc3RfdGVzdF9hdXRob3IiLCJpZCI6Impkb2UifQ.Dpss-QoLYjec5OTsOaAc3FNVsSjA89wACoV-0ra3ClA"
|
||||
request methodGet "/authors_only" [auth] ""
|
||||
`shouldRespondWith` 200
|
||||
@@ -0,0 +1,52 @@
|
||||
{-# LANGUAGE MultiParamTypeClasses #-}
|
||||
{-# LANGUAGE TypeFamilies #-}
|
||||
{-# LANGUAGE UndecidableInstances #-}
|
||||
{-# OPTIONS_GHC -fno-warn-orphans #-}
|
||||
module Feature.ConcurrentSpec where
|
||||
|
||||
import Control.Concurrent.Async (mapConcurrently)
|
||||
import Network.Wai (Application)
|
||||
|
||||
import Control.Monad.Base
|
||||
import Control.Monad.Trans.Control
|
||||
|
||||
import Network.Wai.Test (Session)
|
||||
import Test.Hspec
|
||||
import Test.Hspec.Wai
|
||||
import Test.Hspec.Wai.Internal
|
||||
import Test.Hspec.Wai.JSON
|
||||
|
||||
import Protolude hiding (get)
|
||||
|
||||
spec :: SpecWith ((), Application)
|
||||
spec =
|
||||
describe "Querying in parallel" $
|
||||
it "should not raise 'transaction in progress' error" $
|
||||
raceTest 10 $
|
||||
get "/fakefake"
|
||||
`shouldRespondWith` [json|
|
||||
{ "hint": null,
|
||||
"details":null,
|
||||
"code":"42P01",
|
||||
"message":"relation \"test.fakefake\" does not exist"
|
||||
} |]
|
||||
{ matchStatus = 404
|
||||
, matchHeaders = []
|
||||
}
|
||||
|
||||
raceTest :: Int -> WaiExpectation st -> WaiExpectation st
|
||||
raceTest times = liftBaseDiscard go
|
||||
where
|
||||
go test = void $ mapConcurrently (const test) [1..times]
|
||||
|
||||
instance MonadBaseControl IO (WaiSession st) where
|
||||
type StM (WaiSession st) a = StM Session a
|
||||
liftBaseWith f = WaiSession $
|
||||
liftBaseWith $ \runInBase ->
|
||||
f $ \k -> runInBase (unWaiSession k)
|
||||
restoreM = WaiSession . restoreM
|
||||
{-# INLINE liftBaseWith #-}
|
||||
{-# INLINE restoreM #-}
|
||||
|
||||
instance MonadBase IO (WaiSession st) where
|
||||
liftBase = liftIO
|
||||
@@ -0,0 +1,56 @@
|
||||
module Feature.CorsSpec where
|
||||
|
||||
import Network.Wai (Application)
|
||||
|
||||
import Network.HTTP.Types
|
||||
import Test.Hspec
|
||||
import Test.Hspec.Wai
|
||||
|
||||
import Protolude
|
||||
|
||||
spec :: SpecWith ((), Application)
|
||||
spec =
|
||||
describe "CORS" $ do
|
||||
it "replies naively and permissively to preflight request" $
|
||||
request methodOptions "/"
|
||||
[ ("Accept", "*/*")
|
||||
, ("Origin", "http://example.com")
|
||||
, ("Access-Control-Request-Method", "POST")
|
||||
, ("Access-Control-Request-Headers", "Foo,Bar") ]
|
||||
""
|
||||
`shouldRespondWith`
|
||||
""
|
||||
{ matchHeaders = [ "Access-Control-Allow-Origin" <:> "http://example.com"
|
||||
, "Access-Control-Allow-Credentials" <:> "true"
|
||||
, "Access-Control-Allow-Methods" <:> "GET, POST, PATCH, PUT, DELETE, OPTIONS, HEAD"
|
||||
, "Access-Control-Allow-Headers" <:> "Authorization, Foo, Bar, Accept, Accept-Language, Content-Language"
|
||||
, "Access-Control-Max-Age" <:> "86400" ]
|
||||
}
|
||||
|
||||
it "exposes necesssary response headers to regular request" $
|
||||
request methodGet "/items"
|
||||
[("Origin", "http://example.com")]
|
||||
""
|
||||
`shouldRespondWith`
|
||||
ResponseMatcher
|
||||
{ matchStatus = 200
|
||||
, matchBody = MatchBody (\_ _ -> Nothing) -- match any body
|
||||
, matchHeaders = [ "Access-Control-Expose-Headers" <:>
|
||||
"Content-Encoding, Content-Location, Content-Range, Content-Type, \
|
||||
\Date, Location, Server, Transfer-Encoding, Range-Unit"]
|
||||
}
|
||||
|
||||
it "allows INFO body through even with CORS request headers present to postflight request" $
|
||||
request methodOptions "/items"
|
||||
[ ("Host", "localhost:3000")
|
||||
, ("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.9; rv:32.0) Gecko/20100101 Firefox/32.0")
|
||||
, ("Origin", "http://localhost:8000")
|
||||
, ("Accept", "text/csv, */*; q=0.01")
|
||||
, ("Accept-Language", "en-US,en;q=0.5")
|
||||
, ("Accept-Encoding", "gzip, deflate")
|
||||
, ("Referer", "http://localhost:8000/")
|
||||
, ("Connection", "keep-alive") ]
|
||||
""
|
||||
`shouldRespondWith`
|
||||
""
|
||||
{ matchHeaders = [ "Access-Control-Allow-Origin" <:> "*" ] }
|
||||
@@ -0,0 +1,117 @@
|
||||
module Feature.DeleteSpec where
|
||||
|
||||
import Network.Wai (Application)
|
||||
|
||||
import Network.HTTP.Types
|
||||
import Test.Hspec
|
||||
import Test.Hspec.Wai
|
||||
import Test.Hspec.Wai.JSON
|
||||
|
||||
import Protolude hiding (get)
|
||||
import SpecHelper
|
||||
|
||||
spec :: SpecWith ((), Application)
|
||||
spec =
|
||||
describe "Deleting" $ do
|
||||
context "existing record" $ do
|
||||
it "succeeds with 204 and deletion count" $
|
||||
request methodDelete "/items?id=eq.1"
|
||||
[]
|
||||
""
|
||||
`shouldRespondWith`
|
||||
""
|
||||
{ matchStatus = 204
|
||||
, matchHeaders = [ matchHeaderAbsent hContentType
|
||||
, "Content-Range" <:> "*/*" ]
|
||||
}
|
||||
|
||||
it "returns the deleted item and count if requested" $
|
||||
request methodDelete "/items?id=eq.2" [("Prefer", "return=representation"), ("Prefer", "count=exact")] ""
|
||||
`shouldRespondWith` [json|[{"id":2}]|]
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = ["Content-Range" <:> "*/1"]
|
||||
}
|
||||
|
||||
it "ignores ?select= when return not set or return=minimal" $ do
|
||||
request methodDelete "/items?id=eq.3&select=id"
|
||||
[]
|
||||
""
|
||||
`shouldRespondWith`
|
||||
""
|
||||
{ matchStatus = 204
|
||||
, matchHeaders = [ matchHeaderAbsent hContentType
|
||||
, "Content-Range" <:> "*/*" ]
|
||||
}
|
||||
request methodDelete "/items?id=eq.3&select=id"
|
||||
[("Prefer", "return=minimal")]
|
||||
""
|
||||
`shouldRespondWith`
|
||||
""
|
||||
{ matchStatus = 204
|
||||
, matchHeaders = [ matchHeaderAbsent hContentType
|
||||
, "Content-Range" <:> "*/*" ]
|
||||
}
|
||||
|
||||
it "returns the deleted item and shapes the response" $
|
||||
request methodDelete "/complex_items?id=eq.2&select=id,name" [("Prefer", "return=representation")] ""
|
||||
`shouldRespondWith` [json|[{"id":2,"name":"Two"}]|]
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = ["Content-Range" <:> "*/*"]
|
||||
}
|
||||
|
||||
it "can rename and cast the selected columns" $
|
||||
request methodDelete "/complex_items?id=eq.3&select=ciId:id::text,ciName:name" [("Prefer", "return=representation")] ""
|
||||
`shouldRespondWith` [json|[{"ciId":"3","ciName":"Three"}]|]
|
||||
|
||||
it "can embed (parent) entities" $
|
||||
request methodDelete "/tasks?id=eq.8&select=id,name,project:projects(id)" [("Prefer", "return=representation")] ""
|
||||
`shouldRespondWith` [json|[{"id":8,"name":"Code OSX","project":{"id":4}}]|]
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = ["Content-Range" <:> "*/*"]
|
||||
}
|
||||
|
||||
context "known route, no records matched" $
|
||||
it "includes [] body if return=rep" $
|
||||
request methodDelete "/items?id=eq.101"
|
||||
[("Prefer", "return=representation")] ""
|
||||
`shouldRespondWith` "[]"
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = ["Content-Range" <:> "*/*"]
|
||||
}
|
||||
|
||||
context "totally unknown route" $
|
||||
it "fails with 404" $
|
||||
request methodDelete "/foozle?id=eq.101" [] "" `shouldRespondWith` 404
|
||||
|
||||
context "table with limited privileges" $ do
|
||||
it "fails deleting the row when return=representation and selecting all the columns" $
|
||||
request methodDelete "/app_users?id=eq.1" [("Prefer", "return=representation")] mempty
|
||||
`shouldRespondWith` 401
|
||||
|
||||
it "succeeds deleting the row when return=representation and selecting only the privileged columns" $
|
||||
request methodDelete "/app_users?id=eq.1&select=id,email" [("Prefer", "return=representation")]
|
||||
[json| { "password": "passxyz" } |]
|
||||
`shouldRespondWith` [json|[ { "id": 1, "email": "test@123.com" } ]|]
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = ["Content-Range" <:> "*/*"]
|
||||
}
|
||||
|
||||
it "suceeds deleting the row with no explicit select when using return=minimal" $
|
||||
request methodDelete "/app_users?id=eq.2"
|
||||
[("Prefer", "return=minimal")]
|
||||
mempty
|
||||
`shouldRespondWith`
|
||||
""
|
||||
{ matchStatus = 204
|
||||
, matchHeaders = [matchHeaderAbsent hContentType]
|
||||
}
|
||||
|
||||
it "suceeds deleting the row with no explicit select by default" $
|
||||
request methodDelete "/app_users?id=eq.3"
|
||||
[]
|
||||
mempty
|
||||
`shouldRespondWith`
|
||||
""
|
||||
{ matchStatus = 204
|
||||
, matchHeaders = [matchHeaderAbsent hContentType]
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
module Feature.DisabledOpenApiSpec where
|
||||
|
||||
import Network.HTTP.Types
|
||||
import Network.Wai (Application)
|
||||
|
||||
import Test.Hspec hiding (pendingWith)
|
||||
import Test.Hspec.Wai
|
||||
|
||||
import Protolude
|
||||
|
||||
spec :: SpecWith ((), Application)
|
||||
spec =
|
||||
describe "Disabled OpenApi" $ do
|
||||
it "does not accept application/openapi+json and responds with 415" $
|
||||
request methodGet "/"
|
||||
[("Accept","application/openapi+json")] "" `shouldRespondWith` 415
|
||||
|
||||
it "accepts application/json and responds with 404" $
|
||||
request methodGet "/"
|
||||
[("Accept","application/json")] "" `shouldRespondWith` 404
|
||||
@@ -0,0 +1,440 @@
|
||||
module Feature.EmbedDisambiguationSpec where
|
||||
|
||||
import Network.Wai (Application)
|
||||
|
||||
import Test.Hspec hiding (pendingWith)
|
||||
import Test.Hspec.Wai
|
||||
import Test.Hspec.Wai.JSON
|
||||
|
||||
import Protolude hiding (get)
|
||||
import SpecHelper
|
||||
|
||||
spec :: SpecWith ((), Application)
|
||||
spec =
|
||||
describe "resource embedding disambiguation" $ do
|
||||
context "ambiguous requests that give 300 Multiple Choices" $ do
|
||||
it "errs when there's a table and view that point to the same fk" $
|
||||
get "/message?select=id,body,sender(name,sent)" `shouldRespondWith`
|
||||
[json|
|
||||
{
|
||||
"details": [
|
||||
{
|
||||
"cardinality": "many-to-one",
|
||||
"relationship": "message_sender_fkey[sender][id]",
|
||||
"embedding": "message with person"
|
||||
},
|
||||
{
|
||||
"cardinality": "many-to-one",
|
||||
"relationship": "message_sender_fkey[sender][id]",
|
||||
"embedding": "message with person_detail"
|
||||
}
|
||||
],
|
||||
"hint": "Try changing 'sender' to one of the following: 'person!message_sender_fkey', 'person_detail!message_sender_fkey'. Find the desired relationship in the 'details' key.",
|
||||
"message": "Could not embed because more than one relationship was found for 'message' and 'sender'"
|
||||
}
|
||||
|]
|
||||
{ matchStatus = 300
|
||||
, matchHeaders = [matchContentTypeJson]
|
||||
}
|
||||
|
||||
it "errs when there are o2m and m2m cardinalities to the target table" $
|
||||
get "/sites?select=*,big_projects(*)" `shouldRespondWith`
|
||||
[json|
|
||||
{
|
||||
"details": [
|
||||
{
|
||||
"cardinality": "many-to-one",
|
||||
"relationship": "main_project[main_project_id][big_project_id]",
|
||||
"embedding": "sites with big_projects"
|
||||
},
|
||||
{
|
||||
"cardinality": "many-to-many",
|
||||
"relationship": "test.jobs[jobs_site_id_fkey][jobs_big_project_id_fkey]",
|
||||
"embedding": "sites with big_projects"
|
||||
},
|
||||
{
|
||||
"cardinality": "many-to-many",
|
||||
"relationship": "test.main_jobs[jobs_site_id_fkey][jobs_big_project_id_fkey]",
|
||||
"embedding": "sites with big_projects"
|
||||
}
|
||||
],
|
||||
"hint": "Try changing 'big_projects' to one of the following: 'big_projects!main_project', 'big_projects!jobs', 'big_projects!main_jobs'. Find the desired relationship in the 'details' key.",
|
||||
"message": "Could not embed because more than one relationship was found for 'sites' and 'big_projects'"
|
||||
}
|
||||
|]
|
||||
{ matchStatus = 300
|
||||
, matchHeaders = [matchContentTypeJson]
|
||||
}
|
||||
|
||||
it "errs on an ambiguous embed that has a circular reference" $
|
||||
get "/agents?select=*,departments(*)" `shouldRespondWith`
|
||||
[json|
|
||||
{
|
||||
"details": [
|
||||
{
|
||||
"cardinality": "many-to-one",
|
||||
"relationship": "agents_department_id_fkey[department_id][id]",
|
||||
"embedding": "agents with departments"
|
||||
},
|
||||
{
|
||||
"cardinality": "one-to-many",
|
||||
"relationship": "departments_head_id_fkey[id][head_id]",
|
||||
"embedding": "agents with departments"
|
||||
}
|
||||
],
|
||||
"hint": "Try changing 'departments' to one of the following: 'departments!agents_department_id_fkey', 'departments!departments_head_id_fkey'. Find the desired relationship in the 'details' key.",
|
||||
"message": "Could not embed because more than one relationship was found for 'agents' and 'departments'"
|
||||
}
|
||||
|]
|
||||
{ matchStatus = 300
|
||||
, matchHeaders = [matchContentTypeJson]
|
||||
}
|
||||
|
||||
it "errs when there are more than two fks on a junction table(currently impossible to disambiguate, only choice is to split the table)" $
|
||||
-- We have 4 possibilities for doing the junction JOIN here.
|
||||
-- This could be solved by specifying two additional fks, like whatev_projects!fk1!fk2(*)
|
||||
-- If the need arises this capability can be added later without causing a breaking change
|
||||
get "/whatev_sites?select=*,whatev_projects(*)" `shouldRespondWith`
|
||||
[json|
|
||||
{
|
||||
"details": [
|
||||
{
|
||||
"cardinality": "many-to-many",
|
||||
"relationship": "test.whatev_jobs[whatev_jobs_site_id_1_fkey][whatev_jobs_project_id_1_fkey]",
|
||||
"embedding": "whatev_sites with whatev_projects"
|
||||
},
|
||||
{
|
||||
"cardinality": "many-to-many",
|
||||
"relationship": "test.whatev_jobs[whatev_jobs_site_id_1_fkey][whatev_jobs_project_id_2_fkey]",
|
||||
"embedding": "whatev_sites with whatev_projects"
|
||||
},
|
||||
{
|
||||
"cardinality": "many-to-many",
|
||||
"relationship": "test.whatev_jobs[whatev_jobs_site_id_2_fkey][whatev_jobs_project_id_1_fkey]",
|
||||
"embedding": "whatev_sites with whatev_projects"
|
||||
},
|
||||
{
|
||||
"cardinality": "many-to-many",
|
||||
"relationship": "test.whatev_jobs[whatev_jobs_site_id_2_fkey][whatev_jobs_project_id_2_fkey]",
|
||||
"embedding": "whatev_sites with whatev_projects"
|
||||
}
|
||||
],
|
||||
"hint": "Try changing 'whatev_projects' to one of the following: 'whatev_projects!whatev_jobs', 'whatev_projects!whatev_jobs', 'whatev_projects!whatev_jobs', 'whatev_projects!whatev_jobs'. Find the desired relationship in the 'details' key.",
|
||||
"message": "Could not embed because more than one relationship was found for 'whatev_sites' and 'whatev_projects'"
|
||||
}
|
||||
|]
|
||||
{ matchStatus = 300
|
||||
, matchHeaders = [matchContentTypeJson]
|
||||
}
|
||||
|
||||
context "disambiguating requests with embed hints" $ do
|
||||
|
||||
context "using FK to specify the relationship" $ do
|
||||
it "can embed by FK name" $
|
||||
get "/projects?id=in.(1,3)&select=id,name,client(id,name)" `shouldRespondWith`
|
||||
[json|[{"id":1,"name":"Windows 7","client":{"id":1,"name":"Microsoft"}},{"id":3,"name":"IOS","client":{"id":2,"name":"Apple"}}]|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "can embed by FK name and select the FK column at the same time" $
|
||||
get "/projects?id=in.(1,3)&select=id,name,client_id,client(id,name)" `shouldRespondWith`
|
||||
[json|[{"id":1,"name":"Windows 7","client_id":1,"client":{"id":1,"name":"Microsoft"}},{"id":3,"name":"IOS","client_id":2,"client":{"id":2,"name":"Apple"}}]|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "can embed parent with view!fk and grandparent by using fk" $
|
||||
get "/tasks?id=eq.1&select=id,name,projects_view!project(id,name,client(id,name))" `shouldRespondWith`
|
||||
[json|[{"id":1,"name":"Design w7","projects_view":{"id":1,"name":"Windows 7","client":{"id":1,"name":"Microsoft"}}}]|]
|
||||
|
||||
it "can embed by using a composite FK name" $
|
||||
get "/unit_workdays?select=unit_id,day,fst_shift(car_id,schedule(name)),snd_shift(camera_id,schedule(name))" `shouldRespondWith`
|
||||
[json| [
|
||||
{
|
||||
"day": "2019-12-02",
|
||||
"fst_shift": {
|
||||
"car_id": "CAR-349",
|
||||
"schedule": {
|
||||
"name": "morning"
|
||||
}
|
||||
},
|
||||
"snd_shift": {
|
||||
"camera_id": "CAM-123",
|
||||
"schedule": {
|
||||
"name": "night"
|
||||
}
|
||||
},
|
||||
"unit_id": 1
|
||||
}
|
||||
] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "embeds by using two fks pointing to the same table" $
|
||||
get "/orders?id=eq.1&select=id, name, billing(address), shipping(address)" `shouldRespondWith`
|
||||
[json|[{"id":1,"name":"order 1","billing":{"address": "address 1"},"shipping":{"address": "address 2"}}]|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "fails if the fk is not known" $
|
||||
get "/message?select=id,sender:person!space(name)&id=lt.4" `shouldRespondWith`
|
||||
[json|{
|
||||
"hint":"Verify that 'message' and 'person' exist in the schema 'test' and that there is a foreign key relationship between them. If a new relationship was created, try reloading the schema cache.",
|
||||
"message":"Could not find a relationship between 'message' and 'person' in the schema cache"}|]
|
||||
{ matchStatus = 400
|
||||
, matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "can request a parent with fk" $
|
||||
get "/comments?select=content,user(name)" `shouldRespondWith`
|
||||
[json|[ { "content": "Needs to be delivered ASAP", "user": { "name": "Angela Martin" } } ]|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "can request two parents with fks" $
|
||||
get "/articleStars?select=createdAt,article(id),user(name)&limit=1"
|
||||
`shouldRespondWith`
|
||||
[json|[{"createdAt":"2015-12-08T04:22:57.472738","article":{"id": 1},"user":{"name": "Angela Martin"}}]|]
|
||||
|
||||
it "can specify a view!fk" $
|
||||
get "/message?select=id,body,sender:person_detail!message_sender_fkey(name,sent),recipient:person_detail!message_recipient_fkey(name,received)&id=lt.4" `shouldRespondWith`
|
||||
[json|
|
||||
[{"id":1,"body":"Hello Jane","sender":{"name":"John","sent":2},"recipient":{"name":"Jane","received":2}},
|
||||
{"id":2,"body":"Hi John","sender":{"name":"Jane","sent":1},"recipient":{"name":"John","received":1}},
|
||||
{"id":3,"body":"How are you doing?","sender":{"name":"John","sent":2},"recipient":{"name":"Jane","received":2}}] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "can specify a table!fk hint and request children 2 levels" $
|
||||
get "/clients?id=eq.1&select=id,projects:projects!client(id,tasks(id))" `shouldRespondWith`
|
||||
[json|[{"id":1,"projects":[{"id":1,"tasks":[{"id":1},{"id":2}]},{"id":2,"tasks":[{"id":3},{"id":4}]}]}]|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "can disambiguate with the fk in case of an o2m and m2m relationship to the same table" $
|
||||
get "/sites?select=name,main_project(name)&site_id=eq.1" `shouldRespondWith`
|
||||
[json| [ { "name": "site 1", "main_project": { "name": "big project 1" } } ] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
context "using the column name of the FK to specify the relationship" $ do
|
||||
it "can embed by column" $
|
||||
get "/projects?id=in.(1,3)&select=id,name,client_id(id,name)" `shouldRespondWith`
|
||||
[json|[{"id":1,"name":"Windows 7","client_id":{"id":1,"name":"Microsoft"}},{"id":3,"name":"IOS","client_id":{"id":2,"name":"Apple"}}]|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "can embed by column and select the column at the same time, if aliased" $
|
||||
get "/projects?id=in.(1,3)&select=id,name,client_id,client:client_id(id,name)" `shouldRespondWith`
|
||||
[json|[{"id":1,"name":"Windows 7","client_id":1,"client":{"id":1,"name":"Microsoft"}},{"id":3,"name":"IOS","client_id":2,"client":{"id":2,"name":"Apple"}}]|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "can embed parent by using view!column and grandparent by using the column" $
|
||||
get "/tasks?id=eq.1&select=id,name,project:projects_view!project_id(id,name,client:client_id(id,name))" `shouldRespondWith`
|
||||
[json|[{"id":1,"name":"Design w7","project":{"id":1,"name":"Windows 7","client":{"id":1,"name":"Microsoft"}}}]|]
|
||||
|
||||
it "can specify table!column" $
|
||||
get "/message?select=id,body,sender:person!sender(name),recipient:person!recipient(name)&id=lt.4" `shouldRespondWith`
|
||||
[json|
|
||||
[{"id":1,"body":"Hello Jane","sender":{"name":"John"},"recipient":{"name":"Jane"}},
|
||||
{"id":2,"body":"Hi John","sender":{"name":"Jane"},"recipient":{"name":"John"}},
|
||||
{"id":3,"body":"How are you doing?","sender":{"name":"John"},"recipient":{"name":"Jane"}}] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "will embed using a column that has uppercase chars" $
|
||||
get "/ghostBusters?select=escapeId(*)" `shouldRespondWith`
|
||||
[json| [{"escapeId":{"so6meIdColumn":1}},{"escapeId":{"so6meIdColumn":3}},{"escapeId":{"so6meIdColumn":5}}] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "embeds by using two columns pointing to the same table" $
|
||||
get "/orders?id=eq.1&select=id, name, billing_address_id(id), shipping_address_id(id)" `shouldRespondWith`
|
||||
[json|[{"id":1,"name":"order 1","billing_address_id":{"id":1},"shipping_address_id":{"id":2}}]|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "can disambiguate with the column in case of an o2m and m2m relationship to the same table" $
|
||||
get "/sites?select=name,main_project_id(name)&site_id=eq.1" `shouldRespondWith`
|
||||
[json| [ { "name": "site 1", "main_project_id": { "name": "big project 1" } } ] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
context "using the junction to disambiguate the request" $
|
||||
it "can specify the junction of an m2m relationship" $ do
|
||||
get "/sites?select=*,big_projects!jobs(name)&site_id=in.(1,2)" `shouldRespondWith`
|
||||
[json|
|
||||
[
|
||||
{
|
||||
"big_projects": [
|
||||
{
|
||||
"name": "big project 1"
|
||||
}
|
||||
],
|
||||
"main_project_id": 1,
|
||||
"name": "site 1",
|
||||
"site_id": 1
|
||||
},
|
||||
{
|
||||
"big_projects": [
|
||||
{
|
||||
"name": "big project 1"
|
||||
},
|
||||
{
|
||||
"name": "big project 2"
|
||||
}
|
||||
],
|
||||
"main_project_id": null,
|
||||
"name": "site 2",
|
||||
"site_id": 2
|
||||
}
|
||||
]
|
||||
|]
|
||||
get "/sites?select=*,big_projects!main_jobs(name)&site_id=in.(1,2)" `shouldRespondWith`
|
||||
[json|
|
||||
[
|
||||
{
|
||||
"big_projects": [
|
||||
{
|
||||
"name": "big project 1"
|
||||
}
|
||||
],
|
||||
"main_project_id": 1,
|
||||
"name": "site 1",
|
||||
"site_id": 1
|
||||
},
|
||||
{
|
||||
"big_projects": [],
|
||||
"main_project_id": null,
|
||||
"name": "site 2",
|
||||
"site_id": 2
|
||||
}
|
||||
]
|
||||
|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
context "using a FK column and a FK to specify the relationship" $
|
||||
it "embeds by using a column and a fk pointing to the same table" $
|
||||
get "/orders?id=eq.1&select=id, name, billing_address_id(id), shipping(id)" `shouldRespondWith`
|
||||
[json|[{"id":1,"name":"order 1","billing_address_id":{"id":1},"shipping":{"id":2}}]|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
context "tables with self reference foreign keys" $ do
|
||||
context "one self reference foreign key" $ do
|
||||
it "embeds parents recursively" $
|
||||
get "/family_tree?id=in.(3,4)&select=id,parent(id,name,parent(*))" `shouldRespondWith`
|
||||
[json|[
|
||||
{ "id": "3", "parent": { "id": "1", "name": "Parental Unit", "parent": null } },
|
||||
{ "id": "4", "parent": { "id": "2", "name": "Kid One", "parent": { "id": "1", "name": "Parental Unit", "parent": null } } }
|
||||
]|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "embeds children recursively" $
|
||||
get "/family_tree?id=eq.1&select=id,name, children:family_tree!parent(id,name,children:family_tree!parent(id,name))" `shouldRespondWith`
|
||||
[json|[{
|
||||
"id": "1", "name": "Parental Unit", "children": [
|
||||
{ "id": "2", "name": "Kid One", "children": [ { "id": "4", "name": "Grandkid One" } ] },
|
||||
{ "id": "3", "name": "Kid Two", "children": [ { "id": "5", "name": "Grandkid Two" } ] }
|
||||
]
|
||||
}]|] { matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "embeds parent and then embeds children" $
|
||||
get "/family_tree?id=eq.2&select=id,name,parent(id,name,children:family_tree!parent(id,name))" `shouldRespondWith`
|
||||
[json|[{
|
||||
"id": "2", "name": "Kid One", "parent": {
|
||||
"id": "1", "name": "Parental Unit", "children": [ { "id": "2", "name": "Kid One" }, { "id": "3", "name": "Kid Two"} ]
|
||||
}
|
||||
}]|] { matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
context "two self reference foreign keys" $ do
|
||||
it "embeds parents" $
|
||||
get "/organizations?select=id,name,referee(id,name),auditor(id,name)&id=eq.3" `shouldRespondWith`
|
||||
[json|[{
|
||||
"id": 3, "name": "Acme",
|
||||
"referee": {
|
||||
"id": 1,
|
||||
"name": "Referee Org"
|
||||
},
|
||||
"auditor": {
|
||||
"id": 2,
|
||||
"name": "Auditor Org"
|
||||
}
|
||||
}]|] { matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "embeds children" $ do
|
||||
get "/organizations?select=id,name,refereeds:organizations!referee(id,name)&id=eq.1" `shouldRespondWith`
|
||||
[json|[{
|
||||
"id": 1, "name": "Referee Org",
|
||||
"refereeds": [
|
||||
{
|
||||
"id": 3,
|
||||
"name": "Acme"
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"name": "Umbrella"
|
||||
}
|
||||
]
|
||||
}]|] { matchHeaders = [matchContentTypeJson] }
|
||||
get "/organizations?select=id,name,auditees:organizations!auditor(id,name)&id=eq.2" `shouldRespondWith`
|
||||
[json|[{
|
||||
"id": 2, "name": "Auditor Org",
|
||||
"auditees": [
|
||||
{
|
||||
"id": 3,
|
||||
"name": "Acme"
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"name": "Umbrella"
|
||||
}
|
||||
]
|
||||
}]|] { matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "embeds other relations(manager) besides the self reference" $ do
|
||||
get "/organizations?select=name,manager(name),referee(name,manager(name),auditor(name,manager(name))),auditor(name,manager(name),referee(name,manager(name)))&id=eq.5" `shouldRespondWith`
|
||||
[json|[{
|
||||
"name":"Cyberdyne",
|
||||
"manager":{"name":"Cyberdyne Manager"},
|
||||
"referee":{
|
||||
"name":"Acme",
|
||||
"manager":{"name":"Acme Manager"},
|
||||
"auditor":{
|
||||
"name":"Auditor Org",
|
||||
"manager":{"name":"Auditor Manager"}}},
|
||||
"auditor":{
|
||||
"name":"Umbrella",
|
||||
"manager":{"name":"Umbrella Manager"},
|
||||
"referee":{
|
||||
"name":"Referee Org",
|
||||
"manager":{"name":"Referee Manager"}}}
|
||||
}]|] { matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
get "/organizations?select=name,manager(name),auditees:organizations!auditor(name,manager(name),refereeds:organizations!referee(name,manager(name)))&id=eq.2" `shouldRespondWith`
|
||||
[json|[{
|
||||
"name":"Auditor Org",
|
||||
"manager":{"name":"Auditor Manager"},
|
||||
"auditees":[
|
||||
{"name":"Acme",
|
||||
"manager":{"name":"Acme Manager"},
|
||||
"refereeds":[
|
||||
{"name":"Cyberdyne",
|
||||
"manager":{"name":"Cyberdyne Manager"}},
|
||||
{"name":"Oscorp",
|
||||
"manager":{"name":"Oscorp Manager"}}]},
|
||||
{"name":"Umbrella",
|
||||
"manager":{"name":"Umbrella Manager"},
|
||||
"refereeds":[]}]
|
||||
}]|] { matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
context "m2m embed when there's a junction in an internal schema" $ do
|
||||
-- https://github.com/PostgREST/postgrest/issues/1736
|
||||
it "works with no ambiguity when there's an exposed view of the junction" $ do
|
||||
get "/screens?select=labels(name)" `shouldRespondWith`
|
||||
[json|[{"labels":[{"name":"fruit"}]}, {"labels":[{"name":"vehicles"}]}, {"labels":[{"name":"vehicles"}, {"name":"fruit"}]}]|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
get "/actors?select=*,films(*)" `shouldRespondWith`
|
||||
[json|[ {"id":1,"name":"john","films":[{"id":12,"title":"douze commandements"}]},
|
||||
{"id":2,"name":"mary","films":[{"id":2001,"title":"odyssée de l'espace"}]}]|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
it "doesn't work if the junction is only internal" $
|
||||
get "/end_1?select=end_2(*)" `shouldRespondWith`
|
||||
[json|{
|
||||
"hint":"Verify that 'end_1' and 'end_2' exist in the schema 'test' and that there is a foreign key relationship between them. If a new relationship was created, try reloading the schema cache.",
|
||||
"message":"Could not find a relationship between 'end_1' and 'end_2' in the schema cache"}|]
|
||||
{ matchStatus = 400
|
||||
, matchHeaders = [matchContentTypeJson] }
|
||||
it "shouldn't try to embed if the private junction has an exposed homonym" $
|
||||
-- ensures the "invalid reference to FROM-clause entry for table "rollen" error doesn't happen.
|
||||
-- Ref: https://github.com/PostgREST/postgrest/issues/1587#issuecomment-734995669
|
||||
get "/schauspieler?select=filme(*)" `shouldRespondWith`
|
||||
[json|{
|
||||
"hint":"Verify that 'schauspieler' and 'filme' exist in the schema 'test' and that there is a foreign key relationship between them. If a new relationship was created, try reloading the schema cache.",
|
||||
"message":"Could not find a relationship between 'schauspieler' and 'filme' in the schema cache"}|]
|
||||
{ matchStatus = 400
|
||||
, matchHeaders = [matchContentTypeJson] }
|
||||
@@ -0,0 +1,356 @@
|
||||
module Feature.EmbedInnerJoinSpec where
|
||||
|
||||
import Network.HTTP.Types
|
||||
import Network.Wai (Application)
|
||||
|
||||
import Test.Hspec
|
||||
import Test.Hspec.Wai
|
||||
import Test.Hspec.Wai.JSON
|
||||
|
||||
import Protolude hiding (get)
|
||||
import SpecHelper
|
||||
|
||||
spec :: SpecWith ((), Application)
|
||||
spec =
|
||||
describe "Embedding with an inner join" $ do
|
||||
context "many-to-one relationships" $ do
|
||||
it "ignores null embeddings while the default left join doesn't" $ do
|
||||
get "/projects?select=id,clients!inner(id)" `shouldRespondWith`
|
||||
[json|[
|
||||
{"id":1,"clients":{"id":1}}, {"id":2,"clients":{"id":1}},
|
||||
{"id":3,"clients":{"id":2}}, {"id":4,"clients":{"id":2}}]|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
get "/projects?select=id,clients!left(id)" `shouldRespondWith`
|
||||
[json|[
|
||||
{"id":1,"clients":{"id":1}}, {"id":2,"clients":{"id":1}},
|
||||
{"id":3,"clients":{"id":2}}, {"id":4,"clients":{"id":2}},
|
||||
{"id":5,"clients":null}]|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
request methodHead "/projects?select=id,clients!inner(id)" [("Prefer", "count=exact")] mempty
|
||||
`shouldRespondWith` ""
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = [ matchContentTypeJson
|
||||
, "Content-Range" <:> "0-3/4" ]
|
||||
}
|
||||
|
||||
it "filters source tables when the embedded table is filtered" $ do
|
||||
get "/projects?select=id,clients!inner(id)&clients.id=eq.1" `shouldRespondWith`
|
||||
[json|[
|
||||
{"id":1,"clients":{"id":1}},
|
||||
{"id":2,"clients":{"id":1}}]|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
get "/projects?select=id,clients!inner(id)&clients.id=eq.2" `shouldRespondWith`
|
||||
[json|[
|
||||
{"id":3,"clients":{"id":2}},
|
||||
{"id":4,"clients":{"id":2}}] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
get "/projects?select=id,clients!inner(id)&clients.id=eq.0" `shouldRespondWith`
|
||||
[json|[]|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
request methodHead "/projects?select=id,clients!inner(id)&clients.id=eq.1" [("Prefer", "count=exact")] mempty
|
||||
`shouldRespondWith` ""
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = [ matchContentTypeJson
|
||||
, "Content-Range" <:> "0-1/2" ]
|
||||
}
|
||||
|
||||
it "filters source tables when a two levels below embedded table is filtered" $ do
|
||||
get "/tasks?select=id,projects!inner(id,clients!inner(id))&projects.clients.id=eq.1" `shouldRespondWith`
|
||||
[json|[
|
||||
{"id":1,"projects":{"id":1,"clients":{"id":1}}},
|
||||
{"id":2,"projects":{"id":1,"clients":{"id":1}}},
|
||||
{"id":3,"projects":{"id":2,"clients":{"id":1}}},
|
||||
{"id":4,"projects":{"id":2,"clients":{"id":1}}}]|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
get "/tasks?select=id,projects!inner(id,clients!inner(id))&projects.clients.id=eq.2" `shouldRespondWith`
|
||||
[json|[
|
||||
{"id":5,"projects":{"id":3,"clients":{"id":2}}},
|
||||
{"id":6,"projects":{"id":3,"clients":{"id":2}}},
|
||||
{"id":7,"projects":{"id":4,"clients":{"id":2}}},
|
||||
{"id":8,"projects":{"id":4,"clients":{"id":2}}}]|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
request methodHead "/tasks?select=id,projects!inner(id,clients!inner(id))&projects.clients.id=eq.1" [("Prefer", "count=exact")] mempty
|
||||
`shouldRespondWith` ""
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = [ matchContentTypeJson
|
||||
, "Content-Range" <:> "0-3/4" ]
|
||||
}
|
||||
|
||||
it "only affects the source table rows if his direct embedding is an inner join" $ do
|
||||
get "/tasks?select=id,projects(id,clients!inner(id))&projects.clients.id=eq.2" `shouldRespondWith`
|
||||
[json|[
|
||||
{"id":1,"projects":null},
|
||||
{"id":2,"projects":null},
|
||||
{"id":3,"projects":null},
|
||||
{"id":4,"projects":null},
|
||||
{"id":5,"projects":{"id":3,"clients":{"id":2}}},
|
||||
{"id":6,"projects":{"id":3,"clients":{"id":2}}},
|
||||
{"id":7,"projects":{"id":4,"clients":{"id":2}}},
|
||||
{"id":8,"projects":{"id":4,"clients":{"id":2}}}]|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
request methodHead "/tasks?select=id,projects(id,clients!inner(id))&projects.clients.id=eq.2" [("Prefer", "count=exact")] mempty
|
||||
`shouldRespondWith` ""
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = [ matchContentTypeJson
|
||||
, "Content-Range" <:> "0-7/8" ]
|
||||
}
|
||||
|
||||
it "works with views" $ do
|
||||
get "/books?select=title,authors!inner(name)&authors.name=eq.George%20Orwell" `shouldRespondWith`
|
||||
[json| [{"title":"1984","authors":{"name":"George Orwell"}}] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
request methodHead "/books?select=title,authors!inner(name)&authors.name=eq.George%20Orwell" [("Prefer", "count=exact")] mempty
|
||||
`shouldRespondWith` ""
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = [ matchContentTypeJson
|
||||
, "Content-Range" <:> "0-0/1" ]
|
||||
}
|
||||
|
||||
context "one-to-many relationships" $ do
|
||||
it "ignores empty array embeddings while the default left join doesn't" $ do
|
||||
get "/entities?select=id,child_entities!inner(id)" `shouldRespondWith`
|
||||
[json|[
|
||||
{"id":1,"child_entities":[{"id":1}, {"id":2}, {"id":4}, {"id":5}]},
|
||||
{"id":2,"child_entities":[{"id":3}, {"id":6}]}]|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
get "/entities?select=id,child_entities!left(id)" `shouldRespondWith`
|
||||
[json| [
|
||||
{"id":1,"child_entities":[{"id":1}, {"id":2}, {"id":4}, {"id":5}]},
|
||||
{"id":2,"child_entities":[{"id":3}, {"id":6}]},
|
||||
{"id":3,"child_entities":[]},
|
||||
{"id":4,"child_entities":[]}] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
request methodHead "/entities?select=id,child_entities!inner(id)" [("Prefer", "count=exact")] mempty
|
||||
`shouldRespondWith` ""
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = [ matchContentTypeJson
|
||||
, "Content-Range" <:> "0-1/2" ]
|
||||
}
|
||||
|
||||
it "filters source tables when the embedded table is filtered" $ do
|
||||
get "/entities?select=id,child_entities!inner(id)&child_entities.id=eq.1" `shouldRespondWith`
|
||||
[json|[{"id":1,"child_entities":[{"id":1}]}]|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
get "/entities?select=id,child_entities!inner(id)&child_entities.id=eq.3" `shouldRespondWith`
|
||||
[json|[{"id":2,"child_entities":[{"id":3}]}]|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
get "/entities?select=id,child_entities!inner(id)&child_entities.id=eq.0" `shouldRespondWith`
|
||||
[json|[]|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
request methodHead "/entities?select=id,child_entities!inner(id)&child_entities.id=eq.1" [("Prefer", "count=exact")] mempty
|
||||
`shouldRespondWith` ""
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = [ matchContentTypeJson
|
||||
, "Content-Range" <:> "0-0/1" ]
|
||||
}
|
||||
|
||||
it "filters source tables when a two levels below embedded table is filtered" $ do
|
||||
get "/entities?select=id,child_entities!inner(id,grandchild_entities!inner(id))&child_entities.grandchild_entities.id=in.(1,5)"
|
||||
`shouldRespondWith`
|
||||
[json|[
|
||||
{
|
||||
"id": 1,
|
||||
"child_entities": [
|
||||
{ "id": 1, "grandchild_entities": [ { "id": 1 } ] },
|
||||
{ "id": 2, "grandchild_entities": [ { "id": 5 } ] }]
|
||||
}
|
||||
]|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
get "/entities?select=id,child_entities!inner(id,grandchild_entities!inner(id))&child_entities.grandchild_entities.id=eq.2" `shouldRespondWith`
|
||||
[json|[
|
||||
{
|
||||
"id": 1,
|
||||
"child_entities": [
|
||||
{ "id": 1, "grandchild_entities": [ { "id": 2 } ] } ]
|
||||
}
|
||||
]|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
request methodHead "/entities?select=id,child_entities!inner(id,grandchild_entities!inner(id))&child_entities.grandchild_entities.id=in.(1,5)" [("Prefer", "count=exact")] mempty
|
||||
`shouldRespondWith` ""
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = [ matchContentTypeJson
|
||||
, "Content-Range" <:> "0-0/1" ]
|
||||
}
|
||||
|
||||
it "only affects the source table rows if his direct embedding is an inner join" $ do
|
||||
get "/entities?select=id,child_entities!inner(id,grandchild_entities(id))&child_entities.grandchild_entities.id=eq.2" `shouldRespondWith`
|
||||
[json|[
|
||||
{
|
||||
"id": 1,
|
||||
"child_entities": [
|
||||
{ "id": 1, "grandchild_entities": [ { "id": 2 } ] },
|
||||
{ "id": 2, "grandchild_entities": [] },
|
||||
{ "id": 4, "grandchild_entities": [] },
|
||||
{ "id": 5, "grandchild_entities": [] } ]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"child_entities": [
|
||||
{ "id": 3, "grandchild_entities": [] },
|
||||
{ "id": 6, "grandchild_entities": [] } ]
|
||||
}
|
||||
]|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
request methodHead "/entities?select=id,child_entities!inner(id,grandchild_entities(id))&child_entities.grandchild_entities.id=eq.2" [("Prefer", "count=exact")] mempty
|
||||
`shouldRespondWith` ""
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = [ matchContentTypeJson
|
||||
, "Content-Range" <:> "0-1/2" ]
|
||||
}
|
||||
|
||||
it "works with views" $ do
|
||||
get "/authors?select=*,books!inner(*)&books.title=eq.1984" `shouldRespondWith`
|
||||
[json| [{"id":1,"name":"George Orwell","books":[{"id":1,"title":"1984","publication_year":1949,"author_id":1}]}] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
request methodHead "/authors?select=*,books!inner(*)&books.title=eq.1984" [("Prefer", "count=exact")] mempty
|
||||
`shouldRespondWith` ""
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = [ matchContentTypeJson
|
||||
, "Content-Range" <:> "0-0/1" ]
|
||||
}
|
||||
|
||||
context "many-to-many relationships" $ do
|
||||
it "ignores empty array embeddings while the default left join doesn't" $ do
|
||||
get "/products?select=id,suppliers!inner(id)" `shouldRespondWith`
|
||||
[json| [
|
||||
{"id":1,"suppliers":[{"id":1}, {"id":2}]},
|
||||
{"id":2,"suppliers":[{"id":1}, {"id":3}]}] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
get "/products?select=id,suppliers!left(id)" `shouldRespondWith`
|
||||
[json| [
|
||||
{"id":1,"suppliers":[{"id":1}, {"id":2}]},
|
||||
{"id":2,"suppliers":[{"id":1}, {"id":3}]},
|
||||
{"id":3,"suppliers":[]}] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
request methodHead "/products?select=id,suppliers!inner(id)" [("Prefer", "count=exact")] mempty
|
||||
`shouldRespondWith` ""
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = [ matchContentTypeJson
|
||||
, "Content-Range" <:> "0-1/2" ]
|
||||
}
|
||||
|
||||
it "filters source tables when the embedded table is filtered" $ do
|
||||
get "/products?select=id,suppliers!inner(id)&suppliers.id=eq.2" `shouldRespondWith`
|
||||
[json| [{"id":1,"suppliers":[{"id":2}]}] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
get "/products?select=id,suppliers!inner(id)&suppliers.id=eq.3" `shouldRespondWith`
|
||||
[json| [{"id":2,"suppliers":[{"id":3}]}] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
get "/products?select=id,suppliers!inner(id)&suppliers.id=eq.0" `shouldRespondWith`
|
||||
[json| [] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
request methodHead "/products?select=id,suppliers!inner(id)&suppliers.id=eq.2" [("Prefer", "count=exact")] mempty
|
||||
`shouldRespondWith` ""
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = [ matchContentTypeJson
|
||||
, "Content-Range" <:> "0-0/1" ]
|
||||
}
|
||||
|
||||
it "filters source tables when a two levels below embedded table is filtered" $ do
|
||||
get "/products?select=id,suppliers!inner(id,trade_unions!inner(id))&suppliers.trade_unions.id=eq.3"
|
||||
`shouldRespondWith`
|
||||
[json|[{"id":1,"suppliers":[{"id":2,"trade_unions":[{"id":3}]}]}] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
get "/products?select=id,suppliers!inner(id,trade_unions!inner(id))&suppliers.trade_unions.id=eq.4"
|
||||
`shouldRespondWith`
|
||||
[json|[{"id":1,"suppliers":[{"id":2,"trade_unions":[{"id":4}]}]}] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
request methodHead "/products?select=id,suppliers!inner(id,trade_unions!inner(id))&suppliers.trade_unions.id=eq.3" [("Prefer", "count=exact")] mempty
|
||||
`shouldRespondWith` ""
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = [ matchContentTypeJson
|
||||
, "Content-Range" <:> "0-0/1" ]
|
||||
}
|
||||
|
||||
it "only affects the source table rows if his direct embedding is an inner join" $ do
|
||||
get "/products?select=id,suppliers!inner(id,trade_unions(id))&suppliers.trade_unions.id=eq.3" `shouldRespondWith`
|
||||
[json|[
|
||||
{"id":1,"suppliers":[{"id":1,"trade_unions":[]}, {"id":2,"trade_unions":[{"id":3}]}]},
|
||||
{"id":2,"suppliers":[{"id":1,"trade_unions":[]}, {"id":3,"trade_unions":[]}]}]|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
request methodHead "/products?select=id,suppliers!inner(id,trade_unions(id))&suppliers.trade_unions.id=eq.3" [("Prefer", "count=exact")] mempty
|
||||
`shouldRespondWith` ""
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = [ matchContentTypeJson
|
||||
, "Content-Range" <:> "0-1/2" ]
|
||||
}
|
||||
|
||||
it "works with views" $ do
|
||||
get "/actors?select=*,films!inner(*)&films.title=eq.douze%20commandements" `shouldRespondWith`
|
||||
[json| [{"id":1,"name":"john","films":[{"id":12,"title":"douze commandements"}]}] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
get "/films?select=*,actors!inner(*)&actors.name=eq.john" `shouldRespondWith`
|
||||
[json| [{"id":12,"title":"douze commandements","actors":[{"id":1,"name":"john"}]}] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
request methodHead "/actors?select=*,films!inner(*)&films.title=eq.douze%20commandements" [("Prefer", "count=exact")] mempty
|
||||
`shouldRespondWith` ""
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = [ matchContentTypeJson
|
||||
, "Content-Range" <:> "0-0/1" ]
|
||||
}
|
||||
|
||||
it "works with m2o and m2m relationships combined" $ do
|
||||
get "/projects?select=name,clients!inner(name),users!inner(name)" `shouldRespondWith`
|
||||
[json| [
|
||||
{"name":"Windows 7","clients":{"name":"Microsoft"},"users":[{"name":"Angela Martin"}, {"name":"Dwight Schrute"}]},
|
||||
{"name":"Windows 10","clients":{"name":"Microsoft"},"users":[{"name":"Angela Martin"}]},
|
||||
{"name":"IOS","clients":{"name":"Apple"},"users":[{"name":"Michael Scott"}, {"name":"Dwight Schrute"}]},
|
||||
{"name":"OSX","clients":{"name":"Apple"},"users":[{"name":"Michael Scott"}]}]|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
request methodHead "/projects?select=name,clients!inner(name),users!inner(name)" [("Prefer", "count=exact")] mempty
|
||||
`shouldRespondWith` ""
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = [ matchContentTypeJson
|
||||
, "Content-Range" <:> "0-3/4" ]
|
||||
}
|
||||
|
||||
it "works with rpc" $ do
|
||||
get "/rpc/getallprojects?select=id,clients!inner(id)&clients.id=eq.1" `shouldRespondWith`
|
||||
[json| [{"id":1,"clients":{"id":1}}, {"id":2,"clients":{"id":1}}] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
request methodHead "/rpc/getallprojects?select=id,clients!inner(id)&clients.id=eq.1" [("Prefer", "count=exact")] mempty
|
||||
`shouldRespondWith` ""
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = [ matchContentTypeJson
|
||||
, "Content-Range" <:> "0-1/2" ]
|
||||
}
|
||||
|
||||
it "works when using hints" $ do
|
||||
get "/projects?select=id,clients!client!inner(id)&clients.id=eq.2" `shouldRespondWith`
|
||||
[json| [{"id":3,"clients":{"id":2}}, {"id":4,"clients":{"id":2}}] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
get "/projects?select=id,client!inner(id)&client.id=eq.2" `shouldRespondWith`
|
||||
[json| [{"id":3,"client":{"id":2}}, {"id":4,"client":{"id":2}}] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
request methodHead "/projects?select=id,clients!client!inner(id)&clients.id=eq.2" [("Prefer", "count=exact")] mempty
|
||||
`shouldRespondWith` ""
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = [ matchContentTypeJson
|
||||
, "Content-Range" <:> "0-1/2" ]
|
||||
}
|
||||
|
||||
it "works with many one-to-many relationships" $ do
|
||||
-- https://github.com/PostgREST/postgrest/issues/1977
|
||||
get "/client?select=id,name,contact!inner(name),clientinfo!inner(other)" `shouldRespondWith`
|
||||
[json|[
|
||||
{"id":1,"name":"Walmart","contact":[{"name":"Wally Walton"}, {"name":"Wilma Wellers"}],"clientinfo":[{"other":"123 Main St"}]},
|
||||
{"id":2,"name":"Target", "contact":[{"name":"Tabby Targo"}],"clientinfo":[{"other":"456 South 3rd St"}]},
|
||||
{"id":3,"name":"Big Lots","contact":[{"name":"Bobby Bots"}, {"name":"Bonnie Bits"}, {"name":"Billy Boats"}],"clientinfo":[{"other":"789 Palm Tree Ln"}]}
|
||||
]|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
get "/client?select=id,name,contact!inner(name),clientinfo!inner(other)&contact.name=eq.Wally%20Walton" `shouldRespondWith`
|
||||
[json|[
|
||||
{"id":1,"name":"Walmart","contact":[{"name":"Wally Walton"}],"clientinfo":[{"other":"123 Main St"}]}
|
||||
]|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
get "/client?select=id,name,contact!inner(name),clientinfo!inner(other)&clientinfo.other=eq.456%20South%203rd%20St" `shouldRespondWith`
|
||||
[json|[
|
||||
{"id":2,"name":"Target","clientinfo":[{"other":"456 South 3rd St"}],"contact":[{"name":"Tabby Targo"}]}
|
||||
]|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
request methodHead "/client?select=id,name,contact!inner(name),clientinfo!inner(other)" [("Prefer", "count=exact")] mempty
|
||||
`shouldRespondWith` ""
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = [ matchContentTypeJson
|
||||
, "Content-Range" <:> "0-2/3" ]
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
module Feature.ExtraSearchPathSpec where
|
||||
|
||||
import Network.HTTP.Types
|
||||
import Network.Wai (Application)
|
||||
import Test.Hspec
|
||||
import Test.Hspec.Wai
|
||||
import Test.Hspec.Wai.JSON
|
||||
|
||||
import Protolude hiding (get)
|
||||
import SpecHelper
|
||||
|
||||
spec :: SpecWith ((), Application)
|
||||
spec = describe "extra search path" $ do
|
||||
|
||||
it "finds the ltree <@ operator on the public schema" $
|
||||
request methodGet "/ltree_sample?path=cd.Top.Science.Astronomy" [] ""
|
||||
`shouldRespondWith` [json|[
|
||||
{"path":"Top.Science.Astronomy"},
|
||||
{"path":"Top.Science.Astronomy.Astrophysics"},
|
||||
{"path":"Top.Science.Astronomy.Cosmology"}]|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "finds the ltree nlevel function on the public schema, used through a computed column" $
|
||||
request methodGet "/ltree_sample?select=number_of_labels&path=eq.Top.Science" [] ""
|
||||
`shouldRespondWith` [json|[{"number_of_labels":2}]|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "finds the isn = operator on the extensions schema" $
|
||||
request methodGet "/isn_sample?id=eq.978-0-393-04002-9&select=name" [] ""
|
||||
`shouldRespondWith` [json|[{"name":"Mathematics: From the Birth of Numbers"}]|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "finds the isn is_valid function on the extensions schema" $
|
||||
request methodGet "/rpc/is_valid_isbn?input=978-0-393-04002-9" [] ""
|
||||
`shouldRespondWith` [json|true|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "can detect fk relations through multiple views recursively when middle views are in extra search path" $
|
||||
get "/consumers_extra_view?select=*,orders_view(*)" `shouldRespondWith` 200
|
||||
@@ -0,0 +1,30 @@
|
||||
module Feature.HtmlRawOutputSpec where
|
||||
|
||||
import Network.Wai (Application)
|
||||
|
||||
import Network.HTTP.Types
|
||||
import Test.Hspec hiding (pendingWith)
|
||||
import Test.Hspec.Wai
|
||||
import Text.Heredoc
|
||||
|
||||
import Protolude hiding (get)
|
||||
import SpecHelper (acceptHdrs)
|
||||
|
||||
spec :: SpecWith ((), Application)
|
||||
spec = describe "When raw-media-types is set to \"text/html\"" $
|
||||
it "can get raw output with Accept: text/html" $
|
||||
request methodGet "/rpc/welcome.html" (acceptHdrs "text/html") ""
|
||||
`shouldRespondWith`
|
||||
[str|
|
||||
|<html>
|
||||
| <head>
|
||||
| <title>PostgREST</title>
|
||||
| </head>
|
||||
| <body>
|
||||
| <h1>Welcome to PostgREST</h1>
|
||||
| </body>
|
||||
|</html>
|
||||
|]
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = ["Content-Type" <:> "text/html"]
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
module Feature.IgnorePrivOpenApiSpec where
|
||||
|
||||
import Control.Lens ((^?))
|
||||
|
||||
import Data.Aeson.Lens
|
||||
import Data.Aeson.QQ
|
||||
|
||||
import Network.HTTP.Types
|
||||
import Network.Wai (Application)
|
||||
import Network.Wai.Test (SResponse (..))
|
||||
|
||||
import Test.Hspec hiding (pendingWith)
|
||||
import Test.Hspec.Wai
|
||||
|
||||
import Protolude hiding (get)
|
||||
import SpecHelper
|
||||
|
||||
spec :: SpecWith ((), Application)
|
||||
spec = describe "OpenAPI Ignore Privileges" $ do
|
||||
it "root path returns a valid openapi spec" $ do
|
||||
validateOpenApiResponse [("Accept", "application/openapi+json")]
|
||||
request methodHead "/"
|
||||
(acceptHdrs "application/openapi+json")
|
||||
""
|
||||
`shouldRespondWith`
|
||||
""
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = [ "Content-Type" <:> "application/openapi+json; charset=utf-8" ]
|
||||
}
|
||||
|
||||
describe "table" $ do
|
||||
|
||||
it "includes privileged table even if user does not have permission" $ do
|
||||
r <- simpleBody <$> get "/"
|
||||
let tableTag = r ^? key "paths" . key "/authors_only"
|
||||
. key "post" . key "tags"
|
||||
. nth 0
|
||||
|
||||
liftIO $ tableTag `shouldBe` Just [aesonQQ|"authors_only"|]
|
||||
|
||||
it "only includes tables that belong to another schema if the Accept-Profile header is used" $ do
|
||||
r1 <- simpleBody <$> get "/"
|
||||
let tableKey1 = r1 ^? key "paths" . key "/children"
|
||||
|
||||
liftIO $ tableKey1 `shouldBe` Nothing
|
||||
|
||||
r2 <- simpleBody <$> request methodGet "/" [("Accept-Profile", "v1")] ""
|
||||
let tableKey2 = r2 ^? key "paths" . key "/children"
|
||||
|
||||
liftIO $ tableKey2 `shouldNotBe` Nothing
|
||||
|
||||
it "includes comments on tables" $ do
|
||||
r <- simpleBody <$> get "/"
|
||||
|
||||
let grandChildGet s = key "paths" . key "/grandchild_entities" . key "get" . key s
|
||||
grandChildGetSummary = r ^? grandChildGet "summary"
|
||||
grandChildGetDescription = r ^? grandChildGet "description"
|
||||
|
||||
liftIO $ do
|
||||
grandChildGetSummary `shouldBe` Just "grandchild_entities summary"
|
||||
grandChildGetDescription `shouldBe` Just "grandchild_entities description\nthat spans\nmultiple lines"
|
||||
|
||||
describe "RPC" $ do
|
||||
|
||||
it "includes privileged function even if user does not have permission" $ do
|
||||
r <- simpleBody <$> get "/"
|
||||
let funcTag = r ^? key "paths" . key "/rpc/privileged_hello"
|
||||
. key "post" . key "tags"
|
||||
. nth 0
|
||||
|
||||
liftIO $ funcTag `shouldBe` Just [aesonQQ|"(rpc) privileged_hello"|]
|
||||
|
||||
it "only includes functions that belong to another schema if the Accept-Profile header is used" $ do
|
||||
r1 <- simpleBody <$> get "/"
|
||||
let funcKey1 = r1 ^? key "paths" . key "/rpc/get_parents_below"
|
||||
|
||||
liftIO $ funcKey1 `shouldBe` Nothing
|
||||
|
||||
r2 <- simpleBody <$> request methodGet "/" [("Accept-Profile", "v1")] ""
|
||||
let funcKey2 = r2 ^? key "paths" . key "/rpc/get_parents_below"
|
||||
|
||||
liftIO $ funcKey2 `shouldNotBe` Nothing
|
||||
@@ -0,0 +1,609 @@
|
||||
module Feature.InsertSpec where
|
||||
|
||||
import Data.List (lookup)
|
||||
import Network.Wai (Application)
|
||||
import Network.Wai.Test (SResponse (simpleHeaders))
|
||||
import Test.Hspec hiding (pendingWith)
|
||||
import Test.Hspec.Wai.Matcher (bodyEquals)
|
||||
|
||||
import Network.HTTP.Types
|
||||
import Test.Hspec.Wai
|
||||
import Test.Hspec.Wai.JSON
|
||||
import Text.Heredoc
|
||||
|
||||
import PostgREST.Config.PgVersion (PgVersion, pgVersion110,
|
||||
pgVersion112, pgVersion130)
|
||||
|
||||
import Protolude hiding (get)
|
||||
import SpecHelper
|
||||
|
||||
spec :: PgVersion -> SpecWith ((), Application)
|
||||
spec actualPgVersion = do
|
||||
describe "Posting new record" $ do
|
||||
context "disparate json types" $ do
|
||||
it "accepts disparate json types" $ do
|
||||
post "/menagerie"
|
||||
[json| {
|
||||
"integer": 13, "double": 3.14159, "varchar": "testing!"
|
||||
, "boolean": false, "date": "1900-01-01", "money": "$3.99"
|
||||
, "enum": "foo"
|
||||
} |] `shouldRespondWith` ""
|
||||
{ matchStatus = 201
|
||||
-- should not have content type set when body is empty
|
||||
, matchHeaders = [matchHeaderAbsent hContentType]
|
||||
}
|
||||
|
||||
it "filters columns in result using &select" $
|
||||
request methodPost "/menagerie?select=integer,varchar" [("Prefer", "return=representation")]
|
||||
[json| [{
|
||||
"integer": 14, "double": 3.14159, "varchar": "testing!"
|
||||
, "boolean": false, "date": "1900-01-01", "money": "$3.99"
|
||||
, "enum": "foo"
|
||||
}] |] `shouldRespondWith` [json|[{"integer":14,"varchar":"testing!"}]|]
|
||||
{ matchStatus = 201
|
||||
, matchHeaders = [matchContentTypeJson]
|
||||
}
|
||||
|
||||
it "ignores &select when return not set or using return=minimal" $ do
|
||||
request methodPost "/menagerie?select=integer,varchar"
|
||||
[]
|
||||
[json| [{
|
||||
"integer": 15, "double": 3.14159, "varchar": "testing!",
|
||||
"boolean": false, "date": "1900-01-01", "money": "$3.99",
|
||||
"enum": "foo"
|
||||
}] |]
|
||||
`shouldRespondWith`
|
||||
""
|
||||
{ matchStatus = 201
|
||||
, matchHeaders = [matchHeaderAbsent hContentType]
|
||||
}
|
||||
request methodPost "/menagerie?select=integer,varchar"
|
||||
[("Prefer", "return=minimal")]
|
||||
[json| [{
|
||||
"integer": 16, "double": 3.14159, "varchar": "testing!",
|
||||
"boolean": false, "date": "1900-01-01", "money": "$3.99",
|
||||
"enum": "foo"
|
||||
}] |]
|
||||
`shouldRespondWith`
|
||||
""
|
||||
{ matchStatus = 201
|
||||
, matchHeaders = [matchHeaderAbsent hContentType]
|
||||
}
|
||||
|
||||
context "non uniform json array" $ do
|
||||
it "rejects json array that isn't exclusivily composed of objects" $
|
||||
post "/articles"
|
||||
[json| [{"id": 100, "body": "xxxxx"}, 123, "xxxx", {"id": 111, "body": "xxxx"}] |]
|
||||
`shouldRespondWith`
|
||||
[json| {"message":"All object keys must match"} |]
|
||||
{ matchStatus = 400
|
||||
, matchHeaders = [matchContentTypeJson]
|
||||
}
|
||||
|
||||
it "rejects json array that has objects with different keys" $
|
||||
post "/articles"
|
||||
[json| [{"id": 100, "body": "xxxxx"}, {"id": 111, "body": "xxxx", "owner": "me"}] |]
|
||||
`shouldRespondWith`
|
||||
[json| {"message":"All object keys must match"} |]
|
||||
{ matchStatus = 400
|
||||
, matchHeaders = [matchContentTypeJson]
|
||||
}
|
||||
|
||||
context "requesting full representation" $ do
|
||||
it "includes related data after insert" $
|
||||
request methodPost "/projects?select=id,name,clients(id,name)"
|
||||
[("Prefer", "return=representation"), ("Prefer", "count=exact")]
|
||||
[json|{"id":6,"name":"New Project","client_id":2}|] `shouldRespondWith` [json|[{"id":6,"name":"New Project","clients":{"id":2,"name":"Apple"}}]|]
|
||||
{ matchStatus = 201
|
||||
, matchHeaders = [ matchContentTypeJson
|
||||
, "Location" <:> "/projects?id=eq.6"
|
||||
, "Content-Range" <:> "*/1" ]
|
||||
}
|
||||
|
||||
it "can rename and cast the selected columns" $
|
||||
request methodPost "/projects?select=pId:id::text,pName:name,cId:client_id::text"
|
||||
[("Prefer", "return=representation")]
|
||||
[json|{"id":7,"name":"New Project","client_id":2}|] `shouldRespondWith`
|
||||
[json|[{"pId":"7","pName":"New Project","cId":"2"}]|]
|
||||
{ matchStatus = 201
|
||||
, matchHeaders = [ matchContentTypeJson
|
||||
, "Location" <:> "/projects?id=eq.7"
|
||||
, "Content-Range" <:> "*/*" ]
|
||||
}
|
||||
|
||||
it "should not throw and return location header when selecting without PK" $
|
||||
request methodPost "/projects?select=name,client_id" [("Prefer", "return=representation")]
|
||||
[json|{"id":10,"name":"New Project","client_id":2}|] `shouldRespondWith`
|
||||
[json|[{"name":"New Project","client_id":2}]|]
|
||||
{ matchStatus = 201
|
||||
, matchHeaders = [ matchContentTypeJson
|
||||
, "Location" <:> "/projects?id=eq.10"
|
||||
, "Content-Range" <:> "*/*" ]
|
||||
}
|
||||
|
||||
context "requesting headers only representation" $ do
|
||||
it "should not throw and return location header when selecting without PK" $
|
||||
request methodPost "/projects?select=name,client_id"
|
||||
[("Prefer", "return=headers-only")]
|
||||
[json|{"id":11,"name":"New Project","client_id":2}|]
|
||||
`shouldRespondWith`
|
||||
""
|
||||
{ matchStatus = 201
|
||||
, matchHeaders = [ matchHeaderAbsent hContentType
|
||||
, "Location" <:> "/projects?id=eq.11"
|
||||
, "Content-Range" <:> "*/*" ]
|
||||
}
|
||||
|
||||
when (actualPgVersion >= pgVersion110) $
|
||||
it "should not throw and return location header for partitioned tables when selecting without PK" $
|
||||
request methodPost "/car_models"
|
||||
[("Prefer", "return=headers-only")]
|
||||
[json|{"name":"Enzo","year":2021}|]
|
||||
`shouldRespondWith`
|
||||
""
|
||||
{ matchStatus = 201
|
||||
, matchHeaders = [ matchHeaderAbsent hContentType
|
||||
, "Location" <:> "/car_models?name=eq.Enzo&year=eq.2021"
|
||||
, "Content-Range" <:> "*/*" ]
|
||||
}
|
||||
|
||||
context "requesting no representation" $
|
||||
it "should not throw and return no location header when selecting without PK" $
|
||||
request methodPost "/projects?select=name,client_id"
|
||||
[]
|
||||
[json|{"id":12,"name":"New Project","client_id":2}|]
|
||||
`shouldRespondWith`
|
||||
""
|
||||
{ matchStatus = 201
|
||||
, matchHeaders = [ matchHeaderAbsent hContentType
|
||||
, matchHeaderAbsent hLocation ]
|
||||
}
|
||||
|
||||
context "from an html form" $
|
||||
it "accepts disparate json types" $ do
|
||||
request methodPost "/menagerie"
|
||||
[("Content-Type", "application/x-www-form-urlencoded")]
|
||||
("integer=7&double=2.71828&varchar=forms+are+fun&" <>
|
||||
"boolean=false&date=1900-01-01&money=$3.99&enum=foo")
|
||||
`shouldRespondWith`
|
||||
""
|
||||
{ matchStatus = 201
|
||||
, matchHeaders = [ matchHeaderAbsent hContentType ]
|
||||
}
|
||||
|
||||
context "with no pk supplied" $ do
|
||||
context "into a table with auto-incrementing pk" $
|
||||
it "succeeds with 201 and location header" $ do
|
||||
-- reset pk sequence first to make test repeatable
|
||||
request methodPost "/rpc/reset_sequence"
|
||||
[("Prefer", "tx=commit")]
|
||||
[json|{"name": "auto_incrementing_pk_id_seq", "value": 2}|]
|
||||
`shouldRespondWith`
|
||||
[json|""|]
|
||||
|
||||
request methodPost "/auto_incrementing_pk"
|
||||
[("Prefer", "return=headers-only")]
|
||||
[json| { "non_nullable_string":"not null"} |]
|
||||
`shouldRespondWith`
|
||||
""
|
||||
{ matchStatus = 201
|
||||
, matchHeaders = [ matchHeaderAbsent hContentType
|
||||
, "Location" <:> "/auto_incrementing_pk?id=eq.2" ]
|
||||
}
|
||||
|
||||
context "into a table with simple pk" $
|
||||
it "fails with 400 and error" $
|
||||
post "/simple_pk" [json| { "extra":"foo"} |]
|
||||
`shouldRespondWith`
|
||||
(if actualPgVersion >= pgVersion130 then
|
||||
[json|{"hint":null,"details":"Failing row contains (null, foo).","code":"23502","message":"null value in column \"k\" of relation \"simple_pk\" violates not-null constraint"}|]
|
||||
else
|
||||
[json|{"hint":null,"details":"Failing row contains (null, foo).","code":"23502","message":"null value in column \"k\" violates not-null constraint"}|]
|
||||
)
|
||||
{ matchStatus = 400
|
||||
, matchHeaders = [matchContentTypeJson]
|
||||
}
|
||||
|
||||
context "into a table with no pk" $ do
|
||||
it "succeeds with 201 but no location header" $ do
|
||||
post "/no_pk"
|
||||
[json| { "a":"foo", "b":"bar" } |]
|
||||
`shouldRespondWith`
|
||||
""
|
||||
{ matchStatus = 201
|
||||
, matchHeaders = [ matchHeaderAbsent hContentType
|
||||
, matchHeaderAbsent hLocation ]
|
||||
}
|
||||
|
||||
it "returns full details of inserted record if asked" $ do
|
||||
request methodPost "/no_pk"
|
||||
[("Prefer", "return=representation")]
|
||||
[json| { "a":"bar", "b":"baz" } |]
|
||||
`shouldRespondWith`
|
||||
[json| [{ "a":"bar", "b":"baz" }] |]
|
||||
{ matchStatus = 201
|
||||
, matchHeaders = [matchHeaderAbsent hLocation]
|
||||
}
|
||||
|
||||
it "returns empty array when no items inserted, and return=rep" $ do
|
||||
request methodPost "/no_pk"
|
||||
[("Prefer", "return=representation")]
|
||||
[json| [] |]
|
||||
`shouldRespondWith`
|
||||
[json| [] |]
|
||||
{ matchStatus = 201 }
|
||||
|
||||
it "can post nulls" $ do
|
||||
request methodPost "/no_pk"
|
||||
[("Prefer", "return=representation")]
|
||||
[json| { "a":null, "b":"foo" } |]
|
||||
`shouldRespondWith`
|
||||
[json| [{ "a":null, "b":"foo" }] |]
|
||||
{ matchStatus = 201
|
||||
, matchHeaders = [matchHeaderAbsent hLocation]
|
||||
}
|
||||
|
||||
context "with compound pk supplied" $
|
||||
it "builds response location header appropriately" $ do
|
||||
request methodPost "/compound_pk"
|
||||
[("Prefer", "return=representation")]
|
||||
[json| { "k1":12, "k2":"Rock & R+ll" } |]
|
||||
`shouldRespondWith`
|
||||
[json|[ { "k1":12, "k2":"Rock & R+ll", "extra": null } ]|]
|
||||
{ matchStatus = 201
|
||||
, matchHeaders = [ "Location" <:> "/compound_pk?k1=eq.12&k2=eq.Rock%20%26%20R%2Bll" ]
|
||||
}
|
||||
|
||||
context "with bulk insert" $
|
||||
it "returns 201 but no location header" $ do
|
||||
let bulkData = [json| [ {"k1":21, "k2":"hello world"}
|
||||
, {"k1":22, "k2":"bye for now"}]
|
||||
|]
|
||||
request methodPost "/compound_pk"
|
||||
[]
|
||||
bulkData
|
||||
`shouldRespondWith`
|
||||
""
|
||||
{ matchStatus = 201
|
||||
, matchHeaders = [ matchHeaderAbsent hContentType
|
||||
, matchHeaderAbsent hLocation ]
|
||||
}
|
||||
|
||||
context "with invalid json payload" $
|
||||
it "fails with 400 and error" $
|
||||
post "/simple_pk" "}{ x = 2"
|
||||
`shouldRespondWith`
|
||||
[json|{"message":"Error in $: Failed reading: not a valid json value at '}{x=2'"}|]
|
||||
{ matchStatus = 400
|
||||
, matchHeaders = [matchContentTypeJson]
|
||||
}
|
||||
|
||||
context "with no payload" $
|
||||
it "fails with 400 and error" $
|
||||
post "/simple_pk" ""
|
||||
`shouldRespondWith`
|
||||
[json|{"message":"Error in $: not enough input"}|]
|
||||
{ matchStatus = 400
|
||||
, matchHeaders = [matchContentTypeJson]
|
||||
}
|
||||
|
||||
context "with valid json payload" $
|
||||
it "succeeds and returns 201 created" $
|
||||
post "/simple_pk"
|
||||
[json| { "k":"k1", "extra":"e1" } |]
|
||||
`shouldRespondWith`
|
||||
""
|
||||
{ matchStatus = 201
|
||||
, matchHeaders = [matchHeaderAbsent hContentType]
|
||||
}
|
||||
|
||||
context "attempting to insert a row with the same primary key" $
|
||||
it "fails returning a 409 Conflict" $
|
||||
post "/simple_pk"
|
||||
[json| { "k":"xyyx", "extra":"e1" } |]
|
||||
`shouldRespondWith`
|
||||
[json|{"hint":null,"details":"Key (k)=(xyyx) already exists.","code":"23505","message":"duplicate key value violates unique constraint \"simple_pk_pkey\""}|]
|
||||
{ matchStatus = 409 }
|
||||
|
||||
context "attempting to insert a row with conflicting unique constraint" $
|
||||
it "fails returning a 409 Conflict" $
|
||||
post "/withUnique" [json| { "uni":"nodup", "extra":"e2" } |] `shouldRespondWith` 409
|
||||
|
||||
context "jsonb" $ do
|
||||
it "serializes nested object" $ do
|
||||
let inserted = [json| { "data": { "foo":"bar" } } |]
|
||||
request methodPost "/json_table"
|
||||
[("Prefer", "return=representation")]
|
||||
inserted
|
||||
`shouldRespondWith` [json|[{"data":{"foo":"bar"}}]|]
|
||||
{ matchStatus = 201
|
||||
}
|
||||
|
||||
it "serializes nested array" $ do
|
||||
let inserted = [json| { "data": [1,2,3] } |]
|
||||
request methodPost "/json_table"
|
||||
[("Prefer", "return=representation")]
|
||||
inserted
|
||||
`shouldRespondWith` [json|[{"data":[1,2,3]}]|]
|
||||
{ matchStatus = 201
|
||||
}
|
||||
|
||||
context "empty objects" $ do
|
||||
it "successfully inserts a row with all-default columns" $ do
|
||||
post "/items"
|
||||
[json|{}|]
|
||||
`shouldRespondWith`
|
||||
""
|
||||
{ matchStatus = 201
|
||||
, matchHeaders = [matchHeaderAbsent hContentType]
|
||||
}
|
||||
post "/items" "[{}]" `shouldRespondWith` ""
|
||||
{ matchStatus = 201
|
||||
, matchHeaders = []
|
||||
}
|
||||
|
||||
it "successfully inserts two rows with all-default columns" $
|
||||
post "/items"
|
||||
[json|[{}, {}]|]
|
||||
`shouldRespondWith`
|
||||
""
|
||||
{ matchStatus = 201
|
||||
, matchHeaders = [matchHeaderAbsent hContentType]
|
||||
}
|
||||
|
||||
it "successfully inserts a row with all-default columns with prefer=rep" $ do
|
||||
-- reset pk sequence first to make test repeatable
|
||||
request methodPost "/rpc/reset_sequence"
|
||||
[("Prefer", "tx=commit")]
|
||||
[json|{"name": "items2_id_seq", "value": 20}|]
|
||||
`shouldRespondWith`
|
||||
[json|""|]
|
||||
|
||||
request methodPost "/items2"
|
||||
[("Prefer", "return=representation")]
|
||||
[json|{}|]
|
||||
`shouldRespondWith`
|
||||
[json|[{ id: 20 }]|]
|
||||
{ matchStatus = 201 }
|
||||
|
||||
it "successfully inserts a row with all-default columns with prefer=rep and &select=" $ do
|
||||
-- reset pk sequence first to make test repeatable
|
||||
request methodPost "/rpc/reset_sequence"
|
||||
[("Prefer", "tx=commit")]
|
||||
[json|{"name": "items3_id_seq", "value": 20}|]
|
||||
`shouldRespondWith`
|
||||
[json|""|]
|
||||
|
||||
request methodPost "/items3?select=id"
|
||||
[("Prefer", "return=representation")]
|
||||
[json|{}|]
|
||||
`shouldRespondWith` [json|[{ id: 20 }]|]
|
||||
{ matchStatus = 201 }
|
||||
|
||||
context "POST with ?columns parameter" $ do
|
||||
it "ignores json keys not included in ?columns" $ do
|
||||
request methodPost "/articles?columns=id,body" [("Prefer", "return=representation")]
|
||||
[json| {"id": 200, "body": "xxx", "smth": "here", "other": "stuff", "fake_id": 13} |] `shouldRespondWith`
|
||||
[json|[{"id": 200, "body": "xxx", "owner": "postgrest_test_anonymous"}]|]
|
||||
{ matchStatus = 201
|
||||
, matchHeaders = [] }
|
||||
request methodPost "/articles?columns=id,body&select=id,body" [("Prefer", "return=representation")]
|
||||
[json| [
|
||||
{"id": 201, "body": "yyy", "smth": "here", "other": "stuff", "fake_id": 13},
|
||||
{"id": 202, "body": "zzz", "garbage": "%%$&", "kkk": "jjj"},
|
||||
{"id": 203, "body": "aaa", "hey": "ho"} ]|] `shouldRespondWith`
|
||||
[json|[
|
||||
{"id": 201, "body": "yyy"},
|
||||
{"id": 202, "body": "zzz"},
|
||||
{"id": 203, "body": "aaa"} ]|]
|
||||
{ matchStatus = 201
|
||||
, matchHeaders = [] }
|
||||
|
||||
-- TODO parse columns error message needs to be improved
|
||||
it "disallows blank ?columns" $
|
||||
post "/articles?columns="
|
||||
[json|[
|
||||
{"id": 204, "body": "yyy"},
|
||||
{"id": 205, "body": "zzz"}]|]
|
||||
`shouldRespondWith`
|
||||
[json| {"details":"unexpected end of input expecting field name (* or [a..z0..9_])","message":"\"failed to parse columns parameter ()\" (line 1, column 1)"} |]
|
||||
{ matchStatus = 400
|
||||
, matchHeaders = []
|
||||
}
|
||||
|
||||
it "disallows array elements that are not json objects" $
|
||||
post "/articles?columns=id,body"
|
||||
[json|[
|
||||
{"id": 204, "body": "yyy"},
|
||||
333,
|
||||
"asdf",
|
||||
{"id": 205, "body": "zzz"}]|] `shouldRespondWith`
|
||||
[json|{
|
||||
"code": "22023",
|
||||
"details": null,
|
||||
"hint": null,
|
||||
"message": "argument of json_populate_recordset must be an array of objects"}|]
|
||||
{ matchStatus = 400
|
||||
, matchHeaders = []
|
||||
}
|
||||
|
||||
describe "CSV insert" $ do
|
||||
context "disparate csv types" $
|
||||
it "succeeds with multipart response" $ do
|
||||
pendingWith "Decide on what to do with CSV insert"
|
||||
let inserted = [str|integer,double,varchar,boolean,date,money,enum
|
||||
|13,3.14159,testing!,false,1900-01-01,$3.99,foo
|
||||
|12,0.1,a string,true,1929-10-01,12,bar
|
||||
|]
|
||||
request methodPost "/menagerie" [("Content-Type", "text/csv"), ("Accept", "text/csv"), ("Prefer", "return=representation")] inserted
|
||||
`shouldRespondWith` ResponseMatcher
|
||||
{ matchStatus = 201
|
||||
, matchHeaders = ["Content-Type" <:> "text/csv; charset=utf-8"]
|
||||
, matchBody = bodyEquals inserted
|
||||
}
|
||||
|
||||
context "requesting full representation" $ do
|
||||
it "returns full details of inserted record" $
|
||||
request methodPost "/no_pk"
|
||||
[("Content-Type", "text/csv"), ("Accept", "text/csv"), ("Prefer", "return=representation")]
|
||||
"a,b\nbar,baz"
|
||||
`shouldRespondWith` "a,b\nbar,baz"
|
||||
{ matchStatus = 201
|
||||
, matchHeaders = ["Content-Type" <:> "text/csv; charset=utf-8"]
|
||||
}
|
||||
|
||||
it "can post nulls" $
|
||||
request methodPost "/no_pk"
|
||||
[("Content-Type", "text/csv"), ("Accept", "text/csv"), ("Prefer", "return=representation")]
|
||||
"a,b\nNULL,foo"
|
||||
`shouldRespondWith` "a,b\n,foo"
|
||||
{ matchStatus = 201
|
||||
, matchHeaders = ["Content-Type" <:> "text/csv; charset=utf-8"]
|
||||
}
|
||||
|
||||
it "only returns the requested column header with its associated data" $
|
||||
request methodPost "/projects?select=id"
|
||||
[("Content-Type", "text/csv"), ("Accept", "text/csv"), ("Prefer", "return=representation")]
|
||||
"id,name,client_id\n8,Xenix,1\n9,Windows NT,1"
|
||||
`shouldRespondWith` "id\n8\n9"
|
||||
{ matchStatus = 201
|
||||
, matchHeaders = ["Content-Type" <:> "text/csv; charset=utf-8",
|
||||
"Content-Range" <:> "*/*"]
|
||||
}
|
||||
|
||||
context "with wrong number of columns" $
|
||||
it "fails for too few" $
|
||||
request methodPost "/no_pk" [("Content-Type", "text/csv")] "a,b\nfoo,bar\nbaz"
|
||||
`shouldRespondWith`
|
||||
[json|{"message":"All lines must have same number of fields"}|]
|
||||
{ matchStatus = 400
|
||||
, matchHeaders = [matchContentTypeJson]
|
||||
}
|
||||
|
||||
context "with unicode values" $
|
||||
it "succeeds and returns usable location header" $ do
|
||||
p <- request methodPost "/simple_pk2?select=extra,k"
|
||||
[("Prefer", "tx=commit"), ("Prefer", "return=representation")]
|
||||
[json| { "k":"圍棋", "extra":"¥" } |]
|
||||
pure p `shouldRespondWith`
|
||||
[json|[ { "k":"圍棋", "extra":"¥" } ]|]
|
||||
{ matchStatus = 201 }
|
||||
|
||||
let Just location = lookup hLocation $ simpleHeaders p
|
||||
get location
|
||||
`shouldRespondWith`
|
||||
[json|[ { "k":"圍棋", "extra":"¥" } ]|]
|
||||
|
||||
request methodDelete location
|
||||
[("Prefer", "tx=commit")]
|
||||
""
|
||||
`shouldRespondWith`
|
||||
""
|
||||
{ matchStatus = 204
|
||||
, matchHeaders = [matchHeaderAbsent hContentType]
|
||||
}
|
||||
|
||||
describe "Row level permission" $
|
||||
it "set user_id when inserting rows" $ do
|
||||
request methodPost "/authors_only"
|
||||
[ authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWQiOiJqZG9lIn0.B-lReuGNDwAlU1GOC476MlO0vAt9JNoHIlxg2vwMaO0", ("Prefer", "return=representation") ]
|
||||
[json| { "secret": "nyancat" } |]
|
||||
`shouldRespondWith`
|
||||
[json|[{"owner":"jdoe","secret":"nyancat"}]|]
|
||||
{ matchStatus = 201 }
|
||||
|
||||
request methodPost "/authors_only"
|
||||
-- jwt token for jroe
|
||||
[ authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWQiOiJqcm9lIn0.2e7mx0U4uDcInlbJVOBGlrRufwqWLINDIEDC1vS0nw8", ("Prefer", "return=representation") ]
|
||||
[json| { "secret": "lolcat", "owner": "hacker" } |]
|
||||
`shouldRespondWith`
|
||||
[json|[{"owner":"jroe","secret":"lolcat"}]|]
|
||||
{ matchStatus = 201 }
|
||||
|
||||
context "tables with self reference foreign keys" $ do
|
||||
it "embeds parent after insert" $
|
||||
request methodPost "/web_content?select=id,name,parent_content:p_web_id(name)"
|
||||
[("Prefer", "return=representation")]
|
||||
[json|{"id":6, "name":"wot", "p_web_id":4}|]
|
||||
`shouldRespondWith`
|
||||
[json|[{"id":6,"name":"wot","parent_content":{"name":"wut"}}]|]
|
||||
{ matchStatus = 201
|
||||
, matchHeaders = [ matchContentTypeJson , "Location" <:> "/web_content?id=eq.6" ]
|
||||
}
|
||||
|
||||
context "table with limited privileges" $ do
|
||||
it "succeeds inserting if correct select is applied" $
|
||||
request methodPost "/limited_article_stars?select=article_id,user_id" [("Prefer", "return=representation")]
|
||||
[json| {"article_id": 2, "user_id": 1} |] `shouldRespondWith` [json|[{"article_id":2,"user_id":1}]|]
|
||||
{ matchStatus = 201
|
||||
, matchHeaders = []
|
||||
}
|
||||
|
||||
it "fails inserting if more columns are selected" $
|
||||
request methodPost "/limited_article_stars?select=article_id,user_id,created_at" [("Prefer", "return=representation")]
|
||||
[json| {"article_id": 2, "user_id": 2} |] `shouldRespondWith` (
|
||||
if actualPgVersion >= pgVersion112 then
|
||||
[json|{"hint":null,"details":null,"code":"42501","message":"permission denied for view limited_article_stars"}|]
|
||||
else
|
||||
[json|{"hint":null,"details":null,"code":"42501","message":"permission denied for relation limited_article_stars"}|]
|
||||
)
|
||||
{ matchStatus = 401
|
||||
, matchHeaders = []
|
||||
}
|
||||
|
||||
it "fails inserting if select is not specified" $
|
||||
request methodPost "/limited_article_stars" [("Prefer", "return=representation")]
|
||||
[json| {"article_id": 3, "user_id": 1} |] `shouldRespondWith` (
|
||||
if actualPgVersion >= pgVersion112 then
|
||||
[json|{"hint":null,"details":null,"code":"42501","message":"permission denied for view limited_article_stars"}|]
|
||||
else
|
||||
[json|{"hint":null,"details":null,"code":"42501","message":"permission denied for relation limited_article_stars"}|]
|
||||
)
|
||||
{ matchStatus = 401
|
||||
, matchHeaders = []
|
||||
}
|
||||
|
||||
it "can insert in a table with no select and return=minimal" $ do
|
||||
request methodPost "/insertonly"
|
||||
[("Prefer", "return=minimal")]
|
||||
[json| { "v":"some value" } |]
|
||||
`shouldRespondWith`
|
||||
""
|
||||
{ matchStatus = 201
|
||||
, matchHeaders = [matchHeaderAbsent hContentType]
|
||||
}
|
||||
|
||||
describe "Inserting into VIEWs" $ do
|
||||
context "requesting no representation" $
|
||||
it "succeeds with 201" $
|
||||
post "/compound_pk_view"
|
||||
[json|{"k1":1,"k2":"test","extra":2}|]
|
||||
`shouldRespondWith`
|
||||
""
|
||||
{ matchStatus = 201
|
||||
, matchHeaders = [ matchHeaderAbsent hContentType
|
||||
, matchHeaderAbsent hLocation ]
|
||||
}
|
||||
|
||||
context "requesting header only representation" $ do
|
||||
it "returns a location header" $
|
||||
request methodPost "/compound_pk_view" [("Prefer", "return=headers-only")]
|
||||
[json|{"k1":1,"k2":"test","extra":2}|]
|
||||
`shouldRespondWith`
|
||||
""
|
||||
{ matchStatus = 201
|
||||
, matchHeaders = [ matchHeaderAbsent hContentType
|
||||
, "Location" <:> "/compound_pk_view?k1=eq.1&k2=eq.test"
|
||||
, "Content-Range" <:> "*/*" ]
|
||||
}
|
||||
|
||||
it "should not throw and return location header when a PK is null" $
|
||||
request methodPost "/test_null_pk_competitors_sponsors" [("Prefer", "return=headers-only")]
|
||||
[json|{"id":1}|]
|
||||
`shouldRespondWith`
|
||||
""
|
||||
{ matchStatus = 201
|
||||
, matchHeaders = [ matchHeaderAbsent hContentType
|
||||
, "Location" <:> "/test_null_pk_competitors_sponsors?id=eq.1&sponsor_id=is.null"
|
||||
, "Content-Range" <:> "*/*" ]
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
module Feature.JsonOperatorSpec where
|
||||
|
||||
import Network.Wai (Application)
|
||||
|
||||
import Network.HTTP.Types
|
||||
import Test.Hspec
|
||||
import Test.Hspec.Wai
|
||||
import Test.Hspec.Wai.JSON
|
||||
|
||||
import PostgREST.Config.PgVersion (PgVersion, pgVersion112,
|
||||
pgVersion121)
|
||||
|
||||
import Protolude hiding (get)
|
||||
import SpecHelper
|
||||
|
||||
spec :: PgVersion -> SpecWith ((), Application)
|
||||
spec actualPgVersion = describe "json and jsonb operators" $ do
|
||||
context "Shaping response with select parameter" $ do
|
||||
it "obtains a json subfield one level with casting" $
|
||||
get "/complex_items?id=eq.1&select=settings->>foo::json" `shouldRespondWith`
|
||||
[json| [{"foo":{"int":1,"bar":"baz"}}] |] -- the value of foo here is of type "text"
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "renames json subfield one level with casting" $
|
||||
get "/complex_items?id=eq.1&select=myFoo:settings->>foo::json" `shouldRespondWith`
|
||||
[json| [{"myFoo":{"int":1,"bar":"baz"}}] |] -- the value of foo here is of type "text"
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "fails on bad casting (data of the wrong format)" $
|
||||
get "/complex_items?select=settings->foo->>bar::integer"
|
||||
`shouldRespondWith` (
|
||||
if actualPgVersion >= pgVersion121 then
|
||||
[json| {"hint":null,"details":null,"code":"22P02","message":"invalid input syntax for type integer: \"baz\""} |]
|
||||
else
|
||||
[json| {"hint":null,"details":null,"code":"22P02","message":"invalid input syntax for integer: \"baz\""} |]
|
||||
)
|
||||
{ matchStatus = 400 , matchHeaders = [] }
|
||||
|
||||
it "obtains a json subfield two levels (string)" $
|
||||
get "/complex_items?id=eq.1&select=settings->foo->>bar" `shouldRespondWith`
|
||||
[json| [{"bar":"baz"}] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "renames json subfield two levels (string)" $
|
||||
get "/complex_items?id=eq.1&select=myBar:settings->foo->>bar" `shouldRespondWith`
|
||||
[json| [{"myBar":"baz"}] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "obtains a json subfield two levels with casting (int)" $
|
||||
get "/complex_items?id=eq.1&select=settings->foo->>int::integer" `shouldRespondWith`
|
||||
[json| [{"int":1}] |] -- the value in the db is an int, but here we expect a string for now
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "renames json subfield two levels with casting (int)" $
|
||||
get "/complex_items?id=eq.1&select=myInt:settings->foo->>int::integer" `shouldRespondWith`
|
||||
[json| [{"myInt":1}] |] -- the value in the db is an int, but here we expect a string for now
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
-- TODO the status code for the error is 404, this is because 42883 represents undefined function
|
||||
-- this works fine for /rpc/unexistent requests, but for this case a 500 seems more appropriate
|
||||
it "fails when a double arrow ->> is followed with a single arrow ->" $ do
|
||||
get "/json_arr?select=data->>c->1"
|
||||
`shouldRespondWith` (
|
||||
if actualPgVersion >= pgVersion112 then
|
||||
[json|
|
||||
{"hint":"No operator matches the given name and argument types. You might need to add explicit type casts.",
|
||||
"details":null,"code":"42883","message":"operator does not exist: text -> integer"} |]
|
||||
else
|
||||
[json|
|
||||
{"hint":"No operator matches the given name and argument type(s). You might need to add explicit type casts.",
|
||||
"details":null,"code":"42883","message":"operator does not exist: text -> integer"} |]
|
||||
)
|
||||
{ matchStatus = 404 , matchHeaders = [] }
|
||||
get "/json_arr?select=data->>c->b"
|
||||
`shouldRespondWith` (
|
||||
if actualPgVersion >= pgVersion112 then
|
||||
[json|
|
||||
{"hint":"No operator matches the given name and argument types. You might need to add explicit type casts.",
|
||||
"details":null,"code":"42883","message":"operator does not exist: text -> unknown"} |]
|
||||
else
|
||||
[json|
|
||||
{"hint":"No operator matches the given name and argument type(s). You might need to add explicit type casts.",
|
||||
"details":null,"code":"42883","message":"operator does not exist: text -> unknown"} |]
|
||||
)
|
||||
{ matchStatus = 404 , matchHeaders = [] }
|
||||
|
||||
context "with array index" $ do
|
||||
it "can get array of ints and alias/cast it" $ do
|
||||
get "/json_arr?select=data->>0::int&id=in.(1,2)" `shouldRespondWith`
|
||||
[json| [{"data":1}, {"data":4}] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
get "/json_arr?select=idx0:data->>0::int,idx1:data->>1::int&id=in.(1,2)" `shouldRespondWith`
|
||||
[json| [{"idx0":1,"idx1":2}, {"idx0":4,"idx1":5}] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "can get nested array of ints" $ do
|
||||
get "/json_arr?select=data->0->>1::int&id=in.(3,4)" `shouldRespondWith`
|
||||
[json| [{"data":8}, {"data":7}] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
get "/json_arr?select=data->0->0->>1::int&id=in.(3,4)" `shouldRespondWith`
|
||||
[json| [{"data":null}, {"data":6}] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "can get array of objects" $ do
|
||||
get "/json_arr?select=data->0->>a&id=in.(5,6)" `shouldRespondWith`
|
||||
[json| [{"a":"A"}, {"a":"[1,2,3]"}] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
get "/json_arr?select=data->0->a->>2&id=in.(5,6)" `shouldRespondWith`
|
||||
[json| [{"a":null}, {"a":"3"}] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "can get array in object keys" $ do
|
||||
get "/json_arr?select=data->c->>0::json&id=in.(7,8)" `shouldRespondWith`
|
||||
[json| [{"c":1}, {"c":{"d": [4,5,6,7,8]}}] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
get "/json_arr?select=data->c->0->d->>4::int&id=in.(7,8)" `shouldRespondWith`
|
||||
[json| [{"d":null}, {"d":8}] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "only treats well formed numbers as indexes" $
|
||||
get "/json_arr?select=data->0->0xy1->1->23-xy-45->1->xy-6->>0::int&id=eq.9" `shouldRespondWith`
|
||||
[json| [{"xy-6":3}] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
context "finishing json path with single arrow ->" $ do
|
||||
it "works when finishing with a key" $ do
|
||||
get "/json_arr?select=data->c&id=in.(7,8)" `shouldRespondWith`
|
||||
[json| [{"c":[1,2,3]}, {"c":[{"d": [4,5,6,7,8]}]}] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
get "/json_arr?select=data->0->a&id=in.(5,6)" `shouldRespondWith`
|
||||
[json| [{"a":"A"}, {"a":[1,2,3]}] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "works when finishing with an index" $ do
|
||||
get "/json_arr?select=data->0->a&id=in.(5,6)" `shouldRespondWith`
|
||||
[json| [{"a":"A"}, {"a":[1,2,3]}] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
get "/json_arr?select=data->c->0->d&id=eq.8" `shouldRespondWith`
|
||||
[json| [{"d":[4,5,6,7,8]}] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
context "filtering response" $ do
|
||||
it "can filter by properties inside json column" $ do
|
||||
get "/json_table?data->foo->>bar=eq.baz" `shouldRespondWith`
|
||||
[json| [{"data": {"id": 1, "foo": {"bar": "baz"}}}] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
get "/json_table?data->foo->>bar=eq.fake" `shouldRespondWith`
|
||||
[json| [] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "can filter by properties inside json column using not" $
|
||||
get "/json_table?data->foo->>bar=not.eq.baz" `shouldRespondWith`
|
||||
[json| [] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "can filter by properties inside json column using ->>" $
|
||||
get "/json_table?data->>id=eq.1" `shouldRespondWith`
|
||||
[json| [{"data": {"id": 1, "foo": {"bar": "baz"}}}] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "can be filtered with and/or" $
|
||||
get "/grandchild_entities?or=(jsonb_col->a->>b.eq.foo, jsonb_col->>b.eq.bar)&select=id" `shouldRespondWith`
|
||||
[json|[{id: 4}, {id: 5}]|] { matchStatus = 200, matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "can filter by array indexes" $ do
|
||||
get "/json_arr?select=data&data->>0=eq.1" `shouldRespondWith`
|
||||
[json| [{"data":[1, 2, 3]}] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
get "/json_arr?select=data&data->1->>2=eq.13" `shouldRespondWith`
|
||||
[json| [{"data":[[9, 8, 7], [11, 12, 13]]}] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
get "/json_arr?select=data&data->1->>b=eq.B" `shouldRespondWith`
|
||||
[json| [{"data":[{"a": "A"}, {"b": "B"}]}] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
get "/json_arr?select=data&data->1->b->>1=eq.5" `shouldRespondWith`
|
||||
[json| [{"data":[{"a": [1,2,3]}, {"b": [4,5]}]}] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "can filter jsonb" $ do
|
||||
get "/jsonb_test?data=eq.{\"e\":1}" `shouldRespondWith`
|
||||
[json| [{"id":4,"data":{"e": 1}}] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
get "/jsonb_test?data->a=eq.{\"b\":2}" `shouldRespondWith`
|
||||
[json| [{"id":1,"data":{"a": {"b": 2}}}] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
get "/jsonb_test?data->c=eq.[1,2,3]" `shouldRespondWith`
|
||||
[json| [{"id":2,"data":{"c": [1, 2, 3]}}] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
get "/jsonb_test?data->0=eq.{\"d\":\"test\"}" `shouldRespondWith`
|
||||
[json| [{"id":3,"data":[{"d": "test"}]}] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
context "ordering response" $ do
|
||||
it "orders by a json column property asc" $
|
||||
get "/json_table?order=data->>id.asc" `shouldRespondWith`
|
||||
[json| [{"data": {"id": 0}}, {"data": {"id": 1, "foo": {"bar": "baz"}}}, {"data": {"id": 3}}] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "orders by a json column with two level property nulls first" $
|
||||
get "/json_table?order=data->foo->>bar.nullsfirst" `shouldRespondWith`
|
||||
[json| [{"data": {"id": 3}}, {"data": {"id": 0}}, {"data": {"id": 1, "foo": {"bar": "baz"}}}] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
context "Patching record, in a nonempty table" $
|
||||
it "can set a json column to escaped value" $ do
|
||||
request methodPatch "/json_table?data->>id=eq.3"
|
||||
[("Prefer", "return=representation")]
|
||||
[json| { "data": { "id":" \"escaped" } } |]
|
||||
`shouldRespondWith`
|
||||
[json| [{ "data": { "id":" \"escaped" } }] |]
|
||||
|
||||
context "json array negative index" $ do
|
||||
it "can select with negative indexes" $ do
|
||||
get "/json_arr?select=data->>-1::int&id=in.(1,2)" `shouldRespondWith`
|
||||
[json| [{"data":3}, {"data":6}] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
get "/json_arr?select=data->0->>-2::int&id=in.(3,4)" `shouldRespondWith`
|
||||
[json| [{"data":8}, {"data":7}] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
get "/json_arr?select=data->-2->>a&id=in.(5,6)" `shouldRespondWith`
|
||||
[json| [{"a":"A"}, {"a":"[1,2,3]"}] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "can filter with negative indexes" $ do
|
||||
get "/json_arr?select=data&data->>-3=eq.1" `shouldRespondWith`
|
||||
[json| [{"data":[1, 2, 3]}] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
get "/json_arr?select=data&data->-1->>-3=eq.11" `shouldRespondWith`
|
||||
[json| [{"data":[[9, 8, 7], [11, 12, 13]]}] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
get "/json_arr?select=data&data->-1->>b=eq.B" `shouldRespondWith`
|
||||
[json| [{"data":[{"a": "A"}, {"b": "B"}]}] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
get "/json_arr?select=data&data->-1->b->>-1=eq.5" `shouldRespondWith`
|
||||
[json| [{"data":[{"a": [1,2,3]}, {"b": [4,5]}]}] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "should fail on badly formed negatives" $ do
|
||||
get "/json_arr?select=data->>-78xy" `shouldRespondWith`
|
||||
[json|
|
||||
{"details": "unexpected 'x' expecting digit, \"->\", \"::\" or end of input",
|
||||
"message": "\"failed to parse select parameter (data->>-78xy)\" (line 1, column 11)"} |]
|
||||
{ matchStatus = 400, matchHeaders = [matchContentTypeJson] }
|
||||
get "/json_arr?select=data->>--34" `shouldRespondWith`
|
||||
[json|
|
||||
{"details": "unexpected \"-\" expecting digit",
|
||||
"message": "\"failed to parse select parameter (data->>--34)\" (line 1, column 9)"} |]
|
||||
{ matchStatus = 400, matchHeaders = [matchContentTypeJson] }
|
||||
get "/json_arr?select=data->>-xy-4" `shouldRespondWith`
|
||||
[json|
|
||||
{"details":"unexpected \"x\" expecting digit",
|
||||
"message":"\"failed to parse select parameter (data->>-xy-4)\" (line 1, column 9)"} |]
|
||||
{ matchStatus = 400, matchHeaders = [matchContentTypeJson] }
|
||||
@@ -0,0 +1,68 @@
|
||||
module Feature.LegacyGucsSpec where
|
||||
|
||||
import Network.Wai (Application)
|
||||
|
||||
import Network.HTTP.Types
|
||||
import Test.Hspec hiding (pendingWith)
|
||||
import Test.Hspec.Wai
|
||||
import Test.Hspec.Wai.JSON
|
||||
|
||||
import Protolude hiding (get)
|
||||
import SpecHelper
|
||||
|
||||
spec :: SpecWith ((), Application)
|
||||
spec =
|
||||
describe "remote procedure call with legacy gucs disabled" $ do
|
||||
it "custom header is set" $
|
||||
request methodPost "/rpc/get_guc_value" [("Custom-Header", "test")]
|
||||
[json| { "prefix": "request.headers", "name": "custom-header" } |]
|
||||
`shouldRespondWith`
|
||||
[json|"test"|]
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = [ matchContentTypeJson ]
|
||||
}
|
||||
|
||||
it "standard header is set" $
|
||||
request methodPost "/rpc/get_guc_value" [("Origin", "http://example.com")]
|
||||
[json| { "prefix": "request.headers", "name": "origin" } |]
|
||||
`shouldRespondWith`
|
||||
[json|"http://example.com"|]
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = [ matchContentTypeJson ]
|
||||
}
|
||||
|
||||
it "current role is available as GUC claim" $
|
||||
request methodPost "/rpc/get_guc_value" []
|
||||
[json| { "prefix": "request.jwt.claims", "name": "role" } |]
|
||||
`shouldRespondWith`
|
||||
[json|"postgrest_test_anonymous"|]
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = [ matchContentTypeJson ]
|
||||
}
|
||||
|
||||
it "single cookie ends up as claims" $
|
||||
request methodPost "/rpc/get_guc_value" [("Cookie","acookie=cookievalue")]
|
||||
[json| {"prefix": "request.cookies", "name":"acookie"} |]
|
||||
`shouldRespondWith`
|
||||
[json|"cookievalue"|]
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = []
|
||||
}
|
||||
|
||||
it "multiple cookies ends up as claims" $
|
||||
request methodPost "/rpc/get_guc_value" [("Cookie","acookie=cookievalue;secondcookie=anothervalue")]
|
||||
[json| {"prefix": "request.cookies", "name":"secondcookie"} |]
|
||||
`shouldRespondWith`
|
||||
[json|"anothervalue"|]
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = []
|
||||
}
|
||||
|
||||
it "gets the Authorization value" $
|
||||
request methodPost "/rpc/get_guc_value" [authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIn0.Xod-F15qsGL0WhdOCr2j3DdKuTw9QJERVgoFD3vGaWA"]
|
||||
[json| {"prefix": "request.headers", "name":"authorization"} |]
|
||||
`shouldRespondWith`
|
||||
[json|"Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIn0.Xod-F15qsGL0WhdOCr2j3DdKuTw9QJERVgoFD3vGaWA"|]
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = []
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
module Feature.MultipleSchemaSpec where
|
||||
|
||||
import Control.Lens ((^?))
|
||||
import Data.Aeson.Lens
|
||||
import Data.Aeson.QQ
|
||||
|
||||
import Network.HTTP.Types
|
||||
import Network.Wai (Application)
|
||||
import Network.Wai.Test (SResponse (simpleHeaders), simpleBody)
|
||||
|
||||
import Test.Hspec
|
||||
import Test.Hspec.Wai
|
||||
import Test.Hspec.Wai.JSON
|
||||
|
||||
import Protolude
|
||||
import SpecHelper
|
||||
|
||||
spec :: SpecWith ((), Application)
|
||||
spec =
|
||||
describe "multiple schemas in single instance" $ do
|
||||
context "Reading tables on different schemas" $ do
|
||||
it "succeeds in reading table from default schema v1 if no schema is selected via header" $
|
||||
request methodGet "/parents" [] "" `shouldRespondWith`
|
||||
[json|[
|
||||
{"id":1,"name":"parent v1-1"},
|
||||
{"id":2,"name":"parent v1-2"}
|
||||
]|]
|
||||
{
|
||||
matchStatus = 200
|
||||
, matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v1"]
|
||||
}
|
||||
|
||||
it "succeeds in reading table from default schema v1 after explicitly passing it in the header" $
|
||||
request methodGet "/parents" [("Accept-Profile", "v1")] "" `shouldRespondWith`
|
||||
[json|[
|
||||
{"id":1,"name":"parent v1-1"},
|
||||
{"id":2,"name":"parent v1-2"}
|
||||
]|]
|
||||
{
|
||||
matchStatus = 200
|
||||
, matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v1"]
|
||||
}
|
||||
|
||||
it "succeeds in reading table from schema v2" $
|
||||
request methodGet "/parents" [("Accept-Profile", "v2")] "" `shouldRespondWith`
|
||||
[json|[
|
||||
{"id":3,"name":"parent v2-3"},
|
||||
{"id":4,"name":"parent v2-4"}
|
||||
]|]
|
||||
{
|
||||
matchStatus = 200
|
||||
, matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v2"]
|
||||
}
|
||||
|
||||
it "succeeds in reading another_table from schema v2" $
|
||||
request methodGet "/another_table" [("Accept-Profile", "v2")] "" `shouldRespondWith`
|
||||
[json|[
|
||||
{"id":5,"another_value":"value 5"},
|
||||
{"id":6,"another_value":"value 6"}
|
||||
]|]
|
||||
{
|
||||
matchStatus = 200
|
||||
, matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v2"]
|
||||
}
|
||||
|
||||
it "doesn't find another_table in schema v1" $
|
||||
request methodGet "/another_table" [("Accept-Profile", "v1")] "" `shouldRespondWith` 404
|
||||
|
||||
it "fails trying to read table from unkown schema" $
|
||||
request methodGet "/parents" [("Accept-Profile", "unkown")] "" `shouldRespondWith`
|
||||
[json|{"message":"The schema must be one of the following: v1, v2"}|]
|
||||
{
|
||||
matchStatus = 406
|
||||
}
|
||||
|
||||
context "Inserting tables on different schemas" $ do
|
||||
it "succeeds inserting on default schema and returning it" $
|
||||
request methodPost "/children"
|
||||
[("Prefer", "return=representation")]
|
||||
[json|{"id": 0, "name": "child v1-1", "parent_id": 1}|]
|
||||
`shouldRespondWith`
|
||||
[json|[{"id": 0, "name": "child v1-1", "parent_id": 1}]|]
|
||||
{
|
||||
matchStatus = 201
|
||||
, matchHeaders = ["Content-Profile" <:> "v1"]
|
||||
}
|
||||
|
||||
it "succeeds inserting on the v1 schema and returning its parent" $
|
||||
request methodPost "/children?select=id,parent(*)"
|
||||
[("Prefer", "return=representation"), ("Content-Profile", "v1")]
|
||||
[json|{"id": 0, "name": "child v1-2", "parent_id": 2}|]
|
||||
`shouldRespondWith`
|
||||
[json|[{"id": 0, "parent": {"id": 2, "name": "parent v1-2"}}]|]
|
||||
{
|
||||
matchStatus = 201
|
||||
, matchHeaders = ["Content-Profile" <:> "v1"]
|
||||
}
|
||||
|
||||
it "succeeds inserting on the v2 schema and returning its parent" $
|
||||
request methodPost "/children?select=id,parent(*)"
|
||||
[("Prefer", "return=representation"), ("Content-Profile", "v2")]
|
||||
[json|{"id": 0, "name": "child v2-3", "parent_id": 3}|]
|
||||
`shouldRespondWith`
|
||||
[json|[{"id": 0, "parent": {"id": 3, "name": "parent v2-3"}}]|]
|
||||
{
|
||||
matchStatus = 201
|
||||
, matchHeaders = ["Content-Profile" <:> "v2"]
|
||||
}
|
||||
|
||||
it "fails when inserting on an unknown schema" $
|
||||
request methodPost "/children" [("Content-Profile", "unknown")]
|
||||
[json|{"name": "child 4", "parent_id": 4}|]
|
||||
`shouldRespondWith`
|
||||
[json|{"message":"The schema must be one of the following: v1, v2"}|]
|
||||
{
|
||||
matchStatus = 406
|
||||
}
|
||||
|
||||
context "calling procs on different schemas" $ do
|
||||
it "succeeds in calling the default schema proc" $
|
||||
request methodGet "/rpc/get_parents_below?id=6" [] ""
|
||||
`shouldRespondWith`
|
||||
[json|[{"id":1,"name":"parent v1-1"}, {"id":2,"name":"parent v1-2"}]|]
|
||||
{
|
||||
matchStatus = 200
|
||||
, matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v1"]
|
||||
}
|
||||
|
||||
it "succeeds in calling the v1 schema proc and embedding" $
|
||||
request methodGet "/rpc/get_parents_below?id=6&select=id,name,children(id,name)" [("Accept-Profile", "v1")] ""
|
||||
`shouldRespondWith`
|
||||
[json| [
|
||||
{"id":1,"name":"parent v1-1","children":[{"id":1,"name":"child v1-1"}]},
|
||||
{"id":2,"name":"parent v1-2","children":[{"id":2,"name":"child v1-2"}]}] |]
|
||||
{
|
||||
matchStatus = 200
|
||||
, matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v1"]
|
||||
}
|
||||
|
||||
it "succeeds in calling the v2 schema proc and embedding" $
|
||||
request methodGet "/rpc/get_parents_below?id=6&select=id,name,children(id,name)" [("Accept-Profile", "v2")] ""
|
||||
`shouldRespondWith`
|
||||
[json| [
|
||||
{"id":3,"name":"parent v2-3","children":[{"id":1,"name":"child v2-3"}]},
|
||||
{"id":4,"name":"parent v2-4","children":[]}] |]
|
||||
{
|
||||
matchStatus = 200
|
||||
, matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v2"]
|
||||
}
|
||||
|
||||
it "succeeds in calling the v2 schema proc with POST by using Content-Profile" $
|
||||
request methodPost "/rpc/get_parents_below?select=id,name" [("Content-Profile", "v2")]
|
||||
[json|{"id": "6"}|]
|
||||
`shouldRespondWith`
|
||||
[json| [
|
||||
{"id":3,"name":"parent v2-3"},
|
||||
{"id":4,"name":"parent v2-4"}]|]
|
||||
{
|
||||
matchStatus = 200
|
||||
, matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v2"]
|
||||
}
|
||||
|
||||
context "Modifying tables on different schemas" $ do
|
||||
it "succeeds in patching on the v1 schema and returning its parent" $
|
||||
request methodPatch "/children?select=name,parent(name)&id=eq.1" [("Content-Profile", "v1"), ("Prefer", "return=representation")]
|
||||
[json|{"name": "child v1-1 updated"}|]
|
||||
`shouldRespondWith`
|
||||
[json|[{"name":"child v1-1 updated", "parent": {"name": "parent v1-1"}}]|]
|
||||
{
|
||||
matchStatus = 200
|
||||
, matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v1"]
|
||||
}
|
||||
|
||||
it "succeeds in patching on the v2 schema and returning its parent" $
|
||||
request methodPatch "/children?select=name,parent(name)&id=eq.1" [("Content-Profile", "v2"), ("Prefer", "return=representation")]
|
||||
[json|{"name": "child v2-1 updated"}|]
|
||||
`shouldRespondWith`
|
||||
[json|[{"name":"child v2-1 updated", "parent": {"name": "parent v2-3"}}]|]
|
||||
{
|
||||
matchStatus = 200
|
||||
, matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v2"]
|
||||
}
|
||||
|
||||
it "succeeds on deleting on the v2 schema" $ do
|
||||
request methodDelete "/children?id=eq.1"
|
||||
[("Content-Profile", "v2"), ("Prefer", "return=representation")]
|
||||
""
|
||||
`shouldRespondWith`
|
||||
[json|[{"id": 1, "name": "child v2-3", "parent_id": 3}]|]
|
||||
{ matchHeaders = ["Content-Profile" <:> "v2"] }
|
||||
|
||||
it "succeeds on PUT on the v2 schema" $
|
||||
request methodPut "/children?id=eq.111" [("Content-Profile", "v2"), ("Prefer", "return=representation")]
|
||||
[json| [ { "id": 111, "name": "child v2-111", "parent_id": null } ]|]
|
||||
`shouldRespondWith`
|
||||
[json|[{ "id": 111, "name": "child v2-111", "parent_id": null }]|]
|
||||
{
|
||||
matchStatus = 200
|
||||
, matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v2"]
|
||||
}
|
||||
|
||||
context "OpenAPI output" $ do
|
||||
it "succeeds in reading table definition from default schema v1 if no schema is selected via header" $ do
|
||||
req <- request methodGet "/" [] ""
|
||||
|
||||
liftIO $ do
|
||||
simpleHeaders req `shouldSatisfy` matchHeader "Content-Profile" "v1"
|
||||
|
||||
let def = simpleBody req ^? key "definitions" . key "parents"
|
||||
|
||||
def `shouldBe` Just
|
||||
[aesonQQ|
|
||||
{
|
||||
"type" : "object",
|
||||
"properties" : {
|
||||
"id" : {
|
||||
"description" : "Note:\nThis is a Primary Key.<pk/>",
|
||||
"format" : "integer",
|
||||
"type" : "integer"
|
||||
},
|
||||
"name" : {
|
||||
"format" : "text",
|
||||
"type" : "string"
|
||||
}
|
||||
},
|
||||
"required" : [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
|]
|
||||
|
||||
it "succeeds in reading table definition from default schema v1 after explicitly passing it in the header" $ do
|
||||
r <- request methodGet "/" [("Accept-Profile", "v1")] ""
|
||||
|
||||
liftIO $ do
|
||||
simpleHeaders r `shouldSatisfy` matchHeader "Content-Profile" "v1"
|
||||
|
||||
let def = simpleBody r ^? key "definitions" . key "parents"
|
||||
|
||||
def `shouldBe` Just
|
||||
[aesonQQ|
|
||||
{
|
||||
"type" : "object",
|
||||
"properties" : {
|
||||
"id" : {
|
||||
"description" : "Note:\nThis is a Primary Key.<pk/>",
|
||||
"format" : "integer",
|
||||
"type" : "integer"
|
||||
},
|
||||
"name" : {
|
||||
"format" : "text",
|
||||
"type" : "string"
|
||||
}
|
||||
},
|
||||
"required" : [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
|]
|
||||
|
||||
it "succeeds in reading table definition from schema v2" $ do
|
||||
r <- request methodGet "/" [("Accept-Profile", "v2")] ""
|
||||
|
||||
liftIO $ do
|
||||
simpleHeaders r `shouldSatisfy` matchHeader "Content-Profile" "v2"
|
||||
|
||||
let def = simpleBody r ^? key "definitions" . key "parents"
|
||||
|
||||
def `shouldBe` Just
|
||||
[aesonQQ|
|
||||
{
|
||||
"type" : "object",
|
||||
"properties" : {
|
||||
"id" : {
|
||||
"description" : "Note:\nThis is a Primary Key.<pk/>",
|
||||
"format" : "integer",
|
||||
"type" : "integer"
|
||||
},
|
||||
"name" : {
|
||||
"format" : "text",
|
||||
"type" : "string"
|
||||
}
|
||||
},
|
||||
"required" : [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
|]
|
||||
|
||||
it "succeeds in reading another_table definition from schema v2" $ do
|
||||
r <- request methodGet "/" [("Accept-Profile", "v2")] ""
|
||||
|
||||
liftIO $ do
|
||||
simpleHeaders r `shouldSatisfy` matchHeader "Content-Profile" "v2"
|
||||
|
||||
let def = simpleBody r ^? key "definitions" . key "another_table"
|
||||
|
||||
def `shouldBe` Just
|
||||
[aesonQQ|
|
||||
{
|
||||
"type" : "object",
|
||||
"properties" : {
|
||||
"id" : {
|
||||
"description" : "Note:\nThis is a Primary Key.<pk/>",
|
||||
"format" : "integer",
|
||||
"type" : "integer"
|
||||
},
|
||||
"another_value" : {
|
||||
"format" : "text",
|
||||
"type" : "string"
|
||||
}
|
||||
},
|
||||
"required" : [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
|]
|
||||
|
||||
it "doesn't find another_table definition in schema v1" $ do
|
||||
r <- request methodGet "/" [("Accept-Profile", "v1")] ""
|
||||
|
||||
liftIO $ do
|
||||
let def = simpleBody r ^? key "definitions" . key "another_table"
|
||||
def `shouldBe` Nothing
|
||||
|
||||
it "fails trying to read definitions from unkown schema" $
|
||||
request methodGet "/" [("Accept-Profile", "unkown")] "" `shouldRespondWith`
|
||||
[json|{"message":"The schema must be one of the following: v1, v2"}|]
|
||||
{
|
||||
matchStatus = 406
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
module Feature.NoJwtSpec where
|
||||
|
||||
-- {{{ Imports
|
||||
|
||||
import Network.Wai (Application)
|
||||
|
||||
import Network.HTTP.Types
|
||||
import Test.Hspec
|
||||
import Test.Hspec.Wai
|
||||
import Test.Hspec.Wai.JSON
|
||||
|
||||
import Protolude
|
||||
import SpecHelper
|
||||
-- }}}
|
||||
|
||||
spec :: SpecWith ((), Application)
|
||||
spec = describe "server started without JWT secret" $ do
|
||||
|
||||
-- this test will stop working 9999999999s after the UNIX EPOCH
|
||||
it "responds with error on attempted auth" $ do
|
||||
let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjk5OTk5OTk5OTksInJvbGUiOiJwb3N0Z3Jlc3RfdGVzdF9hdXRob3IiLCJpZCI6Impkb2UifQ.Dpss-QoLYjec5OTsOaAc3FNVsSjA89wACoV-0ra3ClA"
|
||||
request methodGet "/authors_only" [auth] ""
|
||||
`shouldRespondWith`
|
||||
[json|{"message":"Server lacks JWT secret"}|]
|
||||
{ matchStatus = 500
|
||||
, matchHeaders = [ matchContentTypeJson ]
|
||||
}
|
||||
|
||||
it "behaves normally when user does not attempt auth" $
|
||||
request methodGet "/items" [] ""
|
||||
`shouldRespondWith` 200
|
||||
@@ -0,0 +1,17 @@
|
||||
module Feature.NonexistentSchemaSpec where
|
||||
|
||||
import Network.Wai (Application)
|
||||
|
||||
import Test.Hspec
|
||||
import Test.Hspec.Wai
|
||||
|
||||
import Protolude hiding (get)
|
||||
|
||||
spec :: SpecWith ((), Application)
|
||||
spec =
|
||||
describe "Non existent api schema" $ do
|
||||
it "succeeds when requesting root path" $
|
||||
get "/" `shouldRespondWith` 200
|
||||
|
||||
it "gives 404 when requesting a nonexistent table in this nonexistent schema" $
|
||||
get "/nonexistent_table" `shouldRespondWith` 404
|
||||
@@ -0,0 +1,594 @@
|
||||
module Feature.OpenApiSpec where
|
||||
|
||||
import Control.Lens ((^?))
|
||||
import Data.Aeson.Types (Value (..))
|
||||
import Network.Wai (Application)
|
||||
import Network.Wai.Test (SResponse (..))
|
||||
|
||||
import Data.Aeson.Lens
|
||||
import Data.Aeson.QQ
|
||||
import Network.HTTP.Types
|
||||
import Test.Hspec hiding (pendingWith)
|
||||
import Test.Hspec.Wai
|
||||
|
||||
import PostgREST.Config.PgVersion (PgVersion, pgVersion100,
|
||||
pgVersion110)
|
||||
|
||||
import PostgREST.Version (docsVersion)
|
||||
import Protolude hiding (get)
|
||||
import SpecHelper
|
||||
|
||||
spec :: PgVersion -> SpecWith ((), Application)
|
||||
spec actualPgVersion = describe "OpenAPI" $ do
|
||||
it "root path returns a valid openapi spec" $ do
|
||||
validateOpenApiResponse [("Accept", "application/openapi+json")]
|
||||
request methodHead "/"
|
||||
(acceptHdrs "application/openapi+json") ""
|
||||
`shouldRespondWith`
|
||||
""
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = ["Content-Type" <:> "application/openapi+json; charset=utf-8"]
|
||||
}
|
||||
|
||||
it "should respond to openapi request on none root path with 415" $
|
||||
request methodGet "/items"
|
||||
(acceptHdrs "application/openapi+json") ""
|
||||
`shouldRespondWith` 415
|
||||
|
||||
it "includes postgrest.org current version api docs" $ do
|
||||
r <- simpleBody <$> get "/"
|
||||
|
||||
let docsUrl = r ^? key "externalDocs" . key "url"
|
||||
|
||||
liftIO $ docsUrl `shouldBe` Just (String ("https://postgrest.org/en/" <> docsVersion <> "/api.html"))
|
||||
|
||||
describe "table" $ do
|
||||
|
||||
it "includes paths to tables" $ do
|
||||
r <- simpleBody <$> get "/"
|
||||
|
||||
let method s = key "paths" . key "/child_entities" . key s
|
||||
childGetSummary = r ^? method "get" . key "summary"
|
||||
childGetDescription = r ^? method "get" . key "description"
|
||||
getParameters = r ^? method "get" . key "parameters"
|
||||
postParameters = r ^? method "post" . key "parameters"
|
||||
postResponse = r ^? method "post" . key "responses" . key "201" . key "description"
|
||||
patchResponse = r ^? method "patch" . key "responses" . key "204" . key "description"
|
||||
deleteResponse = r ^? method "delete" . key "responses" . key "204" . key "description"
|
||||
|
||||
let grandChildGet s = key "paths" . key "/grandchild_entities" . key "get" . key s
|
||||
grandChildGetSummary = r ^? grandChildGet "summary"
|
||||
grandChildGetDescription = r ^? grandChildGet "description"
|
||||
|
||||
liftIO $ do
|
||||
|
||||
childGetSummary `shouldBe` Just "child_entities comment"
|
||||
|
||||
childGetDescription `shouldBe` Nothing
|
||||
|
||||
grandChildGetSummary `shouldBe` Just "grandchild_entities summary"
|
||||
|
||||
grandChildGetDescription `shouldBe` Just "grandchild_entities description\nthat spans\nmultiple lines"
|
||||
|
||||
getParameters `shouldBe` Just
|
||||
[aesonQQ|
|
||||
[
|
||||
{ "$ref": "#/parameters/rowFilter.child_entities.id" },
|
||||
{ "$ref": "#/parameters/rowFilter.child_entities.name" },
|
||||
{ "$ref": "#/parameters/rowFilter.child_entities.parent_id" },
|
||||
{ "$ref": "#/parameters/select" },
|
||||
{ "$ref": "#/parameters/order" },
|
||||
{ "$ref": "#/parameters/range" },
|
||||
{ "$ref": "#/parameters/rangeUnit" },
|
||||
{ "$ref": "#/parameters/offset" },
|
||||
{ "$ref": "#/parameters/limit" },
|
||||
{ "$ref": "#/parameters/preferCount" }
|
||||
]
|
||||
|]
|
||||
|
||||
postParameters `shouldBe` Just
|
||||
[aesonQQ|
|
||||
[
|
||||
{ "$ref": "#/parameters/body.child_entities" },
|
||||
{ "$ref": "#/parameters/select" },
|
||||
{ "$ref": "#/parameters/preferReturn" }
|
||||
]
|
||||
|]
|
||||
|
||||
postResponse `shouldBe` Just "Created"
|
||||
|
||||
patchResponse `shouldBe` Just "No Content"
|
||||
|
||||
deleteResponse `shouldBe` Just "No Content"
|
||||
|
||||
it "includes an array type for GET responses" $ do
|
||||
r <- simpleBody <$> get "/"
|
||||
|
||||
let childGetSchema = r ^? key "paths"
|
||||
. key "/child_entities"
|
||||
. key "get"
|
||||
. key "responses"
|
||||
. key "200"
|
||||
. key "schema"
|
||||
|
||||
liftIO $
|
||||
childGetSchema `shouldBe` Just
|
||||
[aesonQQ|
|
||||
{
|
||||
"items": {
|
||||
"$ref": "#/definitions/child_entities"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
|]
|
||||
|
||||
it "includes definitions to tables" $ do
|
||||
r <- simpleBody <$> get "/"
|
||||
|
||||
let def = r ^? key "definitions" . key "child_entities"
|
||||
|
||||
liftIO $
|
||||
|
||||
def `shouldBe` Just
|
||||
[aesonQQ|
|
||||
{
|
||||
"type": "object",
|
||||
"description": "child_entities comment",
|
||||
"properties": {
|
||||
"id": {
|
||||
"description": "child_entities id comment\n\nNote:\nThis is a Primary Key.<pk/>",
|
||||
"format": "integer",
|
||||
"type": "integer"
|
||||
},
|
||||
"name": {
|
||||
"description": "child_entities name comment. Can be longer than sixty-three characters long",
|
||||
"format": "text",
|
||||
"type": "string"
|
||||
},
|
||||
"parent_id": {
|
||||
"description": "Note:\nThis is a Foreign Key to `entities.id`.<fk table='entities' column='id'/>",
|
||||
"format": "integer",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
|]
|
||||
|
||||
it "doesn't include privileged table for anonymous" $ do
|
||||
r <- simpleBody <$> get "/"
|
||||
let tablePath = r ^? key "paths" . key "/authors_only"
|
||||
|
||||
liftIO $ tablePath `shouldBe` Nothing
|
||||
|
||||
it "includes table if user has permission" $ do
|
||||
let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIn0.Xod-F15qsGL0WhdOCr2j3DdKuTw9QJERVgoFD3vGaWA"
|
||||
r <- simpleBody <$> request methodGet "/" [auth] ""
|
||||
let tableTag = r ^? key "paths" . key "/authors_only"
|
||||
. key "post" . key "tags"
|
||||
. nth 0
|
||||
liftIO $ tableTag `shouldBe` Just [aesonQQ|"authors_only"|]
|
||||
|
||||
describe "Foreign table" $
|
||||
|
||||
it "includes foreign table properties" $ do
|
||||
r <- simpleBody <$> get "/"
|
||||
|
||||
let method s = key "paths" . key "/projects_dump" . key s
|
||||
getSummary = r ^? method "get" . key "summary"
|
||||
getDescription = r ^? method "get" . key "description"
|
||||
getParameters = r ^? method "get" . key "parameters"
|
||||
|
||||
liftIO $ do
|
||||
|
||||
getSummary `shouldBe` Just "A temporary projects dump"
|
||||
|
||||
getDescription `shouldBe` Just "Just a test for foreign tables"
|
||||
|
||||
getParameters `shouldBe` Just
|
||||
[aesonQQ|
|
||||
[
|
||||
{ "$ref": "#/parameters/rowFilter.projects_dump.id" },
|
||||
{ "$ref": "#/parameters/rowFilter.projects_dump.name" },
|
||||
{ "$ref": "#/parameters/rowFilter.projects_dump.client_id" },
|
||||
{ "$ref": "#/parameters/select" },
|
||||
{ "$ref": "#/parameters/order" },
|
||||
{ "$ref": "#/parameters/range" },
|
||||
{ "$ref": "#/parameters/rangeUnit" },
|
||||
{ "$ref": "#/parameters/offset" },
|
||||
{ "$ref": "#/parameters/limit" },
|
||||
{ "$ref": "#/parameters/preferCount" }
|
||||
]
|
||||
|]
|
||||
|
||||
when (actualPgVersion >= pgVersion100) $ do
|
||||
describe "Partitioned table" $
|
||||
|
||||
it "includes partitioned table properties" $ do
|
||||
r <- simpleBody <$> get "/"
|
||||
|
||||
let method s = key "paths" . key "/car_models" . key s
|
||||
getSummary = r ^? method "get" . key "summary"
|
||||
getDescription = r ^? method "get" . key "description"
|
||||
getParameterName = r ^? method "get" . key "parameters" . nth 0 . key "$ref"
|
||||
getParameterYear = r ^? method "get" . key "parameters" . nth 1 . key "$ref"
|
||||
getParameterRef = r ^? method "get" . key "parameters" . nth 2 . key "$ref"
|
||||
|
||||
liftIO $ do
|
||||
|
||||
getSummary `shouldBe` Just "A partitioned table"
|
||||
|
||||
getDescription `shouldBe` Just "A test for partitioned tables"
|
||||
|
||||
getParameterName `shouldBe` Just "#/parameters/rowFilter.car_models.name"
|
||||
|
||||
getParameterYear `shouldBe` Just "#/parameters/rowFilter.car_models.year"
|
||||
|
||||
when (actualPgVersion >= pgVersion110) $
|
||||
getParameterRef `shouldBe` Just "#/parameters/rowFilter.car_models.car_brand_name"
|
||||
|
||||
describe "Materialized view" $
|
||||
|
||||
it "includes materialized view properties" $ do
|
||||
r <- simpleBody <$> get "/"
|
||||
|
||||
let method s = key "paths" . key "/materialized_projects" . key s
|
||||
summary = r ^? method "get" . key "summary"
|
||||
description = r ^? method "get" . key "description"
|
||||
parameters = r ^? method "get" . key "parameters"
|
||||
|
||||
liftIO $ do
|
||||
|
||||
summary `shouldBe` Just "A materialized view for projects"
|
||||
|
||||
description `shouldBe` Just "Just a test for materialized views"
|
||||
|
||||
parameters `shouldBe` Just
|
||||
[aesonQQ|
|
||||
[
|
||||
{ "$ref": "#/parameters/rowFilter.materialized_projects.id" },
|
||||
{ "$ref": "#/parameters/rowFilter.materialized_projects.name" },
|
||||
{ "$ref": "#/parameters/rowFilter.materialized_projects.client_id" },
|
||||
{ "$ref": "#/parameters/select" },
|
||||
{ "$ref": "#/parameters/order" },
|
||||
{ "$ref": "#/parameters/range" },
|
||||
{ "$ref": "#/parameters/rangeUnit" },
|
||||
{ "$ref": "#/parameters/offset" },
|
||||
{ "$ref": "#/parameters/limit" },
|
||||
{ "$ref": "#/parameters/preferCount" }
|
||||
]
|
||||
|]
|
||||
|
||||
describe "VIEW that has a source FK based on a UNIQUE key" $
|
||||
|
||||
it "includes fk description" $ do
|
||||
r <- simpleBody <$> get "/"
|
||||
|
||||
let referralLink = r ^? key "definitions" . key "referrals" . key "properties" . key "link"
|
||||
|
||||
liftIO $
|
||||
referralLink `shouldBe` Just
|
||||
[aesonQQ|
|
||||
{
|
||||
"format": "integer",
|
||||
"type": "integer",
|
||||
"description": "Note:\nThis is a Foreign Key to `pages.link`.<fk table='pages' column='link'/>"
|
||||
}
|
||||
|]
|
||||
|
||||
describe "PostgreSQL to Swagger Type Mapping" $ do
|
||||
|
||||
it "character varying to string" $ do
|
||||
r <- simpleBody <$> get "/"
|
||||
|
||||
let types = r ^? key "definitions" . key "openapi_types" . key "properties" . key "a_character_varying"
|
||||
|
||||
liftIO $
|
||||
|
||||
types `shouldBe` Just
|
||||
[aesonQQ|
|
||||
{
|
||||
"format": "character varying",
|
||||
"type": "string"
|
||||
}
|
||||
|]
|
||||
it "character(1) to string" $ do
|
||||
r <- simpleBody <$> get "/"
|
||||
|
||||
let types = r ^? key "definitions" . key "openapi_types" . key "properties" . key "a_character"
|
||||
|
||||
liftIO $
|
||||
|
||||
types `shouldBe` Just
|
||||
[aesonQQ|
|
||||
{
|
||||
"maxLength": 1,
|
||||
"format": "character",
|
||||
"type": "string"
|
||||
}
|
||||
|]
|
||||
|
||||
it "text to string" $ do
|
||||
r <- simpleBody <$> get "/"
|
||||
|
||||
let types = r ^? key "definitions" . key "openapi_types" . key "properties" . key "a_text"
|
||||
|
||||
liftIO $
|
||||
|
||||
types `shouldBe` Just
|
||||
[aesonQQ|
|
||||
{
|
||||
"format": "text",
|
||||
"type": "string"
|
||||
}
|
||||
|]
|
||||
|
||||
it "boolean to boolean" $ do
|
||||
r <- simpleBody <$> get "/"
|
||||
|
||||
let types = r ^? key "definitions" . key "openapi_types" . key "properties" . key "a_boolean"
|
||||
|
||||
liftIO $
|
||||
|
||||
types `shouldBe` Just
|
||||
[aesonQQ|
|
||||
{
|
||||
"format": "boolean",
|
||||
"type": "boolean"
|
||||
}
|
||||
|]
|
||||
|
||||
it "smallint to integer" $ do
|
||||
r <- simpleBody <$> get "/"
|
||||
|
||||
let types = r ^? key "definitions" . key "openapi_types" . key "properties" . key "a_smallint"
|
||||
|
||||
liftIO $
|
||||
|
||||
types `shouldBe` Just
|
||||
[aesonQQ|
|
||||
{
|
||||
"format": "smallint",
|
||||
"type": "integer"
|
||||
}
|
||||
|]
|
||||
|
||||
it "integer to integer" $ do
|
||||
r <- simpleBody <$> get "/"
|
||||
|
||||
let types = r ^? key "definitions" . key "openapi_types" . key "properties" . key "a_integer"
|
||||
|
||||
liftIO $
|
||||
|
||||
types `shouldBe` Just
|
||||
[aesonQQ|
|
||||
{
|
||||
"format": "integer",
|
||||
"type": "integer"
|
||||
}
|
||||
|]
|
||||
|
||||
it "bigint to integer" $ do
|
||||
r <- simpleBody <$> get "/"
|
||||
|
||||
let types = r ^? key "definitions" . key "openapi_types" . key "properties" . key "a_bigint"
|
||||
|
||||
liftIO $
|
||||
|
||||
types `shouldBe` Just
|
||||
[aesonQQ|
|
||||
{
|
||||
"format": "bigint",
|
||||
"type": "integer"
|
||||
}
|
||||
|]
|
||||
|
||||
it "numeric to number" $ do
|
||||
r <- simpleBody <$> get "/"
|
||||
|
||||
let types = r ^? key "definitions" . key "openapi_types" . key "properties" . key "a_numeric"
|
||||
|
||||
liftIO $
|
||||
|
||||
types `shouldBe` Just
|
||||
[aesonQQ|
|
||||
{
|
||||
"format": "numeric",
|
||||
"type": "number"
|
||||
}
|
||||
|]
|
||||
|
||||
it "real to number" $ do
|
||||
r <- simpleBody <$> get "/"
|
||||
|
||||
let types = r ^? key "definitions" . key "openapi_types" . key "properties" . key "a_real"
|
||||
|
||||
liftIO $
|
||||
|
||||
types `shouldBe` Just
|
||||
[aesonQQ|
|
||||
{
|
||||
"format": "real",
|
||||
"type": "number"
|
||||
}
|
||||
|]
|
||||
|
||||
it "double_precision to number" $ do
|
||||
r <- simpleBody <$> get "/"
|
||||
|
||||
let types = r ^? key "definitions" . key "openapi_types" . key "properties" . key "a_double_precision"
|
||||
|
||||
liftIO $
|
||||
|
||||
types `shouldBe` Just
|
||||
[aesonQQ|
|
||||
{
|
||||
"format": "double precision",
|
||||
"type": "number"
|
||||
}
|
||||
|]
|
||||
|
||||
describe "Detects default values" $ do
|
||||
|
||||
it "text" $ do
|
||||
r <- simpleBody <$> get "/"
|
||||
|
||||
let defaultValue = r ^? key "definitions" . key "openapi_defaults" . key "properties" . key "text" . key "default"
|
||||
|
||||
liftIO $
|
||||
|
||||
defaultValue `shouldBe` Just "default"
|
||||
|
||||
it "boolean" $ do
|
||||
r <- simpleBody <$> get "/"
|
||||
|
||||
let types = r ^? key "definitions" . key "openapi_defaults" . key "properties" . key "boolean" . key "default"
|
||||
|
||||
liftIO $
|
||||
|
||||
types `shouldBe` Just (Bool False)
|
||||
|
||||
it "integer" $ do
|
||||
r <- simpleBody <$> get "/"
|
||||
|
||||
let types = r ^? key "definitions" . key "openapi_defaults" . key "properties" . key "integer" . key "default"
|
||||
|
||||
liftIO $
|
||||
|
||||
types `shouldBe` Just (Number 42)
|
||||
|
||||
it "numeric" $ do
|
||||
r <- simpleBody <$> get "/"
|
||||
|
||||
let types = r ^? key "definitions" . key "openapi_defaults" . key "properties" . key "numeric" . key "default"
|
||||
|
||||
liftIO $
|
||||
|
||||
types `shouldBe` Just (Number 42.2)
|
||||
|
||||
it "date" $ do
|
||||
r <- simpleBody <$> get "/"
|
||||
|
||||
let types = r ^? key "definitions" . key "openapi_defaults" . key "properties" . key "date" . key "default"
|
||||
|
||||
liftIO $
|
||||
|
||||
types `shouldBe` Just "1900-01-01"
|
||||
|
||||
it "time" $ do
|
||||
r <- simpleBody <$> get "/"
|
||||
|
||||
let types = r ^? key "definitions" . key "openapi_defaults" . key "properties" . key "time" . key "default"
|
||||
|
||||
liftIO $
|
||||
|
||||
types `shouldBe` Just "13:00:00"
|
||||
|
||||
describe "RPC" $ do
|
||||
|
||||
it "includes function summary/description and body schema for arguments" $ do
|
||||
r <- simpleBody <$> get "/"
|
||||
|
||||
let method s = key "paths" . key "/rpc/varied_arguments" . key s
|
||||
args = r ^? method "post" . key "parameters" . nth 0 . key "schema"
|
||||
summary = r ^? method "post" . key "summary"
|
||||
description = r ^? method "post" . key "description"
|
||||
|
||||
liftIO $ do
|
||||
|
||||
summary `shouldBe` Just "An RPC function"
|
||||
|
||||
description `shouldBe` Just "Just a test for RPC function arguments"
|
||||
|
||||
args `shouldBe` Just
|
||||
[aesonQQ|
|
||||
{
|
||||
"required": [
|
||||
"double",
|
||||
"varchar",
|
||||
"boolean",
|
||||
"date",
|
||||
"money",
|
||||
"enum",
|
||||
"arr"
|
||||
],
|
||||
"properties": {
|
||||
"double": {
|
||||
"format": "double precision",
|
||||
"type": "number"
|
||||
},
|
||||
"varchar": {
|
||||
"format": "character varying",
|
||||
"type": "string"
|
||||
},
|
||||
"boolean": {
|
||||
"format": "boolean",
|
||||
"type": "boolean"
|
||||
},
|
||||
"date": {
|
||||
"format": "date",
|
||||
"type": "string"
|
||||
},
|
||||
"money": {
|
||||
"format": "money",
|
||||
"type": "string"
|
||||
},
|
||||
"enum": {
|
||||
"format": "enum_menagerie_type",
|
||||
"type": "string"
|
||||
},
|
||||
"arr": {
|
||||
"format": "text[]",
|
||||
"type": "string"
|
||||
},
|
||||
"integer": {
|
||||
"format": "integer",
|
||||
"type": "integer"
|
||||
},
|
||||
"json": {
|
||||
"format": "json",
|
||||
"type": "string"
|
||||
},
|
||||
"jsonb": {
|
||||
"format": "jsonb",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"description": "An RPC function\n\nJust a test for RPC function arguments"
|
||||
}
|
||||
|]
|
||||
|
||||
it "doesn't include privileged function for anonymous" $ do
|
||||
r <- simpleBody <$> get "/"
|
||||
let funcPath = r ^? key "paths" . key "/rpc/privileged_hello"
|
||||
|
||||
liftIO $ funcPath `shouldBe` Nothing
|
||||
|
||||
it "includes function if user has permission" $ do
|
||||
let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIn0.Xod-F15qsGL0WhdOCr2j3DdKuTw9QJERVgoFD3vGaWA"
|
||||
r <- simpleBody <$> request methodGet "/" [auth] ""
|
||||
let funcTag = r ^? key "paths" . key "/rpc/privileged_hello"
|
||||
. key "post" . key "tags"
|
||||
. nth 0
|
||||
|
||||
liftIO $ funcTag `shouldBe` Just [aesonQQ|"(rpc) privileged_hello"|]
|
||||
|
||||
it "doesn't include OUT params of function as required parameters" $ do
|
||||
r <- simpleBody <$> get "/"
|
||||
let params = r ^? key "paths" . key "/rpc/many_out_params"
|
||||
. key "post" . key "parameters" . nth 0
|
||||
. key "schema". key "required"
|
||||
|
||||
liftIO $ params `shouldBe` Nothing
|
||||
|
||||
it "includes INOUT params(with no DEFAULT) of function as required parameters" $ do
|
||||
r <- simpleBody <$> get "/"
|
||||
let params = r ^? key "paths" . key "/rpc/many_inout_params"
|
||||
. key "post" . key "parameters" . nth 0
|
||||
. key "schema". key "required"
|
||||
|
||||
liftIO $ params `shouldBe` Just [aesonQQ|["num", "str"]|]
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
module Feature.OptionsSpec where
|
||||
|
||||
import Network.Wai (Application)
|
||||
import Network.Wai.Test (SResponse (..))
|
||||
|
||||
import Network.HTTP.Types
|
||||
import Test.Hspec
|
||||
import Test.Hspec.Wai
|
||||
|
||||
import PostgREST.Config.PgVersion (PgVersion, pgVersion100,
|
||||
pgVersion110)
|
||||
|
||||
import Protolude
|
||||
import SpecHelper
|
||||
|
||||
spec :: PgVersion -> SpecWith ((), Application)
|
||||
spec actualPgVersion = describe "Allow header" $ do
|
||||
context "a table" $ do
|
||||
it "includes read/write verbs for writeable table" $ do
|
||||
r <- request methodOptions "/items" [] ""
|
||||
liftIO $
|
||||
simpleHeaders r `shouldSatisfy`
|
||||
matchHeader "Allow" "OPTIONS,GET,HEAD,POST,PUT,PATCH,DELETE"
|
||||
|
||||
when (actualPgVersion >= pgVersion100) $
|
||||
context "a partitioned table" $ do
|
||||
it "includes read/write verbs for writeable partitioned tables" $ do
|
||||
r <- request methodOptions "/car_models" [] ""
|
||||
liftIO $
|
||||
simpleHeaders r `shouldSatisfy`
|
||||
matchHeader "Allow" (
|
||||
if actualPgVersion >= pgVersion110 then
|
||||
"OPTIONS,GET,HEAD,POST,PUT,PATCH,DELETE"
|
||||
else
|
||||
"OPTIONS,GET,HEAD,POST,PATCH,DELETE"
|
||||
)
|
||||
|
||||
context "a view" $ do
|
||||
context "auto updatable" $ do
|
||||
it "includes read/write verbs for auto updatable views with pk" $ do
|
||||
r <- request methodOptions "/projects_auto_updatable_view_with_pk" [] ""
|
||||
liftIO $
|
||||
simpleHeaders r `shouldSatisfy`
|
||||
matchHeader "Allow" "OPTIONS,GET,HEAD,POST,PUT,PATCH,DELETE"
|
||||
|
||||
it "includes read/write verbs for auto updatable views without pk" $ do
|
||||
r <- request methodOptions "/projects_auto_updatable_view_without_pk" [] ""
|
||||
liftIO $
|
||||
simpleHeaders r `shouldSatisfy`
|
||||
matchHeader "Allow" "OPTIONS,GET,HEAD,POST,PATCH,DELETE"
|
||||
|
||||
context "non auto updatable" $ do
|
||||
it "includes read verbs for non auto updatable views" $ do
|
||||
r <- request methodOptions "/projects_view_without_triggers" [] ""
|
||||
liftIO $
|
||||
simpleHeaders r `shouldSatisfy`
|
||||
matchHeader "Allow" "OPTIONS,GET,HEAD"
|
||||
|
||||
it "includes read/write verbs for insertable, updatable and deletable views with pk" $ do
|
||||
r <- request methodOptions "/projects_view_with_all_triggers_with_pk" [] ""
|
||||
liftIO $
|
||||
simpleHeaders r `shouldSatisfy`
|
||||
matchHeader "Allow" "OPTIONS,GET,HEAD,POST,PUT,PATCH,DELETE"
|
||||
|
||||
it "includes read/write verbs for insertable, updatable and deletable views without pk" $ do
|
||||
r <- request methodOptions "/projects_view_with_all_triggers_without_pk" [] ""
|
||||
liftIO $
|
||||
simpleHeaders r `shouldSatisfy`
|
||||
matchHeader "Allow" "OPTIONS,GET,HEAD,POST,PATCH,DELETE"
|
||||
|
||||
it "includes read and insert verbs for insertable views" $ do
|
||||
r <- request methodOptions "/projects_view_with_insert_trigger" [] ""
|
||||
liftIO $
|
||||
simpleHeaders r `shouldSatisfy`
|
||||
matchHeader "Allow" "OPTIONS,GET,HEAD,POST"
|
||||
|
||||
it "includes read and update verbs for updatable views" $ do
|
||||
r <- request methodOptions "/projects_view_with_update_trigger" [] ""
|
||||
liftIO $
|
||||
simpleHeaders r `shouldSatisfy`
|
||||
matchHeader "Allow" "OPTIONS,GET,HEAD,PATCH"
|
||||
|
||||
it "includes read and delete verbs for deletable views" $ do
|
||||
r <- request methodOptions "/projects_view_with_delete_trigger" [] ""
|
||||
liftIO $
|
||||
simpleHeaders r `shouldSatisfy`
|
||||
matchHeader "Allow" "OPTIONS,GET,HEAD,DELETE"
|
||||
@@ -0,0 +1,13 @@
|
||||
module Feature.ProxySpec where
|
||||
|
||||
import Network.Wai (Application)
|
||||
import Test.Hspec hiding (pendingWith)
|
||||
|
||||
import Protolude
|
||||
import SpecHelper
|
||||
|
||||
spec :: SpecWith ((), Application)
|
||||
spec =
|
||||
describe "GET / with proxy" $
|
||||
it "returns a valid openapi spec with proxy" $
|
||||
validateOpenApiResponse [("Accept", "application/openapi+json")]
|
||||
@@ -0,0 +1,79 @@
|
||||
module Feature.QueryLimitedSpec where
|
||||
|
||||
import Network.Wai (Application)
|
||||
|
||||
import Network.HTTP.Types
|
||||
import Test.Hspec
|
||||
import Test.Hspec.Wai
|
||||
import Test.Hspec.Wai.JSON
|
||||
|
||||
import Protolude hiding (get)
|
||||
import SpecHelper
|
||||
|
||||
spec :: SpecWith ((), Application)
|
||||
spec =
|
||||
describe "Requesting many items with server limits(max-rows) enabled" $ do
|
||||
it "restricts results" $
|
||||
get "/items?order=id"
|
||||
`shouldRespondWith`
|
||||
[json| [{"id":1},{"id":2}] |]
|
||||
{ matchHeaders = ["Content-Range" <:> "0-1/*"] }
|
||||
|
||||
it "respects additional client limiting" $ do
|
||||
request methodGet "/items"
|
||||
(rangeHdrs $ ByteRangeFromTo 0 0)
|
||||
""
|
||||
`shouldRespondWith`
|
||||
[json| [{"id":1}] |]
|
||||
{ matchHeaders = ["Content-Range" <:> "0-0/*"] }
|
||||
|
||||
it "works on all levels" $
|
||||
get "/users?select=id,tasks(id)&order=id.asc&tasks.order=id.asc"
|
||||
`shouldRespondWith`
|
||||
[json|[{"id":1,"tasks":[{"id":1},{"id":2}]},{"id":2,"tasks":[{"id":5},{"id":6}]}]|]
|
||||
{ matchHeaders = ["Content-Range" <:> "0-1/*"] }
|
||||
|
||||
it "succeeds in getting parent embeds despite the limit, see #647" $
|
||||
get "/tasks?select=id,project:projects(id)&id=gt.5"
|
||||
`shouldRespondWith`
|
||||
[json|[{"id":6,"project":{"id":3}},{"id":7,"project":{"id":4}}]|]
|
||||
{ matchHeaders = ["Content-Range" <:> "0-1/*"] }
|
||||
|
||||
it "can offset the parent embed, being consistent with the other embed types" $
|
||||
get "/tasks?select=id,project:projects(id)&id=gt.5&project.offset=1"
|
||||
`shouldRespondWith`
|
||||
[json|[{"id":6,"project":null}, {"id":7,"project":null}]|]
|
||||
{ matchHeaders = ["Content-Range" <:> "0-1/*"] }
|
||||
|
||||
context "count=estimated" $ do
|
||||
it "uses the query planner guess when query rows > maxRows" $
|
||||
request methodHead "/getallprojects_view"
|
||||
[("Prefer", "count=estimated")]
|
||||
""
|
||||
`shouldRespondWith`
|
||||
""
|
||||
{ matchStatus = 206
|
||||
, matchHeaders = [ matchContentTypeJson
|
||||
, "Content-Range" <:> "0-1/2019" ]
|
||||
}
|
||||
|
||||
it "gives exact count when query rows <= maxRows" $
|
||||
request methodHead "/getallprojects_view?id=lt.3"
|
||||
[("Prefer", "count=estimated")]
|
||||
""
|
||||
`shouldRespondWith`
|
||||
""
|
||||
{ matchHeaders = [ matchContentTypeJson
|
||||
, "Content-Range" <:> "0-1/2" ]
|
||||
}
|
||||
|
||||
it "only uses the query planner guess if it's indeed greater than the exact count" $
|
||||
request methodHead "/get_projects_above_view"
|
||||
[("Prefer", "count=estimated")]
|
||||
""
|
||||
`shouldRespondWith`
|
||||
""
|
||||
{ matchStatus = 206
|
||||
, matchHeaders = [ matchContentTypeJson
|
||||
, "Content-Range" <:> "0-1/3" ]
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,362 @@
|
||||
module Feature.RangeSpec where
|
||||
|
||||
import qualified Data.ByteString.Lazy as BL
|
||||
|
||||
import Network.Wai (Application)
|
||||
import Network.Wai.Test (SResponse (simpleHeaders, simpleStatus))
|
||||
|
||||
import Network.HTTP.Types
|
||||
import Test.Hspec
|
||||
import Test.Hspec.Wai
|
||||
import Test.Hspec.Wai.JSON
|
||||
|
||||
import Protolude hiding (get)
|
||||
import SpecHelper
|
||||
|
||||
defaultRange :: BL.ByteString
|
||||
defaultRange = [json| { "min": 0, "max": 15 } |]
|
||||
|
||||
emptyRange :: BL.ByteString
|
||||
emptyRange = [json| { "min": 2, "max": 2 } |]
|
||||
|
||||
spec :: SpecWith ((), Application)
|
||||
spec = do
|
||||
describe "POST /rpc/getitemrange" $ do
|
||||
context "without range headers" $ do
|
||||
context "with response under server size limit" $
|
||||
it "returns whole range with status 200" $
|
||||
post "/rpc/getitemrange" defaultRange `shouldRespondWith` 200
|
||||
|
||||
context "when I don't want the count" $ do
|
||||
it "returns range Content-Range with */* for empty range" $
|
||||
request methodPost "/rpc/getitemrange" [] emptyRange
|
||||
`shouldRespondWith` [json| [] |] {matchHeaders = ["Content-Range" <:> "*/*"]}
|
||||
|
||||
it "returns range Content-Range with range/*" $
|
||||
post "/rpc/getitemrange?order=id"
|
||||
defaultRange
|
||||
`shouldRespondWith`
|
||||
[json| [{"id":1},{"id":2},{"id":3},{"id":4},{"id":5},{"id":6},{"id":7},{"id":8},{"id":9},{"id":10},{"id":11},{"id":12},{"id":13},{"id":14},{"id":15}] |]
|
||||
{ matchHeaders = ["Content-Range" <:> "0-14/*"] }
|
||||
|
||||
context "with range headers" $ do
|
||||
context "of acceptable range" $ do
|
||||
it "succeeds with partial content" $ do
|
||||
r <- request methodPost "/rpc/getitemrange"
|
||||
(rangeHdrs $ ByteRangeFromTo 0 1) defaultRange
|
||||
liftIO $ do
|
||||
simpleHeaders r `shouldSatisfy`
|
||||
matchHeader "Content-Range" "0-1/*"
|
||||
simpleStatus r `shouldBe` ok200
|
||||
|
||||
it "understands open-ended ranges" $
|
||||
request methodPost "/rpc/getitemrange"
|
||||
(rangeHdrs $ ByteRangeFrom 0) defaultRange
|
||||
`shouldRespondWith` 200
|
||||
|
||||
it "returns an empty body when there are no results" $
|
||||
request methodPost "/rpc/getitemrange"
|
||||
(rangeHdrs $ ByteRangeFromTo 0 1) emptyRange
|
||||
`shouldRespondWith` "[]"
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = ["Content-Range" <:> "*/*"]
|
||||
}
|
||||
|
||||
it "allows one-item requests" $ do
|
||||
r <- request methodPost "/rpc/getitemrange"
|
||||
(rangeHdrs $ ByteRangeFromTo 0 0) defaultRange
|
||||
liftIO $ do
|
||||
simpleHeaders r `shouldSatisfy`
|
||||
matchHeader "Content-Range" "0-0/*"
|
||||
simpleStatus r `shouldBe` ok200
|
||||
|
||||
it "handles ranges beyond collection length via truncation" $ do
|
||||
r <- request methodPost "/rpc/getitemrange"
|
||||
(rangeHdrs $ ByteRangeFromTo 10 100) defaultRange
|
||||
liftIO $ do
|
||||
simpleHeaders r `shouldSatisfy`
|
||||
matchHeader "Content-Range" "10-14/*"
|
||||
simpleStatus r `shouldBe` ok200
|
||||
|
||||
context "of invalid range" $ do
|
||||
it "fails with 416 for offside range" $
|
||||
request methodPost "/rpc/getitemrange"
|
||||
(rangeHdrs $ ByteRangeFromTo 1 0) emptyRange
|
||||
`shouldRespondWith` 416
|
||||
|
||||
it "refuses a range with nonzero start when there are no items" $
|
||||
request methodPost "/rpc/getitemrange"
|
||||
(rangeHdrsWithCount $ ByteRangeFromTo 1 2) emptyRange
|
||||
`shouldRespondWith` "[]"
|
||||
{ matchStatus = 416
|
||||
, matchHeaders = ["Content-Range" <:> "*/0"]
|
||||
}
|
||||
|
||||
it "refuses a range requesting start past last item" $
|
||||
request methodPost "/rpc/getitemrange"
|
||||
(rangeHdrsWithCount $ ByteRangeFromTo 100 199) defaultRange
|
||||
`shouldRespondWith` "[]"
|
||||
{ matchStatus = 416
|
||||
, matchHeaders = ["Content-Range" <:> "*/15"]
|
||||
}
|
||||
|
||||
describe "GET /items" $ do
|
||||
context "without range headers" $ do
|
||||
context "with response under server size limit" $
|
||||
it "returns whole range with status 200" $
|
||||
get "/items" `shouldRespondWith` 200
|
||||
|
||||
context "when I don't want the count" $ do
|
||||
it "returns range Content-Range with /*" $
|
||||
request methodGet "/menagerie"
|
||||
[("Prefer", "count=none")] ""
|
||||
`shouldRespondWith`
|
||||
[json|[]|]
|
||||
{ matchHeaders = ["Content-Range" <:> "*/*"] }
|
||||
|
||||
it "returns range Content-Range with range/*" $
|
||||
request methodGet "/items?order=id"
|
||||
[("Prefer", "count=none")] ""
|
||||
`shouldRespondWith` [json| [{"id":1},{"id":2},{"id":3},{"id":4},{"id":5},{"id":6},{"id":7},{"id":8},{"id":9},{"id":10},{"id":11},{"id":12},{"id":13},{"id":14},{"id":15}] |]
|
||||
{ matchHeaders = ["Content-Range" <:> "0-14/*"] }
|
||||
|
||||
it "returns range Content-Range with range/* even using other filters" $
|
||||
request methodGet "/items?id=eq.1&order=id"
|
||||
[("Prefer", "count=none")] ""
|
||||
`shouldRespondWith` [json| [{"id":1}] |]
|
||||
{ matchHeaders = ["Content-Range" <:> "0-0/*"] }
|
||||
|
||||
context "with limit/offset parameters" $ do
|
||||
it "no parameters return everything" $
|
||||
get "/items?select=id&order=id.asc"
|
||||
`shouldRespondWith`
|
||||
[json|[{"id":1},{"id":2},{"id":3},{"id":4},{"id":5},{"id":6},{"id":7},{"id":8},{"id":9},{"id":10},{"id":11},{"id":12},{"id":13},{"id":14},{"id":15}]|]
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = ["Content-Range" <:> "0-14/*"]
|
||||
}
|
||||
it "top level limit with parameter" $
|
||||
get "/items?select=id&order=id.asc&limit=3"
|
||||
`shouldRespondWith` [json|[{"id":1},{"id":2},{"id":3}]|]
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = ["Content-Range" <:> "0-2/*"]
|
||||
}
|
||||
it "headers override get parameters" $
|
||||
request methodGet "/items?select=id&order=id.asc&limit=3"
|
||||
(rangeHdrs $ ByteRangeFromTo 0 1) ""
|
||||
`shouldRespondWith` [json|[{"id":1},{"id":2}]|]
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = ["Content-Range" <:> "0-1/*"]
|
||||
}
|
||||
|
||||
it "limit works on all levels" $
|
||||
get "/clients?select=id,projects(id,tasks(id))&order=id.asc&limit=1&projects.order=id.asc&projects.limit=2&projects.tasks.order=id.asc&projects.tasks.limit=1"
|
||||
`shouldRespondWith`
|
||||
[json|[{"id":1,"projects":[{"id":1,"tasks":[{"id":1}]},{"id":2,"tasks":[{"id":3}]}]}]|]
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = ["Content-Range" <:> "0-0/*"]
|
||||
}
|
||||
|
||||
it "limit and offset works on first level" $ do
|
||||
get "/items?select=id&order=id.asc&limit=3&offset=2"
|
||||
`shouldRespondWith` [json|[{"id":3},{"id":4},{"id":5}]|]
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = ["Content-Range" <:> "2-4/*"]
|
||||
}
|
||||
request methodHead "/items?select=id&order=id.asc&limit=3&offset=2"
|
||||
[]
|
||||
mempty
|
||||
`shouldRespondWith`
|
||||
""
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = [ matchContentTypeJson
|
||||
, "Content-Range" <:> "2-4/*" ]
|
||||
}
|
||||
|
||||
it "succeeds if offset equals 0 as a no-op" $
|
||||
get "/items?select=id&offset=0&order=id"
|
||||
`shouldRespondWith`
|
||||
[json|[{"id":1},{"id":2},{"id":3},{"id":4},{"id":5},{"id":6},{"id":7},{"id":8},{"id":9},{"id":10},{"id":11},{"id":12},{"id":13},{"id":14},{"id":15}]|]
|
||||
{ matchHeaders = ["Content-Range" <:> "0-14/*"] }
|
||||
|
||||
it "succeeds if offset is negative as a no-op" $
|
||||
get "/items?select=id&offset=-4&order=id"
|
||||
`shouldRespondWith`
|
||||
[json|[{"id":1},{"id":2},{"id":3},{"id":4},{"id":5},{"id":6},{"id":7},{"id":8},{"id":9},{"id":10},{"id":11},{"id":12},{"id":13},{"id":14},{"id":15}]|]
|
||||
{ matchHeaders = ["Content-Range" <:> "0-14/*"] }
|
||||
|
||||
it "fails if limit equals 0" $
|
||||
get "/items?select=id&limit=0"
|
||||
`shouldRespondWith` [json|{"message":"HTTP Range error"}|]
|
||||
{ matchStatus = 416
|
||||
, matchHeaders = [matchContentTypeJson]
|
||||
}
|
||||
|
||||
it "fails if limit is negative" $
|
||||
get "/items?select=id&limit=-1"
|
||||
`shouldRespondWith` [json|{"message":"HTTP Range error"}|]
|
||||
{ matchStatus = 416
|
||||
, matchHeaders = [matchContentTypeJson]
|
||||
}
|
||||
|
||||
context "when count=planned" $ do
|
||||
it "obtains a filtered range" $ do
|
||||
request methodGet "/items?select=id&id=gt.8"
|
||||
[("Prefer", "count=planned")]
|
||||
""
|
||||
`shouldRespondWith`
|
||||
[json|[{"id":9}, {"id":10}, {"id":11}, {"id":12}, {"id":13}, {"id":14}, {"id":15}]|]
|
||||
{ matchStatus = 206
|
||||
, matchHeaders = ["Content-Range" <:> "0-6/8"]
|
||||
}
|
||||
|
||||
request methodGet "/child_entities?select=id&id=gt.3"
|
||||
[("Prefer", "count=planned")]
|
||||
""
|
||||
`shouldRespondWith`
|
||||
[json|[{"id":4}, {"id":5}, {"id":6}]|]
|
||||
{ matchStatus = 206
|
||||
, matchHeaders = ["Content-Range" <:> "0-2/4"]
|
||||
}
|
||||
|
||||
request methodGet "/getallprojects_view?select=id&id=lt.3"
|
||||
[("Prefer", "count=planned")]
|
||||
""
|
||||
`shouldRespondWith`
|
||||
[json|[{"id":1}, {"id":2}]|]
|
||||
{ matchStatus = 206
|
||||
, matchHeaders = ["Content-Range" <:> "0-1/673"]
|
||||
}
|
||||
|
||||
it "obtains the full range" $ do
|
||||
request methodHead "/items"
|
||||
[("Prefer", "count=planned")]
|
||||
""
|
||||
`shouldRespondWith`
|
||||
""
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = [ matchContentTypeJson
|
||||
, "Content-Range" <:> "0-14/15" ]
|
||||
}
|
||||
|
||||
request methodHead "/child_entities"
|
||||
[("Prefer", "count=planned")]
|
||||
""
|
||||
`shouldRespondWith`
|
||||
""
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = [ matchContentTypeJson
|
||||
, "Content-Range" <:> "0-5/6" ]
|
||||
}
|
||||
|
||||
request methodHead "/getallprojects_view"
|
||||
[("Prefer", "count=planned")]
|
||||
""
|
||||
`shouldRespondWith`
|
||||
""
|
||||
{ matchStatus = 206
|
||||
, matchHeaders = [ matchContentTypeJson
|
||||
, "Content-Range" <:> "0-4/2019" ]
|
||||
}
|
||||
|
||||
it "ignores limit/offset on the planned count" $ do
|
||||
request methodHead "/items?limit=2&offset=3"
|
||||
[("Prefer", "count=planned")]
|
||||
""
|
||||
`shouldRespondWith`
|
||||
""
|
||||
{ matchStatus = 206
|
||||
, matchHeaders = [ matchContentTypeJson
|
||||
, "Content-Range" <:> "3-4/15" ]
|
||||
}
|
||||
|
||||
request methodHead "/child_entities?limit=2"
|
||||
[("Prefer", "count=planned")]
|
||||
""
|
||||
`shouldRespondWith`
|
||||
""
|
||||
{ matchStatus = 206
|
||||
, matchHeaders = [ matchContentTypeJson
|
||||
, "Content-Range" <:> "0-1/6" ]
|
||||
}
|
||||
|
||||
request methodHead "/getallprojects_view?limit=2"
|
||||
[("Prefer", "count=planned")]
|
||||
""
|
||||
`shouldRespondWith`
|
||||
""
|
||||
{ matchStatus = 206
|
||||
, matchHeaders = [ matchContentTypeJson
|
||||
, "Content-Range" <:> "0-1/2019" ]
|
||||
}
|
||||
|
||||
it "works with two levels" $
|
||||
request methodHead "/child_entities?select=*,entities(*)"
|
||||
[("Prefer", "count=planned")]
|
||||
""
|
||||
`shouldRespondWith`
|
||||
""
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = [ matchContentTypeJson
|
||||
, "Content-Range" <:> "0-5/6" ]
|
||||
}
|
||||
|
||||
context "with range headers" $ do
|
||||
context "of acceptable range" $ do
|
||||
it "succeeds with partial content" $ do
|
||||
r <- request methodGet "/items"
|
||||
(rangeHdrs $ ByteRangeFromTo 0 1) ""
|
||||
liftIO $ do
|
||||
simpleHeaders r `shouldSatisfy`
|
||||
matchHeader "Content-Range" "0-1/*"
|
||||
simpleStatus r `shouldBe` ok200
|
||||
|
||||
it "understands open-ended ranges" $
|
||||
request methodGet "/items"
|
||||
(rangeHdrs $ ByteRangeFrom 0) ""
|
||||
`shouldRespondWith` 200
|
||||
|
||||
it "returns an empty body when there are no results" $
|
||||
request methodGet "/menagerie"
|
||||
(rangeHdrs $ ByteRangeFromTo 0 1) ""
|
||||
`shouldRespondWith` "[]"
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = ["Content-Range" <:> "*/*"]
|
||||
}
|
||||
|
||||
it "allows one-item requests" $ do
|
||||
r <- request methodGet "/items"
|
||||
(rangeHdrs $ ByteRangeFromTo 0 0) ""
|
||||
liftIO $ do
|
||||
simpleHeaders r `shouldSatisfy`
|
||||
matchHeader "Content-Range" "0-0/*"
|
||||
simpleStatus r `shouldBe` ok200
|
||||
|
||||
it "handles ranges beyond collection length via truncation" $ do
|
||||
r <- request methodGet "/items"
|
||||
(rangeHdrs $ ByteRangeFromTo 10 100) ""
|
||||
liftIO $ do
|
||||
simpleHeaders r `shouldSatisfy`
|
||||
matchHeader "Content-Range" "10-14/*"
|
||||
simpleStatus r `shouldBe` ok200
|
||||
|
||||
context "of invalid range" $ do
|
||||
it "fails with 416 for offside range" $
|
||||
request methodGet "/items"
|
||||
(rangeHdrs $ ByteRangeFromTo 1 0) ""
|
||||
`shouldRespondWith` 416
|
||||
|
||||
it "refuses a range with nonzero start when there are no items" $
|
||||
request methodGet "/menagerie"
|
||||
(rangeHdrsWithCount $ ByteRangeFromTo 1 2) ""
|
||||
`shouldRespondWith` "[]"
|
||||
{ matchStatus = 416
|
||||
, matchHeaders = ["Content-Range" <:> "*/0"]
|
||||
}
|
||||
|
||||
it "refuses a range requesting start past last item" $
|
||||
request methodGet "/items"
|
||||
(rangeHdrsWithCount $ ByteRangeFromTo 100 199) ""
|
||||
`shouldRespondWith` "[]"
|
||||
{ matchStatus = 416
|
||||
, matchHeaders = ["Content-Range" <:> "*/15"]
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
module Feature.RawOutputTypesSpec where
|
||||
|
||||
import Network.Wai (Application)
|
||||
|
||||
import Network.HTTP.Types
|
||||
import Test.Hspec
|
||||
import Test.Hspec.Wai
|
||||
import Test.Hspec.Wai.JSON
|
||||
|
||||
import Protolude
|
||||
import SpecHelper (acceptHdrs)
|
||||
|
||||
spec :: SpecWith ((), Application)
|
||||
spec = describe "When raw-media-types config variable is missing or left empty" $ do
|
||||
let firefoxAcceptHdrs = acceptHdrs "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"
|
||||
chromeAcceptHdrs = acceptHdrs "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3"
|
||||
it "responds json to a GET request with Firefox Accept headers" $
|
||||
request methodGet "/items?id=eq.1" firefoxAcceptHdrs ""
|
||||
`shouldRespondWith` [json| [{"id":1}] |]
|
||||
{ matchHeaders= ["Content-Type" <:> "application/json; charset=utf-8"] }
|
||||
it "responds json to a GET request with Chrome Accept headers" $
|
||||
request methodGet "/items?id=eq.1" chromeAcceptHdrs ""
|
||||
`shouldRespondWith` [json| [{"id":1}] |]
|
||||
{ matchHeaders= ["Content-Type" <:> "application/json; charset=utf-8"] }
|
||||
|
||||
it "responds json to a GET request to RPC with Firefox Accept headers" $
|
||||
request methodGet "/rpc/get_projects_below?id=3" firefoxAcceptHdrs ""
|
||||
`shouldRespondWith` [json|[{"id":1,"name":"Windows 7","client_id":1}, {"id":2,"name":"Windows 10","client_id":1}]|]
|
||||
{ matchHeaders= ["Content-Type" <:> "application/json; charset=utf-8"] }
|
||||
it "responds json to a GET request to RPC with Chrome Accept headers" $
|
||||
request methodGet "/rpc/get_projects_below?id=3" chromeAcceptHdrs ""
|
||||
`shouldRespondWith` [json|[{"id":1,"name":"Windows 7","client_id":1}, {"id":2,"name":"Windows 10","client_id":1}]|]
|
||||
{ matchHeaders= ["Content-Type" <:> "application/json; charset=utf-8"] }
|
||||
@@ -0,0 +1,263 @@
|
||||
module Feature.RollbackSpec where
|
||||
|
||||
import Network.Wai (Application)
|
||||
|
||||
import Network.HTTP.Types
|
||||
import Test.Hspec
|
||||
import Test.Hspec.Wai
|
||||
import Test.Hspec.Wai.JSON
|
||||
|
||||
import Protolude hiding (get)
|
||||
import SpecHelper
|
||||
|
||||
-- two helpers functions to make sure that each test can setup and cleanup properly
|
||||
|
||||
-- creates Item to work with for PATCH and DELETE
|
||||
postItem =
|
||||
request methodPost "/items"
|
||||
[("Prefer", "tx=commit"), ("Prefer", "resolution=ignore-duplicates")]
|
||||
[json|{"id":0}|]
|
||||
`shouldRespondWith`
|
||||
""
|
||||
{ matchStatus = 201
|
||||
, matchHeaders = [matchHeaderAbsent hContentType] }
|
||||
|
||||
-- removes Items left over from POST, PUT, and PATCH
|
||||
deleteItems =
|
||||
request methodDelete "/items?id=lte.0"
|
||||
[("Prefer", "tx=commit")]
|
||||
""
|
||||
`shouldRespondWith`
|
||||
""
|
||||
{ matchStatus = 204
|
||||
, matchHeaders = [matchHeaderAbsent hContentType] }
|
||||
|
||||
preferDefault = [("Prefer", "return=representation")]
|
||||
preferCommit = [("Prefer", "return=representation"), ("Prefer", "tx=commit")]
|
||||
preferRollback = [("Prefer", "return=representation"), ("Prefer", "tx=rollback")]
|
||||
|
||||
withoutPreferenceApplied = []
|
||||
withPreferenceCommitApplied = [ "Preference-Applied" <:> "tx=commit" ]
|
||||
withPreferenceRollbackApplied = [ "Preference-Applied" <:> "tx=rollback" ]
|
||||
|
||||
shouldRespondToReads reqHeaders respHeaders = do
|
||||
it "responds to GET" $ do
|
||||
request methodGet "/items?id=eq.1"
|
||||
reqHeaders
|
||||
""
|
||||
`shouldRespondWith`
|
||||
[json|[{"id":1}]|]
|
||||
{ matchHeaders = respHeaders }
|
||||
|
||||
it "responds to HEAD" $ do
|
||||
request methodHead "/items?id=eq.1"
|
||||
reqHeaders
|
||||
""
|
||||
`shouldRespondWith`
|
||||
""
|
||||
{ matchHeaders = matchContentTypeJson : respHeaders }
|
||||
|
||||
it "responds to GET on RPC" $ do
|
||||
request methodGet "/rpc/search?id=1"
|
||||
reqHeaders
|
||||
""
|
||||
`shouldRespondWith`
|
||||
[json|[{"id":1}]|]
|
||||
{ matchHeaders = respHeaders }
|
||||
|
||||
it "responds to POST on RPC" $ do
|
||||
request methodPost "/rpc/search"
|
||||
reqHeaders
|
||||
[json|{"id":1}|]
|
||||
`shouldRespondWith`
|
||||
[json|[{"id":1}]|]
|
||||
{ matchHeaders = respHeaders }
|
||||
|
||||
shouldRaiseExceptions reqHeaders respHeaders = do
|
||||
it "raises immediate constraints" $ do
|
||||
request methodPost "/rpc/raise_constraint"
|
||||
reqHeaders
|
||||
""
|
||||
`shouldRespondWith`
|
||||
[json|{
|
||||
"hint":null,
|
||||
"details":"Key (col)=(1) already exists.",
|
||||
"code":"23505",
|
||||
"message":"duplicate key value violates unique constraint \"deferrable_unique_constraint_col_key\""
|
||||
}|]
|
||||
{ matchStatus = 409
|
||||
, matchHeaders = respHeaders }
|
||||
|
||||
it "raises deferred constraints" $ do
|
||||
request methodPost "/rpc/raise_constraint"
|
||||
reqHeaders
|
||||
[json|{"deferred": true}|]
|
||||
`shouldRespondWith`
|
||||
[json|{
|
||||
"hint":null,
|
||||
"details":"Key (col)=(1) already exists.",
|
||||
"code":"23505",
|
||||
"message":"duplicate key value violates unique constraint \"deferrable_unique_constraint_col_key\""
|
||||
}|]
|
||||
{ matchStatus = 409
|
||||
, matchHeaders = respHeaders }
|
||||
|
||||
shouldPersistMutations reqHeaders respHeaders = do
|
||||
it "does persist post" $ do
|
||||
request methodPost "/items"
|
||||
reqHeaders
|
||||
[json|{"id":0}|]
|
||||
`shouldRespondWith`
|
||||
[json|[{"id":0}]|]
|
||||
{ matchStatus = 201
|
||||
, matchHeaders = respHeaders }
|
||||
get "/items?id=eq.0"
|
||||
`shouldRespondWith`
|
||||
[json|[{"id":0}]|]
|
||||
deleteItems
|
||||
|
||||
it "does persist put" $ do
|
||||
request methodPut "/items?id=eq.0"
|
||||
reqHeaders
|
||||
[json|{"id":0}|]
|
||||
`shouldRespondWith`
|
||||
[json|[{"id":0}]|]
|
||||
{ matchHeaders = respHeaders }
|
||||
get "/items?id=eq.0"
|
||||
`shouldRespondWith`
|
||||
[json|[{"id":0}]|]
|
||||
deleteItems
|
||||
|
||||
it "does persist patch" $ do
|
||||
postItem
|
||||
request methodPatch "/items?id=eq.0"
|
||||
reqHeaders
|
||||
[json|{"id":-1}|]
|
||||
`shouldRespondWith`
|
||||
[json|[{"id":-1}]|]
|
||||
{ matchHeaders = respHeaders }
|
||||
get "/items?id=eq.0"
|
||||
`shouldRespondWith`
|
||||
[json|[]|]
|
||||
get "/items?id=eq.-1"
|
||||
`shouldRespondWith`
|
||||
[json|[{"id":-1}]|]
|
||||
deleteItems
|
||||
|
||||
it "does persist delete" $ do
|
||||
postItem
|
||||
request methodDelete "/items?id=eq.0"
|
||||
reqHeaders
|
||||
""
|
||||
`shouldRespondWith`
|
||||
[json|[{"id":0}]|]
|
||||
{ matchHeaders = respHeaders }
|
||||
get "/items?id=eq.0"
|
||||
`shouldRespondWith`
|
||||
[json|[]|]
|
||||
|
||||
shouldNotPersistMutations reqHeaders respHeaders = do
|
||||
it "does not persist post" $ do
|
||||
request methodPost "/items"
|
||||
reqHeaders
|
||||
[json|{"id":0}|]
|
||||
`shouldRespondWith`
|
||||
[json|[{"id":0}]|]
|
||||
{ matchStatus = 201
|
||||
, matchHeaders = respHeaders }
|
||||
get "/items?id=eq.0"
|
||||
`shouldRespondWith`
|
||||
[json|[]|]
|
||||
|
||||
it "does not persist put" $ do
|
||||
request methodPut "/items?id=eq.0"
|
||||
reqHeaders
|
||||
[json|{"id":0}|]
|
||||
`shouldRespondWith`
|
||||
[json|[{"id":0}]|]
|
||||
{ matchHeaders = respHeaders }
|
||||
get "/items?id=eq.0"
|
||||
`shouldRespondWith`
|
||||
[json|[]|]
|
||||
|
||||
it "does not persist patch" $ do
|
||||
request methodPatch "/items?id=eq.1"
|
||||
reqHeaders
|
||||
[json|{"id":0}|]
|
||||
`shouldRespondWith`
|
||||
[json|[{"id":0}]|]
|
||||
{ matchHeaders = respHeaders }
|
||||
get "/items?id=eq.0"
|
||||
`shouldRespondWith`
|
||||
[json|[]|]
|
||||
get "items?id=eq.1"
|
||||
`shouldRespondWith`
|
||||
[json|[{"id":1}]|]
|
||||
|
||||
it "does not persist delete" $ do
|
||||
request methodDelete "/items?id=eq.1"
|
||||
reqHeaders
|
||||
""
|
||||
`shouldRespondWith`
|
||||
[json|[{"id":1}]|]
|
||||
{ matchHeaders = respHeaders }
|
||||
get "/items?id=eq.1"
|
||||
`shouldRespondWith`
|
||||
[json|[{"id":1}]|]
|
||||
|
||||
allowed :: SpecWith ((), Application)
|
||||
allowed = describe "tx-allow-override = true" $ do
|
||||
describe "without Prefer tx" $ do
|
||||
preferDefault `shouldRespondToReads` withoutPreferenceApplied
|
||||
preferDefault `shouldNotPersistMutations` withoutPreferenceApplied
|
||||
preferDefault `shouldRaiseExceptions` withoutPreferenceApplied
|
||||
|
||||
describe "Prefer tx=commit" $ do
|
||||
preferCommit `shouldRespondToReads` withPreferenceCommitApplied
|
||||
preferCommit `shouldPersistMutations` withPreferenceCommitApplied
|
||||
-- Exceptions are always without preference applied,
|
||||
-- because they return before the end of the transaction.
|
||||
preferCommit `shouldRaiseExceptions` withoutPreferenceApplied
|
||||
|
||||
describe "Prefer tx=rollback" $ do
|
||||
preferRollback `shouldRespondToReads` withPreferenceRollbackApplied
|
||||
preferRollback `shouldNotPersistMutations` withPreferenceRollbackApplied
|
||||
-- Exceptions are always without preference applied,
|
||||
-- because they return before the end of the transaction.
|
||||
preferRollback `shouldRaiseExceptions` withoutPreferenceApplied
|
||||
|
||||
disallowed :: SpecWith ((), Application)
|
||||
disallowed = describe "tx-rollback-all = false, tx-allow-override = false" $ do
|
||||
describe "without Prefer tx" $ do
|
||||
preferDefault `shouldRespondToReads` withoutPreferenceApplied
|
||||
preferDefault `shouldPersistMutations` withoutPreferenceApplied
|
||||
preferDefault `shouldRaiseExceptions` withoutPreferenceApplied
|
||||
|
||||
describe "Prefer tx=commit" $ do
|
||||
preferCommit `shouldRespondToReads` withoutPreferenceApplied
|
||||
preferCommit `shouldPersistMutations` withoutPreferenceApplied
|
||||
preferCommit `shouldRaiseExceptions` withoutPreferenceApplied
|
||||
|
||||
describe "Prefer tx=rollback" $ do
|
||||
preferRollback `shouldRespondToReads` withoutPreferenceApplied
|
||||
preferRollback `shouldPersistMutations` withoutPreferenceApplied
|
||||
preferRollback `shouldRaiseExceptions` withoutPreferenceApplied
|
||||
|
||||
|
||||
forced :: SpecWith ((), Application)
|
||||
forced = describe "tx-rollback-all = true, tx-allow-override = false" $ do
|
||||
describe "without Prefer tx" $ do
|
||||
preferDefault `shouldRespondToReads` withoutPreferenceApplied
|
||||
preferDefault `shouldNotPersistMutations` withoutPreferenceApplied
|
||||
preferDefault `shouldRaiseExceptions` withoutPreferenceApplied
|
||||
|
||||
describe "Prefer tx=commit" $ do
|
||||
preferCommit `shouldRespondToReads` withoutPreferenceApplied
|
||||
preferCommit `shouldNotPersistMutations` withoutPreferenceApplied
|
||||
preferCommit `shouldRaiseExceptions` withoutPreferenceApplied
|
||||
|
||||
describe "Prefer tx=rollback" $ do
|
||||
preferRollback `shouldRespondToReads` withoutPreferenceApplied
|
||||
preferRollback `shouldNotPersistMutations` withoutPreferenceApplied
|
||||
preferRollback `shouldRaiseExceptions` withoutPreferenceApplied
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
module Feature.RootSpec where
|
||||
|
||||
import Network.HTTP.Types
|
||||
import Network.Wai (Application)
|
||||
|
||||
import Test.Hspec
|
||||
import Test.Hspec.Wai
|
||||
import Test.Hspec.Wai.JSON
|
||||
|
||||
import Protolude hiding (get)
|
||||
|
||||
import SpecHelper
|
||||
|
||||
spec :: SpecWith ((), Application)
|
||||
spec =
|
||||
describe "root spec function" $ do
|
||||
it "accepts application/openapi+json" $
|
||||
request methodGet "/"
|
||||
[("Accept","application/openapi+json")] "" `shouldRespondWith`
|
||||
[json|{
|
||||
"swagger": "2.0",
|
||||
"info": {"title": "PostgREST API", "description": "This is a dynamic API generated by PostgREST"}
|
||||
}|]
|
||||
{ matchHeaders = ["Content-Type" <:> "application/openapi+json; charset=utf-8"] }
|
||||
|
||||
it "accepts application/json" $
|
||||
request methodGet "/"
|
||||
[("Accept", "application/json")] "" `shouldRespondWith`
|
||||
[json| {
|
||||
"tableName": "orders_view", "tableSchema": "test",
|
||||
"tableDeletable": true, "tableUpdatable": true,
|
||||
"tableInsertable": true, "tableDescription": null
|
||||
} |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
@@ -0,0 +1,96 @@
|
||||
module Feature.RpcPreRequestGucsSpec where
|
||||
|
||||
import Network.Wai (Application)
|
||||
|
||||
import Network.HTTP.Types
|
||||
import Test.Hspec hiding (pendingWith)
|
||||
import Test.Hspec.Wai
|
||||
import Test.Hspec.Wai.JSON
|
||||
|
||||
import Protolude hiding (get, put)
|
||||
import SpecHelper
|
||||
|
||||
spec :: SpecWith ((), Application)
|
||||
spec =
|
||||
describe "GUC headers on all methods via pre-request" $ do
|
||||
it "succeeds setting the headers on POST" $
|
||||
post "/items"
|
||||
[json|[{"id": 11111}]|]
|
||||
`shouldRespondWith`
|
||||
""
|
||||
{ matchStatus = 201
|
||||
, matchHeaders = [ matchHeaderAbsent hContentType
|
||||
, "X-Custom-Header" <:> "mykey=myval" ]
|
||||
}
|
||||
|
||||
it "succeeds setting the headers on GET and HEAD" $ do
|
||||
request methodGet "/items?id=eq.1"
|
||||
[("User-Agent", "MSIE 6.0")]
|
||||
""
|
||||
`shouldRespondWith`
|
||||
[json|[{"id": 1}]|]
|
||||
{ matchHeaders = ["Cache-Control" <:> "no-cache, no-store, must-revalidate"] }
|
||||
|
||||
request methodHead "/items?id=eq.1"
|
||||
[("User-Agent", "MSIE 7.0")]
|
||||
""
|
||||
`shouldRespondWith`
|
||||
""
|
||||
{ matchHeaders = [ matchContentTypeJson
|
||||
, "Cache-Control" <:> "no-cache, no-store, must-revalidate" ]
|
||||
}
|
||||
|
||||
request methodHead "/projects"
|
||||
[("Accept", "text/csv")]
|
||||
""
|
||||
`shouldRespondWith`
|
||||
""
|
||||
{ matchHeaders = [ "Content-Type" <:> "text/csv; charset=utf-8"
|
||||
, "Content-Disposition" <:> "attachment; filename=projects.csv" ]
|
||||
}
|
||||
|
||||
it "succeeds setting the headers on PATCH" $
|
||||
patch "/items?id=eq.1"
|
||||
[json|[{"id": 11111}]|]
|
||||
`shouldRespondWith`
|
||||
""
|
||||
{ matchStatus = 204
|
||||
, matchHeaders = [ matchHeaderAbsent hContentType
|
||||
, "X-Custom-Header" <:> "mykey=myval" ]
|
||||
}
|
||||
|
||||
it "succeeds setting the headers on PUT" $
|
||||
put "/items?id=eq.1"
|
||||
[json|[{"id": 1}]|]
|
||||
`shouldRespondWith`
|
||||
""
|
||||
{ matchStatus = 204
|
||||
, matchHeaders = [ matchHeaderAbsent hContentType
|
||||
, "X-Custom-Header" <:> "mykey=myval" ]
|
||||
}
|
||||
|
||||
it "succeeds setting the headers on DELETE" $
|
||||
delete "/items?id=eq.1"
|
||||
`shouldRespondWith`
|
||||
""
|
||||
{ matchStatus = 204
|
||||
, matchHeaders = [ matchHeaderAbsent hContentType
|
||||
, "X-Custom-Header" <:> "mykey=myval" ]
|
||||
}
|
||||
it "can override the Content-Type header" $ do
|
||||
request methodHead "/clients?id=eq.1"
|
||||
[]
|
||||
""
|
||||
`shouldRespondWith`
|
||||
""
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = ["Content-Type" <:> "application/custom+json"]
|
||||
}
|
||||
request methodHead "/rpc/getallprojects"
|
||||
[]
|
||||
""
|
||||
`shouldRespondWith`
|
||||
""
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = ["Content-Type" <:> "application/custom+json"]
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,327 @@
|
||||
module Feature.SingularSpec where
|
||||
|
||||
import Network.Wai (Application)
|
||||
import Network.Wai.Test (SResponse (..))
|
||||
|
||||
import Network.HTTP.Types
|
||||
import Test.Hspec
|
||||
import Test.Hspec.Wai
|
||||
import Test.Hspec.Wai.JSON
|
||||
|
||||
import Protolude hiding (get)
|
||||
import SpecHelper
|
||||
|
||||
spec :: SpecWith ((), Application)
|
||||
spec =
|
||||
describe "Requesting singular json object" $ do
|
||||
let singular = ("Accept", "application/vnd.pgrst.object+json")
|
||||
|
||||
context "with GET request" $ do
|
||||
it "fails for zero rows" $
|
||||
request methodGet "/items?id=gt.0&id=lt.0" [singular] ""
|
||||
`shouldRespondWith` 406
|
||||
|
||||
it "will select an existing object" $ do
|
||||
request methodGet "/items?id=eq.5" [singular] ""
|
||||
`shouldRespondWith`
|
||||
[json|{"id":5}|]
|
||||
{ matchHeaders = [matchContentTypeSingular] }
|
||||
-- also test without the +json suffix
|
||||
request methodGet "/items?id=eq.5"
|
||||
[("Accept", "application/vnd.pgrst.object")] ""
|
||||
`shouldRespondWith`
|
||||
[json|{"id":5}|]
|
||||
{ matchHeaders = [matchContentTypeSingular] }
|
||||
|
||||
it "can combine multiple prefer values" $
|
||||
request methodGet "/items?id=eq.5" [singular, ("Prefer","count=none")] ""
|
||||
`shouldRespondWith`
|
||||
[json|{"id":5}|]
|
||||
{ matchHeaders = [matchContentTypeSingular] }
|
||||
|
||||
it "can shape plurality singular object routes" $
|
||||
request methodGet "/projects_view?id=eq.1&select=id,name,clients(*),tasks(id,name)" [singular] ""
|
||||
`shouldRespondWith`
|
||||
[json|{"id":1,"name":"Windows 7","clients":{"id":1,"name":"Microsoft"},"tasks":[{"id":1,"name":"Design w7"},{"id":2,"name":"Code w7"}]}|]
|
||||
{ matchHeaders = [matchContentTypeSingular] }
|
||||
|
||||
context "when updating rows" $ do
|
||||
it "works for one row with return=rep" $ do
|
||||
request methodPatch "/addresses?id=eq.1"
|
||||
[("Prefer", "return=representation"), singular]
|
||||
[json| { address: "B Street" } |]
|
||||
`shouldRespondWith`
|
||||
[json|{"id":1,"address":"B Street"}|]
|
||||
{ matchHeaders = [matchContentTypeSingular] }
|
||||
|
||||
it "works for one row with return=minimal" $
|
||||
request methodPatch "/addresses?id=eq.1"
|
||||
[("Prefer", "return=minimal"), singular]
|
||||
[json| { address: "C Street" } |]
|
||||
`shouldRespondWith`
|
||||
""
|
||||
{ matchStatus = 204
|
||||
, matchHeaders = [matchHeaderAbsent hContentType]
|
||||
}
|
||||
|
||||
it "raises an error for multiple rows" $ do
|
||||
request methodPatch "/addresses"
|
||||
[("Prefer", "tx=commit"), singular]
|
||||
[json| { address: "zzz" } |]
|
||||
`shouldRespondWith`
|
||||
[json|{"details":"Results contain 4 rows, application/vnd.pgrst.object+json requires 1 row","message":"JSON object requested, multiple (or no) rows returned"}|]
|
||||
{ matchStatus = 406
|
||||
, matchHeaders = [ matchContentTypeSingular
|
||||
, "Preference-Applied" <:> "tx=commit" ]
|
||||
}
|
||||
|
||||
-- the rows should not be updated, either
|
||||
get "/addresses?id=eq.1"
|
||||
`shouldRespondWith`
|
||||
[json|[{"id":1,"address":"address 1"}]|]
|
||||
|
||||
it "raises an error for multiple rows with return=rep" $ do
|
||||
request methodPatch "/addresses"
|
||||
[("Prefer", "tx=commit"), ("Prefer", "return=representation"), singular]
|
||||
[json| { address: "zzz" } |]
|
||||
`shouldRespondWith`
|
||||
[json|{"details":"Results contain 4 rows, application/vnd.pgrst.object+json requires 1 row","message":"JSON object requested, multiple (or no) rows returned"}|]
|
||||
{ matchStatus = 406
|
||||
, matchHeaders = [ matchContentTypeSingular
|
||||
, "Preference-Applied" <:> "tx=commit" ]
|
||||
}
|
||||
|
||||
-- the rows should not be updated, either
|
||||
get "/addresses?id=eq.1"
|
||||
`shouldRespondWith`
|
||||
[json|[{"id":1,"address":"address 1"}]|]
|
||||
|
||||
it "raises an error for zero rows" $
|
||||
request methodPatch "/items?id=gt.0&id=lt.0"
|
||||
[singular] [json|{"id":1}|]
|
||||
`shouldRespondWith`
|
||||
[json|{"details":"Results contain 0 rows, application/vnd.pgrst.object+json requires 1 row","message":"JSON object requested, multiple (or no) rows returned"}|]
|
||||
{ matchStatus = 406
|
||||
, matchHeaders = [matchContentTypeSingular]
|
||||
}
|
||||
|
||||
it "raises an error for zero rows with return=rep" $
|
||||
request methodPatch "/items?id=gt.0&id=lt.0"
|
||||
[("Prefer", "return=representation"), singular] [json|{"id":1}|]
|
||||
`shouldRespondWith`
|
||||
[json|{"details":"Results contain 0 rows, application/vnd.pgrst.object+json requires 1 row","message":"JSON object requested, multiple (or no) rows returned"}|]
|
||||
{ matchStatus = 406
|
||||
, matchHeaders = [matchContentTypeSingular]
|
||||
}
|
||||
|
||||
context "when creating rows" $ do
|
||||
it "works for one row with return=rep" $ do
|
||||
request methodPost "/addresses"
|
||||
[("Prefer", "return=representation"), singular]
|
||||
[json| [ { id: 102, address: "xxx" } ] |]
|
||||
`shouldRespondWith`
|
||||
[json|{"id":102,"address":"xxx"}|]
|
||||
{ matchStatus = 201
|
||||
, matchHeaders = [matchContentTypeSingular]
|
||||
}
|
||||
|
||||
it "works for one row with return=minimal" $ do
|
||||
request methodPost "/addresses"
|
||||
[("Prefer", "return=minimal"), singular]
|
||||
[json| [ { id: 103, address: "xxx" } ] |]
|
||||
`shouldRespondWith`
|
||||
""
|
||||
{ matchStatus = 201
|
||||
, matchHeaders = [ matchHeaderAbsent hContentType
|
||||
, "Content-Range" <:> "*/*" ]
|
||||
}
|
||||
|
||||
it "raises an error when attempting to create multiple entities" $ do
|
||||
request methodPost "/addresses"
|
||||
[("Prefer", "tx=commit"), singular]
|
||||
[json| [ { id: 200, address: "xxx" }, { id: 201, address: "yyy" } ] |]
|
||||
`shouldRespondWith`
|
||||
[json|{"details":"Results contain 2 rows, application/vnd.pgrst.object+json requires 1 row","message":"JSON object requested, multiple (or no) rows returned"}|]
|
||||
{ matchStatus = 406
|
||||
, matchHeaders = [ matchContentTypeSingular
|
||||
, "Preference-Applied" <:> "tx=commit" ]
|
||||
}
|
||||
|
||||
-- the rows should not exist, either
|
||||
get "/addresses?id=eq.200"
|
||||
`shouldRespondWith`
|
||||
"[]"
|
||||
|
||||
it "raises an error when attempting to create multiple entities with return=rep" $ do
|
||||
request methodPost "/addresses"
|
||||
[("Prefer", "tx=commit"), ("Prefer", "return=representation"), singular]
|
||||
[json| [ { id: 202, address: "xxx" }, { id: 203, address: "yyy" } ] |]
|
||||
`shouldRespondWith`
|
||||
[json|{"details":"Results contain 2 rows, application/vnd.pgrst.object+json requires 1 row","message":"JSON object requested, multiple (or no) rows returned"}|]
|
||||
{ matchStatus = 406
|
||||
, matchHeaders = [ matchContentTypeSingular
|
||||
, "Preference-Applied" <:> "tx=commit" ]
|
||||
}
|
||||
|
||||
-- the rows should not exist, either
|
||||
get "/addresses?id=eq.202"
|
||||
`shouldRespondWith`
|
||||
"[]"
|
||||
|
||||
it "raises an error regardless of return=minimal" $ do
|
||||
request methodPost "/addresses"
|
||||
[("Prefer", "tx=commit"), ("Prefer", "return=minimal"), singular]
|
||||
[json| [ { id: 204, address: "xxx" }, { id: 205, address: "yyy" } ] |]
|
||||
`shouldRespondWith`
|
||||
[json|{"details":"Results contain 2 rows, application/vnd.pgrst.object+json requires 1 row","message":"JSON object requested, multiple (or no) rows returned"}|]
|
||||
{ matchStatus = 406
|
||||
, matchHeaders = [ matchContentTypeSingular
|
||||
, "Preference-Applied" <:> "tx=commit" ]
|
||||
}
|
||||
|
||||
-- the rows should not exist, either
|
||||
get "/addresses?id=eq.204"
|
||||
`shouldRespondWith`
|
||||
"[]"
|
||||
|
||||
it "raises an error when creating zero entities" $
|
||||
request methodPost "/addresses"
|
||||
[singular]
|
||||
[json| [ ] |]
|
||||
`shouldRespondWith`
|
||||
[json|{"details":"Results contain 0 rows, application/vnd.pgrst.object+json requires 1 row","message":"JSON object requested, multiple (or no) rows returned"}|]
|
||||
{ matchStatus = 406
|
||||
, matchHeaders = [matchContentTypeSingular]
|
||||
}
|
||||
|
||||
it "raises an error when creating zero entities with return=rep" $
|
||||
request methodPost "/addresses"
|
||||
[("Prefer", "return=representation"), singular]
|
||||
[json| [ ] |]
|
||||
`shouldRespondWith`
|
||||
[json|{"details":"Results contain 0 rows, application/vnd.pgrst.object+json requires 1 row","message":"JSON object requested, multiple (or no) rows returned"}|]
|
||||
{ matchStatus = 406
|
||||
, matchHeaders = [matchContentTypeSingular]
|
||||
}
|
||||
|
||||
context "when deleting rows" $ do
|
||||
it "works for one row with return=rep" $ do
|
||||
p <- request methodDelete
|
||||
"/items?id=eq.11"
|
||||
[("Prefer", "return=representation"), singular] ""
|
||||
liftIO $ simpleBody p `shouldBe` [json|{"id":11}|]
|
||||
|
||||
it "works for one row with return=minimal" $ do
|
||||
p <- request methodDelete
|
||||
"/items?id=eq.12"
|
||||
[("Prefer", "return=minimal"), singular] ""
|
||||
liftIO $ simpleBody p `shouldBe` ""
|
||||
|
||||
it "raises an error when attempting to delete multiple entities" $ do
|
||||
request methodDelete "/items?id=gt.0&id=lt.6"
|
||||
[("Prefer", "tx=commit"), singular]
|
||||
""
|
||||
`shouldRespondWith`
|
||||
[json|{"details":"Results contain 5 rows, application/vnd.pgrst.object+json requires 1 row","message":"JSON object requested, multiple (or no) rows returned"}|]
|
||||
{ matchStatus = 406
|
||||
, matchHeaders = [ matchContentTypeSingular
|
||||
, "Preference-Applied" <:> "tx=commit" ]
|
||||
}
|
||||
|
||||
-- the rows should still exist
|
||||
get "/items?id=gt.0&id=lt.6&order=id"
|
||||
`shouldRespondWith`
|
||||
[json| [{"id":1},{"id":2},{"id":3},{"id":4},{"id":5}] |]
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = ["Content-Range" <:> "0-4/*"]
|
||||
}
|
||||
|
||||
it "raises an error when attempting to delete multiple entities with return=rep" $ do
|
||||
request methodDelete "/items?id=gt.5&id=lt.11"
|
||||
[("Prefer", "tx=commit"), ("Prefer", "return=representation"), singular] ""
|
||||
`shouldRespondWith`
|
||||
[json|{"details":"Results contain 5 rows, application/vnd.pgrst.object+json requires 1 row","message":"JSON object requested, multiple (or no) rows returned"}|]
|
||||
{ matchStatus = 406
|
||||
, matchHeaders = [ matchContentTypeSingular
|
||||
, "Preference-Applied" <:> "tx=commit" ]
|
||||
}
|
||||
|
||||
-- the rows should still exist
|
||||
get "/items?id=gt.5&id=lt.11"
|
||||
`shouldRespondWith` [json| [{"id":6},{"id":7},{"id":8},{"id":9},{"id":10}] |]
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = ["Content-Range" <:> "0-4/*"]
|
||||
}
|
||||
|
||||
it "raises an error when deleting zero entities" $
|
||||
request methodDelete "/items?id=lt.0"
|
||||
[singular] ""
|
||||
`shouldRespondWith`
|
||||
[json|{"details":"Results contain 0 rows, application/vnd.pgrst.object+json requires 1 row","message":"JSON object requested, multiple (or no) rows returned"}|]
|
||||
{ matchStatus = 406
|
||||
, matchHeaders = [matchContentTypeSingular]
|
||||
}
|
||||
|
||||
it "raises an error when deleting zero entities with return=rep" $
|
||||
request methodDelete "/items?id=lt.0"
|
||||
[("Prefer", "return=representation"), singular] ""
|
||||
`shouldRespondWith`
|
||||
[json|{"details":"Results contain 0 rows, application/vnd.pgrst.object+json requires 1 row","message":"JSON object requested, multiple (or no) rows returned"}|]
|
||||
{ matchStatus = 406
|
||||
, matchHeaders = [matchContentTypeSingular]
|
||||
}
|
||||
|
||||
context "when calling a stored proc" $ do
|
||||
it "fails for zero rows" $
|
||||
request methodPost "/rpc/getproject"
|
||||
[singular] [json|{ "id": 9999999}|]
|
||||
`shouldRespondWith`
|
||||
[json|{"details":"Results contain 0 rows, application/vnd.pgrst.object+json requires 1 row","message":"JSON object requested, multiple (or no) rows returned"}|]
|
||||
{ matchStatus = 406
|
||||
, matchHeaders = [matchContentTypeSingular]
|
||||
}
|
||||
|
||||
-- this one may be controversial, should vnd.pgrst.object include
|
||||
-- the likes of 2 and "hello?"
|
||||
it "succeeds for scalar result" $
|
||||
request methodPost "/rpc/sayhello"
|
||||
[singular] [json|{ "name": "world"}|]
|
||||
`shouldRespondWith` 200
|
||||
|
||||
it "returns a single object for json proc" $
|
||||
request methodPost "/rpc/getproject"
|
||||
[singular] [json|{ "id": 1}|]
|
||||
`shouldRespondWith`
|
||||
[json|{"id":1,"name":"Windows 7","client_id":1}|]
|
||||
{ matchHeaders = [matchContentTypeSingular] }
|
||||
|
||||
it "fails for multiple rows" $
|
||||
request methodPost "/rpc/getallprojects"
|
||||
[singular] "{}"
|
||||
`shouldRespondWith`
|
||||
[json|{"details":"Results contain 5 rows, application/vnd.pgrst.object+json requires 1 row","message":"JSON object requested, multiple (or no) rows returned"}|]
|
||||
{ matchStatus = 406
|
||||
, matchHeaders = [matchContentTypeSingular]
|
||||
}
|
||||
|
||||
it "fails for multiple rows with rolled back changes" $ do
|
||||
post "/rpc/getproject?select=id,name"
|
||||
[json| {"id": 1} |]
|
||||
`shouldRespondWith`
|
||||
[json|[{"id":1,"name":"Windows 7"}]|]
|
||||
|
||||
request methodPost "/rpc/setprojects"
|
||||
[("Prefer", "tx=commit"), singular]
|
||||
[json| {"id_l": 1, "id_h": 2, "name": "changed"} |]
|
||||
`shouldRespondWith`
|
||||
[json|{"details":"Results contain 2 rows, application/vnd.pgrst.object+json requires 1 row","message":"JSON object requested, multiple (or no) rows returned"}|]
|
||||
{ matchStatus = 406
|
||||
, matchHeaders = [ matchContentTypeSingular
|
||||
, "Preference-Applied" <:> "tx=commit" ]
|
||||
}
|
||||
|
||||
-- should rollback function
|
||||
post "/rpc/getproject?select=id,name"
|
||||
[json| {"id": 1} |]
|
||||
`shouldRespondWith`
|
||||
[json|[{"id":1,"name":"Windows 7"}]|]
|
||||
@@ -0,0 +1,38 @@
|
||||
module Feature.UnicodeSpec where
|
||||
|
||||
import Network.Wai (Application)
|
||||
|
||||
import Network.HTTP.Types
|
||||
import Test.Hspec
|
||||
import Test.Hspec.Wai
|
||||
import Test.Hspec.Wai.JSON
|
||||
|
||||
import Protolude hiding (get)
|
||||
import SpecHelper
|
||||
|
||||
spec :: SpecWith ((), Application)
|
||||
spec =
|
||||
describe "Reading and writing to unicode schema and table names" $
|
||||
it "Can read and write values" $ do
|
||||
get "/%D9%85%D9%88%D8%A7%D8%B1%D8%AF"
|
||||
`shouldRespondWith` "[]"
|
||||
|
||||
request methodPost "/%D9%85%D9%88%D8%A7%D8%B1%D8%AF"
|
||||
[("Prefer", "tx=commit"), ("Prefer", "return=representation")]
|
||||
[json| { "هویت": 1 } |]
|
||||
`shouldRespondWith`
|
||||
[json| [{ "هویت": 1 }] |]
|
||||
{ matchStatus = 201 }
|
||||
|
||||
get "/%D9%85%D9%88%D8%A7%D8%B1%D8%AF"
|
||||
`shouldRespondWith`
|
||||
[json| [{ "هویت": 1 }] |]
|
||||
|
||||
request methodDelete "/%D9%85%D9%88%D8%A7%D8%B1%D8%AF"
|
||||
[("Prefer", "tx=commit")]
|
||||
""
|
||||
`shouldRespondWith`
|
||||
""
|
||||
{ matchStatus = 204
|
||||
, matchHeaders = [matchHeaderAbsent hContentType]
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
module Feature.UpdateSpec where
|
||||
|
||||
import Network.Wai (Application)
|
||||
import Test.Hspec hiding (pendingWith)
|
||||
|
||||
import Network.HTTP.Types
|
||||
import Test.Hspec.Wai
|
||||
import Test.Hspec.Wai.JSON
|
||||
|
||||
import Protolude hiding (get)
|
||||
import SpecHelper
|
||||
|
||||
spec :: SpecWith ((), Application)
|
||||
spec = do
|
||||
describe "Patching record" $ do
|
||||
context "to unknown uri" $
|
||||
it "indicates no table found by returning 404" $
|
||||
request methodPatch "/fake" []
|
||||
[json| { "real": false } |]
|
||||
`shouldRespondWith` 404
|
||||
|
||||
context "on an empty table" $
|
||||
it "indicates no records found to update by returning 404" $
|
||||
request methodPatch "/empty_table" []
|
||||
[json| { "extra":20 } |]
|
||||
`shouldRespondWith`
|
||||
""
|
||||
{ matchStatus = 404,
|
||||
matchHeaders = [matchHeaderAbsent hContentType]
|
||||
}
|
||||
|
||||
context "with invalid json payload" $
|
||||
it "fails with 400 and error" $
|
||||
request methodPatch "/simple_pk" [] "}{ x = 2"
|
||||
`shouldRespondWith`
|
||||
[json|{"message":"Error in $: Failed reading: not a valid json value at '}{x=2'"}|]
|
||||
{ matchStatus = 400,
|
||||
matchHeaders = [matchContentTypeJson]
|
||||
}
|
||||
|
||||
context "with no payload" $
|
||||
it "fails with 400 and error" $
|
||||
request methodPatch "/items" [] ""
|
||||
`shouldRespondWith`
|
||||
[json|{"message":"Error in $: not enough input"}|]
|
||||
{ matchStatus = 400,
|
||||
matchHeaders = [matchContentTypeJson]
|
||||
}
|
||||
|
||||
context "in a nonempty table" $ do
|
||||
it "can update a single item" $ do
|
||||
patch "/items?id=eq.2"
|
||||
[json| { "id":42 } |]
|
||||
`shouldRespondWith`
|
||||
""
|
||||
{ matchStatus = 204
|
||||
, matchHeaders = [ matchHeaderAbsent hContentType
|
||||
, "Content-Range" <:> "0-0/*" ]
|
||||
}
|
||||
|
||||
it "returns empty array when no rows updated and return=rep" $
|
||||
request methodPatch "/items?id=eq.999999"
|
||||
[("Prefer", "return=representation")] [json| { "id":999999 } |]
|
||||
`shouldRespondWith` "[]"
|
||||
{
|
||||
matchStatus = 404,
|
||||
matchHeaders = []
|
||||
}
|
||||
|
||||
it "gives a 404 when no rows updated" $
|
||||
request methodPatch "/items?id=eq.99999999" []
|
||||
[json| { "id": 42 } |]
|
||||
`shouldRespondWith` 404
|
||||
|
||||
it "returns updated object as array when return=rep" $
|
||||
request methodPatch "/items?id=eq.2"
|
||||
[("Prefer", "return=representation")] [json| { "id":2 } |]
|
||||
`shouldRespondWith` [json|[{"id":2}]|]
|
||||
{ matchStatus = 200,
|
||||
matchHeaders = ["Content-Range" <:> "0-0/*"]
|
||||
}
|
||||
|
||||
it "can update multiple items" $ do
|
||||
get "/no_pk?select=a&b=eq.1"
|
||||
`shouldRespondWith`
|
||||
[json|[]|]
|
||||
|
||||
request methodPatch "/no_pk?b=eq.0"
|
||||
[("Prefer", "tx=commit")]
|
||||
[json| { b: "1" } |]
|
||||
`shouldRespondWith`
|
||||
""
|
||||
{ matchStatus = 204
|
||||
, matchHeaders = [ matchHeaderAbsent hContentType
|
||||
, "Content-Range" <:> "0-1/*"
|
||||
, "Preference-Applied" <:> "tx=commit" ]
|
||||
}
|
||||
|
||||
-- check it really got updated
|
||||
get "/no_pk?select=a&b=eq.1"
|
||||
`shouldRespondWith`
|
||||
[json|[ { a: "1" }, { a: "2" } ]|]
|
||||
|
||||
-- put value back for other tests
|
||||
request methodPatch "/no_pk?b=eq.1"
|
||||
[("Prefer", "tx=commit")]
|
||||
[json| { b: "0" } |]
|
||||
`shouldRespondWith`
|
||||
""
|
||||
{ matchStatus = 204
|
||||
, matchHeaders = [matchHeaderAbsent hContentType]
|
||||
}
|
||||
|
||||
it "can set a column to NULL" $ do
|
||||
request methodPatch "/no_pk?a=eq.1"
|
||||
[("Prefer", "return=representation")]
|
||||
[json| { b: null } |]
|
||||
`shouldRespondWith`
|
||||
[json| [{ a: "1", b: null }] |]
|
||||
|
||||
context "filtering by a computed column" $ do
|
||||
it "is successful" $
|
||||
request methodPatch
|
||||
"/items?is_first=eq.true"
|
||||
[("Prefer", "return=representation")]
|
||||
[json| { id: 100 } |]
|
||||
`shouldRespondWith` [json| [{ id: 100 }] |]
|
||||
{ matchStatus = 200,
|
||||
matchHeaders = [matchContentTypeJson, "Content-Range" <:> "0-0/*"]
|
||||
}
|
||||
|
||||
it "indicates no records updated by returning 404" $
|
||||
request methodPatch
|
||||
"/items?always_true=eq.false"
|
||||
[("Prefer", "return=representation")]
|
||||
[json| { id: 100 } |]
|
||||
`shouldRespondWith` "[]"
|
||||
{ matchStatus = 404,
|
||||
matchHeaders = []
|
||||
}
|
||||
|
||||
context "with representation requested" $ do
|
||||
it "can provide a representation" $ do
|
||||
_ <- post "/items"
|
||||
[json| { id: 1 } |]
|
||||
request methodPatch
|
||||
"/items?id=eq.1"
|
||||
[("Prefer", "return=representation")]
|
||||
[json| { id: 99 } |]
|
||||
`shouldRespondWith` [json| [{id:99}] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
-- put value back for other tests
|
||||
void $ request methodPatch "/items?id=eq.99" [] [json| { "id":1 } |]
|
||||
|
||||
it "can return computed columns" $
|
||||
request methodPatch
|
||||
"/items?id=eq.1&select=id,always_true"
|
||||
[("Prefer", "return=representation")]
|
||||
[json| { id: 1 } |]
|
||||
`shouldRespondWith` [json| [{ id: 1, always_true: true }] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "can select overloaded computed columns" $ do
|
||||
request methodPatch
|
||||
"/items?id=eq.1&select=id,computed_overload"
|
||||
[("Prefer", "return=representation")]
|
||||
[json| { id: 1 } |]
|
||||
`shouldRespondWith` [json| [{ id: 1, computed_overload: true }] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
request methodPatch
|
||||
"/items2?id=eq.1&select=id,computed_overload"
|
||||
[("Prefer", "return=representation")]
|
||||
[json| { id: 1 } |]
|
||||
`shouldRespondWith` [json| [{ id: 1, computed_overload: true }] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "ignores ?select= when return not set or return=minimal" $ do
|
||||
request methodPatch "/items?id=eq.1&select=id"
|
||||
[] [json| { id:1 } |]
|
||||
`shouldRespondWith`
|
||||
""
|
||||
{ matchStatus = 204
|
||||
, matchHeaders = [ matchHeaderAbsent hContentType
|
||||
, "Content-Range" <:> "0-0/*" ]
|
||||
}
|
||||
request methodPatch "/items?id=eq.1&select=id"
|
||||
[("Prefer", "return=minimal")]
|
||||
[json| { id:1 } |]
|
||||
`shouldRespondWith`
|
||||
""
|
||||
{ matchStatus = 204
|
||||
, matchHeaders = [ matchHeaderAbsent hContentType
|
||||
, "Content-Range" <:> "0-0/*" ]
|
||||
}
|
||||
|
||||
context "when patching with an empty body" $ do
|
||||
it "makes no updates and returns 204 without return= and without ?select=" $ do
|
||||
request methodPatch "/items"
|
||||
[]
|
||||
[json| {} |]
|
||||
`shouldRespondWith`
|
||||
""
|
||||
{ matchStatus = 204
|
||||
, matchHeaders = [ matchHeaderAbsent hContentType
|
||||
, "Content-Range" <:> "*/*" ]
|
||||
}
|
||||
|
||||
request methodPatch "/items"
|
||||
[]
|
||||
[json| [] |]
|
||||
`shouldRespondWith`
|
||||
""
|
||||
{ matchStatus = 204
|
||||
, matchHeaders = [ matchHeaderAbsent hContentType
|
||||
, "Content-Range" <:> "*/*" ]
|
||||
}
|
||||
|
||||
request methodPatch "/items"
|
||||
[]
|
||||
[json| [{}] |]
|
||||
`shouldRespondWith`
|
||||
""
|
||||
{ matchStatus = 204
|
||||
, matchHeaders = [ matchHeaderAbsent hContentType
|
||||
, "Content-Range" <:> "*/*" ]
|
||||
}
|
||||
|
||||
it "makes no updates and returns 204 without return= and with ?select=" $ do
|
||||
request methodPatch "/items?select=id"
|
||||
[]
|
||||
[json| {} |]
|
||||
`shouldRespondWith`
|
||||
""
|
||||
{ matchStatus = 204
|
||||
, matchHeaders = [ matchHeaderAbsent hContentType
|
||||
, "Content-Range" <:> "*/*" ]
|
||||
}
|
||||
|
||||
request methodPatch "/items?select=id"
|
||||
[]
|
||||
[json| [] |]
|
||||
`shouldRespondWith`
|
||||
""
|
||||
{ matchStatus = 204
|
||||
, matchHeaders = [ matchHeaderAbsent hContentType
|
||||
, "Content-Range" <:> "*/*" ]
|
||||
}
|
||||
|
||||
request methodPatch "/items?select=id"
|
||||
[]
|
||||
[json| [{}] |]
|
||||
`shouldRespondWith`
|
||||
""
|
||||
{ matchStatus = 204
|
||||
, matchHeaders = [ matchHeaderAbsent hContentType
|
||||
, "Content-Range" <:> "*/*" ]
|
||||
}
|
||||
|
||||
it "makes no updates and returns 200 with return=rep and without ?select=" $
|
||||
request methodPatch "/items" [("Prefer", "return=representation")] [json| {} |]
|
||||
`shouldRespondWith` "[]"
|
||||
{
|
||||
matchStatus = 200,
|
||||
matchHeaders = ["Content-Range" <:> "*/*"]
|
||||
}
|
||||
|
||||
it "makes no updates and returns 200 with return=rep and with ?select=" $
|
||||
request methodPatch "/items?select=id" [("Prefer", "return=representation")] [json| {} |]
|
||||
`shouldRespondWith` "[]"
|
||||
{
|
||||
matchStatus = 200,
|
||||
matchHeaders = ["Content-Range" <:> "*/*"]
|
||||
}
|
||||
|
||||
it "makes no updates and returns 200 with return=rep and with ?select= for overloaded computed columns" $
|
||||
request methodPatch "/items?select=id,computed_overload" [("Prefer", "return=representation")] [json| {} |]
|
||||
`shouldRespondWith` "[]"
|
||||
{
|
||||
matchStatus = 200,
|
||||
matchHeaders = ["Content-Range" <:> "*/*"]
|
||||
}
|
||||
|
||||
context "with unicode values" $
|
||||
it "succeeds and returns values intact" $ do
|
||||
request methodPatch "/no_pk?a=eq.1"
|
||||
[("Prefer", "return=representation")]
|
||||
[json| { "a":"圍棋", "b":"¥" } |]
|
||||
`shouldRespondWith`
|
||||
[json|[ { "a":"圍棋", "b":"¥" } ]|]
|
||||
|
||||
context "PATCH with ?columns parameter" $ do
|
||||
it "ignores json keys not included in ?columns" $ do
|
||||
request methodPatch "/articles?id=eq.1&columns=body"
|
||||
[("Prefer", "return=representation")]
|
||||
[json| {"body": "Some real content", "smth": "here", "other": "stuff", "fake_id": 13} |]
|
||||
`shouldRespondWith`
|
||||
[json|[{"id": 1, "body": "Some real content", "owner": "postgrest_test_anonymous"}]|]
|
||||
|
||||
it "ignores json keys and gives 404 if no record updated" $
|
||||
request methodPatch "/articles?id=eq.2001&columns=body" [("Prefer", "return=representation")]
|
||||
[json| {"body": "Some real content", "smth": "here", "other": "stuff", "fake_id": 13} |] `shouldRespondWith` 404
|
||||
|
||||
context "tables with self reference foreign keys" $ do
|
||||
it "embeds children after update" $
|
||||
request methodPatch "/web_content?id=eq.0&select=id,name,web_content(name)"
|
||||
[("Prefer", "return=representation")]
|
||||
[json|{"name": "tardis-patched"}|]
|
||||
`shouldRespondWith`
|
||||
[json|
|
||||
[ { "id": 0, "name": "tardis-patched", "web_content": [ { "name": "fezz" }, { "name": "foo" }, { "name": "bar" } ]} ]
|
||||
|]
|
||||
{ matchStatus = 200,
|
||||
matchHeaders = [matchContentTypeJson]
|
||||
}
|
||||
|
||||
it "embeds parent, children and grandchildren after update" $
|
||||
request methodPatch "/web_content?id=eq.0&select=id,name,web_content(name,web_content(name)),parent_content:p_web_id(name)"
|
||||
[("Prefer", "return=representation")]
|
||||
[json|{"name": "tardis-patched-2"}|]
|
||||
`shouldRespondWith`
|
||||
[json| [
|
||||
{
|
||||
"id": 0,
|
||||
"name": "tardis-patched-2",
|
||||
"parent_content": { "name": "wat" },
|
||||
"web_content": [
|
||||
{ "name": "fezz", "web_content": [ { "name": "wut" } ] },
|
||||
{ "name": "foo", "web_content": [] },
|
||||
{ "name": "bar", "web_content": [] }
|
||||
]
|
||||
}
|
||||
] |]
|
||||
{ matchStatus = 200,
|
||||
matchHeaders = [matchContentTypeJson]
|
||||
}
|
||||
|
||||
it "embeds children after update without explicitly including the id in the ?select" $
|
||||
request methodPatch "/web_content?id=eq.0&select=name,web_content(name)"
|
||||
[("Prefer", "return=representation")]
|
||||
[json|{"name": "tardis-patched"}|]
|
||||
`shouldRespondWith`
|
||||
[json|
|
||||
[ { "name": "tardis-patched", "web_content": [ { "name": "fezz" }, { "name": "foo" }, { "name": "bar" } ]} ]
|
||||
|]
|
||||
{ matchStatus = 200,
|
||||
matchHeaders = [matchContentTypeJson]
|
||||
}
|
||||
|
||||
it "embeds an M2M relationship plus parent after update" $
|
||||
request methodPatch "/users?id=eq.1&select=name,tasks(name,project:projects(name))"
|
||||
[("Prefer", "return=representation")]
|
||||
[json|{"name": "Kevin Malone"}|]
|
||||
`shouldRespondWith`
|
||||
[json|[
|
||||
{
|
||||
"name": "Kevin Malone",
|
||||
"tasks": [
|
||||
{ "name": "Design w7", "project": { "name": "Windows 7" } },
|
||||
{ "name": "Code w7", "project": { "name": "Windows 7" } },
|
||||
{ "name": "Design w10", "project": { "name": "Windows 10" } },
|
||||
{ "name": "Code w10", "project": { "name": "Windows 10" } }
|
||||
]
|
||||
}
|
||||
]|]
|
||||
{ matchStatus = 200,
|
||||
matchHeaders = [matchContentTypeJson]
|
||||
}
|
||||
|
||||
context "table with limited privileges" $ do
|
||||
it "succeeds updating row and gives a 204 when using return=minimal" $
|
||||
request methodPatch "/app_users?id=eq.1"
|
||||
[("Prefer", "return=minimal")]
|
||||
[json| { "password": "passxyz" } |]
|
||||
`shouldRespondWith`
|
||||
""
|
||||
{ matchStatus = 204
|
||||
, matchHeaders = [matchHeaderAbsent hContentType]
|
||||
}
|
||||
|
||||
it "can update without return=minimal and no explicit select" $
|
||||
request methodPatch "/app_users?id=eq.1"
|
||||
[]
|
||||
[json| { "password": "passabc" } |]
|
||||
`shouldRespondWith`
|
||||
""
|
||||
{ matchStatus = 204
|
||||
, matchHeaders = [matchHeaderAbsent hContentType]
|
||||
}
|
||||
@@ -0,0 +1,433 @@
|
||||
module Feature.UpsertSpec where
|
||||
|
||||
import Network.Wai (Application)
|
||||
|
||||
import Network.HTTP.Types
|
||||
import Test.Hspec
|
||||
import Test.Hspec.Wai
|
||||
import Test.Hspec.Wai.JSON
|
||||
|
||||
import PostgREST.Config.PgVersion (PgVersion, pgVersion110)
|
||||
|
||||
import Protolude hiding (get, put)
|
||||
import SpecHelper
|
||||
|
||||
spec :: PgVersion -> SpecWith ((), Application)
|
||||
spec actualPgVersion =
|
||||
describe "UPSERT" $ do
|
||||
context "with POST" $ do
|
||||
context "when Prefer: resolution=merge-duplicates is specified" $ do
|
||||
it "INSERTs and UPDATEs rows on pk conflict" $
|
||||
request methodPost "/tiobe_pls" [("Prefer", "return=representation"), ("Prefer", "resolution=merge-duplicates")]
|
||||
[json| [
|
||||
{ "name": "Javascript", "rank": 6 },
|
||||
{ "name": "Java", "rank": 2 },
|
||||
{ "name": "C", "rank": 1 }
|
||||
]|] `shouldRespondWith` [json| [
|
||||
{ "name": "Javascript", "rank": 6 },
|
||||
{ "name": "Java", "rank": 2 },
|
||||
{ "name": "C", "rank": 1 }
|
||||
]|]
|
||||
{ matchStatus = 201
|
||||
, matchHeaders = ["Preference-Applied" <:> "resolution=merge-duplicates", matchContentTypeJson]
|
||||
}
|
||||
|
||||
it "INSERTs and UPDATEs row on composite pk conflict" $
|
||||
request methodPost "/employees" [("Prefer", "return=representation"), ("Prefer", "resolution=merge-duplicates")]
|
||||
[json| [
|
||||
{ "first_name": "Frances M.", "last_name": "Roe", "salary": "30000" },
|
||||
{ "first_name": "Peter S.", "last_name": "Yang", "salary": 42000 }
|
||||
]|] `shouldRespondWith` [json| [
|
||||
{ "first_name": "Frances M.", "last_name": "Roe", "salary": "$30,000.00", "company": "One-Up Realty", "occupation": "Author" },
|
||||
{ "first_name": "Peter S.", "last_name": "Yang", "salary": "$42,000.00", "company": null, "occupation": null }
|
||||
]|]
|
||||
{ matchStatus = 201
|
||||
, matchHeaders = ["Preference-Applied" <:> "resolution=merge-duplicates", matchContentTypeJson]
|
||||
}
|
||||
|
||||
when (actualPgVersion >= pgVersion110) $
|
||||
it "INSERTs and UPDATEs rows on composite pk conflict for partitioned tables" $
|
||||
request methodPost "/car_models" [("Prefer", "return=representation"), ("Prefer", "resolution=merge-duplicates")]
|
||||
[json| [
|
||||
{ "name": "Murcielago", "year": 2001, "car_brand_name": null},
|
||||
{ "name": "Roma", "year": 2021, "car_brand_name": "Ferrari" }
|
||||
]|] `shouldRespondWith` [json| [
|
||||
{ "name": "Murcielago", "year": 2001, "car_brand_name": null},
|
||||
{ "name": "Roma", "year": 2021, "car_brand_name": "Ferrari" }
|
||||
]|]
|
||||
{ matchStatus = 201
|
||||
, matchHeaders = ["Preference-Applied" <:> "resolution=merge-duplicates", matchContentTypeJson]
|
||||
}
|
||||
|
||||
it "succeeds when the payload has no elements" $
|
||||
request methodPost "/articles" [("Prefer", "return=representation"), ("Prefer", "resolution=merge-duplicates")]
|
||||
[json|[]|] `shouldRespondWith`
|
||||
[json|[]|] { matchStatus = 201 , matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "INSERTs and UPDATEs rows on single unique key conflict" $
|
||||
request methodPost "/single_unique?on_conflict=unique_key" [("Prefer", "return=representation"), ("Prefer", "resolution=merge-duplicates")]
|
||||
[json| [
|
||||
{ "unique_key": 1, "value": "B" },
|
||||
{ "unique_key": 2, "value": "C" }
|
||||
]|] `shouldRespondWith` [json| [
|
||||
{ "unique_key": 1, "value": "B" },
|
||||
{ "unique_key": 2, "value": "C" }
|
||||
]|]
|
||||
{ matchStatus = 201
|
||||
, matchHeaders = ["Preference-Applied" <:> "resolution=merge-duplicates", matchContentTypeJson]
|
||||
}
|
||||
|
||||
it "INSERTs and UPDATEs rows on compound unique keys conflict" $
|
||||
request methodPost "/compound_unique?on_conflict=key1,key2" [("Prefer", "return=representation"), ("Prefer", "resolution=merge-duplicates")]
|
||||
[json| [
|
||||
{ "key1": 1, "key2": 1, "value": "B" },
|
||||
{ "key1": 1, "key2": 2, "value": "C" }
|
||||
]|] `shouldRespondWith` [json| [
|
||||
{ "key1": 1, "key2": 1, "value": "B" },
|
||||
{ "key1": 1, "key2": 2, "value": "C" }
|
||||
]|]
|
||||
{ matchStatus = 201
|
||||
, matchHeaders = ["Preference-Applied" <:> "resolution=merge-duplicates", matchContentTypeJson]
|
||||
}
|
||||
|
||||
context "when Prefer: resolution=ignore-duplicates is specified" $ do
|
||||
it "INSERTs and ignores rows on pk conflict" $
|
||||
request methodPost "/tiobe_pls" [("Prefer", "return=representation"), ("Prefer", "resolution=ignore-duplicates")]
|
||||
[json|[
|
||||
{ "name": "PHP", "rank": 9 },
|
||||
{ "name": "Python", "rank": 10 }
|
||||
]|] `shouldRespondWith` [json|[
|
||||
{ "name": "PHP", "rank": 9 }
|
||||
]|]
|
||||
{ matchStatus = 201
|
||||
, matchHeaders = ["Preference-Applied" <:> "resolution=ignore-duplicates", matchContentTypeJson]
|
||||
}
|
||||
|
||||
it "INSERTs and ignores rows on composite pk conflict" $
|
||||
request methodPost "/employees" [("Prefer", "return=representation"), ("Prefer", "resolution=ignore-duplicates")]
|
||||
[json|[
|
||||
{ "first_name": "Daniel B.", "last_name": "Lyon", "salary": "72000", "company": null, "occupation": null },
|
||||
{ "first_name": "Sara M.", "last_name": "Torpey", "salary": 60000, "company": "Burstein-Applebee", "occupation": "Soil scientist" }
|
||||
]|] `shouldRespondWith` [json|[
|
||||
{ "first_name": "Sara M.", "last_name": "Torpey", "salary": "$60,000.00", "company": "Burstein-Applebee", "occupation": "Soil scientist" }
|
||||
]|]
|
||||
{ matchStatus = 201
|
||||
, matchHeaders = ["Preference-Applied" <:> "resolution=ignore-duplicates", matchContentTypeJson]
|
||||
}
|
||||
|
||||
when (actualPgVersion >= pgVersion110) $
|
||||
it "INSERTs and ignores rows on composite pk conflict for partitioned tables" $
|
||||
request methodPost "/car_models" [("Prefer", "return=representation"), ("Prefer", "resolution=ignore-duplicates")]
|
||||
[json| [
|
||||
{ "name": "Murcielago", "year": 2001, "car_brand_name": "Ferrari" },
|
||||
{ "name": "Huracán", "year": 2021, "car_brand_name": "Lamborghini" }
|
||||
]|] `shouldRespondWith` [json| [
|
||||
{ "name": "Huracán", "year": 2021, "car_brand_name": "Lamborghini" }
|
||||
]|]
|
||||
{ matchStatus = 201
|
||||
, matchHeaders = ["Preference-Applied" <:> "resolution=ignore-duplicates", matchContentTypeJson]
|
||||
}
|
||||
|
||||
it "INSERTs and ignores rows on single unique key conflict" $
|
||||
request methodPost "/single_unique?on_conflict=unique_key"
|
||||
[("Prefer", "return=representation"), ("Prefer", "resolution=ignore-duplicates")]
|
||||
[json| [
|
||||
{ "unique_key": 1, "value": "B" },
|
||||
{ "unique_key": 2, "value": "C" },
|
||||
{ "unique_key": 3, "value": "D" }
|
||||
]|]
|
||||
`shouldRespondWith`
|
||||
[json| [
|
||||
{ "unique_key": 2, "value": "C" },
|
||||
{ "unique_key": 3, "value": "D" }
|
||||
]|]
|
||||
{ matchStatus = 201
|
||||
, matchHeaders = ["Preference-Applied" <:> "resolution=ignore-duplicates"]
|
||||
}
|
||||
|
||||
it "INSERTs and UPDATEs rows on compound unique keys conflict" $
|
||||
request methodPost "/compound_unique?on_conflict=key1,key2"
|
||||
[("Prefer", "return=representation"), ("Prefer", "resolution=ignore-duplicates")]
|
||||
[json| [
|
||||
{ "key1": 1, "key2": 1, "value": "B" },
|
||||
{ "key1": 1, "key2": 2, "value": "C" },
|
||||
{ "key1": 1, "key2": 3, "value": "D" }
|
||||
]|]
|
||||
`shouldRespondWith`
|
||||
[json| [
|
||||
{ "key1": 1, "key2": 2, "value": "C" },
|
||||
{ "key1": 1, "key2": 3, "value": "D" }
|
||||
]|]
|
||||
{ matchStatus = 201
|
||||
, matchHeaders = ["Preference-Applied" <:> "resolution=ignore-duplicates"]
|
||||
}
|
||||
|
||||
it "succeeds if the table has only PK cols and no other cols" $ do
|
||||
request methodPost "/only_pk" [("Prefer", "return=representation"), ("Prefer", "resolution=ignore-duplicates")]
|
||||
[json|[ { "id": 1 }, { "id": 2 }, { "id": 3} ]|]
|
||||
`shouldRespondWith`
|
||||
[json|[ { "id": 3} ]|]
|
||||
{ matchStatus = 201 ,
|
||||
matchHeaders = ["Preference-Applied" <:> "resolution=ignore-duplicates",
|
||||
matchContentTypeJson] }
|
||||
|
||||
request methodPost "/only_pk" [("Prefer", "return=representation"), ("Prefer", "resolution=merge-duplicates")]
|
||||
[json|[ { "id": 1 }, { "id": 2 }, { "id": 4} ]|]
|
||||
`shouldRespondWith`
|
||||
[json|[ { "id": 1 }, { "id": 2 }, { "id": 4} ]|]
|
||||
{ matchStatus = 201 ,
|
||||
matchHeaders = ["Preference-Applied" <:> "resolution=merge-duplicates",
|
||||
matchContentTypeJson] }
|
||||
|
||||
it "succeeds and ignores the Prefer: resolution header(no Preference-Applied present) if the table has no PK" $
|
||||
request methodPost "/no_pk" [("Prefer", "return=representation"), ("Prefer", "resolution=merge-duplicates")]
|
||||
[json|[ { "a": "1", "b": "0" } ]|]
|
||||
`shouldRespondWith`
|
||||
[json|[ { "a": "1", "b": "0" } ]|] { matchStatus = 201 , matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "succeeds if not a single resource is created" $ do
|
||||
request methodPost "/tiobe_pls" [("Prefer", "return=representation"), ("Prefer", "resolution=ignore-duplicates")]
|
||||
[json|[ { "name": "Java", "rank": 1 } ]|] `shouldRespondWith`
|
||||
[json|[]|] { matchStatus = 201 , matchHeaders = [matchContentTypeJson] }
|
||||
request methodPost "/tiobe_pls" [("Prefer", "return=representation"), ("Prefer", "resolution=ignore-duplicates")]
|
||||
[json|[ { "name": "Java", "rank": 1 }, { "name": "C", "rank": 2 } ]|] `shouldRespondWith`
|
||||
[json|[]|] { matchStatus = 201 , matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
context "with PUT" $ do
|
||||
context "Restrictions" $ do
|
||||
it "fails if Range is specified" $
|
||||
request methodPut "/tiobe_pls?name=eq.Javascript" [("Range", "0-5")]
|
||||
[json| [ { "name": "Javascript", "rank": 1 } ]|]
|
||||
`shouldRespondWith`
|
||||
[json|{"message":"Range header and limit/offset querystring parameters are not allowed for PUT"}|]
|
||||
{ matchStatus = 400 , matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "fails if limit is specified" $
|
||||
put "/tiobe_pls?name=eq.Javascript&limit=1"
|
||||
[json| [ { "name": "Javascript", "rank": 1 } ]|]
|
||||
`shouldRespondWith`
|
||||
[json|{"message":"Range header and limit/offset querystring parameters are not allowed for PUT"}|]
|
||||
{ matchStatus = 400 , matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "fails if offset is specified" $
|
||||
put "/tiobe_pls?name=eq.Javascript&offset=1"
|
||||
[json| [ { "name": "Javascript", "rank": 1 } ]|]
|
||||
`shouldRespondWith`
|
||||
[json|{"message":"Range header and limit/offset querystring parameters are not allowed for PUT"}|]
|
||||
{ matchStatus = 400 , matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "rejects every other filter than pk cols eq's" $ do
|
||||
put "/tiobe_pls?rank=eq.19"
|
||||
[json| [ { "name": "Go", "rank": 19 } ]|]
|
||||
`shouldRespondWith`
|
||||
[json|{"message":"Filters must include all and only primary key columns with 'eq' operators"}|]
|
||||
{ matchStatus = 405 , matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
put "/tiobe_pls?id=not.eq.Java"
|
||||
[json| [ { "name": "Go", "rank": 19 } ]|]
|
||||
`shouldRespondWith`
|
||||
[json|{"message":"Filters must include all and only primary key columns with 'eq' operators"}|]
|
||||
{ matchStatus = 405 , matchHeaders = [matchContentTypeJson] }
|
||||
put "/tiobe_pls?id=in.(Go)"
|
||||
[json| [ { "name": "Go", "rank": 19 } ]|]
|
||||
`shouldRespondWith`
|
||||
[json|{"message":"Filters must include all and only primary key columns with 'eq' operators"}|]
|
||||
{ matchStatus = 405 , matchHeaders = [matchContentTypeJson] }
|
||||
put "/tiobe_pls?and=(id.eq.Go)"
|
||||
[json| [ { "name": "Go", "rank": 19 } ]|]
|
||||
`shouldRespondWith`
|
||||
[json|{"message":"Filters must include all and only primary key columns with 'eq' operators"}|]
|
||||
{ matchStatus = 405 , matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "fails if not all composite key cols are specified as eq filters" $ do
|
||||
put "/employees?first_name=eq.Susan"
|
||||
[json| [ { "first_name": "Susan", "last_name": "Heidt", "salary": "48000", "company": "GEX", "occupation": "Railroad engineer" } ]|]
|
||||
`shouldRespondWith`
|
||||
[json|{"message":"Filters must include all and only primary key columns with 'eq' operators"}|]
|
||||
{ matchStatus = 405 , matchHeaders = [matchContentTypeJson] }
|
||||
put "/employees?last_name=eq.Heidt"
|
||||
[json| [ { "first_name": "Susan", "last_name": "Heidt", "salary": "48000", "company": "GEX", "occupation": "Railroad engineer" } ]|]
|
||||
`shouldRespondWith`
|
||||
[json|{"message":"Filters must include all and only primary key columns with 'eq' operators"}|]
|
||||
{ matchStatus = 405 , matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "fails if the uri primary key doesn't match the payload primary key" $ do
|
||||
put "/tiobe_pls?name=eq.MATLAB" [json| [ { "name": "Perl", "rank": 17 } ]|]
|
||||
`shouldRespondWith`
|
||||
[json|{"message":"Payload values do not match URL in primary key column(s)"}|]
|
||||
{ matchStatus = 400 , matchHeaders = [matchContentTypeJson] }
|
||||
put "/employees?first_name=eq.Wendy&last_name=eq.Anderson"
|
||||
[json| [ { "first_name": "Susan", "last_name": "Heidt", "salary": "48000", "company": "GEX", "occupation": "Railroad engineer" } ]|]
|
||||
`shouldRespondWith`
|
||||
[json|{"message":"Payload values do not match URL in primary key column(s)"}|]
|
||||
{ matchStatus = 400 , matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "fails if the table has no PK" $
|
||||
put "/no_pk?a=eq.one&b=eq.two" [json| [ { "a": "one", "b": "two" } ]|]
|
||||
`shouldRespondWith`
|
||||
[json|{"message":"Filters must include all and only primary key columns with 'eq' operators"}|]
|
||||
{ matchStatus = 405 , matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
context "Inserting row" $ do
|
||||
it "succeeds on table with single pk col" $ do
|
||||
-- assert that the next request will indeed be an insert
|
||||
get "/tiobe_pls?name=eq.Go"
|
||||
`shouldRespondWith`
|
||||
[json|[]|]
|
||||
|
||||
request methodPut "/tiobe_pls?name=eq.Go"
|
||||
[("Prefer", "return=representation")]
|
||||
[json| [ { "name": "Go", "rank": 19 } ]|]
|
||||
`shouldRespondWith`
|
||||
[json| [ { "name": "Go", "rank": 19 } ]|]
|
||||
|
||||
it "succeeds on table with composite pk" $ do
|
||||
-- assert that the next request will indeed be an insert
|
||||
get "/employees?first_name=eq.Susan&last_name=eq.Heidt"
|
||||
`shouldRespondWith`
|
||||
[json|[]|]
|
||||
|
||||
request methodPut "/employees?first_name=eq.Susan&last_name=eq.Heidt"
|
||||
[("Prefer", "return=representation")]
|
||||
[json| [ { "first_name": "Susan", "last_name": "Heidt", "salary": "48000", "company": "GEX", "occupation": "Railroad engineer" } ]|]
|
||||
`shouldRespondWith`
|
||||
[json| [ { "first_name": "Susan", "last_name": "Heidt", "salary": "$48,000.00", "company": "GEX", "occupation": "Railroad engineer" } ]|]
|
||||
|
||||
when (actualPgVersion >= pgVersion110) $
|
||||
it "succeeds on a partitioned table with composite pk" $ do
|
||||
-- assert that the next request will indeed be an insert
|
||||
get "/car_models?name=eq.Supra&year=eq.2021"
|
||||
`shouldRespondWith`
|
||||
[json|[]|]
|
||||
|
||||
request methodPut "/car_models?name=eq.Supra&year=eq.2021"
|
||||
[("Prefer", "return=representation")]
|
||||
[json| [ { "name": "Supra", "year": 2021 } ]|]
|
||||
`shouldRespondWith`
|
||||
[json| [ { "name": "Supra", "year": 2021, "car_brand_name": null } ]|]
|
||||
|
||||
it "succeeds if the table has only PK cols and no other cols" $ do
|
||||
-- assert that the next request will indeed be an insert
|
||||
get "/only_pk?id=eq.10"
|
||||
`shouldRespondWith`
|
||||
[json|[]|]
|
||||
|
||||
request methodPut "/only_pk?id=eq.10"
|
||||
[("Prefer", "return=representation")]
|
||||
[json|[ { "id": 10 } ]|]
|
||||
`shouldRespondWith`
|
||||
[json|[ { "id": 10 } ]|]
|
||||
|
||||
context "Updating row" $ do
|
||||
it "succeeds on table with single pk col" $ do
|
||||
-- assert that the next request will indeed be an update
|
||||
get "/tiobe_pls?name=eq.Java"
|
||||
`shouldRespondWith`
|
||||
[json|[ { "name": "Java", "rank": 1 } ]|]
|
||||
|
||||
request methodPut "/tiobe_pls?name=eq.Java"
|
||||
[("Prefer", "return=representation")]
|
||||
[json| [ { "name": "Java", "rank": 13 } ]|]
|
||||
`shouldRespondWith`
|
||||
[json| [ { "name": "Java", "rank": 13 } ]|]
|
||||
|
||||
-- TODO: move this to SingularSpec?
|
||||
it "succeeds if the payload has more than one row, but it only puts the first element" $ do
|
||||
-- assert that the next request will indeed be an update
|
||||
get "/tiobe_pls?name=eq.Java"
|
||||
`shouldRespondWith`
|
||||
[json|[ { "name": "Java", "rank": 1 } ]|]
|
||||
|
||||
request methodPut "/tiobe_pls?name=eq.Java"
|
||||
[("Prefer", "return=representation"), ("Accept", "application/vnd.pgrst.object+json")]
|
||||
[json| [ { "name": "Java", "rank": 19 }, { "name": "Swift", "rank": 12 } ] |]
|
||||
`shouldRespondWith`
|
||||
[json|{ "name": "Java", "rank": 19 }|]
|
||||
{ matchHeaders = [matchContentTypeSingular] }
|
||||
|
||||
it "succeeds on table with composite pk" $ do
|
||||
-- assert that the next request will indeed be an update
|
||||
get "/employees?first_name=eq.Frances M.&last_name=eq.Roe"
|
||||
`shouldRespondWith`
|
||||
[json| [ { "first_name": "Frances M.", "last_name": "Roe", "salary": "$24,000.00", "company": "One-Up Realty", "occupation": "Author" } ]|]
|
||||
|
||||
request methodPut "/employees?first_name=eq.Frances M.&last_name=eq.Roe"
|
||||
[("Prefer", "return=representation")]
|
||||
[json| [ { "first_name": "Frances M.", "last_name": "Roe", "salary": "60000", "company": "Gamma Gas", "occupation": "Railroad engineer" } ]|]
|
||||
`shouldRespondWith`
|
||||
[json| [ { "first_name": "Frances M.", "last_name": "Roe", "salary": "$60,000.00", "company": "Gamma Gas", "occupation": "Railroad engineer" } ]|]
|
||||
|
||||
when (actualPgVersion >= pgVersion110) $
|
||||
it "succeeds on a partitioned table with composite pk" $ do
|
||||
-- assert that the next request will indeed be an update
|
||||
get "/car_models?name=eq.DeLorean&year=eq.1981"
|
||||
`shouldRespondWith`
|
||||
[json| [ { "name": "DeLorean", "year": 1981, "car_brand_name": "DMC" } ]|]
|
||||
|
||||
request methodPut "/car_models?name=eq.DeLorean&year=eq.1981"
|
||||
[("Prefer", "return=representation")]
|
||||
[json| [ { "name": "DeLorean", "year": 1981, "car_brand_name": null } ]|]
|
||||
`shouldRespondWith`
|
||||
[json| [ { "name": "DeLorean", "year": 1981, "car_brand_name": null } ]|]
|
||||
|
||||
it "succeeds if the table has only PK cols and no other cols" $ do
|
||||
-- assert that the next request will indeed be an update
|
||||
get "/only_pk?id=eq.1"
|
||||
`shouldRespondWith`
|
||||
[json|[ { "id": 1 } ]|]
|
||||
|
||||
request methodPut "/only_pk?id=eq.1"
|
||||
[("Prefer", "return=representation")]
|
||||
[json|[ { "id": 1 } ]|]
|
||||
`shouldRespondWith`
|
||||
[json|[ { "id": 1 } ]|]
|
||||
|
||||
-- TODO: move this to SingularSpec?
|
||||
it "works with return=representation and vnd.pgrst.object+json" $
|
||||
request methodPut "/tiobe_pls?name=eq.Ruby"
|
||||
[("Prefer", "return=representation"), ("Accept", "application/vnd.pgrst.object+json")]
|
||||
[json| [ { "name": "Ruby", "rank": 11 } ]|]
|
||||
`shouldRespondWith` [json|{ "name": "Ruby", "rank": 11 }|] { matchHeaders = [matchContentTypeSingular] }
|
||||
|
||||
context "with a camel case pk column" $ do
|
||||
it "works with POST and merge-duplicates" $ do
|
||||
request methodPost "/UnitTest"
|
||||
[("Prefer", "return=representation"), ("Prefer", "resolution=merge-duplicates")]
|
||||
[json|[
|
||||
{ "idUnitTest": 1, "nameUnitTest": "name of unittest 1" },
|
||||
{ "idUnitTest": 2, "nameUnitTest": "name of unittest 2" }
|
||||
]|]
|
||||
`shouldRespondWith`
|
||||
[json|[
|
||||
{ "idUnitTest": 1, "nameUnitTest": "name of unittest 1" },
|
||||
{ "idUnitTest": 2, "nameUnitTest": "name of unittest 2" }
|
||||
]|]
|
||||
{ matchStatus = 201
|
||||
, matchHeaders = ["Preference-Applied" <:> "resolution=merge-duplicates"]
|
||||
}
|
||||
|
||||
it "works with POST and ignore-duplicates headers" $ do
|
||||
request methodPost "/UnitTest"
|
||||
[("Prefer", "return=representation"), ("Prefer", "resolution=ignore-duplicates")]
|
||||
[json|[
|
||||
{ "idUnitTest": 1, "nameUnitTest": "name of unittest 1" },
|
||||
{ "idUnitTest": 2, "nameUnitTest": "name of unittest 2" }
|
||||
]|]
|
||||
`shouldRespondWith`
|
||||
[json|[
|
||||
{ "idUnitTest": 2, "nameUnitTest": "name of unittest 2" }
|
||||
]|]
|
||||
{ matchStatus = 201
|
||||
, matchHeaders = ["Preference-Applied" <:> "resolution=ignore-duplicates"]
|
||||
}
|
||||
|
||||
it "works with PUT" $ do
|
||||
put "/UnitTest?idUnitTest=eq.1"
|
||||
[json| [ { "idUnitTest": 1, "nameUnitTest": "unit test 1" } ]|]
|
||||
`shouldRespondWith`
|
||||
""
|
||||
{ matchStatus = 204
|
||||
, matchHeaders = [matchHeaderAbsent hContentType]
|
||||
}
|
||||
get "/UnitTest?idUnitTest=eq.1" `shouldRespondWith`
|
||||
[json| [ { "idUnitTest": 1, "nameUnitTest": "unit test 1" } ]|]
|
||||
Reference in New Issue
Block a user