Repository: cloudflare/cloudflare-go
Pull request #1401 (2023-09-13 20:29:43)

add observatory support
## Description
Add support for Cloudflare Observatory endpoints.

## Has your change been tested?

Unit test have been created matching the API endpoints spec.

## Screenshots (if appropriate):

## Types of changes

What sort of change does your code introduce/modify?

- [ ] Bug fix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to change)

## Checklist:

- [x] My code follows the code style of this project.
- [ ] My change requires a change to the documentation.
- [ ] I have updated the documentation accordingly.
- [x] I have added tests to cover my changes.
- [x] All new and existing tests passed.
- [ ] This change is using publicly documented in [cloudflare/api-schemas](https://github.com/cloudflare/api-schemas) 
      and relies on stable APIs.
  - public API documentation coming soon.


Patch:
diff --git a/.changelog/1401.txt b/.changelog/1401.txt
new file mode 100644
index 000000000..b1ef19e84
--- /dev/null
+++ b/.changelog/1401.txt
@@ -0,0 +1,3 @@
+```release-note:enhancement
+observatory: add support for observatory API
+```
\ No newline at end of file
diff --git a/observatory.go b/observatory.go
new file mode 100644
index 000000000..488640fce
--- /dev/null
+++ b/observatory.go
@@ -0,0 +1,388 @@
+package cloudflare
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"net/http"
+	"net/url"
+	"time"
+
+	"github.com/goccy/go-json"
+)
+
+var (
+	ErrMissingObservatoryUrl    = errors.New("missing required page url")
+	ErrMissingObservatoryTestID = errors.New("missing required test id")
+)
+
+// ObservatoryPage describes all the tests for a web page.
+type ObservatoryPage struct {
+	URL               string                `json:"url"`
+	Region            labeledRegion         `json:"region"`
+	ScheduleFrequency string                `json:"scheduleFrequency"`
+	Tests             []ObservatoryPageTest `json:"tests"`
+}
+
+// ObservatoryPageTest describes a single test for a web page.
+type ObservatoryPageTest struct {
+	ID                string                      `json:"id"`
+	Date              *time.Time                  `json:"date"`
+	URL               string                      `json:"url"`
+	Region            labeledRegion               `json:"region"`
+	ScheduleFrequency *string                     `json:"scheduleFrequency"`
+	MobileReport      ObservatoryLighthouseReport `json:"mobileReport"`
+	DesktopReport     ObservatoryLighthouseReport `json:"desktopReport"`
+}
+
+// labeledRegion describes a region the test was run in.
+type labeledRegion struct {
+	Value string `json:"value"`
+	Label string `json:"label"`
+}
+
+// ObservatorySchedule describe a test schedule.
+type ObservatorySchedule struct {
+	URL       string `json:"url"`
+	Region    string `json:"region"`
+	Frequency string `json:"frequency"`
+}
+
+// ObservatoryLighthouseReport describes the web vital metrics result.
+type ObservatoryLighthouseReport struct {
+	PerformanceScore int    `json:"performanceScore"`
+	State            string `json:"state"`
+	DeviceType       string `json:"deviceType"`
+	// TTFB is time to first byte
+	TTFB int `json:"ttfb"`
+	// FCP is first contentful paint
+	FCP int `json:"fcp"`
+	// LCP is largest contentful pain
+	LCP int `json:"lcp"`
+	// TTI is time to interactive
+	TTI int `json:"tti"`
+	// TBT is total blocking time
+	TBT int `json:"tbt"`
+	// SI is speed index
+	SI int `json:"si"`
+	// CLS is cumulative layout shift
+	CLS   float64          `json:"cls"`
+	Error *lighthouseError `json:"error,omitempty"`
+}
+
+// lighthouseError describes the test error.
+type lighthouseError struct {
+	Code              string `json:"code"`
+	Detail            string `json:"detail"`
+	FinalDisplayedURL string `json:"finalDisplayedUrl"`
+}
+
+// ObservatoryPageTrend describes the web vital metrics trend.
+type ObservatoryPageTrend struct {
+	PerformanceScore []*int     `json:"performanceScore"`
+	TTFB             []*int     `json:"ttfb"`
+	FCP              []*int     `json:"fcp"`
+	LCP              []*int     `json:"lcp"`
+	TTI              []*int     `json:"tti"`
+	TBT              []*int     `json:"tbt"`
+	SI               []*int     `json:"si"`
+	CLS              []*float64 `json:"cls"`
+}
+
+type ListObservatoryPagesParams struct {
+}
+
+// ObservatoryPagesResponse is the API response, containing a list of ObservatoryPage.
+type ObservatoryPagesResponse struct {
+	Response
+	Result []ObservatoryPage `json:"result"`
+}
+
+// ListObservatoryPages returns a list of pages which have been tested.
+//
+// API reference: https://api.cloudflare.com/#speed-list-pages
+func (api *API) ListObservatoryPages(ctx context.Context, rc *ResourceContainer, params ListObservatoryPagesParams) ([]ObservatoryPage, error) {
+	uri := fmt.Sprintf("/zones/%s/speed_api/pages", rc.Identifier)
+	res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil)
+	if err != nil {
+		return nil, err
+	}
+	var r ObservatoryPagesResponse
+	err = json.Unmarshal(res, &r)
+	if err != nil {
+		return nil, fmt.Errorf("%s: %w", errUnmarshalError, err)
+	}
+	return r.Result, nil
+}
+
+type GetObservatoryPageTrendParams struct {
+	URL        string     `url:"-"`
+	Region     string     `url:"region"`
+	DeviceType string     `url:"deviceType"`
+	Start      *time.Time `url:"start"`
+	End        *time.Time `url:"end,omitempty"`
+	Timezone   string     `url:"tz"`
+	Metrics    []string   `url:"metrics"`
+}
+
+type ObservatoryPageTrendResponse struct {
+	Response
+	Result ObservatoryPageTrend `json:"result"`
+}
+
+// GetObservatoryPageTrend returns a the trend of web vital metrics for a page in a specific region.
+//
+// API reference: https://api.cloudflare.com/#speed-list-page-trend
+func (api *API) GetObservatoryPageTrend(ctx context.Context, rc *ResourceContainer, params GetObservatoryPageTrendParams) (*ObservatoryPageTrend, error) {
+	if params.URL == "" {
+		return nil, ErrMissingObservatoryUrl
+	}
+	uri := buildURI(fmt.Sprintf("/zones/%s/speed_api/pages/%s/trend", rc.Identifier, url.PathEscape(params.URL)), params)
+	res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil)
+	if err != nil {
+		return nil, err
+	}
+	var r ObservatoryPageTrendResponse
+	err = json.Unmarshal(res, &r)
+	if err != nil {
+		return nil, fmt.Errorf("%s: %w", errUnmarshalError, err)
+	}
+	return &r.Result, nil
+}
+
+var listObservatoryPageTestDefaultPageSize = 20
+
+type ListObservatoryPageTestParams struct {
+	URL    string `url:"-"`
+	Region string `url:"region"`
+	ResultInfo
+}
+
+type ObservatoryPageTestsResponse struct {
+	Response
+	Result     []ObservatoryPageTest `json:"result"`
+	ResultInfo `json:"result_info"`
+}
+
+// ListObservatoryPageTests returns a list of tests for a page in a specific region.
+//
+// API reference: https://api.cloudflare.com/#speed-list-test-history
+func (api *API) ListObservatoryPageTests(ctx context.Context, rc *ResourceContainer, params ListObservatoryPageTestParams) ([]ObservatoryPageTest, *ResultInfo, error) {
+	if params.URL == "" {
+		return nil, nil, ErrMissingObservatoryUrl
+	}
+	autoPaginate := true
+	if params.PerPage >= 1 || params.Page >= 1 {
+		autoPaginate = false
+	}
+	if params.PerPage < 1 {
+		params.PerPage = listObservatoryPageTestDefaultPageSize
+	}
+	if params.Page < 1 {
+		params.Page = 1
+	}
+	var tests []ObservatoryPageTest
+	var lastResultInfo ResultInfo
+	for {
+		uri := buildURI(fmt.Sprintf("/zones/%s/speed_api/pages/%s/tests", rc.Identifier, url.PathEscape(params.URL)), params)
+		res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil)
+		if err != nil {
+			return nil, nil, err
+		}
+		var r ObservatoryPageTestsResponse
+		err = json.Unmarshal(res, &r)
+		if err != nil {
+			return nil, nil, fmt.Errorf("%s: %w", errUnmarshalError, err)
+		}
+		tests = append(tests, r.Result...)
+		lastResultInfo = r.ResultInfo.Next()
+		if params.ResultInfo.Done() || !autoPaginate {
+			break
+		}
+	}
+	return tests, &lastResultInfo, nil
+}
+
+type CreateObservatoryPageTestParams struct {
+	URL      string
+	Settings CreateObservatoryPageTestSettings
+}
+type CreateObservatoryPageTestSettings struct {
+	Region string `json:"region"`
+}
+
+type ObservatoryPageTestResponse struct {
+	Response
+	Result ObservatoryPageTest `json:"result"`
+}
+
+// CreateObservatoryPageTest starts a test for a page in a specific region.
+//
+// API reference: https://api.cloudflare.com/#speed-create-test
+func (api *API) CreateObservatoryPageTest(ctx context.Context, rc *ResourceContainer, params CreateObservatoryPageTestParams) (*ObservatoryPageTest, error) {
+	if params.URL == "" {
+		return nil, ErrMissingObservatoryUrl
+	}
+	uri := fmt.Sprintf("/zones/%s/speed_api/pages/%s/tests", rc.Identifier, url.PathEscape(params.URL))
+	res, err := api.makeRequestContext(ctx, http.MethodPost, uri, params.Settings)
+	if err != nil {
+		return nil, err
+	}
+	var r ObservatoryPageTestResponse
+	err = json.Unmarshal(res, &r)
+	if err != nil {
+		return nil, fmt.Errorf("%s: %w", errUnmarshalError, err)
+	}
+	return &r.Result, nil
+}
+
+type DeleteObservatoryPageTestsParams struct {
+	URL    string `url:"-"`
+	Region string `url:"region"`
+}
+
+type ObservatoryCountResponse struct {
+	Response
+	Result struct {
+		Count int `json:"count"`
+	} `json:"result"`
+}
+
+// DeleteObservatoryPageTests deletes all tests for a page in a specific region.
+//
+// API reference: https://api.cloudflare.com/#speed-delete-tests
+func (api *API) DeleteObservatoryPageTests(ctx context.Context, rc *ResourceContainer, params DeleteObservatoryPageTestsParams) (*int, error) {
+	if params.URL == "" {
+		return nil, ErrMissingObservatoryUrl
+	}
+	uri := buildURI(fmt.Sprintf("/zones/%s/speed_api/pages/%s/tests", rc.Identifier, url.PathEscape(params.URL)), params)
+	res, err := api.makeRequestContext(ctx, http.MethodDelete, uri, nil)
+	if err != nil {
+		return nil, err
+	}
+	var r ObservatoryCountResponse
+	err = json.Unmarshal(res, &r)
+	if err != nil {
+		return nil, fmt.Errorf("%s: %w", errUnmarshalError, err)
+	}
+	return &r.Result.Count, nil
+}
+
+type GetObservatoryPageTestParams struct {
+	URL    string
+	TestID string
+}
+
+// GetObservatoryPageTest returns a specific test for a page.
+//
+// API reference: https://api.cloudflare.com/#speed-get-test
+func (api *API) GetObservatoryPageTest(ctx context.Context, rc *ResourceContainer, params GetObservatoryPageTestParams) (*ObservatoryPageTest, error) {
+	if params.URL == "" {
+		return nil, ErrMissingObservatoryUrl
+	}
+	if params.TestID == "" {
+		return nil, ErrMissingObservatoryTestID
+	}
+	uri := fmt.Sprintf("/zones/%s/speed_api/pages/%s/tests/%s", rc.Identifier, url.PathEscape(params.URL), params.TestID)
+	res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil)
+	if err != nil {
+		return nil, err
+	}
+	var r ObservatoryPageTestResponse
+	err = json.Unmarshal(res, &r)
+	if err != nil {
+		return nil, fmt.Errorf("%s: %w", errUnmarshalError, err)
+	}
+	return &r.Result, nil
+}
+
+type CreateObservatoryScheduledPageTestParams struct {
+	URL       string `url:"-" json:"-"`
+	Region    string `url:"region" json:"region"`
+	Frequency string `url:"frequency" json:"-"`
+}
+
+type ObservatoryScheduledPageTest struct {
+	Schedule ObservatorySchedule `json:"schedule"`
+	Test     ObservatoryPageTest `json:"test"`
+}
+
+type CreateObservatoryScheduledPageTestResponse struct {
+	Response
+	Result ObservatoryScheduledPageTest `json:"result"`
+}
+
+// CreateObservatoryScheduledPageTest creates a scheduled test for a page in a specific region.
+//
+// API reference: https://api.cloudflare.com/#speed-create-scheduled-test
+func (api *API) CreateObservatoryScheduledPageTest(ctx context.Context, rc *ResourceContainer, params CreateObservatoryScheduledPageTestParams) (*ObservatoryScheduledPageTest, error) {
+	if params.URL == "" {
+		return nil, ErrMissingObservatoryUrl
+	}
+	uri := buildURI(fmt.Sprintf("/zones/%s/speed_api/schedule/%s", rc.Identifier, url.PathEscape(params.URL)), params)
+	res, err := api.makeRequestContext(ctx, http.MethodPost, uri, params)
+	if err != nil {
+		return nil, err
+	}
+	var r CreateObservatoryScheduledPageTestResponse
+	err = json.Unmarshal(res, &r)
+	if err != nil {
+		return nil, fmt.Errorf("%s: %w", errUnmarshalError, err)
+	}
+	return &r.Result, nil
+}
+
+type GetObservatoryScheduledPageTestParams struct {
+	URL    string `url:"-"`
+	Region string `url:"region"`
+}
+
+type ObservatoryScheduleResponse struct {
+	Response
+	Result ObservatorySchedule `json:"result"`
+}
+
+// GetObservatoryScheduledPageTest returns the test schedule for a page in a specific region.
+//
+// API reference: https://api.cloudflare.com/#speed-get-scheduled-test
+func (api *API) GetObservatoryScheduledPageTest(ctx context.Context, rc *ResourceContainer, params GetObservatoryScheduledPageTestParams) (*ObservatorySchedule, error) {
+	if params.URL == "" {
+		return nil, ErrMissingObservatoryUrl
+	}
+	uri := buildURI(fmt.Sprintf("/zones/%s/speed_api/schedule/%s", rc.Identifier, url.PathEscape(params.URL)), params)
+	res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil)
+	if err != nil {
+		return nil, err
+	}
+	var r ObservatoryScheduleResponse
+	err = json.Unmarshal(res, &r)
+	if err != nil {
+		return nil, fmt.Errorf("%s: %w", errUnmarshalError, err)
+	}
+	return &r.Result, nil
+}
+
+type DeleteObservatoryScheduledPageTestParams struct {
+	URL    string `url:"-"`
+	Region string `url:"region"`
+}
+
+// DeleteObservatoryScheduledPageTest deletes the test schedule for a page in a specific region.
+//
+// API reference: https://api.cloudflare.com/#speed-delete-scheduled-test
+func (api *API) DeleteObservatoryScheduledPageTest(ctx context.Context, rc *ResourceContainer, params DeleteObservatoryScheduledPageTestParams) (*int, error) {
+	if params.URL == "" {
+		return nil, ErrMissingObservatoryUrl
+	}
+	uri := buildURI(fmt.Sprintf("/zones/%s/speed_api/schedule/%s", rc.Identifier, url.PathEscape(params.URL)), params)
+	res, err := api.makeRequestContext(ctx, http.MethodDelete, uri, nil)
+	if err != nil {
+		return nil, err
+	}
+	var r ObservatoryCountResponse
+	err = json.Unmarshal(res, &r)
+	if err != nil {
+		return nil, fmt.Errorf("%s: %w", errUnmarshalError, err)
+	}
+	return &r.Result.Count, nil
+}


