test: Reorganize test/ folder into one subdirectory for each test type

This commit is contained in:
Wolfgang Walther
2022-01-07 13:10:36 +01:00
committed by Wolfgang Walther
parent 8060fe3559
commit 8b63d928ae
110 changed files with 19 additions and 19 deletions
+276
View File
@@ -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] }
+21
View File
@@ -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
+179
View File
@@ -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
+21
View File
@@ -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
+52
View File
@@ -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
+56
View File
@@ -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" <:> "*" ] }
+117
View File
@@ -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]
}
+20
View File
@@ -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] }
+356
View File
@@ -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" ]
}
+39
View File
@@ -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
+30
View File
@@ -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
+609
View File
@@ -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" <:> "*/*" ]
}
+253
View File
@@ -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] }
+68
View File
@@ -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 = []
}
+331
View File
@@ -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
}
+31
View File
@@ -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
+594
View File
@@ -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"]|]
+87
View File
@@ -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"
+13
View File
@@ -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")]
+79
View File
@@ -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
+362
View File
@@ -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"]
}
+33
View File
@@ -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"] }
+263
View File
@@ -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
+34
View File
@@ -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
+327
View File
@@ -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"}]|]
+38
View File
@@ -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]
}
+388
View File
@@ -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]
}
+433
View File
@@ -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" } ]|]