From 867db215c85d36c39be250a71aaed39977d96a42 Mon Sep 17 00:00:00 2001 From: NopeCharon <115794254+NopeCharon@users.noreply.github.com> Date: Fri, 13 Nov 2026 04:27:59 +0000 Subject: [PATCH] Add recorded HTTP replay for backend tests Backend tests serve hand-written responses from httptest servers, so they only check what the author expected a forge to send. Add internal/httpreplay, which records real API traffic to fixture files and replays it without network access: - httpreplay.Client(t) returns an *http.Client. By default it serves the responses in testdata/replay/.json and fails the test on a request that is not in the recording or a recorded request that is never made, since some backends discard request errors. - With FORGE_RECORD=1 requests go to the real API and the exchanges are written when the test passes. Only the method, URL and body of a request are stored, so tokens sent in headers never reach a fixture, and Set-Cookie response headers are dropped. Non-UTF-8 bodies are stored as base64. - Identical requests replay their responses in recorded order, and request bodies are part of the match. Add TestReplay* tests recorded from api.github.com, gitlab.com, codeberg.org and api.bitbucket.org covering repository lookup, not found errors, branch listing (including GitLab pagination), file contents and GitHub commit resolution. Document recording and replay in the README. Fixes #23 --- README.md | 23 ++ bitbucket/replay_test.go | 39 ++ .../testdata/replay/TestReplayGetRepo.json | 129 +++++++ .../replay/TestReplayGetRepoNotFound.json | 128 +++++++ gitea/replay_test.go | 68 ++++ gitea/testdata/replay/TestReplayGetFile.json | 53 +++ gitea/testdata/replay/TestReplayGetRepo.json | 110 ++++++ .../replay/TestReplayGetRepoNotFound.json | 53 +++ .../replay/TestReplayListBranches.json | 59 +++ github/replay_test.go | 81 ++++ github/testdata/replay/TestReplayGetFile.json | 89 +++++ github/testdata/replay/TestReplayGetRepo.json | 89 +++++ .../replay/TestReplayGetRepoNotFound.json | 77 ++++ .../replay/TestReplayListBranches.json | 86 +++++ .../replay/TestReplayResolveCommit.json | 89 +++++ gitlab/replay_test.go | 71 ++++ gitlab/testdata/replay/TestReplayGetFile.json | 116 ++++++ gitlab/testdata/replay/TestReplayGetRepo.json | 86 +++++ .../replay/TestReplayGetRepoNotFound.json | 83 +++++ .../replay/TestReplayListBranches.json | 212 +++++++++++ internal/httpreplay/httpreplay.go | 288 +++++++++++++++ internal/httpreplay/httpreplay_test.go | 349 ++++++++++++++++++ .../replay/TestClientReplaysFixture.json | 17 + 23 files changed, 2395 insertions(+) create mode 100644 bitbucket/replay_test.go create mode 100644 bitbucket/testdata/replay/TestReplayGetRepo.json create mode 100644 bitbucket/testdata/replay/TestReplayGetRepoNotFound.json create mode 100644 gitea/replay_test.go create mode 100644 gitea/testdata/replay/TestReplayGetFile.json create mode 100644 gitea/testdata/replay/TestReplayGetRepo.json create mode 100644 gitea/testdata/replay/TestReplayGetRepoNotFound.json create mode 100644 gitea/testdata/replay/TestReplayListBranches.json create mode 100644 github/replay_test.go create mode 100644 github/testdata/replay/TestReplayGetFile.json create mode 100644 github/testdata/replay/TestReplayGetRepo.json create mode 100644 github/testdata/replay/TestReplayGetRepoNotFound.json create mode 100644 github/testdata/replay/TestReplayListBranches.json create mode 100644 github/testdata/replay/TestReplayResolveCommit.json create mode 100644 gitlab/replay_test.go create mode 100644 gitlab/testdata/replay/TestReplayGetFile.json create mode 100644 gitlab/testdata/replay/TestReplayGetRepo.json create mode 100644 gitlab/testdata/replay/TestReplayGetRepoNotFound.json create mode 100644 gitlab/testdata/replay/TestReplayListBranches.json create mode 100644 internal/httpreplay/httpreplay.go create mode 100644 internal/httpreplay/httpreplay_test.go create mode 100644 internal/httpreplay/testdata/replay/TestClientReplaysFixture.json diff --git a/README.md b/README.md index b9d479d..d26c051 100644 --- a/README.md +++ b/README.md @@ -283,6 +283,29 @@ resolver := githubforge.NewCommitResolver(os.Getenv("GITHUB_TOKEN"), nil) sha, err := resolver.ResolveCommit(ctx, "actions", "checkout", "v4.2.1") ``` +## Testing + +```sh +go test ./... +``` + +Most backend tests run against `httptest` servers with hand-written responses. Tests named `TestReplay*` instead replay responses recorded from the real APIs (api.github.com, gitlab.com, codeberg.org and api.bitbucket.org), so they check the backends against what the forges actually send without needing network access. The recordings live in `testdata/replay/.json` next to each test. + +Set `FORGE_RECORD=1` to send the requests to the real API and rewrite the recordings, for example after adding a test or when an API changes: + +```sh +FORGE_RECORD=1 go test ./github -run TestReplayGetRepo +``` + +A recording is only written when the test passes. Only the method, URL and body of each request are stored, so a token sent in a request header never ends up in a fixture, and `Set-Cookie` response headers are dropped. Replay fails the test if the code makes a request that is not in the recording, or if a recorded request is never made. + +New replay tests get their client from `internal/httpreplay`: + +```go +f := github.New("", httpreplay.Client(t)) +repo, err := f.Repos().Get(ctx, "octocat", "Hello-World") +``` + ## License [MIT](LICENSE). diff --git a/bitbucket/replay_test.go b/bitbucket/replay_test.go new file mode 100644 index 0000000..c9edbd1 --- /dev/null +++ b/bitbucket/replay_test.go @@ -0,0 +1,39 @@ +package bitbucket + +import ( + "context" + "errors" + "testing" + + forge "github.com/git-pkgs/forge" + "github.com/git-pkgs/forge/internal/httpreplay" +) + +// These tests replay responses recorded from api.bitbucket.org. Re-record +// them with FORGE_RECORD=1 go test ./bitbucket -run TestReplay. + +func TestReplayGetRepo(t *testing.T) { + f := New("", httpreplay.Client(t)) + repo, err := f.Repos().Get(context.Background(), "tutorials", "markdowndemo") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + assertEqual(t, "FullName", "tutorials/markdowndemo", repo.FullName) + assertEqual(t, "Owner", "tutorials-team", repo.Owner) + assertEqual(t, "Name", "markdowndemo", repo.Name) + assertEqual(t, "HTMLURL", "https://bitbucket.org/tutorials/markdowndemo", repo.HTMLURL) + assertEqual(t, "DefaultBranch", "master", repo.DefaultBranch) + assertEqualBool(t, "Private", false, repo.Private) + if repo.CloneURL == "" { + t.Error("CloneURL is empty") + } +} + +func TestReplayGetRepoNotFound(t *testing.T) { + f := New("", httpreplay.Client(t)) + _, err := f.Repos().Get(context.Background(), "tutorials", "this-repository-does-not-exist") + if !errors.Is(err, forge.ErrNotFound) { + t.Fatalf("expected ErrNotFound, got %v", err) + } +} diff --git a/bitbucket/testdata/replay/TestReplayGetRepo.json b/bitbucket/testdata/replay/TestReplayGetRepo.json new file mode 100644 index 0000000..33d51c0 --- /dev/null +++ b/bitbucket/testdata/replay/TestReplayGetRepo.json @@ -0,0 +1,129 @@ +[ + { + "request": { + "method": "GET", + "url": "https://api.bitbucket.org/2.0/repositories/tutorials/markdowndemo" + }, + "response": { + "status_code": 200, + "header": { + "Age": [ + "0" + ], + "Atl-Request-Id": [ + "fa76de95-4504-44c5-bc60-efb41265d706" + ], + "Atl-Traceid": [ + "fa76de95450444c5bc60efb41265d706" + ], + "Cache-Control": [ + "max-age=900" + ], + "Content-Type": [ + "application/json; charset=utf-8" + ], + "Date": [ + "Fri, 13 Nov 2026 04:13:10 GMT" + ], + "Etag": [ + "W/\"542ef878500116b3fdf8ec9da10487a7\"" + ], + "Nel": [ + "{\"failure_fraction\": 0.01, \"include_subdomains\": true, \"max_age\": 600, \"report_to\": \"endpoint-1\"}" + ], + "Report-To": [ + "{\"endpoints\": [{\"url\": \"https://dz8aopenkvv6s.cloudfront.net\"}], \"group\": \"endpoint-1\", \"include_subdomains\": true, \"max_age\": 600}" + ], + "Server": [ + "AtlassianEdge" + ], + "Server-Timing": [ + "cdn-upstream-layer;desc=\"EDGE\",cdn-upstream-dns;dur=0,cdn-upstream-connect;dur=0,cdn-upstream-fbl;dur=334,atl-edge;dur=130,atl-edge-internal;dur=9,atl-edge-upstream;dur=121,atl-edge-pop;desc=\"aws-us-east-1\",cdn-cache-miss,cdn-pop;desc=\"HYD57-P6\",cdn-rid;desc=\"zpUcAbr-tPY3-6k8cpBL8z16RjSfvxISe08eudpKqITVmPP_K6VJYw==\",cdn-downstream-fbl;dur=338" + ], + "Strict-Transport-Security": [ + "max-age=63072000; includeSubDomains; preload" + ], + "Vary": [ + "Authorization, origin", + "Accept-Encoding" + ], + "Via": [ + "1.1 b9bc310de68fa79af0899ef6365bb4bc.cloudfront.net (CloudFront)" + ], + "X-Accepted-Oauth-Scopes": [ + "repository" + ], + "X-Amz-Cf-Id": [ + "zpUcAbr-tPY3-6k8cpBL8z16RjSfvxISe08eudpKqITVmPP_K6VJYw==" + ], + "X-Amz-Cf-Pop": [ + "HYD57-P6" + ], + "X-Asap-Succeeded": [ + "True" + ], + "X-B3-Spanid": [ + "10b4a9ae58b2bfd6" + ], + "X-B3-Traceid": [ + "fa76de95450444c5bc60efb41265d706" + ], + "X-Cache": [ + "Miss from cloudfront" + ], + "X-Content-Type-Options": [ + "nosniff" + ], + "X-Credential-Type": [ + "unauthenticated_identity" + ], + "X-Dc-Location": [ + "Micros-3" + ], + "X-Envoy-Attempt-Count": [ + "1" + ], + "X-Frame-Options": [ + "SAMEORIGIN" + ], + "X-Render-Time": [ + "0.10052609443664551" + ], + "X-Request-Count": [ + "562" + ], + "X-Served-By": [ + "9002dd80eba1" + ], + "X-Static-Version": [ + "650ce3fb4191" + ], + "X-Usage-Input-Ops": [ + "0" + ], + "X-Usage-Output-Ops": [ + "0" + ], + "X-Usage-System-Time": [ + "0.003965" + ], + "X-Usage-User-Time": [ + "0.059263" + ], + "X-Used-Mesh": [ + "False" + ], + "X-Version": [ + "650ce3fb4191" + ], + "X-View-Name": [ + "bitbucket.apps.repo2.api.v20.repo.RepositoryHandler" + ], + "X-Xss-Protection": [ + "1; mode=block" + ] + }, + "body": "{\"type\": \"repository\", \"full_name\": \"tutorials/markdowndemo\", \"links\": {\"self\": {\"href\": \"https://api.bitbucket.org/2.0/repositories/tutorials/markdowndemo\"}, \"html\": {\"href\": \"https://bitbucket.org/tutorials/markdowndemo\"}, \"avatar\": {\"href\": \"https://bytebucket.org/ravatar/%7B97a9a6a1-2b16-445f-bda9-5781d0153d70%7D?ts=default\"}, \"pullrequests\": {\"href\": \"https://api.bitbucket.org/2.0/repositories/tutorials/markdowndemo/pullrequests\"}, \"commits\": {\"href\": \"https://api.bitbucket.org/2.0/repositories/tutorials/markdowndemo/commits\"}, \"forks\": {\"href\": \"https://api.bitbucket.org/2.0/repositories/tutorials/markdowndemo/forks\"}, \"watchers\": {\"href\": \"https://api.bitbucket.org/2.0/repositories/tutorials/markdowndemo/watchers\"}, \"branches\": {\"href\": \"https://api.bitbucket.org/2.0/repositories/tutorials/markdowndemo/refs/branches\"}, \"tags\": {\"href\": \"https://api.bitbucket.org/2.0/repositories/tutorials/markdowndemo/refs/tags\"}, \"downloads\": {\"href\": \"https://api.bitbucket.org/2.0/repositories/tutorials/markdowndemo/downloads\"}, \"source\": {\"href\": \"https://api.bitbucket.org/2.0/repositories/tutorials/markdowndemo/src\"}, \"clone\": [{\"name\": \"https\", \"href\": \"https://bitbucket.org/tutorials/markdowndemo.git\"}, {\"name\": \"ssh\", \"href\": \"git@ssh.bitbucket.org:tutorials/markdowndemo.git\"}], \"issues\": {\"href\": \"https://api.bitbucket.org/2.0/repositories/tutorials/markdowndemo/issues\"}, \"hooks\": {\"href\": \"https://api.bitbucket.org/2.0/repositories/tutorials/markdowndemo/hooks\"}}, \"name\": \"MarkdownDemo\", \"slug\": \"markdowndemo\", \"description\": \"\", \"scm\": \"git\", \"website\": \"\", \"owner\": {\"display_name\": \"tutorials account\", \"links\": {\"self\": {\"href\": \"https://api.bitbucket.org/2.0/workspaces/%7Bc788b2da-b7a2-404c-9e26-d3f077557007%7D\"}, \"avatar\": {\"href\": \"https://bitbucket.org/account/tutorials-team/avatar/\"}, \"html\": {\"href\": \"https://bitbucket.org/%7B7995b79e-9d2c-4c79-8303-635527d06dbb%7D/\"}}, \"type\": \"team\", \"uuid\": \"{7995b79e-9d2c-4c79-8303-635527d06dbb}\", \"username\": \"tutorials-team\"}, \"workspace\": {\"type\": \"workspace\", \"uuid\": \"{c788b2da-b7a2-404c-9e26-d3f077557007}\", \"name\": \"tutorials account\", \"slug\": \"tutorials\", \"links\": {\"avatar\": {\"href\": \"https://bitbucket.org/workspaces/tutorials/avatar/?ts=1543463025\"}, \"html\": {\"href\": \"https://bitbucket.org/tutorials/\"}, \"self\": {\"href\": \"https://api.bitbucket.org/2.0/workspaces/tutorials\"}}}, \"is_private\": false, \"project\": {\"type\": \"project\", \"key\": \"PROJ\", \"uuid\": \"{142f0b6a-6cdf-4f19-b99f-dcc5e58a8f03}\", \"name\": \"Untitled project\", \"links\": {\"self\": {\"href\": \"https://api.bitbucket.org/2.0/workspaces/tutorials/projects/PROJ\"}, \"html\": {\"href\": \"https://bitbucket.org/tutorials/workspace/projects/PROJ\"}, \"avatar\": {\"href\": \"https://bitbucket.org/tutorials/workspace/projects/PROJ/avatar/32?ts=1543463025\"}}}, \"fork_policy\": \"allow_forks\", \"created_on\": \"2013-08-26T16:37:02.542269+00:00\", \"updated_on\": \"2026-10-08T08:08:35.973576+00:00\", \"size\": 1554546, \"language\": \"\", \"uuid\": \"{97a9a6a1-2b16-445f-bda9-5781d0153d70}\", \"mainbranch\": {\"name\": \"master\", \"type\": \"branch\"}, \"override_settings\": {\"default_merge_strategy\": false, \"branching_model\": false}, \"parent\": null, \"enforced_signed_commits\": null, \"has_issues\": true, \"has_wiki\": true}" + } + } +] diff --git a/bitbucket/testdata/replay/TestReplayGetRepoNotFound.json b/bitbucket/testdata/replay/TestReplayGetRepoNotFound.json new file mode 100644 index 0000000..73adf4a --- /dev/null +++ b/bitbucket/testdata/replay/TestReplayGetRepoNotFound.json @@ -0,0 +1,128 @@ +[ + { + "request": { + "method": "GET", + "url": "https://api.bitbucket.org/2.0/repositories/tutorials/this-repository-does-not-exist" + }, + "response": { + "status_code": 404, + "header": { + "Age": [ + "0" + ], + "Atl-Request-Id": [ + "92cd09f7-7628-4b0f-bd9f-7b84d2bc5d25" + ], + "Atl-Traceid": [ + "92cd09f776284b0fbd9f7b84d2bc5d25" + ], + "Cache-Control": [ + "max-age=900" + ], + "Content-Type": [ + "application/json; charset=utf-8" + ], + "Date": [ + "Fri, 13 Nov 2026 04:13:10 GMT" + ], + "Etag": [ + "\"540e4ce8c3885eb9205fe234e50848c2\"" + ], + "Nel": [ + "{\"failure_fraction\": 0.01, \"include_subdomains\": true, \"max_age\": 600, \"report_to\": \"endpoint-1\"}" + ], + "Report-To": [ + "{\"endpoints\": [{\"url\": \"https://dz8aopenkvv6s.cloudfront.net\"}], \"group\": \"endpoint-1\", \"include_subdomains\": true, \"max_age\": 600}" + ], + "Server": [ + "AtlassianEdge" + ], + "Server-Timing": [ + "atl-edge;dur=74,atl-edge-internal;dur=7,atl-edge-upstream;dur=67,atl-edge-pop;desc=\"aws-us-east-1\"" + ], + "Strict-Transport-Security": [ + "max-age=63072000; includeSubDomains; preload" + ], + "Vary": [ + "authorization, cookie, user-context, origin" + ], + "Via": [ + "1.1 b9bc310de68fa79af0899ef6365bb4bc.cloudfront.net (CloudFront)" + ], + "X-Accepted-Oauth-Scopes": [ + "repository" + ], + "X-Amz-Cf-Id": [ + "YqT7rO9d3Hyw3oEJIWwN71cmOLXgdtAVUuGg6Jr_DjVgf0ghUzN-8Q==" + ], + "X-Amz-Cf-Pop": [ + "HYD57-P6" + ], + "X-Asap-Succeeded": [ + "True" + ], + "X-B3-Spanid": [ + "938d3fc44740ee12" + ], + "X-B3-Traceid": [ + "92cd09f776284b0fbd9f7b84d2bc5d25" + ], + "X-Cache": [ + "Error from cloudfront" + ], + "X-Content-Type-Options": [ + "nosniff" + ], + "X-Credential-Type": [ + "unauthenticated_identity" + ], + "X-Dc-Location": [ + "Micros-3" + ], + "X-Envoy-Attempt-Count": [ + "1" + ], + "X-Frame-Options": [ + "SAMEORIGIN" + ], + "X-Render-Time": [ + "0.03886270523071289" + ], + "X-Request-Count": [ + "611" + ], + "X-Served-By": [ + "a73bb510fedf" + ], + "X-Static-Version": [ + "650ce3fb4191" + ], + "X-Usage-Input-Ops": [ + "0" + ], + "X-Usage-Output-Ops": [ + "0" + ], + "X-Usage-System-Time": [ + "0.000876" + ], + "X-Usage-User-Time": [ + "0.032838" + ], + "X-Used-Mesh": [ + "False" + ], + "X-Version": [ + "650ce3fb4191" + ], + "X-View-Name": [ + "bitbucket.apps.repo2.api.v20.repo.RepositoryHandler" + ], + "X-Xss-Protection": [ + "1; mode=block" + ] + }, + "body": "{\"type\": \"error\", \"error\": {\"message\": \"You may not have access to this repository or it no longer exists in this workspace. If you think this repository exists and you have access, make sure you are authenticated.\"}}" + } + } +] diff --git a/gitea/replay_test.go b/gitea/replay_test.go new file mode 100644 index 0000000..82319c7 --- /dev/null +++ b/gitea/replay_test.go @@ -0,0 +1,68 @@ +package gitea + +import ( + "context" + "errors" + "strings" + "testing" + + forge "github.com/git-pkgs/forge" + "github.com/git-pkgs/forge/internal/httpreplay" +) + +// These tests replay responses recorded from codeberg.org, which runs +// Forgejo. Re-record them with FORGE_RECORD=1 go test ./gitea -run TestReplay. + +func TestReplayGetRepo(t *testing.T) { + f := New("https://codeberg.org", "", httpreplay.Client(t)) + repo, err := f.Repos().Get(context.Background(), "Codeberg", "Documentation") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + assertEqual(t, "FullName", "Codeberg/Documentation", repo.FullName) + assertEqual(t, "Owner", "Codeberg", repo.Owner) + assertEqual(t, "Name", "Documentation", repo.Name) + assertEqual(t, "HTMLURL", "https://codeberg.org/Codeberg/Documentation", repo.HTMLURL) + assertEqual(t, "CloneURL", "https://codeberg.org/Codeberg/Documentation.git", repo.CloneURL) + assertEqual(t, "DefaultBranch", "main", repo.DefaultBranch) + assertEqualBool(t, "Private", false, repo.Private) + if repo.CreatedAt.IsZero() { + t.Error("CreatedAt is zero") + } +} + +func TestReplayGetRepoNotFound(t *testing.T) { + f := New("https://codeberg.org", "", httpreplay.Client(t)) + _, err := f.Repos().Get(context.Background(), "Codeberg", "this-repository-does-not-exist") + if !errors.Is(err, forge.ErrNotFound) { + t.Fatalf("expected ErrNotFound, got %v", err) + } +} + +func TestReplayListBranches(t *testing.T) { + f := New("https://codeberg.org", "", httpreplay.Client(t)) + branches, err := f.Branches().List(context.Background(), "Codeberg", "Documentation", forge.ListBranchOpts{Limit: 1}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + assertEqualInt(t, "len(branches)", 1, len(branches)) + if branches[0].Name == "" || len(branches[0].SHA) != 40 { + t.Errorf("branch missing name or SHA: %+v", branches[0]) + } +} + +func TestReplayGetFile(t *testing.T) { + f := New("https://codeberg.org", "", httpreplay.Client(t)) + file, err := f.Files().Get(context.Background(), "Codeberg", "Documentation", "LICENSE.md", "main") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + assertEqual(t, "Name", "LICENSE.md", file.Name) + assertEqual(t, "Path", "LICENSE.md", file.Path) + if !strings.Contains(string(file.Content), "licensed under [CC-BY-SA 4.0]") { + t.Errorf("Content does not look like the decoded license: %q", file.Content) + } +} diff --git a/gitea/testdata/replay/TestReplayGetFile.json b/gitea/testdata/replay/TestReplayGetFile.json new file mode 100644 index 0000000..a5d197f --- /dev/null +++ b/gitea/testdata/replay/TestReplayGetFile.json @@ -0,0 +1,53 @@ +[ + { + "request": { + "method": "GET", + "url": "https://codeberg.org/api/v1/repos/Codeberg/Documentation/contents/LICENSE.md?ref=main" + }, + "response": { + "status_code": 200, + "header": { + "Alt-Svc": [ + "h3=\":443\";ma=2592000;" + ], + "Cache-Control": [ + "max-age=0, private, must-revalidate" + ], + "Content-Type": [ + "application/json;charset=utf-8" + ], + "Date": [ + "Fri, 13 Nov 2026 04:12:54 GMT" + ], + "Permissions-Policy": [ + "interest-cohort=()" + ], + "Ratelimit": [ + "\"baseline\";r=1991;t=600" + ], + "Ratelimit-Policy": [ + "\"baseline\";q=2000;w=600" + ], + "Strict-Transport-Security": [ + "max-age=63072000; includeSubDomains; preload;" + ], + "Vary": [ + "Origin,Accept-Encoding" + ], + "X-Backend-Name": [ + "b_forgejo_secondary" + ], + "X-Content-Type-Options": [ + "nosniff" + ], + "X-Frame-Options": [ + "SAMEORIGIN" + ], + "X-Server-Name": [ + "s_forgejo_secondary_alpspitz" + ] + }, + "body": "{\"name\":\"LICENSE.md\",\"path\":\"LICENSE.md\",\"sha\":\"88913eaa483ba67826545d3b8efba22782ef485e\",\"last_commit_sha\":\"c6ac9a9f948772a1eddd1a255507403b6823cef0\",\"last_commit_when\":\"2025-06-14T11:38:21+02:00\",\"type\":\"file\",\"size\":1104,\"encoding\":\"base64\",\"content\":\"PCEtLSBtYXJrZG93bmxpbnQtZGlzYWJsZSBNRDA0MSAtLT4KClRoaXMgd2Vic2l0ZSBpcyBsaWNlbnNlZCB1bmRlciBbQ0MtQlktU0EgNC4wXShodHRwczovL2NyZWF0aXZlY29tbW9ucy5vcmcvbGljZW5zZXMvYnktc2EvNC4wL2RlZWQuZW4pLgoKRHVlIHRvIHRoZSB3aWtpLWxpa2UgbmF0dXJlIG9mIHRoaXMgd2Vic2l0ZSwgcGFnZXMgdXN1YWxseSBoYXZlIG11bHRpcGxlIGF1dGhvcnMgYW5kIGFyZSBjaGFuZ2VkCmZyZXF1ZW50bHkuIENoYW5nZXMgdG8gdGhlIG9yaWdpbmFsIHZlcnNpb25zIG9mIHRoZSB3b3JrcyBhcyB3ZWxsIGFzIHRoZWlyIGluZGl2aWR1YWwgYXV0aG9ycyBjYW4gYmUKbG9va2VkIHVwIGluIFt0aGUgd2Vic2l0ZSdzIGNvbW1pdCBoaXN0b3J5XShodHRwczovL2NvZGViZXJnLm9yZy9Db2RlYmVyZy9Eb2N1bWVudGF0aW9uL2NvbW1pdHMvYnJhbmNoL21haW4pLgoKVGhlIENvZGViZXJnIGxvZ29zIGluIHRoaXMgd2Vic2l0ZSBhcmUgYnkgQG1yYXksCmxpY2Vuc2VkIHVuZGVyIFtDQzAgMS4wXShodHRwOi8vY3JlYXRpdmVjb21tb25zLm9yZy9wdWJsaWNkb21haW4vemVyby8xLjAvKS4KClRoZSBmb250cyBpbiB0aGlzIHdlYnNpdGUgYXJlIGRvY3VtZW50ZWQgb24gdGhlIFtDb2RlYmVyZyBEZXNpZ24gcmVwb10oaHR0cHM6Ly9jb2RlYmVyZy5vcmcvQ29kZWJlcmcvRGVzaWduI2ZvbnQtaW50ZXIpLgoKVGhlIGljb25zIGluIHRoaXMgd2Vic2l0ZSBhcmUgYnkgW0ZvbnQgQXdlc29tZV0oaHR0cHM6Ly9naXRodWIuY29tL0ZvcnRBd2Vzb21lL0ZvbnQtQXdlc29tZSksCmxpY2Vuc2VkIHVuZGVyIFttdWx0aXBsZSBsaWNlbnNlc10oaHR0cHM6Ly9mb250YXdlc29tZS5jb20vbGljZW5zZS9mcmVlKS4KCkNvZGViZXJnIGFuZCB0aGUgQ29kZWJlcmcgTG9nbyBhcmUgdHJhZGVtYXJrcyBvZiBDb2RlYmVyZyBlLlYuCgoiS251dCB0aGUgUG9sYXIgQmVhciIgaGFzIGJlZW4gZGVyaXZlZCBmcm9tIFtodHRwczovL29wZW5jbGlwYXJ0Lm9yZy9kZXRhaWwvMTkzMjQzL3BvbGFyLWJlYXItcmVtaXhdKGh0dHBzOi8vb3BlbmNsaXBhcnQub3JnL2RldGFpbC8xOTMyNDMvcG9sYXItYmVhci1yZW1peCksCnVuZGVyIENDMCAxLjAK\",\"target\":null,\"url\":\"https://codeberg.org/api/v1/repos/Codeberg/Documentation/contents/LICENSE.md?ref=main\",\"html_url\":\"https://codeberg.org/Codeberg/Documentation/src/branch/main/LICENSE.md\",\"git_url\":\"https://codeberg.org/api/v1/repos/Codeberg/Documentation/git/blobs/88913eaa483ba67826545d3b8efba22782ef485e\",\"download_url\":\"https://codeberg.org/Codeberg/Documentation/raw/branch/main/LICENSE.md\",\"submodule_git_url\":null,\"_links\":{\"self\":\"https://codeberg.org/api/v1/repos/Codeberg/Documentation/contents/LICENSE.md?ref=main\",\"git\":\"https://codeberg.org/api/v1/repos/Codeberg/Documentation/git/blobs/88913eaa483ba67826545d3b8efba22782ef485e\",\"html\":\"https://codeberg.org/Codeberg/Documentation/src/branch/main/LICENSE.md\"}}\n" + } + } +] diff --git a/gitea/testdata/replay/TestReplayGetRepo.json b/gitea/testdata/replay/TestReplayGetRepo.json new file mode 100644 index 0000000..c2a11fe --- /dev/null +++ b/gitea/testdata/replay/TestReplayGetRepo.json @@ -0,0 +1,110 @@ +[ + { + "request": { + "method": "GET", + "url": "https://codeberg.org/api/v1/repos/Codeberg/Documentation" + }, + "response": { + "status_code": 200, + "header": { + "Alt-Svc": [ + "h3=\":443\";ma=2592000;" + ], + "Cache-Control": [ + "max-age=0, private, must-revalidate" + ], + "Content-Type": [ + "application/json;charset=utf-8" + ], + "Date": [ + "Fri, 13 Nov 2026 04:12:53 GMT" + ], + "Permissions-Policy": [ + "interest-cohort=()" + ], + "Ratelimit": [ + "\"baseline\";r=1995;t=600" + ], + "Ratelimit-Policy": [ + "\"baseline\";q=2000;w=600" + ], + "Strict-Transport-Security": [ + "max-age=63072000; includeSubDomains; preload;" + ], + "Vary": [ + "Origin,Accept-Encoding" + ], + "X-Backend-Name": [ + "b_forgejo_secondary" + ], + "X-Content-Type-Options": [ + "nosniff" + ], + "X-Frame-Options": [ + "SAMEORIGIN" + ], + "X-Server-Name": [ + "s_forgejo_secondary_alpspitz" + ] + }, + "body": "{\"id\":855,\"owner\":{\"id\":37566,\"login\":\"Codeberg\",\"login_name\":\"\",\"source_id\":0,\"full_name\":\"Codeberg\",\"email\":\"codeberg@noreply.codeberg.org\",\"avatar_url\":\"https://codeberg.org/avatars/8333683f10a92a1a06956d9ab61594d2acf9af24f4b70d6cd784aafa5a61a3db\",\"html_url\":\"https://codeberg.org/Codeberg\",\"language\":\"\",\"is_admin\":false,\"last_login\":\"0001-01-01T00:00:00Z\",\"created\":\"2022-01-09T23:34:52+01:00\",\"restricted\":false,\"active\":false,\"prohibit_login\":false,\"location\":\"distributed\",\"pronouns\":\"\",\"website\":\"https://codeberg.org\",\"description\":\"Codeberg is a non-profit, community-led effort that provides Git hosting and other services for free and open source projects.\",\"visibility\":\"public\",\"followers_count\":550,\"following_count\":0,\"starred_repos_count\":0,\"username\":\"Codeberg\"},\"name\":\"Documentation\",\"full_name\":\"Codeberg/Documentation\",\"description\":\"Codeberg Documentation – tutorials, how-to's and other Codeberg-specific resources for users.\",\"empty\":false,\"private\":false,\"fork\":false,\"template\":false,\"parent\":null,\"mirror\":false,\"size\":20789,\"language\":\"CSS\",\"languages_url\":\"https://codeberg.org/api/v1/repos/Codeberg/Documentation/languages\",\"html_url\":\"https://codeberg.org/Codeberg/Documentation\",\"url\":\"https://codeberg.org/api/v1/repos/Codeberg/Documentation\",\"link\":\"\",\"ssh_url\":\"ssh://git@codeberg.org/Codeberg/Documentation.git\",\"clone_url\":\"https://codeberg.org/Codeberg/Documentation.git\",\"original_url\":\"\",\"website\":\"https://docs.codeberg.org\",\"stars_count\":214,\"forks_count\":171,\"watchers_count\":44,\"open_issues_count\":71,\"open_pr_counter\":11,\"release_counter\":0,\"default_branch\":\"main\",\"archived\":false,\"created_at\":\"2019-06-04T16:53:46+02:00\",\"updated_at\":\"2026-11-10T01:09:47+01:00\",\"archived_at\":\"1970-01-01T01:00:00+01:00\",\"permissions\":{\"admin\":false,\"push\":false,\"pull\":true},\"has_issues\":true,\"internal_tracker\":{\"enable_time_tracker\":true,\"allow_only_contributors_to_track_time\":true,\"enable_issue_dependencies\":true},\"has_wiki\":true,\"has_wiki_contents\":true,\"external_wiki\":{\"external_wiki_url\":\"https://docs.codeberg.org/improving-documentation/\"},\"wiki_branch\":\"master\",\"wiki_ssh_url\":\"ssh://git@codeberg.org/Codeberg/Documentation.wiki.git\",\"wiki_clone_url\":\"https://codeberg.org/Codeberg/Documentation.wiki.git\",\"globally_editable_wiki\":false,\"has_pull_requests\":true,\"has_projects\":false,\"has_releases\":false,\"has_packages\":false,\"has_actions\":false,\"ignore_whitespace_conflicts\":false,\"allow_merge_commits\":false,\"allow_rebase\":false,\"allow_rebase_explicit\":false,\"allow_squash_merge\":true,\"allow_fast_forward_only_merge\":false,\"allow_rebase_update\":true,\"default_delete_branch_after_merge\":true,\"default_merge_style\":\"squash\",\"default_allow_maintainer_edit\":true,\"default_update_style\":\"merge\",\"avatar_url\":\"https://codeberg.org/repo-avatars/855-3e1afcfb1480c4a83338aec024f4e574\",\"internal\":false,\"mirror_interval\":\"\",\"object_format_name\":\"sha1\",\"mirror_updated\":\"0001-01-01T00:00:00Z\",\"repo_transfer\":null,\"topics\":[\"codeberg\",\"docs\",\"eleventy\",\"docuthon\",\"contributions-welcome\"]}\n" + } + }, + { + "request": { + "method": "GET", + "url": "https://codeberg.org/api/v1/repos/Codeberg/Documentation/topics?limit=0&page=1" + }, + "response": { + "status_code": 200, + "header": { + "Access-Control-Expose-Headers": [ + "X-Total-Count" + ], + "Alt-Svc": [ + "h3=\":443\";ma=2592000;" + ], + "Cache-Control": [ + "max-age=0, private, must-revalidate" + ], + "Content-Type": [ + "application/json;charset=utf-8" + ], + "Date": [ + "Fri, 13 Nov 2026 04:12:53 GMT" + ], + "Permissions-Policy": [ + "interest-cohort=()" + ], + "Ratelimit": [ + "\"baseline\";r=1994;t=600" + ], + "Ratelimit-Policy": [ + "\"baseline\";q=2000;w=600" + ], + "Strict-Transport-Security": [ + "max-age=63072000; includeSubDomains; preload;" + ], + "Vary": [ + "Origin" + ], + "X-Backend-Name": [ + "b_forgejo_secondary" + ], + "X-Content-Type-Options": [ + "nosniff" + ], + "X-Frame-Options": [ + "SAMEORIGIN" + ], + "X-Server-Name": [ + "s_forgejo_secondary_alpspitz" + ], + "X-Total-Count": [ + "5" + ] + }, + "body": "{\"topics\":[\"codeberg\",\"contributions-welcome\",\"docs\",\"docuthon\",\"eleventy\"]}\n" + } + } +] diff --git a/gitea/testdata/replay/TestReplayGetRepoNotFound.json b/gitea/testdata/replay/TestReplayGetRepoNotFound.json new file mode 100644 index 0000000..3522eef --- /dev/null +++ b/gitea/testdata/replay/TestReplayGetRepoNotFound.json @@ -0,0 +1,53 @@ +[ + { + "request": { + "method": "GET", + "url": "https://codeberg.org/api/v1/repos/Codeberg/this-repository-does-not-exist" + }, + "response": { + "status_code": 404, + "header": { + "Alt-Svc": [ + "h3=\":443\";ma=2592000;" + ], + "Cache-Control": [ + "max-age=0, private, must-revalidate" + ], + "Content-Type": [ + "application/json;charset=utf-8" + ], + "Date": [ + "Fri, 13 Nov 2026 04:12:53 GMT" + ], + "Permissions-Policy": [ + "interest-cohort=()" + ], + "Ratelimit": [ + "\"baseline\";r=1993;t=600" + ], + "Ratelimit-Policy": [ + "\"baseline\";q=2000;w=600" + ], + "Strict-Transport-Security": [ + "max-age=63072000; includeSubDomains; preload;" + ], + "Vary": [ + "Origin" + ], + "X-Backend-Name": [ + "b_forgejo_secondary" + ], + "X-Content-Type-Options": [ + "nosniff" + ], + "X-Frame-Options": [ + "SAMEORIGIN" + ], + "X-Server-Name": [ + "s_forgejo_secondary_alpspitz" + ] + }, + "body": "{\"message\":\"The target couldn't be found.\",\"url\":\"https://codeberg.org/api/swagger\",\"errors\":[]}\n" + } + } +] diff --git a/gitea/testdata/replay/TestReplayListBranches.json b/gitea/testdata/replay/TestReplayListBranches.json new file mode 100644 index 0000000..cf41546 --- /dev/null +++ b/gitea/testdata/replay/TestReplayListBranches.json @@ -0,0 +1,59 @@ +[ + { + "request": { + "method": "GET", + "url": "https://codeberg.org/api/v1/repos/Codeberg/Documentation/branches?limit=50&page=1" + }, + "response": { + "status_code": 200, + "header": { + "Access-Control-Expose-Headers": [ + "X-Total-Count" + ], + "Alt-Svc": [ + "h3=\":443\";ma=2592000;" + ], + "Cache-Control": [ + "max-age=0, private, must-revalidate" + ], + "Content-Type": [ + "application/json;charset=utf-8" + ], + "Date": [ + "Fri, 13 Nov 2026 04:12:54 GMT" + ], + "Permissions-Policy": [ + "interest-cohort=()" + ], + "Ratelimit": [ + "\"baseline\";r=1992;t=600" + ], + "Ratelimit-Policy": [ + "\"baseline\";q=2000;w=600" + ], + "Strict-Transport-Security": [ + "max-age=63072000; includeSubDomains; preload;" + ], + "Vary": [ + "Origin,Accept-Encoding" + ], + "X-Backend-Name": [ + "b_forgejo_secondary" + ], + "X-Content-Type-Options": [ + "nosniff" + ], + "X-Frame-Options": [ + "SAMEORIGIN" + ], + "X-Server-Name": [ + "s_forgejo_secondary_alpspitz" + ], + "X-Total-Count": [ + "10" + ] + }, + "body": "[{\"name\":\"renovate/pnpm-12.x\",\"commit\":{\"id\":\"7610635e023bd543163c8a4a3a544e0f05b47929\",\"message\":\"chore(deps): update dependency pnpm to v12.18.0\\n\",\"url\":\"https://codeberg.org/Codeberg/Documentation/commit/7610635e023bd543163c8a4a3a544e0f05b47929\",\"author\":{\"name\":\"woodpecker-bot\",\"email\":\"woodpecker-bot@obermui.de\",\"username\":\"woodpecker-bot\"},\"committer\":{\"name\":\"woodpecker-bot\",\"email\":\"woodpecker-bot@obermui.de\",\"username\":\"woodpecker-bot\"},\"verification\":{\"verified\":false,\"reason\":\"gpg.error.not_signed_commit\",\"signature\":\"\",\"signer\":null,\"payload\":\"\"},\"timestamp\":\"2026-11-10T00:09:44Z\",\"added\":null,\"removed\":null,\"modified\":null},\"protected\":false,\"required_approvals\":0,\"enable_status_check\":false,\"status_check_contexts\":[],\"user_can_push\":false,\"user_can_merge\":false,\"effective_branch_protection_name\":\"\"},{\"name\":\"renovate/11ty-eleventy-3.x\",\"commit\":{\"id\":\"d7f9c9650891e8b3deaba5e85b9c1ab880b8157c\",\"message\":\"chore(deps): update dependency @11ty/eleventy to v3.1.8\\n\",\"url\":\"https://codeberg.org/Codeberg/Documentation/commit/d7f9c9650891e8b3deaba5e85b9c1ab880b8157c\",\"author\":{\"name\":\"woodpecker-bot\",\"email\":\"woodpecker-bot@obermui.de\",\"username\":\"woodpecker-bot\"},\"committer\":{\"name\":\"woodpecker-bot\",\"email\":\"woodpecker-bot@obermui.de\",\"username\":\"woodpecker-bot\"},\"verification\":{\"verified\":false,\"reason\":\"gpg.error.not_signed_commit\",\"signature\":\"\",\"signer\":null,\"payload\":\"\"},\"timestamp\":\"2026-11-10T00:09:38Z\",\"added\":null,\"removed\":null,\"modified\":null},\"protected\":false,\"required_approvals\":0,\"enable_status_check\":false,\"status_check_contexts\":[],\"user_can_push\":false,\"user_can_merge\":false,\"effective_branch_protection_name\":\"\"},{\"name\":\"renovate/alpine-3.x\",\"commit\":{\"id\":\"778cc00fa58ef523d670a0eca8e6d85e460e023f\",\"message\":\"chore(deps): update alpine docker tag to v3.24.4\\n\",\"url\":\"https://codeberg.org/Codeberg/Documentation/commit/778cc00fa58ef523d670a0eca8e6d85e460e023f\",\"author\":{\"name\":\"woodpecker-bot\",\"email\":\"woodpecker-bot@obermui.de\",\"username\":\"woodpecker-bot\"},\"committer\":{\"name\":\"woodpecker-bot\",\"email\":\"woodpecker-bot@obermui.de\",\"username\":\"woodpecker-bot\"},\"verification\":{\"verified\":false,\"reason\":\"gpg.error.not_signed_commit\",\"signature\":\"\",\"signer\":null,\"payload\":\"\"},\"timestamp\":\"2026-11-10T00:09:17Z\",\"added\":null,\"removed\":null,\"modified\":null},\"protected\":false,\"required_approvals\":0,\"enable_status_check\":false,\"status_check_contexts\":[],\"user_can_push\":false,\"user_can_merge\":false,\"effective_branch_protection_name\":\"\"},{\"name\":\"renovate/node-24-alpine3.24\",\"commit\":{\"id\":\"ae0015c582c1c7011758a58a620ec9a497f0f762\",\"message\":\"chore(deps): update node.js to a34ab6d\\n\",\"url\":\"https://codeberg.org/Codeberg/Documentation/commit/ae0015c582c1c7011758a58a620ec9a497f0f762\",\"author\":{\"name\":\"woodpecker-bot\",\"email\":\"woodpecker-bot@obermui.de\",\"username\":\"woodpecker-bot\"},\"committer\":{\"name\":\"woodpecker-bot\",\"email\":\"woodpecker-bot@obermui.de\",\"username\":\"woodpecker-bot\"},\"verification\":{\"verified\":false,\"reason\":\"gpg.error.not_signed_commit\",\"signature\":\"\",\"signer\":null,\"payload\":\"\"},\"timestamp\":\"2026-11-10T00:09:08Z\",\"added\":null,\"removed\":null,\"modified\":null},\"protected\":false,\"required_approvals\":0,\"enable_status_check\":false,\"status_check_contexts\":[],\"user_can_push\":false,\"user_can_merge\":false,\"effective_branch_protection_name\":\"\"},{\"name\":\"renovate/node-lts-slim\",\"commit\":{\"id\":\"c0034798e1d07360120063354fc25f42d5a82e96\",\"message\":\"chore(deps): update node.js to 5adad64\\n\",\"url\":\"https://codeberg.org/Codeberg/Documentation/commit/c0034798e1d07360120063354fc25f42d5a82e96\",\"author\":{\"name\":\"woodpecker-bot\",\"email\":\"woodpecker-bot@obermui.de\",\"username\":\"woodpecker-bot\"},\"committer\":{\"name\":\"woodpecker-bot\",\"email\":\"woodpecker-bot@obermui.de\",\"username\":\"woodpecker-bot\"},\"verification\":{\"verified\":false,\"reason\":\"gpg.error.not_signed_commit\",\"signature\":\"\",\"signer\":null,\"payload\":\"\"},\"timestamp\":\"2026-11-10T00:09:03Z\",\"added\":null,\"removed\":null,\"modified\":null},\"protected\":false,\"required_approvals\":0,\"enable_status_check\":false,\"status_check_contexts\":[],\"user_can_push\":false,\"user_can_merge\":false,\"effective_branch_protection_name\":\"\"},{\"name\":\"main\",\"commit\":{\"id\":\"b233bfedacee500d9a3364741297cc28caa4491a\",\"message\":\"Codeberg Pages: modify instructions for Forgejo's git-pages webhook type (#978)\\n\\nReviewed-on: https://codeberg.org/Codeberg/Documentation/pulls/978\\nReviewed-by: Catherine \\u003cwhitequark@whitequark.org\\u003e\\nReviewed-by: Robert Wolff \\u003cmahlzahn@posteo.de\\u003e\\n\",\"url\":\"https://codeberg.org/Codeberg/Documentation/commit/b233bfedacee500d9a3364741297cc28caa4491a\",\"author\":{\"name\":\"crystal\",\"email\":\"crystal@noreply.codeberg.org\",\"username\":\"crystal\"},\"committer\":{\"name\":\"Robert Wolff\",\"email\":\"mahlzahn@posteo.de\",\"username\":\"mahlzahn\"},\"verification\":{\"verified\":false,\"reason\":\"gpg.error.not_signed_commit\",\"signature\":\"\",\"signer\":null,\"payload\":\"\"},\"timestamp\":\"2026-11-08T08:45:54+01:00\",\"added\":null,\"removed\":null,\"modified\":null},\"protected\":true,\"required_approvals\":0,\"enable_status_check\":true,\"status_check_contexts\":[\"ci/woodpecker/pr/lint\",\"ci/woodpecker/pr/deploy-preview\"],\"user_can_push\":false,\"user_can_merge\":false,\"effective_branch_protection_name\":\"\"},{\"name\":\"ci_pr_close_no_preview\",\"commit\":{\"id\":\"888709e1105d62bc3773d12622439e9ea70cb4c7\",\"message\":\"fix: do not run preview pipeline on PR closing (revert 2d9da16b4b84c589f85f7b7aa5f4bd9c3f41c51f)\\n\\nrevert trigger pipeline on `pull_request_closed` event\\r\\n\\r\\nNo need to do preview on closing a PR …\\n\",\"url\":\"https://codeberg.org/Codeberg/Documentation/commit/888709e1105d62bc3773d12622439e9ea70cb4c7\",\"author\":{\"name\":\"mahlzahn\",\"email\":\"mahlzahn@posteo.de\",\"username\":\"mahlzahn\"},\"committer\":{\"name\":\"mahlzahn\",\"email\":\"mahlzahn@posteo.de\",\"username\":\"mahlzahn\"},\"verification\":{\"verified\":false,\"reason\":\"gpg.error.not_signed_commit\",\"signature\":\"\",\"signer\":null,\"payload\":\"\"},\"timestamp\":\"2026-06-25T11:32:22+02:00\",\"added\":null,\"removed\":null,\"modified\":null},\"protected\":false,\"required_approvals\":0,\"enable_status_check\":false,\"status_check_contexts\":[],\"user_can_push\":false,\"user_can_merge\":false,\"effective_branch_protection_name\":\"\"},{\"name\":\"pr-554\",\"commit\":{\"id\":\"9e3c0c6ed07a77a60c74dbceb56fa20e21487b56\",\"message\":\"chore: linter \\u0026 spelling\\n\",\"url\":\"https://codeberg.org/Codeberg/Documentation/commit/9e3c0c6ed07a77a60c74dbceb56fa20e21487b56\",\"author\":{\"name\":\"pat-s\",\"email\":\"patrick.schratz@gmail.com\",\"username\":\"pat-s\"},\"committer\":{\"name\":\"pat-s\",\"email\":\"patrick.schratz@gmail.com\",\"username\":\"pat-s\"},\"verification\":{\"verified\":true,\"reason\":\"pat-s / 3C6318841EF78925\",\"signature\":\"-----BEGIN PGP SIGNATURE-----\\n\\niQIzBAABCgAdFiEE11xPG27iyM03J9FCPGMYhB73iSUFAmhJ4mAACgkQPGMYhB73\\niSXK7RAAzk0/Zxdh1YX5A/dd4RxVBM5BLLtERbjHaWEad7ATwV5QnaeX47iLPXBj\\nlLs704/yJBI/GdoRFSQK9wU2EIILEA7LkVJgUH1pC74+eRTyjptxxZX67xVhLmfX\\nS9Ozfs/stNjvT5gZwVV7Qknof5U3EGtqrD6+W/dLoHQm21sFbbCzmSUaIDMlLCPf\\n9lq8Cj/BEksS3A4mgZJu2y2xp98154dmOOHdAyyzfZqLyEkeGp1/rEL6/eHjfcej\\nd2P6HFFQw6L+3+3tPLIt1FWJ3Z2+5whGO+mAwXrqn/WVNZ1z8rUUAchjtITsozAv\\n/SXzBT9lz2/8TkPKi+jcshdjbdbfu0lfrqgFCpxe29A9S16e2qXZezmAZmB/HLwG\\n+uCOlb/PTdS3KUKr2C7mSlyvNUBY6A2P1F1WYjESYjgEwnrdkj7ozJM0+PnY54kW\\nWBDEqiiSPkX1rZz1rjewpqkZ8hsjj6Lx4AxAON8bAXRDeD5AJVcG9sSnCt2sDr04\\nQOxXBrvoThSgOoi6CgssiycXjCixktvNnYpETp6tlsAiVDQ17jzu2wAMIN8Digaj\\nJEUKCoFPcIXpoOCDa6jrvNGsGpv/ywnKCuqUC2gasMajWiJwDafuFu0cT2TGl7mO\\nU+wp5EJeSJMYoyWUCq63sO0oJ6ktB5mYIqj/SZ9ZZKzoS4ZfG5Q=\\n=8ZMT\\n-----END PGP SIGNATURE-----\\n\",\"signer\":{\"name\":\"pat-s\",\"email\":\"patrick.schratz@gmail.com\",\"username\":\"\"},\"payload\":\"tree 18e10463755b697e194c1303af6de45079749234\\nparent 258064e9265532afd298a13f4be105d9bcd3ea0a\\nauthor pat-s \\u003cpatrick.schratz@gmail.com\\u003e 1749672544 +0200\\ncommitter pat-s \\u003cpatrick.schratz@gmail.com\\u003e 1749672544 +0200\\n\\nchore: linter \\u0026 spelling\\n\"},\"timestamp\":\"2025-06-11T22:09:04+02:00\",\"added\":null,\"removed\":null,\"modified\":null},\"protected\":false,\"required_approvals\":0,\"enable_status_check\":false,\"status_check_contexts\":[],\"user_can_push\":false,\"user_can_merge\":false,\"effective_branch_protection_name\":\"\"},{\"name\":\"pr/554\",\"commit\":{\"id\":\"9e3c0c6ed07a77a60c74dbceb56fa20e21487b56\",\"message\":\"chore: linter \\u0026 spelling\\n\",\"url\":\"https://codeberg.org/Codeberg/Documentation/commit/9e3c0c6ed07a77a60c74dbceb56fa20e21487b56\",\"author\":{\"name\":\"pat-s\",\"email\":\"patrick.schratz@gmail.com\",\"username\":\"pat-s\"},\"committer\":{\"name\":\"pat-s\",\"email\":\"patrick.schratz@gmail.com\",\"username\":\"pat-s\"},\"verification\":{\"verified\":true,\"reason\":\"pat-s / 3C6318841EF78925\",\"signature\":\"-----BEGIN PGP SIGNATURE-----\\n\\niQIzBAABCgAdFiEE11xPG27iyM03J9FCPGMYhB73iSUFAmhJ4mAACgkQPGMYhB73\\niSXK7RAAzk0/Zxdh1YX5A/dd4RxVBM5BLLtERbjHaWEad7ATwV5QnaeX47iLPXBj\\nlLs704/yJBI/GdoRFSQK9wU2EIILEA7LkVJgUH1pC74+eRTyjptxxZX67xVhLmfX\\nS9Ozfs/stNjvT5gZwVV7Qknof5U3EGtqrD6+W/dLoHQm21sFbbCzmSUaIDMlLCPf\\n9lq8Cj/BEksS3A4mgZJu2y2xp98154dmOOHdAyyzfZqLyEkeGp1/rEL6/eHjfcej\\nd2P6HFFQw6L+3+3tPLIt1FWJ3Z2+5whGO+mAwXrqn/WVNZ1z8rUUAchjtITsozAv\\n/SXzBT9lz2/8TkPKi+jcshdjbdbfu0lfrqgFCpxe29A9S16e2qXZezmAZmB/HLwG\\n+uCOlb/PTdS3KUKr2C7mSlyvNUBY6A2P1F1WYjESYjgEwnrdkj7ozJM0+PnY54kW\\nWBDEqiiSPkX1rZz1rjewpqkZ8hsjj6Lx4AxAON8bAXRDeD5AJVcG9sSnCt2sDr04\\nQOxXBrvoThSgOoi6CgssiycXjCixktvNnYpETp6tlsAiVDQ17jzu2wAMIN8Digaj\\nJEUKCoFPcIXpoOCDa6jrvNGsGpv/ywnKCuqUC2gasMajWiJwDafuFu0cT2TGl7mO\\nU+wp5EJeSJMYoyWUCq63sO0oJ6ktB5mYIqj/SZ9ZZKzoS4ZfG5Q=\\n=8ZMT\\n-----END PGP SIGNATURE-----\\n\",\"signer\":{\"name\":\"pat-s\",\"email\":\"patrick.schratz@gmail.com\",\"username\":\"\"},\"payload\":\"tree 18e10463755b697e194c1303af6de45079749234\\nparent 258064e9265532afd298a13f4be105d9bcd3ea0a\\nauthor pat-s \\u003cpatrick.schratz@gmail.com\\u003e 1749672544 +0200\\ncommitter pat-s \\u003cpatrick.schratz@gmail.com\\u003e 1749672544 +0200\\n\\nchore: linter \\u0026 spelling\\n\"},\"timestamp\":\"2025-06-11T22:09:04+02:00\",\"added\":null,\"removed\":null,\"modified\":null},\"protected\":false,\"required_approvals\":0,\"enable_status_check\":false,\"status_check_contexts\":[],\"user_can_push\":false,\"user_can_merge\":false,\"effective_branch_protection_name\":\"\"},{\"name\":\"gitea-icons-shortcode\",\"commit\":{\"id\":\"e6533299e544bfe1619d8ea7e3c2f84f27e6388e\",\"message\":\"Add Gitea icons shortcode\\n\",\"url\":\"https://codeberg.org/Codeberg/Documentation/commit/e6533299e544bfe1619d8ea7e3c2f84f27e6388e\",\"author\":{\"name\":\"n\",\"email\":\"n@noreply.codeberg.org\",\"username\":\"n\"},\"committer\":{\"name\":\"n\",\"email\":\"n@noreply.codeberg.org\",\"username\":\"n\"},\"verification\":{\"verified\":false,\"reason\":\"gpg.error.not_signed_commit\",\"signature\":\"\",\"signer\":null,\"payload\":\"\"},\"timestamp\":\"2021-05-16T20:10:34+09:00\",\"added\":null,\"removed\":null,\"modified\":null},\"protected\":false,\"required_approvals\":0,\"enable_status_check\":false,\"status_check_contexts\":[],\"user_can_push\":false,\"user_can_merge\":false,\"effective_branch_protection_name\":\"\"}]\n" + } + } +] diff --git a/github/replay_test.go b/github/replay_test.go new file mode 100644 index 0000000..eb04b7d --- /dev/null +++ b/github/replay_test.go @@ -0,0 +1,81 @@ +package github + +import ( + "context" + "errors" + "testing" + + forge "github.com/git-pkgs/forge" + "github.com/git-pkgs/forge/internal/httpreplay" +) + +// These tests replay responses recorded from api.github.com. Re-record them +// with FORGE_RECORD=1 go test ./github -run TestReplay. + +func TestReplayGetRepo(t *testing.T) { + f := New("", httpreplay.Client(t)) + repo, err := f.Repos().Get(context.Background(), "octocat", "Hello-World") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + assertEqual(t, "FullName", "octocat/Hello-World", repo.FullName) + assertEqual(t, "Owner", "octocat", repo.Owner) + assertEqual(t, "Name", "Hello-World", repo.Name) + assertEqual(t, "HTMLURL", "https://github.com/octocat/Hello-World", repo.HTMLURL) + assertEqual(t, "CloneURL", "https://github.com/octocat/Hello-World.git", repo.CloneURL) + assertEqual(t, "SSHURL", "git@github.com:octocat/Hello-World.git", repo.SSHURL) + assertEqual(t, "DefaultBranch", "master", repo.DefaultBranch) + assertEqualBool(t, "Fork", false, repo.Fork) + assertEqualBool(t, "Private", false, repo.Private) + if repo.CreatedAt.IsZero() { + t.Error("CreatedAt is zero") + } +} + +func TestReplayGetRepoNotFound(t *testing.T) { + f := New("", httpreplay.Client(t)) + _, err := f.Repos().Get(context.Background(), "octocat", "this-repository-does-not-exist") + if !errors.Is(err, forge.ErrNotFound) { + t.Fatalf("expected ErrNotFound, got %v", err) + } +} + +func TestReplayListBranches(t *testing.T) { + f := New("", httpreplay.Client(t)) + branches, err := f.Branches().List(context.Background(), "octocat", "Hello-World", forge.ListBranchOpts{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + byName := make(map[string]forge.Branch, len(branches)) + for _, b := range branches { + byName[b.Name] = b + } + master, ok := byName["master"] + if !ok { + t.Fatalf("master branch missing from %v", branches) + } + assertEqual(t, "master.SHA", "7fd1a60b01f91b314f59955a4e4d4e80d8edf11d", master.SHA) +} + +func TestReplayGetFile(t *testing.T) { + f := New("", httpreplay.Client(t)) + file, err := f.Files().Get(context.Background(), "octocat", "Hello-World", "README", "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + assertEqual(t, "Name", "README", file.Name) + assertEqual(t, "Path", "README", file.Path) + assertEqual(t, "Content", "Hello World!\n", string(file.Content)) +} + +func TestReplayResolveCommit(t *testing.T) { + f := New("", httpreplay.Client(t)) + sha, err := f.Commits().ResolveCommit(context.Background(), "octocat", "Hello-World", "7fd1a60") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + assertEqual(t, "SHA", "7fd1a60b01f91b314f59955a4e4d4e80d8edf11d", sha) +} diff --git a/github/testdata/replay/TestReplayGetFile.json b/github/testdata/replay/TestReplayGetFile.json new file mode 100644 index 0000000..eb2e180 --- /dev/null +++ b/github/testdata/replay/TestReplayGetFile.json @@ -0,0 +1,89 @@ +[ + { + "request": { + "method": "GET", + "url": "https://api.github.com/repos/octocat/Hello-World/contents/README" + }, + "response": { + "status_code": 200, + "header": { + "Accept-Ranges": [ + "bytes" + ], + "Access-Control-Allow-Origin": [ + "*" + ], + "Access-Control-Expose-Headers": [ + "ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Used, X-RateLimit-Resource, X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type, X-GitHub-SSO, X-GitHub-Request-Id, Deprecation, Sunset, Warning" + ], + "Cache-Control": [ + "public, max-age=60, s-maxage=60" + ], + "Content-Security-Policy": [ + "default-src 'none'" + ], + "Content-Type": [ + "application/json; charset=utf-8" + ], + "Date": [ + "Fri, 13 Nov 2026 04:11:47 GMT" + ], + "Etag": [ + "\"980a0d5f19a64b4b30a87d4206aade58726b60e3\"" + ], + "Last-Modified": [ + "Tue, 06 Mar 2012 23:06:50 GMT" + ], + "Referrer-Policy": [ + "origin-when-cross-origin, strict-origin-when-cross-origin" + ], + "Server": [ + "github.com" + ], + "Strict-Transport-Security": [ + "max-age=31536000; includeSubdomains; preload" + ], + "Vary": [ + "Accept, Accept-Encoding, Accept, X-Requested-With" + ], + "X-Content-Type-Options": [ + "nosniff" + ], + "X-Frame-Options": [ + "deny" + ], + "X-Github-Api-Version-Selected": [ + "2022-11-28" + ], + "X-Github-Edge-Region": [ + "centralindia" + ], + "X-Github-Media-Type": [ + "github.v3; format=json" + ], + "X-Github-Request-Id": [ + "AA9C:3382A:483478:7628D1:6AF68E03" + ], + "X-Ratelimit-Limit": [ + "60" + ], + "X-Ratelimit-Remaining": [ + "54" + ], + "X-Ratelimit-Reset": [ + "1794546512" + ], + "X-Ratelimit-Resource": [ + "core" + ], + "X-Ratelimit-Used": [ + "6" + ], + "X-Xss-Protection": [ + "0" + ] + }, + "body": "{\"name\":\"README\",\"path\":\"README\",\"sha\":\"980a0d5f19a64b4b30a87d4206aade58726b60e3\",\"size\":13,\"url\":\"https://api.github.com/repos/octocat/Hello-World/contents/README?ref=master\",\"html_url\":\"https://github.com/octocat/Hello-World/blob/master/README\",\"git_url\":\"https://api.github.com/repos/octocat/Hello-World/git/blobs/980a0d5f19a64b4b30a87d4206aade58726b60e3\",\"download_url\":\"https://raw.githubusercontent.com/octocat/Hello-World/master/README\",\"type\":\"file\",\"content\":\"SGVsbG8gV29ybGQhCg==\\n\",\"encoding\":\"base64\",\"_links\":{\"self\":\"https://api.github.com/repos/octocat/Hello-World/contents/README?ref=master\",\"git\":\"https://api.github.com/repos/octocat/Hello-World/git/blobs/980a0d5f19a64b4b30a87d4206aade58726b60e3\",\"html\":\"https://github.com/octocat/Hello-World/blob/master/README\"}}" + } + } +] diff --git a/github/testdata/replay/TestReplayGetRepo.json b/github/testdata/replay/TestReplayGetRepo.json new file mode 100644 index 0000000..b3b2009 --- /dev/null +++ b/github/testdata/replay/TestReplayGetRepo.json @@ -0,0 +1,89 @@ +[ + { + "request": { + "method": "GET", + "url": "https://api.github.com/repos/octocat/Hello-World" + }, + "response": { + "status_code": 200, + "header": { + "Accept-Ranges": [ + "bytes" + ], + "Access-Control-Allow-Origin": [ + "*" + ], + "Access-Control-Expose-Headers": [ + "ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Used, X-RateLimit-Resource, X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type, X-GitHub-SSO, X-GitHub-Request-Id, Deprecation, Sunset, Warning" + ], + "Cache-Control": [ + "public, max-age=60, s-maxage=60" + ], + "Content-Security-Policy": [ + "default-src 'none'" + ], + "Content-Type": [ + "application/json; charset=utf-8" + ], + "Date": [ + "Fri, 13 Nov 2026 04:11:46 GMT" + ], + "Etag": [ + "W/\"806975a1733d5061d926e648f26317f24e7bb7f842b051d9c384f381afbe7a93\"" + ], + "Last-Modified": [ + "Fri, 13 Nov 2026 03:45:40 GMT" + ], + "Referrer-Policy": [ + "origin-when-cross-origin, strict-origin-when-cross-origin" + ], + "Server": [ + "github.com" + ], + "Strict-Transport-Security": [ + "max-age=31536000; includeSubdomains; preload" + ], + "Vary": [ + "Accept,Accept-Encoding, Accept, X-Requested-With" + ], + "X-Content-Type-Options": [ + "nosniff" + ], + "X-Frame-Options": [ + "deny" + ], + "X-Github-Api-Version-Selected": [ + "2022-11-28" + ], + "X-Github-Edge-Region": [ + "centralindia" + ], + "X-Github-Media-Type": [ + "github.v3; param=scarlet-witch-preview; format=json, github.mercy-preview; param=baptiste-preview.nebula-preview; format=json" + ], + "X-Github-Request-Id": [ + "AA9C:3382A:4832C9:76263C:6AF68E02" + ], + "X-Ratelimit-Limit": [ + "60" + ], + "X-Ratelimit-Remaining": [ + "57" + ], + "X-Ratelimit-Reset": [ + "1794546512" + ], + "X-Ratelimit-Resource": [ + "core" + ], + "X-Ratelimit-Used": [ + "3" + ], + "X-Xss-Protection": [ + "0" + ] + }, + "body": "{\"id\":1296269,\"node_id\":\"MDEwOlJlcG9zaXRvcnkxMjk2MjY5\",\"name\":\"Hello-World\",\"full_name\":\"octocat/Hello-World\",\"private\":false,\"owner\":{\"login\":\"octocat\",\"id\":583231,\"node_id\":\"MDQ6VXNlcjU4MzIzMQ==\",\"avatar_url\":\"https://avatars.githubusercontent.com/u/583231?v=4\",\"gravatar_id\":\"\",\"url\":\"https://api.github.com/users/octocat\",\"html_url\":\"https://github.com/octocat\",\"followers_url\":\"https://api.github.com/users/octocat/followers\",\"following_url\":\"https://api.github.com/users/octocat/following{/other_user}\",\"gists_url\":\"https://api.github.com/users/octocat/gists{/gist_id}\",\"starred_url\":\"https://api.github.com/users/octocat/starred{/owner}{/repo}\",\"subscriptions_url\":\"https://api.github.com/users/octocat/subscriptions\",\"organizations_url\":\"https://api.github.com/users/octocat/orgs\",\"repos_url\":\"https://api.github.com/users/octocat/repos\",\"events_url\":\"https://api.github.com/users/octocat/events{/privacy}\",\"received_events_url\":\"https://api.github.com/users/octocat/received_events\",\"type\":\"User\",\"user_view_type\":\"public\",\"site_admin\":false},\"html_url\":\"https://github.com/octocat/Hello-World\",\"description\":\"My first repository on GitHub!\",\"fork\":false,\"url\":\"https://api.github.com/repos/octocat/Hello-World\",\"forks_url\":\"https://api.github.com/repos/octocat/Hello-World/forks\",\"keys_url\":\"https://api.github.com/repos/octocat/Hello-World/keys{/key_id}\",\"collaborators_url\":\"https://api.github.com/repos/octocat/Hello-World/collaborators{/collaborator}\",\"teams_url\":\"https://api.github.com/repos/octocat/Hello-World/teams\",\"hooks_url\":\"https://api.github.com/repos/octocat/Hello-World/hooks\",\"issue_events_url\":\"https://api.github.com/repos/octocat/Hello-World/issues/events{/number}\",\"events_url\":\"https://api.github.com/repos/octocat/Hello-World/events\",\"assignees_url\":\"https://api.github.com/repos/octocat/Hello-World/assignees{/user}\",\"branches_url\":\"https://api.github.com/repos/octocat/Hello-World/branches{/branch}\",\"tags_url\":\"https://api.github.com/repos/octocat/Hello-World/tags\",\"blobs_url\":\"https://api.github.com/repos/octocat/Hello-World/git/blobs{/sha}\",\"git_tags_url\":\"https://api.github.com/repos/octocat/Hello-World/git/tags{/sha}\",\"git_refs_url\":\"https://api.github.com/repos/octocat/Hello-World/git/refs{/sha}\",\"trees_url\":\"https://api.github.com/repos/octocat/Hello-World/git/trees{/sha}\",\"statuses_url\":\"https://api.github.com/repos/octocat/Hello-World/statuses/{sha}\",\"languages_url\":\"https://api.github.com/repos/octocat/Hello-World/languages\",\"stargazers_url\":\"https://api.github.com/repos/octocat/Hello-World/stargazers\",\"contributors_url\":\"https://api.github.com/repos/octocat/Hello-World/contributors\",\"subscribers_url\":\"https://api.github.com/repos/octocat/Hello-World/subscribers\",\"subscription_url\":\"https://api.github.com/repos/octocat/Hello-World/subscription\",\"commits_url\":\"https://api.github.com/repos/octocat/Hello-World/commits{/sha}\",\"git_commits_url\":\"https://api.github.com/repos/octocat/Hello-World/git/commits{/sha}\",\"comments_url\":\"https://api.github.com/repos/octocat/Hello-World/comments{/number}\",\"issue_comment_url\":\"https://api.github.com/repos/octocat/Hello-World/issues/comments{/number}\",\"contents_url\":\"https://api.github.com/repos/octocat/Hello-World/contents/{+path}\",\"compare_url\":\"https://api.github.com/repos/octocat/Hello-World/compare/{base}...{head}\",\"merges_url\":\"https://api.github.com/repos/octocat/Hello-World/merges\",\"archive_url\":\"https://api.github.com/repos/octocat/Hello-World/{archive_format}{/ref}\",\"downloads_url\":\"https://api.github.com/repos/octocat/Hello-World/downloads\",\"issues_url\":\"https://api.github.com/repos/octocat/Hello-World/issues{/number}\",\"pulls_url\":\"https://api.github.com/repos/octocat/Hello-World/pulls{/number}\",\"milestones_url\":\"https://api.github.com/repos/octocat/Hello-World/milestones{/number}\",\"notifications_url\":\"https://api.github.com/repos/octocat/Hello-World/notifications{?since,all,participating}\",\"labels_url\":\"https://api.github.com/repos/octocat/Hello-World/labels{/name}\",\"releases_url\":\"https://api.github.com/repos/octocat/Hello-World/releases{/id}\",\"deployments_url\":\"https://api.github.com/repos/octocat/Hello-World/deployments\",\"created_at\":\"2011-01-26T19:01:12Z\",\"updated_at\":\"2026-11-13T03:45:40Z\",\"pushed_at\":\"2024-08-20T23:54:42Z\",\"git_url\":\"git://github.com/octocat/Hello-World.git\",\"ssh_url\":\"git@github.com:octocat/Hello-World.git\",\"clone_url\":\"https://github.com/octocat/Hello-World.git\",\"svn_url\":\"https://github.com/octocat/Hello-World\",\"homepage\":\"\",\"size\":1,\"stargazers_count\":3926,\"watchers_count\":3926,\"language\":null,\"has_issues\":true,\"has_projects\":true,\"has_downloads\":false,\"has_wiki\":true,\"has_pages\":false,\"has_discussions\":false,\"forks_count\":7506,\"mirror_url\":null,\"archived\":false,\"disabled\":false,\"open_issues_count\":7546,\"license\":null,\"allow_forking\":true,\"is_template\":false,\"web_commit_signoff_required\":false,\"has_pull_requests\":true,\"pull_request_creation_policy\":\"all\",\"topics\":[],\"visibility\":\"public\",\"forks\":7506,\"open_issues\":7546,\"watchers\":3926,\"default_branch\":\"master\",\"temp_clone_token\":null,\"network_count\":7506,\"subscribers_count\":1739}" + } + } +] diff --git a/github/testdata/replay/TestReplayGetRepoNotFound.json b/github/testdata/replay/TestReplayGetRepoNotFound.json new file mode 100644 index 0000000..eecd4d1 --- /dev/null +++ b/github/testdata/replay/TestReplayGetRepoNotFound.json @@ -0,0 +1,77 @@ +[ + { + "request": { + "method": "GET", + "url": "https://api.github.com/repos/octocat/this-repository-does-not-exist" + }, + "response": { + "status_code": 404, + "header": { + "Access-Control-Allow-Origin": [ + "*" + ], + "Access-Control-Expose-Headers": [ + "ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Used, X-RateLimit-Resource, X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type, X-GitHub-SSO, X-GitHub-Request-Id, Deprecation, Sunset, Warning" + ], + "Content-Security-Policy": [ + "default-src 'none'" + ], + "Content-Type": [ + "application/json; charset=utf-8" + ], + "Date": [ + "Fri, 13 Nov 2026 04:11:46 GMT" + ], + "Referrer-Policy": [ + "origin-when-cross-origin, strict-origin-when-cross-origin" + ], + "Server": [ + "github.com" + ], + "Strict-Transport-Security": [ + "max-age=31536000; includeSubdomains; preload" + ], + "Vary": [ + "Accept-Encoding, Accept, X-Requested-With" + ], + "X-Content-Type-Options": [ + "nosniff" + ], + "X-Frame-Options": [ + "deny" + ], + "X-Github-Api-Version-Selected": [ + "2022-11-28" + ], + "X-Github-Edge-Region": [ + "centralindia" + ], + "X-Github-Media-Type": [ + "github.v3; param=scarlet-witch-preview; format=json, github.mercy-preview; param=baptiste-preview.nebula-preview; format=json" + ], + "X-Github-Request-Id": [ + "AA9C:3382A:483357:762710:6AF68E02" + ], + "X-Ratelimit-Limit": [ + "60" + ], + "X-Ratelimit-Remaining": [ + "56" + ], + "X-Ratelimit-Reset": [ + "1794546512" + ], + "X-Ratelimit-Resource": [ + "core" + ], + "X-Ratelimit-Used": [ + "4" + ], + "X-Xss-Protection": [ + "0" + ] + }, + "body": "{\"message\":\"Not Found\",\"documentation_url\":\"https://docs.github.com/rest/repos/repos#get-a-repository\",\"status\":\"404\"}" + } + } +] diff --git a/github/testdata/replay/TestReplayListBranches.json b/github/testdata/replay/TestReplayListBranches.json new file mode 100644 index 0000000..771ebfe --- /dev/null +++ b/github/testdata/replay/TestReplayListBranches.json @@ -0,0 +1,86 @@ +[ + { + "request": { + "method": "GET", + "url": "https://api.github.com/repos/octocat/Hello-World/branches?page=1&per_page=30" + }, + "response": { + "status_code": 200, + "header": { + "Accept-Ranges": [ + "bytes" + ], + "Access-Control-Allow-Origin": [ + "*" + ], + "Access-Control-Expose-Headers": [ + "ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Used, X-RateLimit-Resource, X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type, X-GitHub-SSO, X-GitHub-Request-Id, Deprecation, Sunset, Warning" + ], + "Cache-Control": [ + "public, max-age=60, s-maxage=60" + ], + "Content-Security-Policy": [ + "default-src 'none'" + ], + "Content-Type": [ + "application/json; charset=utf-8" + ], + "Date": [ + "Fri, 13 Nov 2026 04:11:46 GMT" + ], + "Etag": [ + "W/\"b9e88b6c02a76791856e8d3ba41153e867f2b48d93acdb4d1052cdd103ea2293\"" + ], + "Referrer-Policy": [ + "origin-when-cross-origin, strict-origin-when-cross-origin" + ], + "Server": [ + "github.com" + ], + "Strict-Transport-Security": [ + "max-age=31536000; includeSubdomains; preload" + ], + "Vary": [ + "Accept,Accept-Encoding, Accept, X-Requested-With" + ], + "X-Content-Type-Options": [ + "nosniff" + ], + "X-Frame-Options": [ + "deny" + ], + "X-Github-Api-Version-Selected": [ + "2022-11-28" + ], + "X-Github-Edge-Region": [ + "centralindia" + ], + "X-Github-Media-Type": [ + "github.v3; format=json" + ], + "X-Github-Request-Id": [ + "AA9C:3382A:4833E7:7627E4:6AF68E02" + ], + "X-Ratelimit-Limit": [ + "60" + ], + "X-Ratelimit-Remaining": [ + "55" + ], + "X-Ratelimit-Reset": [ + "1794546512" + ], + "X-Ratelimit-Resource": [ + "core" + ], + "X-Ratelimit-Used": [ + "5" + ], + "X-Xss-Protection": [ + "0" + ] + }, + "body": "[{\"name\":\"master\",\"commit\":{\"sha\":\"7fd1a60b01f91b314f59955a4e4d4e80d8edf11d\",\"url\":\"https://api.github.com/repos/octocat/Hello-World/commits/7fd1a60b01f91b314f59955a4e4d4e80d8edf11d\"},\"protected\":false},{\"name\":\"octocat-patch-1\",\"commit\":{\"sha\":\"b1b3f9723831141a31a1a7252a213e216ea76e56\",\"url\":\"https://api.github.com/repos/octocat/Hello-World/commits/b1b3f9723831141a31a1a7252a213e216ea76e56\"},\"protected\":false},{\"name\":\"test\",\"commit\":{\"sha\":\"b3cbd5bbd7e81436d2eee04537ea2b4c0cad4cdf\",\"url\":\"https://api.github.com/repos/octocat/Hello-World/commits/b3cbd5bbd7e81436d2eee04537ea2b4c0cad4cdf\"},\"protected\":false}]" + } + } +] diff --git a/github/testdata/replay/TestReplayResolveCommit.json b/github/testdata/replay/TestReplayResolveCommit.json new file mode 100644 index 0000000..7435fcd --- /dev/null +++ b/github/testdata/replay/TestReplayResolveCommit.json @@ -0,0 +1,89 @@ +[ + { + "request": { + "method": "GET", + "url": "https://api.github.com/repos/octocat/Hello-World/commits/7fd1a60" + }, + "response": { + "status_code": 200, + "header": { + "Accept-Ranges": [ + "bytes" + ], + "Access-Control-Allow-Origin": [ + "*" + ], + "Access-Control-Expose-Headers": [ + "ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Used, X-RateLimit-Resource, X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type, X-GitHub-SSO, X-GitHub-Request-Id, Deprecation, Sunset, Warning" + ], + "Cache-Control": [ + "public, max-age=60, s-maxage=60" + ], + "Content-Security-Policy": [ + "default-src 'none'" + ], + "Content-Type": [ + "application/vnd.github.v3.sha; charset=utf-8" + ], + "Date": [ + "Fri, 13 Nov 2026 04:11:47 GMT" + ], + "Etag": [ + "\"7fd1a60b01f91b314f59955a4e4d4e80d8edf11d\"" + ], + "Last-Modified": [ + "Tue, 06 Mar 2012 23:06:50 GMT" + ], + "Referrer-Policy": [ + "origin-when-cross-origin, strict-origin-when-cross-origin" + ], + "Server": [ + "github.com" + ], + "Strict-Transport-Security": [ + "max-age=31536000; includeSubdomains; preload" + ], + "Vary": [ + "Accept,Accept-Encoding, Accept, X-Requested-With" + ], + "X-Content-Type-Options": [ + "nosniff" + ], + "X-Frame-Options": [ + "deny" + ], + "X-Github-Api-Version-Selected": [ + "2022-11-28" + ], + "X-Github-Edge-Region": [ + "centralindia" + ], + "X-Github-Media-Type": [ + "github.v3; param=sha" + ], + "X-Github-Request-Id": [ + "AA9C:3382A:4834FE:7629AE:6AF68E03" + ], + "X-Ratelimit-Limit": [ + "60" + ], + "X-Ratelimit-Remaining": [ + "53" + ], + "X-Ratelimit-Reset": [ + "1794546512" + ], + "X-Ratelimit-Resource": [ + "core" + ], + "X-Ratelimit-Used": [ + "7" + ], + "X-Xss-Protection": [ + "0" + ] + }, + "body": "7fd1a60b01f91b314f59955a4e4d4e80d8edf11d" + } + } +] diff --git a/gitlab/replay_test.go b/gitlab/replay_test.go new file mode 100644 index 0000000..1054286 --- /dev/null +++ b/gitlab/replay_test.go @@ -0,0 +1,71 @@ +//go:build !tinygo + +package gitlab + +import ( + "context" + "errors" + "testing" + + forge "github.com/git-pkgs/forge" + "github.com/git-pkgs/forge/internal/httpreplay" +) + +// These tests replay responses recorded from gitlab.com. Re-record them with +// FORGE_RECORD=1 go test ./gitlab -run TestReplay. + +func TestReplayGetRepo(t *testing.T) { + f := New("https://gitlab.com", "", httpreplay.Client(t)) + repo, err := f.Repos().Get(context.Background(), "gitlab-org", "gitlab-test") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + assertEqual(t, "FullName", "gitlab-org/gitlab-test", repo.FullName) + assertEqual(t, "Owner", "gitlab-org", repo.Owner) + assertEqual(t, "Name", "gitlab-test", repo.Name) + assertEqual(t, "HTMLURL", "https://gitlab.com/gitlab-org/gitlab-test", repo.HTMLURL) + assertEqual(t, "CloneURL", "https://gitlab.com/gitlab-org/gitlab-test.git", repo.CloneURL) + assertEqual(t, "SSHURL", "git@gitlab.com:gitlab-org/gitlab-test.git", repo.SSHURL) + assertEqual(t, "DefaultBranch", "master", repo.DefaultBranch) + assertEqualBool(t, "Private", false, repo.Private) + if repo.CreatedAt.IsZero() { + t.Error("CreatedAt is zero") + } +} + +func TestReplayGetRepoNotFound(t *testing.T) { + f := New("https://gitlab.com", "", httpreplay.Client(t)) + _, err := f.Repos().Get(context.Background(), "gitlab-org", "this-project-does-not-exist") + if !errors.Is(err, forge.ErrNotFound) { + t.Fatalf("expected ErrNotFound, got %v", err) + } +} + +func TestReplayListBranches(t *testing.T) { + f := New("https://gitlab.com", "", httpreplay.Client(t)) + branches, err := f.Branches().List(context.Background(), "gitlab-org", "gitlab-test", forge.ListBranchOpts{Limit: 3, PerPage: 2}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Two pages of two branches, capped at the limit. + assertEqualInt(t, "len(branches)", 3, len(branches)) + for _, b := range branches { + if b.Name == "" || len(b.SHA) != 40 { + t.Errorf("branch missing name or SHA: %+v", b) + } + } +} + +func TestReplayGetFile(t *testing.T) { + f := New("https://gitlab.com", "", httpreplay.Client(t)) + file, err := f.Files().Get(context.Background(), "gitlab-org", "gitlab-test", "VERSION", "master") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + assertEqual(t, "Name", "VERSION", file.Name) + assertEqual(t, "Path", "VERSION", file.Path) + assertEqual(t, "Content", "6.7.0.pre\n", string(file.Content)) +} diff --git a/gitlab/testdata/replay/TestReplayGetFile.json b/gitlab/testdata/replay/TestReplayGetFile.json new file mode 100644 index 0000000..e8c7afe --- /dev/null +++ b/gitlab/testdata/replay/TestReplayGetFile.json @@ -0,0 +1,116 @@ +[ + { + "request": { + "method": "GET", + "url": "https://gitlab.com/api/v4/projects/gitlab-org%2Fgitlab-test/repository/files/VERSION?ref=master" + }, + "response": { + "status_code": 200, + "header": { + "Cache-Control": [ + "max-age=0, private, must-revalidate" + ], + "Cf-Cache-Status": [ + "BYPASS" + ], + "Cf-Ray": [ + "a59af0dbcd5da46b-HYD" + ], + "Content-Security-Policy": [ + "default-src 'none'" + ], + "Content-Type": [ + "application/json" + ], + "Date": [ + "Fri, 13 Nov 2026 04:12:55 GMT" + ], + "Etag": [ + "W/\"03b379beb38b0b7212b99d47f4d1e767\"" + ], + "Gitlab-Lb": [ + "haproxy-main-http-us-east1-d-865979db98-pgp7p" + ], + "Gitlab-Sv": [ + "api-gke-us-east1-d" + ], + "Nel": [ + "{\"max_age\": 0}" + ], + "Ratelimit-Limit": [ + "500" + ], + "Ratelimit-Name": [ + "throttle_unauthenticated_api" + ], + "Ratelimit-Observed": [ + "5" + ], + "Ratelimit-Remaining": [ + "495" + ], + "Ratelimit-Reset": [ + "1794543180" + ], + "Referrer-Policy": [ + "strict-origin-when-cross-origin" + ], + "Server": [ + "cloudflare" + ], + "Strict-Transport-Security": [ + "max-age=31536000" + ], + "Vary": [ + "Origin" + ], + "X-Content-Type-Options": [ + "nosniff" + ], + "X-Frame-Options": [ + "SAMEORIGIN" + ], + "X-Gitlab-Blob-Id": [ + "998707b421c89bd9a3063333f9f728ef3e43d101" + ], + "X-Gitlab-Commit-Id": [ + "ddd0f15ae83993f5cb66a927a28673882e99100b" + ], + "X-Gitlab-Content-Sha256": [ + "2f6dc79336c0d560984e748c5c38172f4b83a369b3af687f7ada4799bc1e6323" + ], + "X-Gitlab-Encoding": [ + "base64" + ], + "X-Gitlab-Execute-Filemode": [ + "false" + ], + "X-Gitlab-File-Name": [ + "VERSION" + ], + "X-Gitlab-File-Path": [ + "VERSION" + ], + "X-Gitlab-Last-Commit-Id": [ + "913c66a37b4a45b9769037c55c2d238bd0942d2e" + ], + "X-Gitlab-Meta": [ + "{\"correlation_id\":\"a59af0dbcd5da46b-HYD\",\"version\":\"1\"}" + ], + "X-Gitlab-Ref": [ + "master" + ], + "X-Gitlab-Size": [ + "10" + ], + "X-Request-Id": [ + "a59af0dbcd5da46b-HYD" + ], + "X-Runtime": [ + "0.106101" + ] + }, + "body": "{\"file_name\":\"VERSION\",\"file_path\":\"VERSION\",\"size\":10,\"encoding\":\"base64\",\"content_sha256\":\"2f6dc79336c0d560984e748c5c38172f4b83a369b3af687f7ada4799bc1e6323\",\"ref\":\"master\",\"blob_id\":\"998707b421c89bd9a3063333f9f728ef3e43d101\",\"commit_id\":\"ddd0f15ae83993f5cb66a927a28673882e99100b\",\"last_commit_id\":\"913c66a37b4a45b9769037c55c2d238bd0942d2e\",\"execute_filemode\":false,\"content\":\"Ni43LjAucHJlCg==\"}" + } + } +] diff --git a/gitlab/testdata/replay/TestReplayGetRepo.json b/gitlab/testdata/replay/TestReplayGetRepo.json new file mode 100644 index 0000000..406d2d3 --- /dev/null +++ b/gitlab/testdata/replay/TestReplayGetRepo.json @@ -0,0 +1,86 @@ +[ + { + "request": { + "method": "GET", + "url": "https://gitlab.com/api/v4/projects/gitlab-org%2Fgitlab-test?license=true" + }, + "response": { + "status_code": 200, + "header": { + "Cache-Control": [ + "max-age=0, private, must-revalidate" + ], + "Cf-Cache-Status": [ + "BYPASS" + ], + "Cf-Ray": [ + "a59af0cecd23a46b-HYD" + ], + "Content-Security-Policy": [ + "default-src 'none'" + ], + "Content-Type": [ + "application/json" + ], + "Date": [ + "Fri, 13 Nov 2026 04:12:53 GMT" + ], + "Etag": [ + "W/\"0c523c54a90cab342e3c5e8f4c3d9fc1\"" + ], + "Gitlab-Lb": [ + "haproxy-main-http-us-east1-d-865979db98-xmcg5" + ], + "Gitlab-Sv": [ + "api-gke-us-east1-d" + ], + "Nel": [ + "{\"max_age\": 0}" + ], + "Ratelimit-Limit": [ + "500" + ], + "Ratelimit-Name": [ + "throttle_unauthenticated_api" + ], + "Ratelimit-Observed": [ + "1" + ], + "Ratelimit-Remaining": [ + "499" + ], + "Ratelimit-Reset": [ + "1794543180" + ], + "Referrer-Policy": [ + "strict-origin-when-cross-origin" + ], + "Server": [ + "cloudflare" + ], + "Strict-Transport-Security": [ + "max-age=31536000" + ], + "Vary": [ + "Origin" + ], + "X-Content-Type-Options": [ + "nosniff" + ], + "X-Frame-Options": [ + "SAMEORIGIN" + ], + "X-Gitlab-Meta": [ + "{\"correlation_id\":\"a59af0cecd23a46b-HYD\",\"version\":\"1\"}" + ], + "X-Request-Id": [ + "a59af0cecd23a46b-HYD" + ], + "X-Runtime": [ + "0.123226" + ] + }, + "body": "{\"id\":72724,\"description\":\"Repository used by GitLab CE/EE and Gitaly tests\",\"name\":\"gitlab-test\",\"name_with_namespace\":\"GitLab.org / gitlab-test\",\"path\":\"gitlab-test\",\"path_with_namespace\":\"gitlab-org/gitlab-test\",\"created_at\":\"2014-07-31T20:00:19.516Z\",\"default_branch\":\"master\",\"tag_list\":[],\"topics\":[],\"ssh_url_to_repo\":\"git@gitlab.com:gitlab-org/gitlab-test.git\",\"http_url_to_repo\":\"https://gitlab.com/gitlab-org/gitlab-test.git\",\"web_url\":\"https://gitlab.com/gitlab-org/gitlab-test\",\"readme_url\":\"https://gitlab.com/gitlab-org/gitlab-test/-/blob/master/README.md\",\"forks_count\":119,\"license_url\":\"https://gitlab.com/gitlab-org/gitlab-test/-/blob/master/LICENSE\",\"license\":{\"key\":\"mit\",\"name\":\"MIT License\",\"nickname\":null,\"html_url\":\"https://opensource.org/licenses/MIT\",\"source_url\":null},\"avatar_url\":null,\"star_count\":4,\"last_activity_at\":\"2026-11-05T16:11:19.968Z\",\"visibility\":\"public\",\"namespace\":{\"id\":9970,\"name\":\"GitLab.org\",\"path\":\"gitlab-org\",\"kind\":\"group\",\"full_path\":\"gitlab-org\",\"parent_id\":null,\"avatar_url\":\"/uploads/-/system/group/avatar/9970/project_avatar.png?v=1750616408\",\"web_url\":\"https://gitlab.com/groups/gitlab-org\"}}" + } + } +] diff --git a/gitlab/testdata/replay/TestReplayGetRepoNotFound.json b/gitlab/testdata/replay/TestReplayGetRepoNotFound.json new file mode 100644 index 0000000..6feea1d --- /dev/null +++ b/gitlab/testdata/replay/TestReplayGetRepoNotFound.json @@ -0,0 +1,83 @@ +[ + { + "request": { + "method": "GET", + "url": "https://gitlab.com/api/v4/projects/gitlab-org%2Fthis-project-does-not-exist?license=true" + }, + "response": { + "status_code": 404, + "header": { + "Cache-Control": [ + "no-store, private, max-age=0" + ], + "Cf-Cache-Status": [ + "BYPASS" + ], + "Cf-Ray": [ + "a59af0d17850a46b-HYD" + ], + "Content-Security-Policy": [ + "default-src 'none'" + ], + "Content-Type": [ + "application/json" + ], + "Date": [ + "Fri, 13 Nov 2026 04:12:54 GMT" + ], + "Gitlab-Lb": [ + "haproxy-main-http-us-east1-d-865979db98-4ms7j" + ], + "Gitlab-Sv": [ + "api-gke-us-east1-d" + ], + "Nel": [ + "{\"max_age\": 0}" + ], + "Ratelimit-Limit": [ + "500" + ], + "Ratelimit-Name": [ + "throttle_unauthenticated_api" + ], + "Ratelimit-Observed": [ + "2" + ], + "Ratelimit-Remaining": [ + "498" + ], + "Ratelimit-Reset": [ + "1794543180" + ], + "Referrer-Policy": [ + "strict-origin-when-cross-origin" + ], + "Server": [ + "cloudflare" + ], + "Strict-Transport-Security": [ + "max-age=31536000" + ], + "Vary": [ + "Origin" + ], + "X-Content-Type-Options": [ + "nosniff" + ], + "X-Frame-Options": [ + "SAMEORIGIN" + ], + "X-Gitlab-Meta": [ + "{\"correlation_id\":\"a59af0d17850a46b-HYD\",\"version\":\"1\"}" + ], + "X-Request-Id": [ + "a59af0d17850a46b-HYD" + ], + "X-Runtime": [ + "0.050751" + ] + }, + "body": "{\"message\":\"404 Project Not Found\"}" + } + } +] diff --git a/gitlab/testdata/replay/TestReplayListBranches.json b/gitlab/testdata/replay/TestReplayListBranches.json new file mode 100644 index 0000000..7ea4d98 --- /dev/null +++ b/gitlab/testdata/replay/TestReplayListBranches.json @@ -0,0 +1,212 @@ +[ + { + "request": { + "method": "GET", + "url": "https://gitlab.com/api/v4/projects/gitlab-org%2Fgitlab-test/repository/branches?page=1&per_page=2" + }, + "response": { + "status_code": 200, + "header": { + "Cache-Control": [ + "max-age=0, private, must-revalidate" + ], + "Cf-Cache-Status": [ + "BYPASS" + ], + "Cf-Ray": [ + "a59af0d6dfe0a46b-HYD" + ], + "Content-Security-Policy": [ + "default-src 'none'" + ], + "Content-Type": [ + "application/json" + ], + "Date": [ + "Fri, 13 Nov 2026 04:12:54 GMT" + ], + "Etag": [ + "W/\"fc2f61ebc0b2ca6143f9c17e85fbbe99\"" + ], + "Gitlab-Lb": [ + "haproxy-main-http-us-east1-c-544d8f9bcc-vp26l" + ], + "Gitlab-Sv": [ + "api-gke-us-east1-c" + ], + "Link": [ + "; rel=\"next\", ; rel=\"first\", ; rel=\"last\"" + ], + "Nel": [ + "{\"max_age\": 0}" + ], + "Ratelimit-Limit": [ + "500" + ], + "Ratelimit-Name": [ + "throttle_unauthenticated_api" + ], + "Ratelimit-Observed": [ + "3" + ], + "Ratelimit-Remaining": [ + "497" + ], + "Ratelimit-Reset": [ + "1794543180" + ], + "Referrer-Policy": [ + "strict-origin-when-cross-origin" + ], + "Server": [ + "cloudflare" + ], + "Strict-Transport-Security": [ + "max-age=31536000" + ], + "Vary": [ + "Origin" + ], + "X-Content-Type-Options": [ + "nosniff" + ], + "X-Frame-Options": [ + "SAMEORIGIN" + ], + "X-Gitlab-Meta": [ + "{\"correlation_id\":\"a59af0d6dfe0a46b-HYD\",\"version\":\"1\"}" + ], + "X-Next-Page": [ + "2" + ], + "X-Page": [ + "1" + ], + "X-Per-Page": [ + "2" + ], + "X-Prev-Page": [ + "" + ], + "X-Request-Id": [ + "a59af0d6dfe0a46b-HYD" + ], + "X-Runtime": [ + "0.117971" + ], + "X-Total": [ + "133" + ], + "X-Total-Pages": [ + "67" + ] + }, + "body": "[{\"name\":\"'test'\",\"commit\":{\"id\":\"e56497bb5f03a90a51293fc6d516788730953899\",\"short_id\":\"e56497bb\",\"created_at\":\"2015-01-10T21:23:29.000+00:00\",\"parent_ids\":[\"5937ac0a7beb003549fc5fd26fc247adbce4a52e\",\"4cd80ccab63c82b4bad16faa5193fbd2aa06df40\"],\"title\":\"Merge branch 'tree_helper_spec' into 'master'\",\"message\":\"Merge branch 'tree_helper_spec' into 'master'\\n\\nAdd directory structure for tree_helper spec\\n\\nThis directory structure is needed for a testing the method flatten_tree(tree) in the TreeHelper module\\n\\nSee [merge request #275](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/275#note_732774)\\n\\nSee merge request !2\\n\",\"author_name\":\"Sytse Sijbrandij\",\"author_email\":\"sytse@gitlab.com\",\"authored_date\":\"2015-01-10T21:23:29.000+00:00\",\"committer_name\":\"Sytse Sijbrandij\",\"committer_email\":\"sytse@gitlab.com\",\"committed_date\":\"2015-01-10T21:23:29.000+00:00\",\"trailers\":{},\"extended_trailers\":{},\"web_url\":\"https://gitlab.com/gitlab-org/gitlab-test/-/commit/e56497bb5f03a90a51293fc6d516788730953899\"},\"merged\":true,\"protected\":true,\"developers_can_push\":false,\"developers_can_merge\":false,\"can_push\":false,\"default\":false,\"web_url\":\"https://gitlab.com/gitlab-org/gitlab-test/-/tree/'test'\"},{\"name\":\"100%branch\",\"commit\":{\"id\":\"1b12f15a11fc6e62177bef08f47bc7b5ce50b141\",\"short_id\":\"1b12f15a\",\"created_at\":\"2016-08-18T22:21:18.000+00:00\",\"parent_ids\":[\"6907208d755b60ebeacb2e9dfea74c92c3449a1f\",\"38008cb17ce1466d8fec2dfa6f6ab8dcfe5cf49e\"],\"title\":\"Merge branch 'add-directory-with-space' into 'master'\",\"message\":\"Merge branch 'add-directory-with-space' into 'master'\\r\\n\\r\\nAdd a directory containing a space in its name\\r\\n\\r\\nneeded for verifying the fix of `https://gitlab.com/gitlab-com/support-forum/issues/952` \\r\\n\\r\\nSee merge request !11\",\"author_name\":\"Stan Hu\",\"author_email\":\"stanhu@gmail.com\",\"authored_date\":\"2016-08-18T22:21:18.000+00:00\",\"committer_name\":\"Stan Hu\",\"committer_email\":\"stanhu@gmail.com\",\"committed_date\":\"2016-08-18T22:21:18.000+00:00\",\"trailers\":{},\"extended_trailers\":{},\"web_url\":\"https://gitlab.com/gitlab-org/gitlab-test/-/commit/1b12f15a11fc6e62177bef08f47bc7b5ce50b141\"},\"merged\":true,\"protected\":true,\"developers_can_push\":false,\"developers_can_merge\":false,\"can_push\":false,\"default\":false,\"web_url\":\"https://gitlab.com/gitlab-org/gitlab-test/-/tree/100%25branch\"}]" + } + }, + { + "request": { + "method": "GET", + "url": "https://gitlab.com/api/v4/projects/gitlab-org%2Fgitlab-test/repository/branches?page=2&per_page=2" + }, + "response": { + "status_code": 200, + "header": { + "Cache-Control": [ + "max-age=0, private, must-revalidate" + ], + "Cf-Cache-Status": [ + "BYPASS" + ], + "Cf-Ray": [ + "a59af0d96b46a46b-HYD" + ], + "Content-Security-Policy": [ + "default-src 'none'" + ], + "Content-Type": [ + "application/json" + ], + "Date": [ + "Fri, 13 Nov 2026 04:12:54 GMT" + ], + "Etag": [ + "W/\"08af2b2677668481cf70da0df9b8d84b\"" + ], + "Gitlab-Lb": [ + "haproxy-main-http-us-east1-c-544d8f9bcc-n5mfx" + ], + "Gitlab-Sv": [ + "api-gke-us-east1-c" + ], + "Link": [ + "; rel=\"prev\", ; rel=\"next\", ; rel=\"first\", ; rel=\"last\"" + ], + "Nel": [ + "{\"max_age\": 0}" + ], + "Ratelimit-Limit": [ + "500" + ], + "Ratelimit-Name": [ + "throttle_unauthenticated_api" + ], + "Ratelimit-Observed": [ + "4" + ], + "Ratelimit-Remaining": [ + "496" + ], + "Ratelimit-Reset": [ + "1794543180" + ], + "Referrer-Policy": [ + "strict-origin-when-cross-origin" + ], + "Server": [ + "cloudflare" + ], + "Strict-Transport-Security": [ + "max-age=31536000" + ], + "Vary": [ + "Origin" + ], + "X-Content-Type-Options": [ + "nosniff" + ], + "X-Frame-Options": [ + "SAMEORIGIN" + ], + "X-Gitlab-Meta": [ + "{\"correlation_id\":\"a59af0d96b46a46b-HYD\",\"version\":\"1\"}" + ], + "X-Next-Page": [ + "3" + ], + "X-Page": [ + "2" + ], + "X-Per-Page": [ + "2" + ], + "X-Prev-Page": [ + "1" + ], + "X-Request-Id": [ + "a59af0d96b46a46b-HYD" + ], + "X-Runtime": [ + "0.087959" + ], + "X-Total": [ + "133" + ], + "X-Total-Pages": [ + "67" + ] + }, + "body": "[{\"name\":\"1942eed5cc108b19c7405106e81fa96125d0be22\",\"commit\":{\"id\":\"1942eed5cc108b19c7405106e81fa96125d0be22\",\"short_id\":\"1942eed5\",\"created_at\":\"2018-02-21T12:08:14.000+01:00\",\"parent_ids\":[\"60ecb67744cb56576c30214ff52294f8ce2def98\"],\"title\":\"Create empty commit\",\"message\":\"Create empty commit\\n\\nI need a branch name where the name is the same as the commit id it\\npoints to. To explain why this commit is here, I needed to create a new\\ncommit. As the title suggests, the tree is the same as its parent.\\n\",\"author_name\":\"Zeger-Jan van de Weg\",\"author_email\":\"git@zjvandeweg.nl\",\"authored_date\":\"2018-02-21T12:08:14.000+01:00\",\"committer_name\":\"Zeger-Jan van de Weg\",\"committer_email\":\"git@zjvandeweg.nl\",\"committed_date\":\"2018-02-21T12:08:14.000+01:00\",\"trailers\":{},\"extended_trailers\":{},\"web_url\":\"https://gitlab.com/gitlab-org/gitlab-test/-/commit/1942eed5cc108b19c7405106e81fa96125d0be22\"},\"merged\":false,\"protected\":true,\"developers_can_push\":false,\"developers_can_merge\":false,\"can_push\":false,\"default\":false,\"web_url\":\"https://gitlab.com/gitlab-org/gitlab-test/-/tree/1942eed5cc108b19c7405106e81fa96125d0be22\"},{\"name\":\"2-mb-file\",\"commit\":{\"id\":\"bf12d2567099e26f59692896f73ac819bae45b00\",\"short_id\":\"bf12d256\",\"created_at\":\"2018-08-05T13:11:44.000-05:00\",\"parent_ids\":[\"7975be0116940bf2ad4321f79d02a55c5f7779aa\"],\"title\":\"Add file of 2 MB for tests\",\"message\":\"Add file of 2 MB for tests\\n\",\"author_name\":\"Rubén Dávila\",\"author_email\":\"ruben@gitlab.com\",\"authored_date\":\"2018-08-05T13:11:44.000-05:00\",\"committer_name\":\"Rubén Dávila\",\"committer_email\":\"ruben@gitlab.com\",\"committed_date\":\"2018-08-05T13:11:44.000-05:00\",\"trailers\":{},\"extended_trailers\":{},\"web_url\":\"https://gitlab.com/gitlab-org/gitlab-test/-/commit/bf12d2567099e26f59692896f73ac819bae45b00\"},\"merged\":false,\"protected\":true,\"developers_can_push\":false,\"developers_can_merge\":false,\"can_push\":false,\"default\":false,\"web_url\":\"https://gitlab.com/gitlab-org/gitlab-test/-/tree/2-mb-file\"}]" + } + } +] diff --git a/internal/httpreplay/httpreplay.go b/internal/httpreplay/httpreplay.go new file mode 100644 index 0000000..64a8e7c --- /dev/null +++ b/internal/httpreplay/httpreplay.go @@ -0,0 +1,288 @@ +// Package httpreplay records real forge API traffic to fixture files and +// replays it in tests. +// +// Tests call Client to get an *http.Client for the backend under test. By +// default the client serves responses from the test's fixture file and never +// touches the network. With FORGE_RECORD=1 set, requests go to the real API +// and the exchanges are written to the fixture file when the test passes: +// +// FORGE_RECORD=1 go test ./github -run TestReplay +// +// Fixtures live in testdata/replay/.json next to the test. Only +// the method, URL and body of each request are stored, so credentials sent +// in headers never reach a fixture. Set-Cookie response headers are dropped. +package httpreplay + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strconv" + "sync" + "testing" + "unicode/utf8" +) + +// recordEnv is the environment variable that switches Client to record mode +// when set to 1. +const recordEnv = "FORGE_RECORD" + +// interaction is one recorded request and the response the server sent. +type interaction struct { + Request recordedRequest `json:"request"` + Response recordedResponse `json:"response"` +} + +// recordedRequest holds the parts of a request used to match it during +// replay. +type recordedRequest struct { + Method string `json:"method"` + URL string `json:"url"` + Body string `json:"body,omitempty"` +} + +// recordedResponse holds a recorded response. Body is stored as text when it +// is valid UTF-8 and as base64 otherwise, with BodyEncoding set to "base64". +type recordedResponse struct { + StatusCode int `json:"status_code"` + Header http.Header `json:"header,omitempty"` + Body string `json:"body,omitempty"` + BodyEncoding string `json:"body_encoding,omitempty"` +} + +// Client returns an HTTP client that replays the fixture for t, or records a +// new one when FORGE_RECORD=1 is set. A missing fixture, a request with no +// recorded match and a recorded request that is never made all fail t. +func Client(t testing.TB) *http.Client { + t.Helper() + path := fixturePath(t) + if os.Getenv(recordEnv) == "1" { + return &http.Client{Transport: newRecorder(t, path, http.DefaultTransport)} + } + r, err := newReplayer(t, path) + if err != nil { + t.Fatalf("httpreplay: %v; run with %s=1 to record it", err, recordEnv) + } + return &http.Client{Transport: r} +} + +// fixturePath returns the fixture file used for t. Subtests map to nested +// directories. +func fixturePath(t testing.TB) string { + return filepath.Join("testdata", "replay", filepath.FromSlash(t.Name())+".json") +} + +// recorder sends requests to the network and keeps every exchange. The +// exchanges are written to path when the test finishes without failing. +type recorder struct { + base http.RoundTripper + + mu sync.Mutex + interactions []interaction +} + +func newRecorder(t testing.TB, path string, base http.RoundTripper) *recorder { + r := &recorder{base: base, interactions: []interaction{}} + t.Cleanup(func() { + if t.Failed() { + t.Logf("httpreplay: test failed, not writing %s", path) + return + } + r.mu.Lock() + defer r.mu.Unlock() + if err := writeFixture(path, r.interactions); err != nil { + t.Errorf("httpreplay: %v", err) + } + }) + return r +} + +func (r *recorder) RoundTrip(req *http.Request) (*http.Response, error) { + reqBody, err := readRequestBody(req) + if err != nil { + return nil, err + } + // RoundTrip must not modify req, so the body that was just read is sent + // on a copy. + out := req.Clone(req.Context()) + if reqBody != nil { + out.Body = io.NopCloser(bytes.NewReader(reqBody)) + } + + resp, err := r.base.RoundTrip(out) + if err != nil { + return nil, err + } + respBody, err := io.ReadAll(resp.Body) + _ = resp.Body.Close() + if err != nil { + return nil, fmt.Errorf("httpreplay: reading response body: %w", err) + } + resp.Body = io.NopCloser(bytes.NewReader(respBody)) + + header := resp.Header.Clone() + header.Del("Set-Cookie") + // The stored body has already been decompressed by the transport when + // the server gzipped it, and replay sets the length from the stored body. + header.Del("Content-Length") + recorded := recordedResponse{StatusCode: resp.StatusCode, Header: header} + recorded.Body, recorded.BodyEncoding = encodeBody(respBody) + + r.mu.Lock() + r.interactions = append(r.interactions, interaction{ + Request: recordedRequest{Method: req.Method, URL: req.URL.String(), Body: string(reqBody)}, + Response: recorded, + }) + r.mu.Unlock() + + return resp, nil +} + +// replayer serves recorded responses. Each interaction is used at most once, +// in recorded order, so repeated identical requests get their responses back +// in sequence. +type replayer struct { + t testing.TB + path string + + mu sync.Mutex + interactions []interaction + used []bool +} + +func newReplayer(t testing.TB, path string) (*replayer, error) { + interactions, err := readFixture(path) + if err != nil { + return nil, err + } + r := &replayer{ + t: t, + path: path, + interactions: interactions, + used: make([]bool, len(interactions)), + } + t.Cleanup(r.checkAllUsed) + return r, nil +} + +func (r *replayer) RoundTrip(req *http.Request) (*http.Response, error) { + body, err := readRequestBody(req) + if err != nil { + return nil, err + } + target := req.URL.String() + + r.mu.Lock() + defer r.mu.Unlock() + for i, in := range r.interactions { + if r.used[i] || in.Request.Method != req.Method || in.Request.URL != target || in.Request.Body != string(body) { + continue + } + r.used[i] = true + return in.Response.httpResponse(req) + } + + // Backends sometimes discard request errors, so fail the test directly + // rather than relying on the error reaching an assertion. + err = fmt.Errorf("httpreplay: no recorded response for %s %s in %s", req.Method, target, r.path) + r.t.Errorf("%v", err) + return nil, err +} + +func (r *replayer) checkAllUsed() { + r.mu.Lock() + defer r.mu.Unlock() + for i, in := range r.interactions { + if !r.used[i] { + r.t.Errorf("httpreplay: recorded request %s %s in %s was never made", in.Request.Method, in.Request.URL, r.path) + } + } +} + +func (resp recordedResponse) httpResponse(req *http.Request) (*http.Response, error) { + body, err := decodeBody(resp.Body, resp.BodyEncoding) + if err != nil { + return nil, err + } + header := resp.Header.Clone() + if header == nil { + header = http.Header{} + } + return &http.Response{ + Status: strconv.Itoa(resp.StatusCode) + " " + http.StatusText(resp.StatusCode), + StatusCode: resp.StatusCode, + Proto: "HTTP/1.1", + ProtoMajor: 1, + ProtoMinor: 1, + Header: header, + Body: io.NopCloser(bytes.NewReader(body)), + ContentLength: int64(len(body)), + Request: req, + }, nil +} + +// readRequestBody reads and closes the request body. +func readRequestBody(req *http.Request) ([]byte, error) { + if req.Body == nil || req.Body == http.NoBody { + return nil, nil + } + body, err := io.ReadAll(req.Body) + _ = req.Body.Close() + if err != nil { + return nil, fmt.Errorf("httpreplay: reading request body: %w", err) + } + return body, nil +} + +func encodeBody(body []byte) (text, encoding string) { + if utf8.Valid(body) { + return string(body), "" + } + return base64.StdEncoding.EncodeToString(body), "base64" +} + +func decodeBody(text, encoding string) ([]byte, error) { + switch encoding { + case "": + return []byte(text), nil + case "base64": + return base64.StdEncoding.DecodeString(text) + default: + return nil, fmt.Errorf("httpreplay: unknown body encoding %q", encoding) + } +} + +func readFixture(path string) ([]interaction, error) { + data, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + return nil, fmt.Errorf("no recording at %s", path) + } + if err != nil { + return nil, err + } + var interactions []interaction + if err := json.Unmarshal(data, &interactions); err != nil { + return nil, fmt.Errorf("parsing %s: %w", path, err) + } + return interactions, nil +} + +func writeFixture(path string, interactions []interaction) error { + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) + enc.SetIndent("", " ") + if err := enc.Encode(interactions); err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + return os.WriteFile(path, buf.Bytes(), 0o644) +} diff --git a/internal/httpreplay/httpreplay_test.go b/internal/httpreplay/httpreplay_test.go new file mode 100644 index 0000000..8f4654f --- /dev/null +++ b/internal/httpreplay/httpreplay_test.go @@ -0,0 +1,349 @@ +package httpreplay + +import ( + "bytes" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strconv" + "strings" + "sync/atomic" + "testing" +) + +// fakeTB collects errors and cleanups so tests can check how the transports +// report problems without failing the real test. +type fakeTB struct { + testing.TB + errors []string + cleanups []func() +} + +func (f *fakeTB) Helper() {} +func (f *fakeTB) Logf(string, ...any) {} +func (f *fakeTB) Failed() bool { return len(f.errors) > 0 } +func (f *fakeTB) Cleanup(fn func()) { f.cleanups = append(f.cleanups, fn) } + +func (f *fakeTB) Errorf(format string, args ...any) { + f.errors = append(f.errors, fmt.Sprintf(format, args...)) +} + +func (f *fakeTB) finish() { + for i := len(f.cleanups) - 1; i >= 0; i-- { + f.cleanups[i]() + } +} + +const testToken = "secret-token-value" + +func newCountingServer(t *testing.T) *httptest.Server { + t.Helper() + var hits atomic.Int64 + mux := http.NewServeMux() + mux.HandleFunc("GET /repos/octocat/hello", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Link", `; rel="next"`) + http.SetCookie(w, &http.Cookie{Name: "session", Value: "cookie-value"}) + _, _ = w.Write([]byte(`{"name":"hello","html":"&"}`)) + }) + mux.HandleFunc("POST /repos/octocat/hello/issues", func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + w.WriteHeader(http.StatusCreated) + _, _ = w.Write(append([]byte("created:"), body...)) + }) + mux.HandleFunc("GET /counter", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(strconv.FormatInt(hits.Add(1), 10))) + }) + mux.HandleFunc("GET /binary", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte{0xff, 0x00, 0xfe}) + }) + mux.HandleFunc("GET /missing", func(w http.ResponseWriter, r *http.Request) { + http.NotFound(w, r) + }) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + return srv +} + +type exchange struct { + method, path, body string + wantStatus int + wantBody []byte +} + +func exchanges() []exchange { + return []exchange{ + {method: "GET", path: "/repos/octocat/hello", wantStatus: 200, wantBody: []byte(`{"name":"hello","html":"&"}`)}, + {method: "POST", path: "/repos/octocat/hello/issues", body: `{"title":"a"}`, wantStatus: 201, wantBody: []byte(`created:{"title":"a"}`)}, + {method: "POST", path: "/repos/octocat/hello/issues", body: `{"title":"b"}`, wantStatus: 201, wantBody: []byte(`created:{"title":"b"}`)}, + {method: "GET", path: "/counter", wantStatus: 200, wantBody: []byte("1")}, + {method: "GET", path: "/counter", wantStatus: 200, wantBody: []byte("2")}, + {method: "GET", path: "/binary", wantStatus: 200, wantBody: []byte{0xff, 0x00, 0xfe}}, + {method: "GET", path: "/missing", wantStatus: 404, wantBody: []byte("404 page not found\n")}, + } +} + +func do(t *testing.T, hc *http.Client, baseURL string, ex exchange) *http.Response { + t.Helper() + var body io.Reader + if ex.body != "" { + body = strings.NewReader(ex.body) + } + req, err := http.NewRequest(ex.method, baseURL+ex.path, body) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Authorization", "Bearer "+testToken) + resp, err := hc.Do(req) + if err != nil { + t.Fatalf("%s %s: %v", ex.method, ex.path, err) + } + got, err := io.ReadAll(resp.Body) + _ = resp.Body.Close() + if err != nil { + t.Fatal(err) + } + if resp.StatusCode != ex.wantStatus { + t.Errorf("%s %s: status = %d, want %d", ex.method, ex.path, resp.StatusCode, ex.wantStatus) + } + if !bytes.Equal(got, ex.wantBody) { + t.Errorf("%s %s: body = %q, want %q", ex.method, ex.path, got, ex.wantBody) + } + return resp +} + +func TestRecordThenReplay(t *testing.T) { + path := filepath.Join(t.TempDir(), "testdata", "replay", "TestExample.json") + srv := newCountingServer(t) + + t.Run("record", func(t *testing.T) { + hc := &http.Client{Transport: newRecorder(t, path, http.DefaultTransport)} + for _, ex := range exchanges() { + do(t, hc, srv.URL, ex) + } + }) + + fixture, err := os.ReadFile(path) + if err != nil { + t.Fatalf("fixture not written: %v", err) + } + for _, leaked := range []string{testToken, "cookie-value", "Set-Cookie"} { + if bytes.Contains(fixture, []byte(leaked)) { + t.Errorf("fixture contains %q:\n%s", leaked, fixture) + } + } + if !bytes.Contains(fixture, []byte(`&`)) { + t.Errorf("fixture escapes HTML in bodies:\n%s", fixture) + } + + // Replay with the server gone to prove nothing reaches the network. + srv.Close() + + t.Run("replay", func(t *testing.T) { + r, err := newReplayer(t, path) + if err != nil { + t.Fatal(err) + } + hc := &http.Client{Transport: r} + for _, ex := range exchanges() { + resp := do(t, hc, srv.URL, ex) + if ex.path == "/repos/octocat/hello" { + if got := resp.Header.Get("Link"); !strings.Contains(got, `rel="next"`) { + t.Errorf("Link header = %q, want the recorded value", got) + } + if got := resp.Header.Get("Content-Type"); got != "application/json" { + t.Errorf("Content-Type = %q, want application/json", got) + } + } + } + }) +} + +func writeTestFixture(t *testing.T, interactions []interaction) string { + t.Helper() + path := filepath.Join(t.TempDir(), "fixture.json") + if err := writeFixture(path, interactions); err != nil { + t.Fatal(err) + } + return path +} + +func TestReplayUnmatchedRequestFailsTest(t *testing.T) { + path := writeTestFixture(t, []interaction{{ + Request: recordedRequest{Method: "GET", URL: "https://api.example.test/a"}, + Response: recordedResponse{StatusCode: 200, Body: "a"}, + }}) + ft := &fakeTB{} + r, err := newReplayer(ft, path) + if err != nil { + t.Fatal(err) + } + hc := &http.Client{Transport: r} + + for _, target := range []string{"https://api.example.test/a", "https://api.example.test/a", "https://api.example.test/b"} { + resp, err := hc.Get(target) + if err == nil { + _ = resp.Body.Close() + } + } + ft.finish() + + want := []string{ + "httpreplay: no recorded response for GET https://api.example.test/a in " + path, + "httpreplay: no recorded response for GET https://api.example.test/b in " + path, + } + if len(ft.errors) != len(want) { + t.Fatalf("errors = %q, want %q", ft.errors, want) + } + for i := range want { + if ft.errors[i] != want[i] { + t.Errorf("errors[%d] = %q, want %q", i, ft.errors[i], want[i]) + } + } +} + +func TestReplayMatchesRequestBody(t *testing.T) { + path := writeTestFixture(t, []interaction{{ + Request: recordedRequest{Method: "POST", URL: "https://api.example.test/issues", Body: `{"title":"a"}`}, + Response: recordedResponse{StatusCode: 201}, + }}) + ft := &fakeTB{} + r, err := newReplayer(ft, path) + if err != nil { + t.Fatal(err) + } + hc := &http.Client{Transport: r} + + resp, err := hc.Post("https://api.example.test/issues", "application/json", strings.NewReader(`{"title":"b"}`)) + if err == nil { + _ = resp.Body.Close() + t.Fatal("request with a different body matched the recording") + } + if len(ft.errors) != 1 || !strings.Contains(ft.errors[0], "no recorded response for POST") { + t.Errorf("errors = %q, want one unmatched POST", ft.errors) + } +} + +func TestReplayUnusedInteractionFailsTest(t *testing.T) { + path := writeTestFixture(t, []interaction{ + {Request: recordedRequest{Method: "GET", URL: "https://api.example.test/a"}, Response: recordedResponse{StatusCode: 200}}, + {Request: recordedRequest{Method: "GET", URL: "https://api.example.test/b"}, Response: recordedResponse{StatusCode: 200}}, + }) + ft := &fakeTB{} + r, err := newReplayer(ft, path) + if err != nil { + t.Fatal(err) + } + resp, err := (&http.Client{Transport: r}).Get("https://api.example.test/a") + if err != nil { + t.Fatal(err) + } + _ = resp.Body.Close() + ft.finish() + + want := "httpreplay: recorded request GET https://api.example.test/b in " + path + " was never made" + if len(ft.errors) != 1 || ft.errors[0] != want { + t.Errorf("errors = %q, want [%q]", ft.errors, want) + } +} + +func TestRecorderSkipsWriteWhenTestFails(t *testing.T) { + srv := newCountingServer(t) + path := filepath.Join(t.TempDir(), "fixture.json") + ft := &fakeTB{} + hc := &http.Client{Transport: newRecorder(ft, path, http.DefaultTransport)} + resp, err := hc.Get(srv.URL + "/counter") + if err != nil { + t.Fatal(err) + } + _ = resp.Body.Close() + + ft.errors = append(ft.errors, "assertion failed") + ft.finish() + + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Errorf("fixture written for a failed test: stat err = %v", err) + } +} + +func TestRecorderWritesEmptyFixture(t *testing.T) { + path := filepath.Join(t.TempDir(), "fixture.json") + ft := &fakeTB{} + newRecorder(ft, path, http.DefaultTransport) + ft.finish() + + got, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(got) != "[]\n" { + t.Errorf("fixture = %q, want %q", got, "[]\n") + } +} + +func TestNewReplayerErrors(t *testing.T) { + dir := t.TempDir() + + missing := filepath.Join(dir, "missing.json") + if _, err := newReplayer(&fakeTB{}, missing); err == nil || err.Error() != "no recording at "+missing { + t.Errorf("missing fixture: err = %v", err) + } + + invalid := filepath.Join(dir, "invalid.json") + if err := os.WriteFile(invalid, []byte("{"), 0o644); err != nil { + t.Fatal(err) + } + if _, err := newReplayer(&fakeTB{}, invalid); err == nil || !strings.HasPrefix(err.Error(), "parsing "+invalid) { + t.Errorf("invalid fixture: err = %v", err) + } +} + +func TestReplayUnknownBodyEncoding(t *testing.T) { + path := writeTestFixture(t, []interaction{{ + Request: recordedRequest{Method: "GET", URL: "https://api.example.test/a"}, + Response: recordedResponse{StatusCode: 200, Body: "a", BodyEncoding: "gzip"}, + }}) + r, err := newReplayer(&fakeTB{}, path) + if err != nil { + t.Fatal(err) + } + if _, err := (&http.Client{Transport: r}).Get("https://api.example.test/a"); err == nil || !strings.Contains(err.Error(), `unknown body encoding "gzip"`) { + t.Errorf("err = %v, want unknown body encoding", err) + } +} + +func TestFixturePath(t *testing.T) { + t.Run("sub test", func(t *testing.T) { + want := filepath.Join("testdata", "replay", "TestFixturePath", "sub_test.json") + if got := fixturePath(t); got != want { + t.Errorf("fixturePath = %q, want %q", got, want) + } + }) +} + +func TestClientRecordsWhenEnabled(t *testing.T) { + srv := newCountingServer(t) + dir := t.TempDir() + + t.Run("record", func(t *testing.T) { + t.Setenv(recordEnv, "1") + t.Chdir(dir) + hc := Client(t) + do(t, hc, srv.URL, exchange{method: "GET", path: "/counter", wantStatus: 200, wantBody: []byte("1")}) + }) + + path := filepath.Join(dir, "testdata", "replay", "TestClientRecordsWhenEnabled", "record.json") + if _, err := os.Stat(path); err != nil { + t.Fatalf("fixture not written: %v", err) + } +} + +// TestClientReplaysFixture replays testdata/replay/TestClientReplaysFixture.json. +func TestClientReplaysFixture(t *testing.T) { + t.Setenv(recordEnv, "") + hc := Client(t) + do(t, hc, "https://api.example.test", exchange{method: "GET", path: "/hello", wantStatus: 200, wantBody: []byte("hello from the fixture")}) +} diff --git a/internal/httpreplay/testdata/replay/TestClientReplaysFixture.json b/internal/httpreplay/testdata/replay/TestClientReplaysFixture.json new file mode 100644 index 0000000..dc45070 --- /dev/null +++ b/internal/httpreplay/testdata/replay/TestClientReplaysFixture.json @@ -0,0 +1,17 @@ +[ + { + "request": { + "method": "GET", + "url": "https://api.example.test/hello" + }, + "response": { + "status_code": 200, + "header": { + "Content-Type": [ + "text/plain" + ] + }, + "body": "hello from the fixture" + } + } +]