Pudu programming language
Menu
Package

@chrismichaelps / pudu-lang-httpclient

Named HTTP clients for Pudu: a client factory, delegating handlers, pooled keep-alive connections, handler lifetimes, logging, and resilience

0.1.1Apache-2.01

InstallClose

PlaceholderCrudTest.pudu

Pudu154 lines11.1 KB

GitHub ↗
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}3536/// One record created, read, replaced, patched, listed, removed, and then sought, replaced, patched,37/// and removed again once it is gone.38fn 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}6263/// The checks every lifecycle must pass: each step after the removal is not found.64fn 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}7273/// The status a request answers.74fn statusOf(outcome: HttpClient.Outcome[Response.Response]) -> Result[Int, HttpClient.Failure] {75  Result.map(outcome, |response: Response.Response| response.status.code)76}7778/// Runs the suite.79fn 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", &parallelIds, &[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