diff --git a/app/placeholder/Dockerfile b/app/placeholder/Dockerfile index cfce24d4..5b0a5972 100644 --- a/app/placeholder/Dockerfile +++ b/app/placeholder/Dockerfile @@ -18,4 +18,5 @@ ARG PROJECT_ID=YOURPROJECTID FROM gcr.io/$PROJECT_ID/firebase COPY . ./ + ENTRYPOINT ./placeholder-deploy.sh diff --git a/app/placeholder/firebaserc.tmpl b/app/placeholder/firebaserc.tmpl new file mode 100644 index 00000000..c7491bcc --- /dev/null +++ b/app/placeholder/firebaserc.tmpl @@ -0,0 +1,13 @@ +{ + "projects": {}, + "targets": { + "$PROJECT_ID": { + "hosting": { + "$SUFFIX": [ + "${PROJECT_ID}-${SUFFIX}" + ] + } + } + }, + "etags": {} +} diff --git a/app/placeholder/placeholder-deploy.sh b/app/placeholder/placeholder-deploy.sh index 81fc7172..c3ce9941 100755 --- a/app/placeholder/placeholder-deploy.sh +++ b/app/placeholder/placeholder-deploy.sh @@ -17,6 +17,24 @@ # any errors? exit immediately. set -e +# if deploying with a suffix (from infra/jobs.tf), adjust the config to suit the custom site +# https://firebase.google.com/docs/hosting/multisites#set_up_deploy_targets +if [[ -n $SUFFIX ]]; then + json -I -f firebase.json -e "this.hosting.target='$SUFFIX'" + UPDATED=true + + # Use template file to generate configuration + envsubst < firebaserc.tmpl > .firebaserc + echo "Customised .firebaserc created to support site." + cat .firebaserc +fi + +# If anything was updated, then export the output. +if [[ -n $UPDATED ]]; then + echo "Deploying with the following updated config: " + cat firebase.json +fi + echo "Deploying placeholder to Firebase..." firebase deploy --project "$PROJECT_ID" --only hosting diff --git a/build/int.cloudbuild.yaml b/build/int.cloudbuild.yaml index 1c61fd84..c7f2ee3b 100644 --- a/build/int.cloudbuild.yaml +++ b/build/int.cloudbuild.yaml @@ -22,22 +22,55 @@ steps: - 'TF_VAR_org_id=$_ORG_ID' - 'TF_VAR_folder_id=$_FOLDER_ID' - 'TF_VAR_billing_account=$_BILLING_ACCOUNT' + +# Initialize all tests, then run two parallel sets of tests. +- id: init-all + name: 'gcr.io/cloud-foundation-cicd/$_DOCKER_IMAGE_DEVELOPER_TOOLS:$_DOCKER_TAG_VERSION_DEVELOPER_TOOLS' + args: ['/bin/bash', '-c', 'cft test run all --stage init --verbose'] + +# Simple example - one deployment per project - id: simple-example-init + waitFor: ['init-all'] dir: infra name: 'gcr.io/cloud-foundation-cicd/$_DOCKER_IMAGE_DEVELOPER_TOOLS:$_DOCKER_TAG_VERSION_DEVELOPER_TOOLS' args: ['/bin/bash', '-c', 'cft test run TestSimpleExample --stage init --verbose'] - id: simple-example-apply + waitFor: ['simple-example-init'] dir: infra name: 'gcr.io/cloud-foundation-cicd/$_DOCKER_IMAGE_DEVELOPER_TOOLS:$_DOCKER_TAG_VERSION_DEVELOPER_TOOLS' args: ['/bin/bash', '-c', 'cft test run TestSimpleExample --stage apply --verbose'] - id: simple-example-verify + waitFor: ['simple-example-apply'] dir: infra name: 'gcr.io/cloud-foundation-cicd/$_DOCKER_IMAGE_DEVELOPER_TOOLS:$_DOCKER_TAG_VERSION_DEVELOPER_TOOLS' args: ['/bin/bash', '-c', 'cft test run TestSimpleExample --stage verify --verbose'] - id: simple-example-teardown + waitFor: ['simple-example-verify'] dir: infra name: 'gcr.io/cloud-foundation-cicd/$_DOCKER_IMAGE_DEVELOPER_TOOLS:$_DOCKER_TAG_VERSION_DEVELOPER_TOOLS' args: ['/bin/bash', '-c', 'cft test run TestSimpleExample --stage teardown --verbose'] + +# Suffix example - supports multiple deployments per project +- id: suffix-example-init + waitFor: ['init-all'] + dir: infra + name: 'gcr.io/cloud-foundation-cicd/$_DOCKER_IMAGE_DEVELOPER_TOOLS:$_DOCKER_TAG_VERSION_DEVELOPER_TOOLS' + args: ['/bin/bash', '-c', 'cft test run TestSuffixExample --stage init --verbose'] +- id: suffix-example-apply + waitFor: ['suffix-example-init'] + dir: infra + name: 'gcr.io/cloud-foundation-cicd/$_DOCKER_IMAGE_DEVELOPER_TOOLS:$_DOCKER_TAG_VERSION_DEVELOPER_TOOLS' + args: ['/bin/bash', '-c', 'cft test run TestSuffixExample --stage apply --verbose'] +- id: suffix-example-verify + waitFor: ['suffix-example-apply'] + dir: infra + name: 'gcr.io/cloud-foundation-cicd/$_DOCKER_IMAGE_DEVELOPER_TOOLS:$_DOCKER_TAG_VERSION_DEVELOPER_TOOLS' + args: ['/bin/bash', '-c', 'cft test run TestSuffixExample --stage verify --verbose'] +- id: suffix-example-teardown + waitFor: ['suffix-example-verify'] + dir: infra + name: 'gcr.io/cloud-foundation-cicd/$_DOCKER_IMAGE_DEVELOPER_TOOLS:$_DOCKER_TAG_VERSION_DEVELOPER_TOOLS' + args: ['/bin/bash', '-c', 'cft test run TestSuffixExample --stage teardown --verbose'] tags: - 'ci' - 'integration' diff --git a/infra/README.md b/infra/README.md index 8169a346..d602c48b 100644 --- a/infra/README.md +++ b/infra/README.md @@ -62,10 +62,12 @@ Functional examples are included in the | Name | Description | |------|-------------| +| client\_job\_name | Name of the Cloud Run Job, deploying the front end | | django\_admin\_password | Djando Admin password | | django\_admin\_url | Djando Admin URL | | firebase\_url | Firebase URL | | neos\_toc\_url | Neos Tutorial URL | +| server\_service\_name | Name of the Cloud Run service, hosting the server API | | usage | Next steps for usage | diff --git a/infra/apis.tf b/infra/apis.tf index 997ed12e..a2acf6ad 100644 --- a/infra/apis.tf +++ b/infra/apis.tf @@ -28,6 +28,7 @@ locals { "secretmanager.googleapis.com", "cloudresourcemanager.googleapis.com", "firebase.googleapis.com", + "firebasehosting.googleapis.com", ] } diff --git a/infra/examples/simple_example/README.md b/infra/examples/simple_example/README.md index ef26cb31..c2a6140c 100644 --- a/infra/examples/simple_example/README.md +++ b/infra/examples/simple_example/README.md @@ -13,7 +13,9 @@ This example illustrates how to use the `dynamic-python-webapp` module. | Name | Description | |------|-------------| +| client\_job\_name | Client Cloud Run job name | | firebase\_url | Firebase URL | +| server\_service\_name | Server Cloud Run service name | | usage | Connection details for the project | diff --git a/infra/examples/simple_example/main.tf b/infra/examples/simple_example/main.tf index f4d2207a..a7c11857 100644 --- a/infra/examples/simple_example/main.tf +++ b/infra/examples/simple_example/main.tf @@ -15,7 +15,7 @@ */ module "dynamic-python-webapp" { - source = "../.." - - project_id = var.project_id + source = "../.." + project_id = var.project_id + random_suffix = false } diff --git a/infra/examples/simple_example/outputs.tf b/infra/examples/simple_example/outputs.tf index 6f6f3abe..1fe64cdf 100644 --- a/infra/examples/simple_example/outputs.tf +++ b/infra/examples/simple_example/outputs.tf @@ -24,3 +24,13 @@ output "firebase_url" { description = "Firebase URL" value = module.dynamic-python-webapp.firebase_url } + +output "server_service_name" { + description = "Server Cloud Run service name" + value = module.dynamic-python-webapp.server_service_name +} + +output "client_job_name" { + description = "Client Cloud Run job name" + value = module.dynamic-python-webapp.client_job_name +} diff --git a/infra/examples/suffix_example/README.md b/infra/examples/suffix_example/README.md new file mode 100644 index 00000000..52395783 --- /dev/null +++ b/infra/examples/suffix_example/README.md @@ -0,0 +1,29 @@ +# Simple Example + +This example illustrates how to use the `dynamic-python-webapp` module. + +This example uses a randomly generated suffix when naming resources to support multiple deployments per project. + + +## Inputs + +| Name | Description | Type | Default | Required | +|------|-------------|------|---------|:--------:| +| project\_id | The ID of the project in which to provision resources. | `string` | n/a | yes | + +## Outputs + +| Name | Description | +|------|-------------| +| client\_job\_name | Client Cloud Run job name | +| firebase\_url | Firebase URL | +| server\_service\_name | Server Cloud Run service name | +| usage | Connection details for the project | + + + +To provision this example, run the following from within this directory: +- `terraform init` to get the plugins +- `terraform plan` to see the infrastructure plan +- `terraform apply` to apply the infrastructure build +- `terraform destroy` to destroy the built infrastructure diff --git a/infra/examples/suffix_example/main.tf b/infra/examples/suffix_example/main.tf new file mode 100644 index 00000000..f4d2207a --- /dev/null +++ b/infra/examples/suffix_example/main.tf @@ -0,0 +1,21 @@ +/** + * Copyright 2023 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +module "dynamic-python-webapp" { + source = "../.." + + project_id = var.project_id +} diff --git a/infra/examples/suffix_example/outputs.tf b/infra/examples/suffix_example/outputs.tf new file mode 100644 index 00000000..d9b7bd2f --- /dev/null +++ b/infra/examples/suffix_example/outputs.tf @@ -0,0 +1,37 @@ +/** + * Copyright 2023 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +output "usage" { + sensitive = true + description = "Connection details for the project" + value = module.dynamic-python-webapp.usage +} + +output "firebase_url" { + description = "Firebase URL" + value = module.dynamic-python-webapp.firebase_url +} + +output "server_service_name" { + description = "Server Cloud Run service name" + value = module.dynamic-python-webapp.server_service_name +} + +output "client_job_name" { + description = "Client Cloud Run job name" + value = module.dynamic-python-webapp.client_job_name +} + diff --git a/infra/examples/suffix_example/variables.tf b/infra/examples/suffix_example/variables.tf new file mode 100644 index 00000000..10a4e2da --- /dev/null +++ b/infra/examples/suffix_example/variables.tf @@ -0,0 +1,20 @@ +/** + * Copyright 2023 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +variable "project_id" { + description = "The ID of the project in which to provision resources." + type = string +} diff --git a/infra/examples/suffix_example/versions.tf b/infra/examples/suffix_example/versions.tf new file mode 100644 index 00000000..96fc6080 --- /dev/null +++ b/infra/examples/suffix_example/versions.tf @@ -0,0 +1,25 @@ +/** + * Copyright 2023 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +terraform { + required_providers { + google = { + source = "hashicorp/google" + version = "~> 4.0" + } + } + required_version = ">= 0.13" +} diff --git a/infra/firebase.tf b/infra/firebase.tf index 38f7ae6b..c31261eb 100644 --- a/infra/firebase.tf +++ b/infra/firebase.tf @@ -20,3 +20,15 @@ resource "google_firebase_project" "default" { depends_on = [google_project_service.enabled] } + +resource "google_firebase_hosting_site" "client" { + + # By default, a firebase site will be named "project_id". Only create a custom site if using suffixes + count = var.random_suffix ? 1 : 0 + + provider = google-beta + project = google_firebase_project.default.project + site_id = "${var.project_id}-${random_id.suffix.hex}" + + depends_on = [google_project_service.enabled] +} diff --git a/infra/jobs.tf b/infra/jobs.tf index 598513ef..9c97c3f9 100644 --- a/infra/jobs.tf +++ b/infra/jobs.tf @@ -115,6 +115,13 @@ resource "google_cloud_run_v2_job" "client" { service_account = google_service_account.client.email containers { image = local.client_image + + # Variables used to customise Firebase configuration on deployment + # https://github.com/GoogleCloudPlatform/avocano/blob/main/client/docker-deploy.sh + env { + name = "SUFFIX" + value = var.random_suffix ? random_id.suffix.hex : "" + } env { name = "SERVICE_NAME" value = google_cloud_run_v2_service.server.name @@ -154,6 +161,10 @@ resource "google_cloud_run_v2_job" "placeholder" { value = var.project_id } + env { + name = "SUFFIX" + value = var.random_suffix ? random_id.suffix.hex : "" + } } } } diff --git a/infra/main.tf b/infra/main.tf index c51d7ad6..df067440 100644 --- a/infra/main.tf +++ b/infra/main.tf @@ -20,3 +20,8 @@ provider "google" { project = var.project_id region = var.region } + +provider "google-beta" { + project = var.project_id + region = var.region +} diff --git a/infra/metadata.yaml b/infra/metadata.yaml index 4b79b18b..85aa3300 100644 --- a/infra/metadata.yaml +++ b/infra/metadata.yaml @@ -45,6 +45,8 @@ spec: examples: - name: simple_example location: examples/simple_example + - name: suffix_example + location: examples/suffix_example interfaces: variables: - name: client_image_host @@ -100,6 +102,8 @@ spec: varType: string defaultValue: us-central1-c outputs: + - name: client_job_name + description: Name of the Cloud Run Job, deploying the front end - name: django_admin_password description: Djando Admin password - name: django_admin_url @@ -108,6 +112,8 @@ spec: description: Firebase URL - name: neos_toc_url description: Neos Tutorial URL + - name: server_service_name + description: Name of the Cloud Run service, hosting the server API - name: usage description: Next steps for usage requirements: diff --git a/infra/outputs.tf b/infra/outputs.tf index 3cbc5e34..563c5f3b 100644 --- a/infra/outputs.tf +++ b/infra/outputs.tf @@ -16,7 +16,7 @@ locals { server_url = google_cloud_run_v2_service.server.uri - firebase_url = "https://${var.project_id}.web.app" + firebase_url = var.random_suffix ? google_firebase_hosting_site.client[0].default_url : "https://${var.project_id}.web.app" } output "firebase_url" { @@ -63,3 +63,13 @@ output "usage" { Password: ${google_secret_manager_secret_version.django_admin_password.secret_data} EOF } + +output "server_service_name" { + description = "Name of the Cloud Run service, hosting the server API" + value = google_cloud_run_v2_service.server.name +} + +output "client_job_name" { + description = "Name of the Cloud Run Job, deploying the front end" + value = google_cloud_run_v2_job.client.name +} diff --git a/infra/postdeployment.tf b/infra/postdeployment.tf index 2007eb0a..6eac7e04 100644 --- a/infra/postdeployment.tf +++ b/infra/postdeployment.tf @@ -72,7 +72,7 @@ resource "google_compute_instance" "gce_init" { service_account { email = google_service_account.compute[0].email - scopes = ["cloud-platform"] # TODO: Restrict? + scopes = ["cloud-platform"] # TODO: Restrict?? } metadata_startup_script = <Avocano") - } - }) - example.Test() -} - -func assertResponseContains(assert *assert.Assertions, url string, text ...string) { - code, responseBody, err := httpGetRequest(url) - assert.Nil(err) - assert.GreaterOrEqual(code, 200) - assert.LessOrEqual(code, 299) - for _, fragment := range text { - assert.Containsf(responseBody, fragment, "couldn't find %q in response body", fragment) - } -} - -func assertErrorResponseContains(assert *assert.Assertions, url string, wantCode int, text string) { - code, responseBody, err := httpGetRequest(url) - assert.Nil(err) - assert.Equal(code, wantCode) - assert.Containsf(responseBody, text, "couldn't find %q in response body", text) -} - -func httpGetRequest(url string) (statusCode int, body string, err error) { - res, err := http.Get(url) - if err != nil { - return 0, "", err - } - defer res.Body.Close() - - buffer, err := io.ReadAll(res.Body) - return res.StatusCode, string(buffer), err + test.AssertExample(t) } diff --git a/infra/test/integration/suffix_example/suffix_example_test.go b/infra/test/integration/suffix_example/suffix_example_test.go new file mode 100644 index 00000000..0cb22e43 --- /dev/null +++ b/infra/test/integration/suffix_example/suffix_example_test.go @@ -0,0 +1,25 @@ +// Copyright 2023 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package simple_example + +import ( + "testing" + + test "github.com/terraform-google-modules/dynamic-python-webapp/test/integration" +) + +func TestSuffixExample(t *testing.T) { + test.AssertExample(t) +} diff --git a/infra/test/integration/testhelper.go b/infra/test/integration/testhelper.go new file mode 100644 index 00000000..10180b56 --- /dev/null +++ b/infra/test/integration/testhelper.go @@ -0,0 +1,153 @@ +package test + +// Copyright 2023 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import ( + "io" + "net/http" + "strings" + "testing" + "time" + + "github.com/GoogleCloudPlatform/cloud-foundation-toolkit/infra/blueprint-test/pkg/gcloud" + "github.com/GoogleCloudPlatform/cloud-foundation-toolkit/infra/blueprint-test/pkg/tft" + "github.com/GoogleCloudPlatform/cloud-foundation-toolkit/infra/blueprint-test/pkg/utils" + "github.com/gruntwork-io/terratest/modules/terraform" + "github.com/stretchr/testify/assert" +) + +func AssertExample(t *testing.T) { + example := tft.NewTFBlueprintTest(t) + + example.DefineApply(func(assert *assert.Assertions) { + example.DefaultApply(assert) + + // This module deploys a 'placeholder' Firebase Hosting release early + // in the process, to prevent a "Site Not Found" displaying when Terraform + // has finished applying, but the deployment is not yet complete. + // + // This extension of apply is meant to emulate that behavior. We confirm + // the placeholder behavior here to boost confidence that the frontend test in + // example.DefineVerify proves the placeholder page is replaced. + // + // If the check is flaky, remove it in favor of + // a simpler HTTP request. + // + // https://github.com/GoogleCloudPlatform/terraform-dynamic-python-webapp/issues/64 + firebase_url := terraform.OutputRequired(t, example.GetTFOptions(), "firebase_url") + t.Log("Firebase Hosting should be running at ", firebase_url) + assertResponseContains(assert, firebase_url, "Your application is still deploying") + }) + + example.DefineVerify(func(assert *assert.Assertions) { + example.DefaultVerify(assert) + + projectID := example.GetTFSetupStringOutput("project_id") + firebase_url := terraform.OutputRequired(t, example.GetTFOptions(), "firebase_url") + server_service_name := terraform.OutputRequired(t, example.GetTFOptions(), "server_service_name") + client_job_name := terraform.OutputRequired(t, example.GetTFOptions(), "client_job_name") + + flagshipProduct := "Sparkly Avocado" + region := "us-central1" + t.Logf("Using Project ID %q", projectID) + + { + // Check that the Cloud Storage API is enabled + services := gcloud.Run(t, "services list", gcloud.WithCommonArgs([]string{"--project", projectID, "--format", "json"})).Array() + match := utils.GetFirstMatchResult(t, services, "config.name", "storage.googleapis.com") + assert.Equal("ENABLED", match.Get("state").String(), "storage service should be enabled") + } + + { + // Check that the expected Cloud Run service is deployed, is serving, and accepts unauthenticated requests + cloudRunServices := gcloud.Run(t, "run services list", gcloud.WithCommonArgs([]string{"--filter", "metadata.name=" + server_service_name, "--project", projectID, "--format", "json"})).Array() + nbServices := len(cloudRunServices) + assert.Equal(1, nbServices, "we expected a single Cloud Run service called %s to be deployed, found %d services", server_service_name, nbServices) + match := utils.GetFirstMatchResult(t, cloudRunServices, "kind", "Service") + serviceURL := match.Get("status.url").String() + assert.Truef(strings.HasSuffix(serviceURL, ".run.app"), "unexpected service URL %q", serviceURL) + t.Log("Cloud Run service is running at", serviceURL) + + // The Cloud Run service is the app's API backend (it does not serve the Avocano homepage) + assertResponseContains(assert, serviceURL, "/api", "/admin") + + // The data is populated by two Cloud Run jobs, so wait for the final job to finish before continuing. + // A job execution is completed if it has a completed time. + isJobFinished := func() (bool, error) { + clientJobExecs := gcloud.Run(t, "run jobs executions list ", gcloud.WithCommonArgs([]string{"--filter", "metadata.name~" + client_job_name, "--project", projectID, "--region", region, "--format", "json"})).Array() + + if len(clientJobExecs) == 0 { + t.Log("Cloud Run job been executed. Retrying...") + return true, nil + } + + match := utils.GetFirstMatchResult(t, clientJobExecs, "kind", "Execution") + completionTime := match.Get("status.completionTime").String() + + if completionTime == "" { + // retry + t.Log("Cloud Run job execution hasn't completed. Retrying...") + return true, nil + } + + t.Log("Cloud Run job completed", completionTime) + assert.NotEqual(completionTime, "", "completedTime must have a value") + + succeededCount := match.Get("status.succeededCount").Int() + assert.Equal(succeededCount, int64(1), "succeededCount must not be 0") + + return false, nil + } + utils.Poll(t, isJobFinished, 10, time.Second*10) + + // The API must return a list that includes our flagship product + assertResponseContains(assert, serviceURL+"/api/products/", flagshipProduct) + } + { + // Check that the Avocano front page is deployed to Firebase Hosting, and serving + t.Log("Firebase Hosting should be running at ", firebase_url) + assertResponseContains(assert, firebase_url, "Avocano") + } + }) + example.Test() +} + +func assertResponseContains(assert *assert.Assertions, url string, text ...string) { + code, responseBody, err := httpGetRequest(url) + assert.Nil(err) + assert.GreaterOrEqual(code, 200) + assert.LessOrEqual(code, 299) + for _, fragment := range text { + assert.Containsf(responseBody, fragment, "couldn't find %q in response body", fragment) + } +} + +func assertErrorResponseContains(assert *assert.Assertions, url string, wantCode int, text string) { + code, responseBody, err := httpGetRequest(url) + assert.Nil(err) + assert.Equal(code, wantCode) + assert.Containsf(responseBody, text, "couldn't find %q in response body", text) +} + +func httpGetRequest(url string) (statusCode int, body string, err error) { + res, err := http.Get(url) + if err != nil { + return 0, "", err + } + defer res.Body.Close() + + buffer, err := io.ReadAll(res.Body) + return res.StatusCode, string(buffer), err +}