
PlaceholderCrudTest.pudu
Pudu154 lines11.1 KB
1/** @Test.Integration.PlaceholderCrud.Suite — every CRUD verb on every placeholder resource */2module Integration.PlaceholderCrudTest34import Std.Concurrent as Concurrent5import Std.Http as Http6import Std.Io as Io7import Std.Json as Json8import Std.Result as Result9import Std.Test as Test10import PuduLangHttpClient.Client as Client11import PuduLangHttpClient.Content as Content12import PuduLangHttpClient.Factory.Builder as Builder13import PuduLangHttpClient.Factory as Factory14import PuduLangHttpClient as HttpClient15import PuduLangHttpClient.Request as Request16import PuduLangHttpClient.Response as Response17import PuduLangHttpClient.Utils.Shared as Shared18import Support.Placeholder.Api as Api19import Support.Placeholder.Server as Placeholder20import Support.Placeholder.Store as Store2122/** @Test.Integration.PlaceholderCrud.Lifecycle — what each step of one record's life answered */23type Lifecycle[T] = {24 created: HttpClient.Outcome[T],25 read: HttpClient.Outcome[T],26 replaced: HttpClient.Outcome[T],27 patched: HttpClient.Outcome[T],28 listed: Bool,29 removed: HttpClient.Outcome[Int],30 gone: HttpClient.Outcome[T],31 removedAgain: HttpClient.Outcome[Int],32 replacedGone: HttpClient.Outcome[T],33 patchedGone: HttpClient.Outcome[T]34}35363738fn lifecycle[T: Json.Encode + Json.Decode](api: &Api.Api, resource: Str, fresh: &T, replacement: &T, fields: &Json.Json, idOf: fn(T) -> Int) -> Lifecycle[T] {39 let created: HttpClient.Outcome[T] = Api.create(api, resource, fresh)40 let id = match created {41 case Ok(record) => idOf(record)42 case Err(_) => 043 }44 let read: HttpClient.Outcome[T] = Api.find(api, resource, id)45 let replaced: HttpClient.Outcome[T] = Api.replace(api, resource, id, replacement)46 let patched: HttpClient.Outcome[T] = Api.patch(api, resource, id, fields)47 let listing: HttpClient.Outcome[Array[Json.Json]] = Api.list(api, resource, &[("id", show(id))])48 let removed = Api.remove(api, resource, id)49 Lifecycle {50 created: created,51 read: read,52 replaced: replaced,53 patched: patched,54 listed: Result.map(listing, |items: Array[Json.Json]| items.length()) == Ok(1),55 removed: removed,56 gone: Api.find(api, resource, id),57 removedAgain: Api.remove(api, resource, id),58 replacedGone: Api.replace(api, resource, id, replacement),59 patchedGone: Api.patch(api, resource, id, fields)60 }61}626364fn removalChecks[T](resource: Str, life: &Lifecycle[T]) -> Array[Test.Check] {65 let notFound = HttpClient.Unsuccessful(404, "Not Found")66 [67 Test.that(resource + ": the new record is listed by its id", life.listed),68 Test.equals(resource + ": removing answers 200", &life.removed, &Ok(200)),69 Test.that(resource + ": a removed record is not found, removed, replaced, or patched again", life.gone == Err(notFound) && life.removedAgain == Err(notFound) && life.replacedGone == Err(notFound) && life.patchedGone == Err(notFound))70 ]71}727374fn statusOf(outcome: HttpClient.Outcome[Response.Response]) -> Result[Int, HttpClient.Failure] {75 Result.map(outcome, |response: Response.Response| response.status.code)76}777879fn main() -> Int {80 let service = Placeholder.start()81 let factory = match Factory.build([82 Builder.named("placeholder").withBaseAddress(service.base + "/").withDefaultHeader("user-agent", "placeholder-crud/1.0").withTimeout(10000)83 ]) {84 case Ok(built) => built85 case Err(invalid) => panic(Factory.explain(&invalid))86 }87 let api = Factory.typed(&factory, "placeholder", |client: Client.Client| Api.Api{client: client})8889 let post = lifecycle(&api, "posts", &Api.Post{userId: 1, id: 999, title: "draft", body: "first words"}, &Api.Post{userId: 2, id: 0, title: "final", body: "rewritten"}, &Json.object(&[("title", Json.Text("edited")), ("id", Json.Number(7))]), |p: Api.Post| p.id)90 let comment = lifecycle(&api, "comments", &Api.Comment{postId: 1, id: 0, name: "hello", email: "a@b.test", body: "nice"}, &Api.Comment{postId: 2, id: 0, name: "bye", email: "c@d.test", body: "fine"}, &Json.object(&[("email", Json.Text("e@f.test"))]), |c: Api.Comment| c.id)91 let album = lifecycle(&api, "albums", &Api.Album{userId: 1, id: 0, title: "summer"}, &Api.Album{userId: 3, id: 0, title: "winter"}, &Json.object(&[("title", Json.Text("autumn"))]), |a: Api.Album| a.id)92 let photo = lifecycle(&api, "photos", &Api.Photo{albumId: 1, id: 0, title: "sea", url: "https://img.test/1", thumbnailUrl: "https://img.test/1/t"}, &Api.Photo{albumId: 2, id: 0, title: "sky", url: "https://img.test/2", thumbnailUrl: "https://img.test/2/t"}, &Json.object(&[("title", Json.Text("cloud"))]), |p: Api.Photo| p.id)93 let todo = lifecycle(&api, "todos", &Api.Todo{userId: 1, id: 0, title: "write tests", completed: false}, &Api.Todo{userId: 1, id: 0, title: "ship", completed: false}, &Json.object(&[("completed", Json.Boolean(true))]), |t: Api.Todo| t.id)94 let user = lifecycle(&api, "users", &Api.User{id: 0, name: "Ada", username: "ada", email: "ada@lovelace.test", address: Api.Address{street: "St", suite: "1", city: "London", zipcode: "N1", geo: Api.Geo{lat: "51.5", lng: "-0.1"}}, phone: "0", website: "ada.test", company: Api.Company{name: "Engines", catchPhrase: "Analytical", bs: "compute"}}, &Api.User{id: 0, name: "Grace", username: "grace", email: "grace@hopper.test", address: Api.Address{street: "Rd", suite: "2", city: "New York", zipcode: "10001", geo: Api.Geo{lat: "40.7", lng: "-74.0"}}, phone: "1", website: "grace.test", company: Api.Company{name: "Navy", catchPhrase: "Compilers", bs: "debug"}}, &Json.object(&[("address", Json.object(&[("street", Json.Text("Ave")), ("suite", Json.Text("3")), ("city", Json.Text("Arlington")), ("zipcode", Json.Text("22201")), ("geo", Json.object(&[("lat", Json.Text("38.9")), ("lng", Json.Text("-77.1"))]))]))]), |u: Api.User| u.id)9596 let client = api.client97 let created = Client.post(&client, "posts", Content.jsonOf(&Api.Post{userId: 4, id: 0, title: "located", body: "b"}))98 let location = Result.map(created, |r: Response.Response| Response.header(&r, "location"))99 let followed = match location {100 case Ok(Some(address)) => statusOf(Client.get(&client, address))101 case _ => Ok(0)102 }103 let malformed = statusOf(Client.post(&client, "posts", Content.text("\{\"title\": ", "application/json")))104 let notAnObject = statusOf(Client.post(&client, "posts", Content.text("[1, 2]", "application/json")))105 let badReplace = statusOf(Client.put(&client, "posts/1", Content.text("nope", "application/json")))106 let reason = Result.andThen(Client.post(&client, "posts", Content.text("[]", "application/json")), |r: Response.Response| Response.json(&r))107 let rawDelete = statusOf(Client.send(&client, Request.create(Http.Delete, "posts/2")))108109 let before = Store.count(&service.store, "todos")110 let ids = Shared.shared([])111 let workers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25].map(fn(n: Int) -> Concurrent.Task {112 match Concurrent.start(fn() -> () {113 let made: HttpClient.Outcome[Api.Todo] = Api.create(&Api.Api{client: Factory.createClient(&factory, "placeholder")}, "todos", &Api.Todo{userId: 9, id: 0, title: "parallel " + show(n), completed: false})114 if let Ok(todo) = made { Shared.update(&ids, |held: Array[Int]| held.push(todo.id)) }115 }) {116 case Ok(started) => started117 case Err(problem) => panic(show(problem))118 }119 })120 let _joined = Concurrent.joinAll(&workers)121 let parallelIds = Shared.current(&ids).sortBy(|left: Int, right: Int| left < right)122 let after = Store.count(&service.store, "todos")123 let stats = Factory.statistics(&factory, "placeholder")124 Factory.dispose(&factory)125 Placeholder.stop(&service)126127 let checks = Test.suite("Integration.PlaceholderCrud", &[128 Test.equals("posts: a created post gets the next id whatever id it sent", &Result.map(post.created, |p: Api.Post| (p.id, p.title)), &Ok((101, "draft"))),129 Test.equals("posts: a created post reads back as stored", &post.read, &post.created),130 Test.equals("posts: replacing changes every field and keeps the id", &post.replaced, &Ok(Api.Post{userId: 2, id: 101, title: "final", body: "rewritten"})),131 Test.equals("posts: patching changes only the named fields and never the id", &post.patched, &Ok(Api.Post{userId: 2, id: 101, title: "edited", body: "rewritten"})),132 Test.equals("comments: create, replace, and patch", &(Result.map(comment.created, |c: Api.Comment| c.id), Result.map(comment.replaced, |c: Api.Comment| c.name), Result.map(comment.patched, |c: Api.Comment| (c.name, c.email))), &(Ok(501), Ok("bye"), Ok(("bye", "e@f.test")))),133 Test.equals("albums: create, replace, and patch", &(Result.map(album.created, |a: Api.Album| a.id), Result.map(album.replaced, |a: Api.Album| a.userId), Result.map(album.patched, |a: Api.Album| a.title)), &(Ok(101), Ok(3), Ok("autumn"))),134 Test.equals("photos: create, replace, and patch", &(Result.map(photo.created, |p: Api.Photo| p.id), Result.map(photo.replaced, |p: Api.Photo| p.url), Result.map(photo.patched, |p: Api.Photo| (p.title, p.albumId))), &(Ok(5001), Ok("https://img.test/2"), Ok(("cloud", 2)))),135 Test.equals("todos: create, replace, and patch", &(Result.map(todo.created, |t: Api.Todo| t.id), Result.map(todo.replaced, |t: Api.Todo| t.title), Result.map(todo.patched, |t: Api.Todo| (t.title, t.completed))), &(Ok(201), Ok("ship"), Ok(("ship", true)))),136 Test.equals("users: create, replace, and patch a nested address", &(Result.map(user.created, |u: Api.User| (u.id, u.address.city)), Result.map(user.replaced, |u: Api.User| u.company.name), Result.map(user.patched, |u: Api.User| (u.name, u.address.city, u.address.geo.lat))), &(Ok((11, "London")), Ok("Navy"), Ok(("Grace", "Arlington", "38.9")))),137 Test.equals("a new record's location answers it", &followed, &Ok(200)),138 Test.equals("a body that is not JSON, not an object, or not a valid replacement is a bad request", &(malformed, notAnObject, badReplace), &(Ok(400), Ok(400), Ok(400))),139 Test.equals("a bad request says why", &Result.map(reason, |value: Json.Json| Json.field(&value, "error")), &Ok(Some(Json.Text("the body must be a JSON object")))),140 Test.equals("a raw delete through the client answers 200", &rawDelete, &Ok(200)),141 Test.equals("twenty-five parallel creates get distinct consecutive ids after the highest remaining one", ¶llelIds, &[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25].map(|n: Int| 200 + n)),142 Test.equals("every parallel create was stored", &(after - before), &25),143 Test.that("parallel requests shared a bounded number of pooled connections", match stats {144 case Some(found) => found.opened <= 25 && found.active == 0145 case None => false146 })147 ].concat(removalChecks("posts", &post)).concat(removalChecks("comments", &comment)).concat(removalChecks("albums", &album)).concat(removalChecks("photos", &photo)).concat(removalChecks("todos", &todo)).concat(removalChecks("users", &user)))148 let ran = Test.run(&checks)149 for failure in Test.failuresOf(&ran) {150 let _reported = Io.writeErrorLine(failure)151 }152 Test.report(&ran)153}154