Test patch:
diff --git a/observatory_test.go b/observatory_test.go
new file mode 100644
index 000000000..98ff51209
--- /dev/null
+++ b/observatory_test.go
@@ -0,0 +1,452 @@
+package cloudflare
+
+import (
+	"context"
+	"fmt"
+	"io"
+	"net/http"
+	"strings"
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/assert"
+)
+
+var testURL = "example.com"
+var region = "us-central1"
+var regionLabel = "Iowa, USA"
+var frequency = "DAILY"
+var observatoryTestID = "52cc96c9-b709-4ffe-9048-338853d3db46"
+var date = time.Now().UTC()
+
+var pageJSON = fmt.Sprintf(`
+{
+  "url": "%[1]s",
+  "region": {
+    "value": "%[2]s",
+    "label": "%[3]s"
+  },
+  "scheduleFrequency": "%[4]s",
+  "tests": [
+    {
+      "id": "%[5]s",
+      "date": "%[6]s",
+      "url": "%[1]s",
+      "scheduleFrequency": "%[4]s",
+      "region": {
+        "value": "%[2]s",
+        "label": "%[3]s"
+      },
+      "mobileReport": {
+        "performanceScore": 100,
+        "ttfb": 10,
+        "fcp": 10,
+        "lcp": 10,
+        "tti": 10,
+        "tbt": 10,
+        "si": 10,
+        "cls": 0.10,
+        "state": "COMPLETED",
+        "deviceType": "DESKTOP"
+      },
+      "desktopReport": {
+        "performanceScore": 100,
+        "ttfb": 10,
+        "fcp": 10,
+        "lcp": 10,
+        "tti": 10,
+        "tbt": 10,
+        "si": 10,
+        "cls": 0.10,
+        "state": "COMPLETED",
+        "deviceType": "DESKTOP"
+      }
+    }
+  ]
+}`, testURL, region, regionLabel, frequency, observatoryTestID, date.Format(time.RFC3339Nano))
+
+var scheduledPageTestJSON = fmt.Sprintf(`
+{
+  "schedule": {
+    "url": "%[1]s",
+    "region": "%[2]s",
+    "frequency": "%[3]s"
+  },
+  "test": %[4]s
+}
+`, testURL, region, frequency, pageTestJSON)
+
+var scheduleJSON = fmt.Sprintf(`
+{
+  "url": "%[1]s",
+  "region": "%[2]s",
+  "frequency": "%[3]s"
+}
+`, testURL, region, frequency)
+
+var pageTestJSON = fmt.Sprintf(`
+{
+  "id": "%[1]s",
+  "date": "%[2]s",
+  "url": "%[3]s",
+  "region": {
+    "value": "%[4]s",
+    "label": "%[5]s"
+  },
+  "scheduleFrequency": "%[6]s",
+  "mobileReport": %[7]s,
+  "desktopReport": %[7]s
+}`, observatoryTestID, date.Format(time.RFC3339Nano), testURL, region, regionLabel, frequency, reportJSON, reportJSON)
+
+var reportJSON = `
+{
+  "state": "COMPLETED",
+  "deviceType": "DESKTOP",
+  "performanceScore": 100,
+  "ttfb": 10,
+  "fcp": 10,
+  "lcp": 10,
+  "tti": 10,
+  "tbt": 10,
+  "si": 10,
+  "cls": 0.10
+}
+`
+
+var report = ObservatoryLighthouseReport{
+	PerformanceScore: 100,
+	State:            "COMPLETED",
+	DeviceType:       "DESKTOP",
+	TTFB:             10,
+	FCP:              10,
+	LCP:              10,
+	TTI:              10,
+	TBT:              10,
+	SI:               10,
+	CLS:              0.10,
+	Error:            nil,
+}
+
+var page = ObservatoryPage{
+	URL: testURL,
+	Region: labeledRegion{
+		Value: region,
+		Label: regionLabel,
+	},
+	ScheduleFrequency: frequency,
+	Tests: []ObservatoryPageTest{
+		pageTest,
+	},
+}
+var pageTest = ObservatoryPageTest{
+	ID:   observatoryTestID,
+	Date: &date,
+	URL:  testURL,
+	Region: labeledRegion{
+		Value: region,
+		Label: regionLabel,
+	},
+	ScheduleFrequency: &frequency,
+	MobileReport:      report,
+	DesktopReport:     report,
+}
+
+var scheduledPageTest = ObservatoryScheduledPageTest{
+	Schedule: ObservatorySchedule{
+		URL:       testURL,
+		Region:    region,
+		Frequency: frequency,
+	},
+	Test: pageTest,
+}
+
+var schedule = ObservatorySchedule{
+	URL:       testURL,
+	Region:    region,
+	Frequency: frequency,
+}
+
+func TestListObservatoryPages(t *testing.T) {
+	setup()
+	defer teardown()
+
+	handler := func(w http.ResponseWriter, r *http.Request) {
+		assert.Equal(t, http.MethodGet, r.Method, "Expected method 'GET', got %s", r.Method)
+		w.Header().Set("content-type", "application/json")
+		fmt.Fprintf(w, `{
+			  "success": true,
+			  "errors": [],
+			  "messages": [],
+			  "result": [
+			    %s
+			  ]
+			}
+		`, pageJSON)
+	}
+	mux.HandleFunc("/zones/"+testZoneID+"/speed_api/pages", handler)
+	want := []ObservatoryPage{
+		page,
+	}
+	pages, err := client.ListObservatoryPages(context.Background(), ZoneIdentifier(testZoneID), ListObservatoryPagesParams{})
+	if assert.NoError(t, err) {
+		assert.Equal(t, want, pages)
+	}
+}
+
+func TestObservatoryPageTrend(t *testing.T) {
+	setup()
+	defer teardown()
+
+	handler := func(w http.ResponseWriter, r *http.Request) {
+		assert.Equal(t, http.MethodGet, r.Method, "Expected method 'GET', got %s", r.Method)
+		assert.Equal(t, "DESKTOP", r.URL.Query().Get("deviceType"))
+		assert.Equal(t, "America/Chicago", r.URL.Query().Get("tz"))
+		assert.Equal(t, "fcp,lcp", r.URL.Query().Get("metrics"))
+		w.Header().Set("content-type", "application/json")
+		fmt.Fprintf(w, `{
+			  "success": true,
+			  "errors": [],
+			  "messages": [],
+			  "result": {
+			    "performanceScore": [null, 100],
+			    "ttfb": [null, 10],
+			    "fcp": [null, 10],
+			    "lcp": [null, 10],
+			    "tti": [null, 10],
+			    "tbt": [null, 10],
+			    "si": [null, 10],
+			    "cls": [null, 0.10]
+			  }
+			}
+		`)
+	}
+	mux.HandleFunc("/zones/"+testZoneID+"/speed_api/pages/"+testURL+"/trend", handler)
+	want := ObservatoryPageTrend{
+		PerformanceScore: []*int{nil, IntPtr(100)},
+		TTFB:             []*int{nil, IntPtr(10)},
+		FCP:              []*int{nil, IntPtr(10)},
+		LCP:              []*int{nil, IntPtr(10)},
+		TTI:              []*int{nil, IntPtr(10)},
+		TBT:              []*int{nil, IntPtr(10)},
+		SI:               []*int{nil, IntPtr(10)},
+		CLS:              []*float64{nil, Float64Ptr(0.10)},
+	}
+	trend, err := client.GetObservatoryPageTrend(context.Background(), ZoneIdentifier(testZoneID), GetObservatoryPageTrendParams{
+		URL:        testURL,
+		Region:     region,
+		DeviceType: "DESKTOP",
+		Start:      &date,
+		End:        &date,
+		Timezone:   "America/Chicago",
+		Metrics:    []string{"fcp,lcp"},
+	})
+	if assert.NoError(t, err) {
+		assert.Equal(t, &want, trend)
+	}
+}
+
+func TestListObservatoryPageTests(t *testing.T) {
+	setup()
+	defer teardown()
+
+	handler := func(w http.ResponseWriter, r *http.Request) {
+		assert.Equal(t, http.MethodGet, r.Method, "Expected method 'GET', got %s", r.Method)
+		assert.Equal(t, region, r.URL.Query().Get("region"))
+		assert.Equal(t, "1", r.URL.Query().Get("page"))
+		assert.Equal(t, "10", r.URL.Query().Get("per_page"))
+		w.Header().Set("content-type", "application/json")
+		fmt.Fprintf(w, `{
+			  "success": true,
+			  "errors": [],
+			  "messages": [],
+			  "result": [%s]
+			}
+		`, pageTestJSON)
+	}
+	mux.HandleFunc("/zones/"+testZoneID+"/speed_api/pages/"+testURL+"/tests", handler)
+	want := []ObservatoryPageTest{
+		pageTest,
+	}
+	tests, _, err := client.ListObservatoryPageTests(context.Background(), ZoneIdentifier(testZoneID), ListObservatoryPageTestParams{
+		URL: testURL,
+		ResultInfo: ResultInfo{
+			Page:    1,
+			PerPage: 10,
+		},
+		Region: region,
+	})
+	if assert.NoError(t, err) {
+		assert.Equal(t, want, tests)
+	}
+}
+
+func TestCreateObservatoryPageTest(t *testing.T) {
+	setup()
+	defer teardown()
+
+	handler := func(w http.ResponseWriter, r *http.Request) {
+		assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method)
+		b, err := io.ReadAll(r.Body)
+		assert.NoError(t, err)
+		assert.True(t, strings.Contains(string(b), region))
+		w.Header().Set("content-type", "application/json")
+		fmt.Fprintf(w, `{
+			  "success": true,
+			  "errors": [],
+			  "messages": [],
+			  "result": %s
+			}
+		`, pageTestJSON)
+	}
+	mux.HandleFunc("/zones/"+testZoneID+"/speed_api/pages/"+testURL+"/tests", handler)
+	want := pageTest
+	test, err := client.CreateObservatoryPageTest(context.Background(), ZoneIdentifier(testZoneID), CreateObservatoryPageTestParams{
+		URL: testURL,
+		Settings: CreateObservatoryPageTestSettings{
+			Region: region,
+		},
+	})
+	if assert.NoError(t, err) {
+		assert.Equal(t, &want, test)
+	}
+}
+
+func TestDeleteObservatoryPageTests(t *testing.T) {
+	setup()
+	defer teardown()
+
+	handler := func(w http.ResponseWriter, r *http.Request) {
+		assert.Equal(t, http.MethodDelete, r.Method, "Expected method 'DELETE', got %s", r.Method)
+		assert.Equal(t, region, r.URL.Query().Get("region"))
+		w.Header().Set("content-type", "application/json")
+		fmt.Fprintf(w, `{
+			  "success": true,
+			  "errors": [],
+			  "messages": [],
+			  "result": {
+                "count": 2
+              }
+			}
+		`)
+	}
+	mux.HandleFunc("/zones/"+testZoneID+"/speed_api/pages/"+testURL+"/tests", handler)
+	want := 2
+	count, err := client.DeleteObservatoryPageTests(context.Background(), ZoneIdentifier(testZoneID), DeleteObservatoryPageTestsParams{
+		URL:    testURL,
+		Region: region,
+	})
+	if assert.NoError(t, err) {
+		assert.Equal(t, &want, count)
+	}
+}
+
+func TestGetObservatoryPageTest(t *testing.T) {
+	setup()
+	defer teardown()
+
+	handler := func(w http.ResponseWriter, r *http.Request) {
+		assert.Equal(t, http.MethodGet, r.Method, "Expected method 'GET', got %s", r.Method)
+		w.Header().Set("content-type", "application/json")
+		fmt.Fprintf(w, `{
+			  "success": true,
+			  "errors": [],
+			  "messages": [],
+			  "result": %s
+			}
+		`, pageTestJSON)
+	}
+	mux.HandleFunc("/zones/"+testZoneID+"/speed_api/pages/"+testURL+"/tests/"+observatoryTestID, handler)
+	want := pageTest
+	test, err := client.GetObservatoryPageTest(context.Background(), ZoneIdentifier(testZoneID), GetObservatoryPageTestParams{
+		TestID: observatoryTestID,
+		URL:    testURL,
+	})
+	if assert.NoError(t, err) {
+		assert.Equal(t, &want, test)
+	}
+}
+
+func TestCreateObservatoryScheduledPageTest(t *testing.T) {
+	setup()
+	defer teardown()
+
+	handler := func(w http.ResponseWriter, r *http.Request) {
+		assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method)
+		assert.Equal(t, frequency, r.URL.Query().Get("frequency"))
+		assert.Equal(t, region, r.URL.Query().Get("region"))
+		w.Header().Set("content-type", "application/json")
+		fmt.Fprintf(w, `{
+			  "success": true,
+			  "errors": [],
+			  "messages": [],
+			  "result": %s
+			}
+		`, scheduledPageTestJSON)
+	}
+	mux.HandleFunc("/zones/"+testZoneID+"/speed_api/schedule/"+testURL, handler)
+	want := scheduledPageTest
+	pages, err := client.CreateObservatoryScheduledPageTest(context.Background(), ZoneIdentifier(testZoneID), CreateObservatoryScheduledPageTestParams{
+		Frequency: frequency,
+		URL:       testURL,
+		Region:    region,
+	})
+	if assert.NoError(t, err) {
+		assert.Equal(t, &want, pages)
+	}
+}
+
+func TestObservatoryScheduledPageTest(t *testing.T) {
+	setup()
+	defer teardown()
+
+	handler := func(w http.ResponseWriter, r *http.Request) {
+		assert.Equal(t, http.MethodGet, r.Method, "Expected method 'GET', got %s", r.Method)
+		assert.Equal(t, region, r.URL.Query().Get("region"))
+		w.Header().Set("content-type", "application/json")
+		fmt.Fprintf(w, `{
+			  "success": true,
+			  "errors": [],
+			  "messages": [],
+			  "result": %s
+			}
+		`, scheduleJSON)
+	}
+	mux.HandleFunc("/zones/"+testZoneID+"/speed_api/schedule/"+testURL, handler)
+	want := schedule
+	schedule, err := client.GetObservatoryScheduledPageTest(context.Background(), ZoneIdentifier(testZoneID), GetObservatoryScheduledPageTestParams{
+		URL:    testURL,
+		Region: region,
+	})
+	if assert.NoError(t, err) {
+		assert.Equal(t, &want, schedule)
+	}
+}
+
+func TestDeleteObservatoryScheduledPageTest(t *testing.T) {
+	setup()
+	defer teardown()
+
+	handler := func(w http.ResponseWriter, r *http.Request) {
+		assert.Equal(t, http.MethodDelete, r.Method, "Expected method 'DELETE', got %s", r.Method)
+		assert.Equal(t, region, r.URL.Query().Get("region"))
+		w.Header().Set("content-type", "application/json")
+		fmt.Fprintf(w, `{
+			  "success": true,
+			  "errors": [],
+			  "messages": [],
+			  "result": { 
+                "count": 2
+              }
+			}
+		`)
+	}
+	mux.HandleFunc("/zones/"+testZoneID+"/speed_api/schedule/"+testURL, handler)
+	want := 2
+	count, err := client.DeleteObservatoryScheduledPageTest(context.Background(), ZoneIdentifier(testZoneID), DeleteObservatoryScheduledPageTestParams{
+		URL:    testURL,
+		Region: region,
+	})
+	if assert.NoError(t, err) {
+		assert.Equal(t, &want, count)
+	}
+}