-
Notifications
You must be signed in to change notification settings - Fork 6
Add golden tests. #362
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add golden tests. #362
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,115 @@ | ||
| // This Source Code Form is subject to the terms of the Mozilla Public | ||
| // License, v. 2.0. If a copy of the MPL was not distributed with this | ||
| // file, You can obtain one at https://mozilla.org/MPL/2.0/. | ||
|
|
||
| package oxide | ||
|
|
||
| import ( | ||
| "encoding/json" | ||
| "os" | ||
| "testing" | ||
| "time" | ||
|
|
||
| "github.com/google/go-cmp/cmp" | ||
| "github.com/stretchr/testify/require" | ||
| ) | ||
|
|
||
| // TestGoldenRoundTrip tests that real API responses can be unmarshaled and | ||
| // marshaled back to equivalent JSON. This catches mismatches between our | ||
| // generated types and the actual API format. | ||
| // | ||
| // To refresh the fixtures, run: | ||
| // | ||
| // go run ./oxide/testdata/main.go | ||
| func TestGoldenRoundTrip(t *testing.T) { | ||
| tests := []struct { | ||
| name string | ||
| fixture string | ||
| test func(t *testing.T, fixture string) | ||
| }{ | ||
| { | ||
| name: "timeseries_query_response", | ||
| fixture: "testdata/recordings/timeseries_query_response.json", | ||
| test: testRoundTrip[OxqlQueryResult], | ||
| }, | ||
| { | ||
| name: "disk_list_response", | ||
| fixture: "testdata/recordings/disk_list_response.json", | ||
| test: testRoundTrip[DiskResultsPage], | ||
| }, | ||
| { | ||
| name: "loopback_addresses_response", | ||
| fixture: "testdata/recordings/loopback_addresses_response.json", | ||
| test: testRoundTrip[LoopbackAddressResultsPage], | ||
| }, | ||
| } | ||
|
|
||
| for _, tt := range tests { | ||
| t.Run(tt.name, func(t *testing.T) { | ||
| tt.test(t, tt.fixture) | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| func testRoundTrip[T any](t *testing.T, fixturePath string) { | ||
| data, err := os.ReadFile(fixturePath) | ||
| require.NoError(t, err, "failed to read fixture") | ||
|
|
||
| var typed T | ||
| err = json.Unmarshal(data, &typed) | ||
| require.NoError(t, err, "failed to unmarshal fixture") | ||
|
|
||
| remarshaled, err := json.Marshal(typed) | ||
| require.NoError(t, err, "failed to marshal") | ||
|
|
||
| var expected, actual any | ||
| require.NoError(t, json.Unmarshal(data, &expected)) | ||
| require.NoError(t, json.Unmarshal(remarshaled, &actual)) | ||
|
|
||
| expected = stripNulls(expected) | ||
| actual = stripNulls(actual) | ||
|
|
||
| if diff := cmp.Diff(expected, actual, timestampComparer()); diff != "" { | ||
| t.Errorf("round-trip mismatch (-fixture +remarshaled):\n%s", diff) | ||
| } | ||
| } | ||
|
|
||
| // timestampComparer returns a cmp.Option that compares timestamp strings | ||
| // by their actual time value, handling precision differences. Rust and go format timestamps | ||
| // slightly differently, so we need to normalize to avoid spurious differences in marshalled values. | ||
| func timestampComparer() cmp.Option { | ||
| return cmp.Comparer(func(a, b string) bool { | ||
| ta, errA := time.Parse(time.RFC3339Nano, a) | ||
| tb, errB := time.Parse(time.RFC3339Nano, b) | ||
| if errA == nil && errB == nil { | ||
| return ta.Equal(tb) | ||
| } | ||
| return a == b | ||
| }) | ||
| } | ||
|
|
||
| // stripNulls recursively removes null values from JSON-unmarshaled data. We use this workaround | ||
| // because the SDK and API don't always handle null fields consistently. | ||
| // | ||
| // TODO: Investigate options to harmonize null handling across services so that we don't have to | ||
| // pre-process the responses here. | ||
| func stripNulls(v any) any { | ||
| switch val := v.(type) { | ||
| case map[string]any: | ||
| result := make(map[string]any) | ||
| for k, v := range val { | ||
| if v != nil { | ||
| result[k] = stripNulls(v) | ||
| } | ||
| } | ||
| return result | ||
| case []any: | ||
| result := make([]any, len(val)) | ||
| for i, v := range val { | ||
| result[i] = stripNulls(v) | ||
| } | ||
| return result | ||
| default: | ||
| return v | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,179 @@ | ||
| // This Source Code Form is subject to the terms of the Mozilla Public | ||
| // License, v. 2.0. If a copy of the MPL was not distributed with this | ||
| // file, You can obtain one at https://mozilla.org/MPL/2.0/. | ||
|
|
||
| //go:build ignore | ||
|
|
||
| // This script records real API responses for use in golden file tests. | ||
| // Run with: go run ./oxide/testdata/main.go [-api-version VERSION] | ||
| // | ||
| // Requires OXIDE_HOST, OXIDE_TOKEN, and OXIDE_PROJECT environment variables. | ||
| // Optionally pass -api-version to set the API-Version header on requests. | ||
| package main | ||
|
|
||
| import ( | ||
| "bytes" | ||
| "crypto/tls" | ||
| "encoding/json" | ||
| "flag" | ||
| "fmt" | ||
| "io" | ||
| "log" | ||
| "net/http" | ||
| "os" | ||
| "path/filepath" | ||
| ) | ||
|
|
||
| var ( | ||
| apiVersion = flag.String("api-version", "", "API version to send in requests (optional)") | ||
|
|
||
| client = &http.Client{ | ||
| Transport: &http.Transport{ | ||
| TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, | ||
| }, | ||
| } | ||
| ) | ||
|
|
||
| func main() { | ||
| flag.Parse() | ||
|
|
||
| host := os.Getenv("OXIDE_HOST") | ||
| token := os.Getenv("OXIDE_TOKEN") | ||
| project := os.Getenv("OXIDE_PROJECT") | ||
|
|
||
| if host == "" || token == "" || project == "" { | ||
| log.Fatalf("OXIDE_HOST, OXIDE_TOKEN, and OXIDE_PROJECT environment variables must be set") | ||
| } | ||
|
|
||
| if *apiVersion != "" { | ||
| fmt.Printf("Using API-Version: %s\n", *apiVersion) | ||
| } | ||
|
|
||
| testdataDir := "./oxide/testdata/recordings" | ||
|
|
||
| recordTimeseriesQuery(host, token, testdataDir) | ||
| recordDiskList(host, token, project, testdataDir) | ||
| recordLoopbackAddresses(host, token, testdataDir) | ||
| } | ||
|
|
||
| func recordTimeseriesQuery(host, token, testdataDir string) { | ||
| fmt.Println("Recording timeseries query response...") | ||
|
|
||
| body := `{"query": "get hardware_component:voltage | filter slot == 0 && sensor == \"V1P0_MGMT\" | filter timestamp > @now() - 5m | last 5"}` | ||
| data, err := doRequest("POST", host+"/v1/system/timeseries/query", token, body) | ||
| if err != nil { | ||
| log.Printf("Warning: timeseries query failed: %v", err) | ||
| return | ||
| } | ||
|
|
||
| normalized, err := normalizeJSON(data) | ||
| if err != nil { | ||
| log.Printf("Warning: failed to normalize JSON: %v", err) | ||
| return | ||
| } | ||
| if err := saveFixture(testdataDir, "timeseries_query_response.json", normalized); err != nil { | ||
| log.Printf("Warning: %v", err) | ||
| return | ||
| } | ||
| } | ||
|
|
||
| func recordDiskList(host, token, project, testdataDir string) { | ||
| fmt.Println("Recording disk list response...") | ||
|
|
||
| url := fmt.Sprintf("%s/v1/disks?project=%s&limit=5", host, project) | ||
| data, err := doRequest("GET", url, token, "") | ||
| if err != nil { | ||
| log.Printf("Warning: disk list failed: %v", err) | ||
| return | ||
| } | ||
|
|
||
| normalized, err := normalizeJSON(data) | ||
| if err != nil { | ||
| log.Printf("Warning: failed to normalize JSON: %v", err) | ||
| return | ||
| } | ||
| if err := saveFixture(testdataDir, "disk_list_response.json", normalized); err != nil { | ||
| log.Printf("Warning: %v", err) | ||
| return | ||
| } | ||
| } | ||
|
|
||
| func recordLoopbackAddresses(host, token, testdataDir string) { | ||
| fmt.Println("Recording loopback addresses response...") | ||
|
|
||
| url := fmt.Sprintf("%s/v1/system/networking/loopback-address?limit=5", host) | ||
| data, err := doRequest("GET", url, token, "") | ||
| if err != nil { | ||
| log.Printf("Warning: loopback addresses failed: %v", err) | ||
| return | ||
| } | ||
|
|
||
| normalized, err := normalizeJSON(data) | ||
| if err != nil { | ||
| log.Printf("Warning: failed to normalize JSON: %v", err) | ||
| return | ||
| } | ||
| if err := saveFixture(testdataDir, "loopback_addresses_response.json", normalized); err != nil { | ||
| log.Printf("Warning: %v", err) | ||
| return | ||
| } | ||
| } | ||
|
|
||
| // doRequest makes a request to the configured nexus instance. We use the standard library here | ||
| // and not our own sdk because we're generating test files to verify the generated code. | ||
| func doRequest(method, url, token, body string) ([]byte, error) { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I wonder if we should send the API version in the request to make sure we're testing the same structure? For example, if I update the golden files pointing to server that has an older released, the tests will probably fail in a (possibly) confusing way.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm going to make this optional for now by adding a flag to the record command. Maybe I'm thinking about this wrong, but I think if VERSION_OMICRON is ahead of our test rack (in this case I used dogfood), we can't record responses. I think we can change this later if it causes problems. |
||
| var reqBody io.Reader | ||
| if body != "" { | ||
| reqBody = bytes.NewBufferString(body) | ||
| } | ||
|
|
||
| req, err := http.NewRequest(method, url, reqBody) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("failed to create request: %w", err) | ||
| } | ||
| req.Header.Set("Authorization", "Bearer "+token) | ||
| if *apiVersion != "" { | ||
| req.Header.Set("API-Version", *apiVersion) | ||
| } | ||
| if body != "" { | ||
| req.Header.Set("Content-Type", "application/json") | ||
| } | ||
|
|
||
| resp, err := client.Do(req) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("failed to send request: %w", err) | ||
| } | ||
| defer resp.Body.Close() | ||
|
|
||
| if resp.StatusCode != http.StatusOK { | ||
| respBody, _ := io.ReadAll(resp.Body) | ||
| return nil, fmt.Errorf("API returned status %d: %s", resp.StatusCode, respBody) | ||
| } | ||
|
|
||
| return io.ReadAll(resp.Body) | ||
| } | ||
|
|
||
| func saveFixture(testdataDir, filename string, data []byte) error { | ||
| path := filepath.Join(testdataDir, filename) | ||
| if err := os.WriteFile(path, data, 0644); err != nil { | ||
| return fmt.Errorf("failed to write %s: %w", path, err) | ||
| } | ||
| return nil | ||
| } | ||
|
|
||
| // normalizeJSON strips undocumented fields from API responses. | ||
| func normalizeJSON(data []byte) ([]byte, error) { | ||
| var v any | ||
| if err := json.Unmarshal(data, &v); err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
| // Nexus returns an undocumented `query_summaries` field that's not in the OpenAPI spec. Ignore it for now. | ||
| // | ||
| // TODO: fully drop `query_summaries` from nexus unless requested. | ||
| if m, ok := v.(map[string]any); ok { | ||
| delete(m, "query_summaries") | ||
| } | ||
|
|
||
| return json.Marshal(v) | ||
| } | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Another minor nit, but it may be better to keep these files in a separate directory. I think we'll start collecting a bunch of them
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Agreed, I moved these around into their own directory. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| {"items":[{"block_size":512,"description":"boot","device_path":"/mnt/boot","disk_type":"distributed","id":"9caa44a1-2683-4f52-a5ca-8a5ee96c7362","image_id":"14e36227-0984-484e-8c94-baab1a6be648","name":"boot","project_id":"4fb1705d-6f8e-4711-9c38-85fe0fbdc8c1","size":21474836480,"snapshot_id":null,"state":{"state":"detached"},"time_created":"2025-09-25T15:11:27.776013Z","time_modified":"2025-09-25T15:11:27.776013Z"},{"block_size":512,"description":"Created as a boot disk for builder-omni","device_path":"/mnt/builder-omni-omnios-bloody-20250124-d6fb1a","disk_type":"distributed","id":"810c075b-27d0-41b1-b259-7551fed1a004","image_id":"7e7c352c-f4aa-4d8b-a34d-7e7c6eeb615a","name":"builder-omni-omnios-bloody-20250124-d6fb1a","project_id":"4fb1705d-6f8e-4711-9c38-85fe0fbdc8c1","size":1098437885952,"snapshot_id":null,"state":{"state":"detached"},"time_created":"2025-11-27T02:51:41.697116Z","time_modified":"2025-11-27T02:51:41.697116Z"},{"block_size":512,"description":"Created as a boot disk for builder-omni","device_path":"/mnt/builder-omni-omnios-r151056-cloud-228ca2","disk_type":"distributed","id":"2c310fbf-a0bf-4c31-8a3d-a4e38feb53c7","image_id":"6e989035-2319-4980-88a3-31fe7112d87f","name":"builder-omni-omnios-r151056-cloud-228ca2","project_id":"4fb1705d-6f8e-4711-9c38-85fe0fbdc8c1","size":1073741824000,"snapshot_id":null,"state":{"state":"detached"},"time_created":"2025-12-05T20:57:41.635747Z","time_modified":"2025-12-05T20:57:41.635747Z"},{"block_size":512,"description":"Created as a boot disk for ch-builder-omni","device_path":"/mnt/ch-builder-omni-omnios-cloud-1693471113-aadda2","disk_type":"distributed","id":"d1c47f4b-a998-42aa-a273-3b7b87e3e780","image_id":"04ffb229-6c78-4bc4-baa3-b4f07f16bea3","name":"ch-builder-omni-omnios-cloud-1693471113-aadda2","project_id":"4fb1705d-6f8e-4711-9c38-85fe0fbdc8c1","size":1073741824000,"snapshot_id":null,"state":{"state":"detached"},"time_created":"2025-11-27T02:21:32.352141Z","time_modified":"2025-11-27T02:21:32.352141Z"},{"block_size":4096,"description":"data","device_path":"/mnt/data","disk_type":"distributed","id":"84a528d2-b2d5-4420-ae39-3b4643d46a92","image_id":null,"name":"data","project_id":"4fb1705d-6f8e-4711-9c38-85fe0fbdc8c1","size":214748364800,"snapshot_id":null,"state":{"state":"detached"},"time_created":"2025-09-25T15:11:27.728556Z","time_modified":"2025-09-25T15:11:27.728556Z"}],"next_page":"eyJ2IjoidjEiLCJwYWdlX3N0YXJ0Ijp7InNvcnRfYnkiOiJuYW1lX2FzY2VuZGluZyIsInByb2plY3QiOiJjYXJwIiwibGFzdF9zZWVuIjoiZGF0YSJ9fQ=="} |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| {"items":[{"address":"fd00:99::1/64","address_lot_block_id":"e30373de-88b5-427c-acb7-65f896695e40","id":"887c5912-4bc6-4b6e-a2d6-64d4aa64b5e0","rack_id":"de608e01-b8e4-4d93-b972-a7dbed36dd22","switch_location":"switch0"},{"address":"fd00:99::1/64","address_lot_block_id":"e30373de-88b5-427c-acb7-65f896695e40","id":"b7101671-162a-4a5a-b30e-1bd7696984c5","rack_id":"de608e01-b8e4-4d93-b972-a7dbed36dd22","switch_location":"switch1"}],"next_page":"eyJ2IjoidjEiLCJwYWdlX3N0YXJ0Ijp7InNvcnRfYnkiOiJpZF9hc2NlbmRpbmciLCJsYXN0X3NlZW4iOiJiNzEwMTY3MS0xNjJhLTRhNWEtYjMwZS0xYmQ3Njk2OTg0YzUifX0="} |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| {"tables":[{"name":"hardware_component:voltage","timeseries":[{"fields":{"chassis_kind":{"type":"string","value":"switch"},"chassis_model":{"type":"string","value":"913-0000006"},"chassis_revision":{"type":"u32","value":4},"chassis_serial":{"type":"string","value":"BRM44220012"},"component_id":{"type":"string","value":"U21"},"component_kind":{"type":"string","value":"tps546b24a"},"description":{"type":"string","value":"V1P0_MGMT rail"},"gateway_id":{"type":"uuid","value":"c0cea24f-ab91-4026-8593-870d64c34673"},"hubris_archive_id":{"type":"string","value":"29806c00ad5fc171"},"rack_id":{"type":"uuid","value":"de608e01-b8e4-4d93-b972-a7dbed36dd22"},"sensor":{"type":"string","value":"V1P0_MGMT"},"slot":{"type":"u32","value":0}},"points":{"start_times":null,"timestamps":["2026-01-12T21:50:24.195794351Z","2026-01-12T21:50:25.192639350Z","2026-01-12T21:50:26.193419069Z","2026-01-12T21:50:27.193322592Z","2026-01-12T21:50:28.290379572Z"],"values":[{"metric_type":"gauge","values":{"type":"double","values":[1.001953125,1.001953125,1.001953125,1.001953125,1.001953125]}}]}},{"fields":{"chassis_kind":{"type":"string","value":"switch"},"chassis_model":{"type":"string","value":"913-0000006"},"chassis_revision":{"type":"u32","value":4},"chassis_serial":{"type":"string","value":"BRM44220012"},"component_id":{"type":"string","value":"U21"},"component_kind":{"type":"string","value":"tps546b24a"},"description":{"type":"string","value":"V1P0_MGMT rail"},"gateway_id":{"type":"uuid","value":"eb645e8f-4228-43fa-9a55-97feabf8ab66"},"hubris_archive_id":{"type":"string","value":"29806c00ad5fc171"},"rack_id":{"type":"uuid","value":"de608e01-b8e4-4d93-b972-a7dbed36dd22"},"sensor":{"type":"string","value":"V1P0_MGMT"},"slot":{"type":"u32","value":0}},"points":{"start_times":null,"timestamps":["2026-01-12T21:50:24.492202137Z","2026-01-12T21:50:25.562686874Z","2026-01-12T21:50:26.493035980Z","2026-01-12T21:50:27.492070298Z","2026-01-12T21:50:28.709615454Z"],"values":[{"metric_type":"gauge","values":{"type":"double","values":[1.001953125,1.001953125,1.001953125,1.001953125,1.001953125]}}]}}]}]} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
"The Golden Round Trip" sounds like a cool movie 😄