Implement Phase 4 test coverage
This commit is contained in:
@@ -0,0 +1,382 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func captureStdout(t *testing.T, fn func()) string {
|
||||
t.Helper()
|
||||
old := os.Stdout
|
||||
r, w, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
os.Stdout = w
|
||||
fn()
|
||||
_ = w.Close()
|
||||
os.Stdout = old
|
||||
out, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
func newMockServer(t *testing.T) (*Server, sqlmock.Sqlmock, func()) {
|
||||
t.Helper()
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &Server{db: db, storage: t.TempDir(), publicURL: "http://example.test"}, mock, func() { _ = db.Close() }
|
||||
}
|
||||
|
||||
func expectBearerUser(mock sqlmock.Sqlmock, token string, user User) {
|
||||
mock.ExpectQuery("FROM auth_tokens t JOIN users u").
|
||||
WithArgs(hashToken(token)).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "email", "username", "is_admin"}).AddRow(user.ID, user.Email, user.Username, user.IsAdmin))
|
||||
}
|
||||
|
||||
func TestRunDispatchUsageAndHelp(t *testing.T) {
|
||||
out := captureStdout(t, func() {
|
||||
if err := Run(nil); !errors.Is(err, ErrUsage) {
|
||||
t.Fatalf("Run(nil) error = %v", err)
|
||||
}
|
||||
})
|
||||
if !strings.Contains(out, "gitocean - CLI") {
|
||||
t.Fatalf("usage output missing heading: %s", out)
|
||||
}
|
||||
out = captureStdout(t, func() {
|
||||
if err := Run([]string{"help"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
if !strings.Contains(out, "repo search QUERY") {
|
||||
t.Fatalf("help output missing commands: %s", out)
|
||||
}
|
||||
if err := Run([]string{"does-not-exist"}); err == nil || !strings.Contains(err.Error(), "unknown command") {
|
||||
t.Fatalf("unexpected unknown command error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIRequest(t *testing.T) {
|
||||
var seenAuth, seenContentType string
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
seenAuth = r.Header.Get("Authorization")
|
||||
seenContentType = r.Header.Get("Content-Type")
|
||||
if r.URL.Path == "/error" {
|
||||
writeError(w, http.StatusBadRequest, "bad request")
|
||||
return
|
||||
}
|
||||
if r.URL.Path == "/unauthorized" {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
var in map[string]string
|
||||
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"echo": in["name"]})
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
var out map[string]string
|
||||
if err := apiRequest(context.Background(), ts.URL+"/", http.MethodPost, "/ok", "tok", map[string]string{"name": "demo"}, &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out["echo"] != "demo" || seenAuth != "Bearer tok" || seenContentType != "application/json" {
|
||||
t.Fatalf("unexpected request/result: out=%#v auth=%q ct=%q", out, seenAuth, seenContentType)
|
||||
}
|
||||
if err := apiRequest(context.Background(), ts.URL, http.MethodGet, "/error", "", nil, nil); err == nil || err.Error() != "bad request" {
|
||||
t.Fatalf("unexpected error response: %v", err)
|
||||
}
|
||||
if err := apiRequest(context.Background(), ts.URL, http.MethodGet, "/unauthorized", "", nil, nil); err == nil || !strings.Contains(err.Error(), "gitocean login") {
|
||||
t.Fatalf("expected login hint for unauthorized, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPITextRequest(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") != "Bearer tok" {
|
||||
t.Fatalf("missing auth header: %q", r.Header.Get("Authorization"))
|
||||
}
|
||||
if r.URL.Path == "/fail" {
|
||||
http.Error(w, "nope", http.StatusTeapot)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte("hello"))
|
||||
}))
|
||||
defer ts.Close()
|
||||
got, err := apiTextRequest(context.Background(), ts.URL+"/", "/ok", "tok")
|
||||
if err != nil || got != "hello" {
|
||||
t.Fatalf("got %q err %v", got, err)
|
||||
}
|
||||
if _, err := apiTextRequest(context.Background(), ts.URL, "/fail", "tok"); err == nil || !strings.Contains(err.Error(), "HTTP 418") {
|
||||
t.Fatalf("expected HTTP error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutputFormatting(t *testing.T) {
|
||||
repo := Repository{Owner: "alice", Name: "demo", Visibility: "public", Description: "desc", DefaultBranch: "main", Archived: true}
|
||||
out := captureStdout(t, func() { printRepoCreated("http://server/", repo) })
|
||||
if !strings.Contains(out, "Created public repository alice/demo") || !strings.Contains(out, "git clone http://server/alice/demo.git") {
|
||||
t.Fatalf("unexpected created output: %s", out)
|
||||
}
|
||||
out = captureStdout(t, func() { printRepoView("http://server", repo) })
|
||||
for _, want := range []string{"alice/demo", "Visibility: public", "Archived: true", "Description: desc"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("repo view missing %q: %s", want, out)
|
||||
}
|
||||
}
|
||||
out = captureStdout(t, func() { printRepoTable([]Repository{repo}) })
|
||||
if !strings.Contains(out, "REPOSITORY") || !strings.Contains(out, "alice/demo") {
|
||||
t.Fatalf("unexpected table: %s", out)
|
||||
}
|
||||
out = captureStdout(t, func() {
|
||||
printPR(PullRequest{Number: 3, Status: "open", Title: "Fix", Author: "alice", SourceOwner: "alice", SourceRepo: "demo", SourceBranch: "feature", TargetOwner: "bob", TargetRepo: "demo", TargetBranch: "main", Description: "body"})
|
||||
})
|
||||
if !strings.Contains(out, "#3 open: Fix") || !strings.Contains(out, "From: alice/demo:feature") || !strings.Contains(out, "body") {
|
||||
t.Fatalf("unexpected PR output: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthPureHelpers(t *testing.T) {
|
||||
if hashToken("tok") == hashToken("other") || len(hashToken("tok")) != 64 {
|
||||
t.Fatal("hashToken should produce distinct sha256 hex strings")
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.Header.Set("Authorization", "Bearer abc ")
|
||||
if got := bearerToken(req); got != "abc" {
|
||||
t.Fatalf("bearerToken = %q", got)
|
||||
}
|
||||
req.Header.Set("Authorization", "Basic nope")
|
||||
if got := bearerToken(req); got != "" {
|
||||
t.Fatalf("bearerToken should ignore non-bearer header, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateToken(t *testing.T) {
|
||||
s, mock, cleanup := newMockServer(t)
|
||||
defer cleanup()
|
||||
mock.ExpectExec("INSERT INTO auth_tokens").WithArgs(int64(7), sqlmock.AnyArg(), sqlmock.AnyArg()).WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
token, expires, err := s.createToken(7)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.HasPrefix(token, "go_") {
|
||||
t.Fatalf("token prefix = %q", token)
|
||||
}
|
||||
if time.Until(expires) < tokenTTL-time.Minute || time.Until(expires) > tokenTTL+time.Minute {
|
||||
t.Fatalf("unexpected expiry: %s", expires)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserFromTokenAndBasic(t *testing.T) {
|
||||
s, mock, cleanup := newMockServer(t)
|
||||
defer cleanup()
|
||||
user := User{ID: 1, Email: "alice@example.com", Username: "alice", IsAdmin: true}
|
||||
expectBearerUser(mock, "tok", user)
|
||||
got, err := s.userFromToken("tok", "")
|
||||
if err != nil || got != user {
|
||||
t.Fatalf("got %#v err %v", got, err)
|
||||
}
|
||||
mock.ExpectQuery("FROM auth_tokens t JOIN users u").WithArgs(hashToken("basic-token"), "alice").WillReturnRows(sqlmock.NewRows([]string{"id", "email", "username", "is_admin"}).AddRow(user.ID, user.Email, user.Username, user.IsAdmin))
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.SetBasicAuth("Alice", "basic-token")
|
||||
got, ok := s.userFromBasic(req)
|
||||
if !ok || got != user {
|
||||
t.Fatalf("basic auth got %#v ok=%v", got, ok)
|
||||
}
|
||||
if _, err := s.userFromToken("", ""); err == nil {
|
||||
t.Fatal("expected missing token to fail")
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleRegisterSuccessAndValidation(t *testing.T) {
|
||||
s, mock, cleanup := newMockServer(t)
|
||||
defer cleanup()
|
||||
mock.ExpectQuery("SELECT COUNT").WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0))
|
||||
mock.ExpectExec("INSERT INTO users").WithArgs("alice@example.com", "alice", sqlmock.AnyArg(), true).WillReturnResult(sqlmock.NewResult(42, 1))
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/register", strings.NewReader(`{"email":"Alice@Example.com","username":"Alice","password":"password123"}`))
|
||||
s.handleRegister(rr, req)
|
||||
if rr.Code != http.StatusCreated {
|
||||
t.Fatalf("status = %d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
var user User
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &user); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if user.ID != 42 || user.Email != "alice@example.com" || user.Username != "alice" || !user.IsAdmin {
|
||||
t.Fatalf("unexpected user: %#v", user)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
body string
|
||||
want string
|
||||
}{
|
||||
{"bad email", `{"email":"bad","username":"bob","password":"password123"}`, "invalid email"},
|
||||
{"reserved username", `{"email":"bob@example.com","username":"api","password":"password123"}`, "invalid or reserved username"},
|
||||
{"short password", `{"email":"bob@example.com","username":"bob","password":"short"}`, "password"},
|
||||
{"unknown field", `{"email":"bob@example.com","username":"bob","password":"password123","extra":true}`, "invalid json"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
rr := httptest.NewRecorder()
|
||||
s.handleRegister(rr, httptest.NewRequest(http.MethodPost, "/api/register", strings.NewReader(tc.body)))
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
if !strings.Contains(rr.Body.String(), tc.want) {
|
||||
t.Fatalf("body %q missing %q", rr.Body.String(), tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleLoginSuccessAndFailure(t *testing.T) {
|
||||
s, mock, cleanup := newMockServer(t)
|
||||
defer cleanup()
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte("password123"), bcrypt.MinCost)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mock.ExpectQuery("SELECT id, email, username, is_admin, password_hash FROM users").WithArgs("alice", "alice").WillReturnRows(sqlmock.NewRows([]string{"id", "email", "username", "is_admin", "password_hash"}).AddRow(1, "alice@example.com", "alice", false, string(hash)))
|
||||
mock.ExpectExec("INSERT INTO auth_tokens").WithArgs(int64(1), sqlmock.AnyArg(), sqlmock.AnyArg()).WillReturnResult(sqlmock.NewResult(9, 1))
|
||||
rr := httptest.NewRecorder()
|
||||
s.handleLogin(rr, httptest.NewRequest(http.MethodPost, "/api/login", strings.NewReader(`{"login":"Alice","password":"password123"}`)))
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
var out struct {
|
||||
User User `json:"user"`
|
||||
Token string `json:"token"`
|
||||
}
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out.User.Username != "alice" || !strings.HasPrefix(out.Token, "go_") {
|
||||
t.Fatalf("unexpected login out: %#v", out)
|
||||
}
|
||||
|
||||
mock.ExpectQuery("SELECT id, email, username, is_admin, password_hash FROM users").WithArgs("alice", "alice").WillReturnRows(sqlmock.NewRows([]string{"id", "email", "username", "is_admin", "password_hash"}).AddRow(1, "alice@example.com", "alice", false, string(hash)))
|
||||
rr = httptest.NewRecorder()
|
||||
s.handleLogin(rr, httptest.NewRequest(http.MethodPost, "/api/login", strings.NewReader(`{"login":"alice","password":"wrong"}`)))
|
||||
if rr.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoutingErrors(t *testing.T) {
|
||||
s := &Server{}
|
||||
for _, tc := range []struct {
|
||||
method string
|
||||
path string
|
||||
code int
|
||||
}{
|
||||
{http.MethodGet, "/nope", http.StatusNotFound},
|
||||
{http.MethodGet, "/api/nope", http.StatusNotFound},
|
||||
{http.MethodPost, "/api/repos/alice/demo", http.StatusMethodNotAllowed},
|
||||
{http.MethodGet, "/api/repos/alice/demo/pulls/not-a-number", http.StatusBadRequest},
|
||||
} {
|
||||
rr := httptest.NewRecorder()
|
||||
s.ServeHTTP(rr, httptest.NewRequest(tc.method, tc.path, nil))
|
||||
if rr.Code != tc.code {
|
||||
t.Fatalf("%s %s status=%d body=%s", tc.method, tc.path, rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepoPathsAndPermissionHelpers(t *testing.T) {
|
||||
s, mock, cleanup := newMockServer(t)
|
||||
defer cleanup()
|
||||
if got := s.repoPath("Alice", "Demo"); !strings.HasSuffix(got, "repos/alice/demo.git") {
|
||||
t.Fatalf("repoPath = %q", got)
|
||||
}
|
||||
owner := User{ID: 1, Username: "alice"}
|
||||
reader := User{ID: 2, Username: "reader"}
|
||||
repo := Repository{ID: 10, OwnerUserID: owner.ID, Visibility: "public"}
|
||||
if !s.canReadRepo(repo, User{}, false) {
|
||||
t.Fatal("public repos should be readable anonymously")
|
||||
}
|
||||
repo.Visibility = "private"
|
||||
if s.canReadRepo(repo, User{}, false) {
|
||||
t.Fatal("private repo should not be readable anonymously")
|
||||
}
|
||||
if !s.canReadRepo(repo, owner, true) || !s.canWriteRepo(repo, owner) {
|
||||
t.Fatal("owner should read/write private repo")
|
||||
}
|
||||
mock.ExpectQuery("SELECT role FROM repository_collaborators").WithArgs(repo.ID, reader.ID).WillReturnRows(sqlmock.NewRows([]string{"role"}).AddRow("read"))
|
||||
if !s.canReadRepo(repo, reader, true) {
|
||||
t.Fatal("read collaborator should read private repo")
|
||||
}
|
||||
mock.ExpectQuery("SELECT role FROM repository_collaborators").WithArgs(repo.ID, reader.ID).WillReturnRows(sqlmock.NewRows([]string{"role"}).AddRow("write"))
|
||||
if !s.canWriteRepo(repo, reader) {
|
||||
t.Fatal("write collaborator should write private repo")
|
||||
}
|
||||
mock.ExpectQuery("SELECT role FROM repository_collaborators").WithArgs(repo.ID, int64(99)).WillReturnError(sql.ErrNoRows)
|
||||
if role := s.collaboratorRole(repo.ID, 99); role != "" {
|
||||
t.Fatalf("unexpected missing collaborator role: %q", role)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleRepoCreateSuccessAndErrors(t *testing.T) {
|
||||
s, mock, cleanup := newMockServer(t)
|
||||
defer cleanup()
|
||||
user := User{ID: 1, Email: "alice@example.com", Username: "alice"}
|
||||
token := "tok"
|
||||
expectBearerUser(mock, token, user)
|
||||
mock.ExpectExec("INSERT INTO repositories").WithArgs(user.ID, "demo", "public", "desc").WillReturnResult(sqlmock.NewResult(20, 1))
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/repos", strings.NewReader(`{"name":"Demo","visibility":"public","description":"desc"}`))
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
s.handleRepoCreate(rr, req)
|
||||
if rr.Code != http.StatusCreated {
|
||||
t.Fatalf("status = %d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
var repo Repository
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &repo); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if repo.ID != 20 || repo.Name != "demo" || repo.DefaultBranch != "main" {
|
||||
t.Fatalf("unexpected repo: %#v", repo)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rr = httptest.NewRecorder()
|
||||
s.handleRepoCreate(rr, httptest.NewRequest(http.MethodPost, "/api/repos", bytes.NewReader(nil)))
|
||||
if rr.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected unauthorized without token, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
)
|
||||
|
||||
func TestHandleLogoutAndMe(t *testing.T) {
|
||||
s, mock, cleanup := newMockServer(t)
|
||||
defer cleanup()
|
||||
user := User{ID: 1, Email: "alice@example.com", Username: "alice", IsAdmin: true}
|
||||
token := "tok"
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
s.handleLogout(rr, httptest.NewRequest(http.MethodPost, "/api/logout", nil))
|
||||
if rr.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("logout without token status=%d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
mock.ExpectExec("UPDATE auth_tokens SET revoked_at").WithArgs(hashToken(token)).WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
rr = httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/logout", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
s.handleLogout(rr, req)
|
||||
if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "ok") {
|
||||
t.Fatalf("logout status=%d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
expectBearerUser(mock, token, user)
|
||||
rr = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/me", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
s.handleMe(rr, req)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("me status=%d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
var got User
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != user {
|
||||
t.Fatalf("me got %#v", got)
|
||||
}
|
||||
|
||||
rr = httptest.NewRecorder()
|
||||
s.handleMe(rr, httptest.NewRequest(http.MethodGet, "/api/me", nil))
|
||||
if rr.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("me without token status=%d", rr.Code)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCLIRepoMutatingHTTPCommands(t *testing.T) {
|
||||
t.Setenv("GITOOCEAN_CONFIG", filepath.Join(t.TempDir(), "client.json"))
|
||||
seen := map[string]int{}
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
key := r.Method + " " + r.URL.Path
|
||||
seen[key]++
|
||||
if r.Header.Get("Authorization") != "Bearer tok" {
|
||||
t.Fatalf("missing auth for %s: %q", key, r.Header.Get("Authorization"))
|
||||
}
|
||||
switch key {
|
||||
case "POST /api/repos":
|
||||
writeJSON(w, http.StatusCreated, Repository{Owner: "alice", Name: "demo", Visibility: "public", Description: "desc", DefaultBranch: "main"})
|
||||
case "PATCH /api/repos/alice/demo":
|
||||
writeJSON(w, http.StatusOK, Repository{Owner: "alice", Name: "demo", Visibility: "private", Description: "new", DefaultBranch: "main", Archived: seen[key] > 1})
|
||||
case "DELETE /api/repos/alice/demo":
|
||||
if r.URL.Query().Get("force") != "true" {
|
||||
t.Fatalf("expected force=true, got %s", r.URL.RawQuery)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
|
||||
case "POST /api/repos/bob/demo/fork":
|
||||
writeJSON(w, http.StatusCreated, Repository{Owner: "alice", Name: "fork", Visibility: "public", DefaultBranch: "main"})
|
||||
case "GET /api/repos/alice/demo/collaborators":
|
||||
writeJSON(w, http.StatusOK, []Collaborator{{Username: "bob", Role: "write"}})
|
||||
case "POST /api/repos/alice/demo/collaborators":
|
||||
writeJSON(w, http.StatusOK, map[string]string{"username": "bob", "role": "read"})
|
||||
case "DELETE /api/repos/alice/demo/collaborators/bob":
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "removed"})
|
||||
default:
|
||||
t.Fatalf("unexpected request %s", key)
|
||||
}
|
||||
}))
|
||||
defer ts.Close()
|
||||
if err := saveConfig(Config{Server: ts.URL, Token: "tok", Username: "alice"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
checks := []struct {
|
||||
name string
|
||||
fn func() error
|
||||
want string
|
||||
}{
|
||||
{"create", func() error { return cliRepoCreate([]string{"demo", "--public", "--description", "desc"}) }, "Created public repository"},
|
||||
{"create json", func() error { return cliRepoCreate([]string{"demo", "--public", "--json"}) }, `"owner": "alice"`},
|
||||
{"set", func() error {
|
||||
return cliRepoSet([]string{"alice/demo", "--description", "new", "--visibility", "private"})
|
||||
}, "Visibility: private"},
|
||||
{"archive", func() error { return cliRepoArchive([]string{"alice/demo"}, true) }, "Archived: true"},
|
||||
{"delete", func() error { return cliRepoDelete([]string{"alice/demo", "--force"}) }, "Deleted alice/demo"},
|
||||
{"fork", func() error { return cliRepoFork([]string{"bob/demo", "--name", "fork"}) }, "Forked bob/demo to alice/fork"},
|
||||
{"collaborators", func() error { return cliRepoCollaborators([]string{"alice/demo"}) }, "bob"},
|
||||
{"collaborator add", func() error { return cliRepoCollaborator([]string{"add", "alice/demo", "bob", "--role", "read"}) }, "Added bob as read"},
|
||||
{"collaborator remove", func() error { return cliRepoCollaborator([]string{"remove", "alice/demo", "bob"}) }, ""},
|
||||
}
|
||||
for _, tc := range checks {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
out := captureStdout(t, func() {
|
||||
if err := tc.fn(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
if !strings.Contains(out, tc.want) {
|
||||
t.Fatalf("output missing %q: %s", tc.want, out)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCLIPRMutatingHTTPCommands(t *testing.T) {
|
||||
t.Setenv("GITOOCEAN_CONFIG", filepath.Join(t.TempDir(), "client.json"))
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
key := r.Method + " " + r.URL.Path
|
||||
switch key {
|
||||
case "POST /api/repos/alice/demo/pulls":
|
||||
writeJSON(w, http.StatusCreated, PullRequest{Number: 1, Status: "open", Title: "Fix", Author: "alice", SourceOwner: "alice", SourceRepo: "demo", SourceBranch: "feature", TargetOwner: "alice", TargetRepo: "demo", TargetBranch: "main"})
|
||||
case "GET /api/repos/alice/demo/pulls/1/diff":
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
_, _ = fmt.Fprint(w, "diff --git a/file b/file\n")
|
||||
case "POST /api/repos/alice/demo/pulls/1/comments":
|
||||
writeJSON(w, http.StatusCreated, PRComment{Author: "alice", Body: "hello"})
|
||||
case "POST /api/repos/alice/demo/pulls/1/close", "POST /api/repos/alice/demo/pulls/1/merge":
|
||||
writeJSON(w, http.StatusOK, PullRequest{Number: 1, Status: strings.TrimPrefix(r.URL.Path, "/api/repos/alice/demo/pulls/1/"), Title: "Fix", Author: "alice", SourceOwner: "alice", SourceRepo: "demo", SourceBranch: "feature", TargetOwner: "alice", TargetRepo: "demo", TargetBranch: "main"})
|
||||
default:
|
||||
t.Fatalf("unexpected request %s", key)
|
||||
}
|
||||
}))
|
||||
defer ts.Close()
|
||||
if err := saveConfig(Config{Server: ts.URL, Token: "tok", Username: "alice"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
checks := []struct {
|
||||
name string
|
||||
fn func() error
|
||||
want string
|
||||
}{
|
||||
{"create same", func() error {
|
||||
return cliPRCreate([]string{"--repo", "alice/demo", "--from", "feature", "--to", "main", "--title", "Fix"})
|
||||
}, "#1 open: Fix"},
|
||||
{"create cross", func() error {
|
||||
return cliPRCreate([]string{"--from", "alice/demo:feature", "--to", "alice/demo:main", "--title", "Fix"})
|
||||
}, "#1 open: Fix"},
|
||||
{"diff", func() error { return cliPRDiff([]string{"alice/demo", "1"}) }, "diff --git"},
|
||||
{"comment", func() error { return cliPRComment([]string{"alice/demo", "1", "hello"}) }, "Commented as alice"},
|
||||
{"close", func() error { return cliPRAction([]string{"alice/demo", "1"}, "close") }, "#1 close: Fix"},
|
||||
{"merge", func() error { return cliPRAction([]string{"alice/demo", "1"}, "merge") }, "#1 merge: Fix"},
|
||||
}
|
||||
for _, tc := range checks {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
out := captureStdout(t, func() {
|
||||
if err := tc.fn(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
if !strings.Contains(out, tc.want) {
|
||||
t.Fatalf("output missing %q: %s", tc.want, out)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCLITokenAdminAndBackupCommands(t *testing.T) {
|
||||
t.Setenv("GITOOCEAN_CONFIG", filepath.Join(t.TempDir(), "client.json"))
|
||||
backupCfgPath := filepath.Join(t.TempDir(), "server.json")
|
||||
t.Setenv("GITOOCEAN_SERVER_CONFIG", backupCfgPath)
|
||||
storage := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(storage, "repos", "alice", "demo.git"), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := saveServerConfig(backupCfgPath, ServerConfig{Storage: storage, MySQLDSN: "u:p@tcp(127.0.0.1:3306)/gitocean"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method + " " + r.URL.Path {
|
||||
case "GET /api/tokens":
|
||||
writeJSON(w, http.StatusOK, []TokenInfo{{ID: 1, Active: true}})
|
||||
case "DELETE /api/tokens/1", "POST /api/tokens/prune", "DELETE /api/admin/users/bob", "POST /api/admin/tokens/prune":
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
case "GET /api/admin/users":
|
||||
writeJSON(w, http.StatusOK, []User{{Username: "alice", Email: "alice@example.com", IsAdmin: true}})
|
||||
case "GET /api/admin/repos":
|
||||
writeJSON(w, http.StatusOK, []Repository{{Owner: "alice", Name: "demo", Visibility: "public"}})
|
||||
default:
|
||||
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer ts.Close()
|
||||
if err := saveConfig(Config{Server: ts.URL, Token: "tok", Username: "alice"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
checks := []struct {
|
||||
name string
|
||||
fn func() error
|
||||
want string
|
||||
}{
|
||||
{"token list", func() error { return cliToken([]string{"list"}) }, "active"},
|
||||
{"token revoke", func() error { return cliToken([]string{"revoke", "1"}) }, ""},
|
||||
{"token prune", func() error { return cliToken([]string{"prune"}) }, ""},
|
||||
{"admin users", func() error { return cliAdmin([]string{"users", "list"}) }, "alice@example.com"},
|
||||
{"admin users delete", func() error { return cliAdmin([]string{"users", "delete", "bob"}) }, ""},
|
||||
{"admin repos", func() error { return cliAdmin([]string{"repos", "list"}) }, "alice/demo"},
|
||||
{"admin storage", func() error { return cliAdmin([]string{"storage", "check"}) }, "demo.git"},
|
||||
}
|
||||
for _, tc := range checks {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
out := captureStdout(t, func() {
|
||||
if err := tc.fn(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
if !strings.Contains(out, tc.want) {
|
||||
t.Fatalf("output missing %q: %s", tc.want, out)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func withStdin(t *testing.T, input string, fn func()) {
|
||||
t.Helper()
|
||||
old := os.Stdin
|
||||
r, w, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := w.WriteString(input); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = w.Close()
|
||||
os.Stdin = r
|
||||
defer func() { os.Stdin = old }()
|
||||
fn()
|
||||
}
|
||||
|
||||
func TestCLIAuthWorkflowCommands(t *testing.T) {
|
||||
configFile := filepath.Join(t.TempDir(), "client.json")
|
||||
t.Setenv("GITOOCEAN_CONFIG", configFile)
|
||||
var logoutAuth string
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method + " " + r.URL.Path {
|
||||
case "POST /api/register":
|
||||
writeJSON(w, http.StatusCreated, User{ID: 1, Email: "alice@example.com", Username: "alice", IsAdmin: true})
|
||||
case "POST /api/login":
|
||||
writeJSON(w, http.StatusOK, map[string]any{"user": User{ID: 1, Email: "alice@example.com", Username: "alice", IsAdmin: true}, "token": "tok", "expires_at": time.Now().UTC().Add(tokenTTL)})
|
||||
case "GET /api/me":
|
||||
if r.Header.Get("Authorization") != "Bearer tok" {
|
||||
t.Fatalf("missing whoami auth: %q", r.Header.Get("Authorization"))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, User{ID: 1, Email: "alice@example.com", Username: "alice", IsAdmin: true})
|
||||
case "POST /api/logout":
|
||||
logoutAuth = r.Header.Get("Authorization")
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
default:
|
||||
t.Fatalf("unexpected auth request %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
out := captureStdout(t, func() {
|
||||
withStdin(t, "alice@example.com\nalice\npassword123\n", func() {
|
||||
if err := cliRegister([]string{"--server", ts.URL}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
})
|
||||
if !strings.Contains(out, "Registered alice") || !strings.Contains(out, "admin account") {
|
||||
t.Fatalf("register output: %s", out)
|
||||
}
|
||||
|
||||
out = captureStdout(t, func() {
|
||||
withStdin(t, "alice\npassword123\n", func() {
|
||||
if err := cliLogin([]string{"--server", ts.URL}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
})
|
||||
if !strings.Contains(out, "logged in as alice") {
|
||||
t.Fatalf("login output: %s", out)
|
||||
}
|
||||
cfg, err := loadConfig()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.Server != ts.URL || cfg.Token != "tok" || cfg.Username != "alice" {
|
||||
t.Fatalf("unexpected saved config: %#v", cfg)
|
||||
}
|
||||
|
||||
out = captureStdout(t, func() {
|
||||
if err := cliWhoami(nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
if !strings.Contains(out, "Username: alice") || !strings.Contains(out, "Admin: true") {
|
||||
t.Fatalf("whoami output: %s", out)
|
||||
}
|
||||
out = captureStdout(t, func() {
|
||||
if err := cliLogout(nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
if logoutAuth != "Bearer tok" || !strings.Contains(out, "logged out") {
|
||||
t.Fatalf("logout auth=%q output=%s", logoutAuth, out)
|
||||
}
|
||||
if _, err := os.Stat(configFile); !os.IsNotExist(err) {
|
||||
t.Fatalf("expected config file removed, stat err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCLICloneUsage(t *testing.T) {
|
||||
if err := cliClone(nil); err == nil || !strings.Contains(err.Error(), "usage") {
|
||||
t.Fatalf("expected clone usage, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestCLIRepoHTTPCommandsOutput(t *testing.T) {
|
||||
t.Setenv("GITOOCEAN_CONFIG", filepath.Join(t.TempDir(), "client.json"))
|
||||
var auth string
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
auth = r.Header.Get("Authorization")
|
||||
switch r.URL.Path {
|
||||
case "/api/repos/alice/demo":
|
||||
writeJSON(w, http.StatusOK, Repository{Owner: "alice", Name: "demo", Visibility: "public", DefaultBranch: "main", Description: "desc"})
|
||||
case "/api/repos/search":
|
||||
if r.URL.Query().Get("q") != "demo" || r.URL.Query().Get("scope") != "mine" {
|
||||
t.Fatalf("unexpected search query: %s", r.URL.RawQuery)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, []Repository{{Owner: "alice", Name: "demo", Visibility: "public", Description: "desc"}})
|
||||
case "/api/repos/alice/demo/branches":
|
||||
writeJSON(w, http.StatusOK, []RefInfo{{Name: "main", Commit: "1234567890abcdef", Date: time.Date(2026, 1, 2, 3, 4, 0, 0, time.UTC), Message: "initial"}})
|
||||
default:
|
||||
t.Fatalf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer ts.Close()
|
||||
if err := saveConfig(Config{Server: ts.URL, Token: "tok", Username: "alice"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
out := captureStdout(t, func() {
|
||||
if err := cliRepoView([]string{"alice/demo"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
if auth != "Bearer tok" || !strings.Contains(out, "alice/demo") || strings.Contains(strings.TrimSpace(out), "{") {
|
||||
t.Fatalf("unexpected repo view output/auth: auth=%q out=%s", auth, out)
|
||||
}
|
||||
|
||||
out = captureStdout(t, func() {
|
||||
if err := cliRepoView([]string{"alice/demo", "--json"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
if !strings.Contains(out, `"owner": "alice"`) {
|
||||
t.Fatalf("expected JSON output, got %s", out)
|
||||
}
|
||||
|
||||
out = captureStdout(t, func() {
|
||||
if err := cliRepoSearch([]string{"demo", "--mine"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
if !strings.Contains(out, "REPOSITORY") || !strings.Contains(out, "alice/demo") {
|
||||
t.Fatalf("unexpected search output: %s", out)
|
||||
}
|
||||
|
||||
out = captureStdout(t, func() {
|
||||
if err := cliRepoRefs([]string{"alice/demo"}, "branches"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
if !strings.Contains(out, "MAIN") && !strings.Contains(out, "main") {
|
||||
t.Fatalf("unexpected refs output: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCLIPRAndTokenHTTPCommandsOutput(t *testing.T) {
|
||||
t.Setenv("GITOOCEAN_CONFIG", filepath.Join(t.TempDir(), "client.json"))
|
||||
created := time.Date(2026, 1, 2, 3, 4, 0, 0, time.UTC)
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/repos/alice/demo/pulls":
|
||||
writeJSON(w, http.StatusOK, []PullRequest{{Number: 2, Status: "open", Title: "Fix", SourceOwner: "alice", SourceRepo: "demo", SourceBranch: "feature", TargetOwner: "alice", TargetRepo: "demo", TargetBranch: "main"}})
|
||||
case "/api/repos/alice/demo/pulls/2":
|
||||
writeJSON(w, http.StatusOK, PullRequest{Number: 2, Status: "open", Title: "Fix", Author: "alice", SourceOwner: "alice", SourceRepo: "demo", SourceBranch: "feature", TargetOwner: "alice", TargetRepo: "demo", TargetBranch: "main"})
|
||||
case "/api/repos/alice/demo/pulls/2/comments":
|
||||
writeJSON(w, http.StatusOK, []PRComment{{ID: 1, Author: "alice", Body: "looks good", CreatedAt: created, UpdatedAt: created}})
|
||||
case "/api/tokens":
|
||||
writeJSON(w, http.StatusOK, []TokenInfo{{ID: 1, Active: true, CreatedAt: created, ExpiresAt: created.Add(tokenTTL)}})
|
||||
default:
|
||||
t.Fatalf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer ts.Close()
|
||||
if err := saveConfig(Config{Server: ts.URL, Token: "tok", Username: "alice"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
out := captureStdout(t, func() {
|
||||
if err := cliPRList([]string{"alice/demo"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
if !strings.Contains(out, "#2") || strings.Contains(strings.TrimSpace(out), "[") {
|
||||
t.Fatalf("unexpected PR list output: %s", out)
|
||||
}
|
||||
out = captureStdout(t, func() {
|
||||
if err := cliPRView([]string{"alice/demo", "2", "--json"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
if !strings.Contains(out, `"number": 2`) {
|
||||
t.Fatalf("unexpected PR view JSON: %s", out)
|
||||
}
|
||||
out = captureStdout(t, func() {
|
||||
if err := cliPRComments([]string{"alice/demo", "2"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
if !strings.Contains(out, "looks good") {
|
||||
t.Fatalf("unexpected comments output: %s", out)
|
||||
}
|
||||
out = captureStdout(t, func() {
|
||||
if err := cliToken([]string{"list", "--json"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
if !strings.Contains(out, `"id": 1`) {
|
||||
t.Fatalf("unexpected token JSON: %s", out)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCLICommandValidation(t *testing.T) {
|
||||
t.Setenv("GITOOCEAN_CONFIG", filepath.Join(t.TempDir(), "missing.json"))
|
||||
cases := []struct {
|
||||
name string
|
||||
fn func() error
|
||||
want string
|
||||
}{
|
||||
{"repo missing", func() error { return cliRepo(nil) }, "usage"},
|
||||
{"repo unknown", func() error { return cliRepo([]string{"wat"}) }, "unknown repo"},
|
||||
{"repo create invalid", func() error { return cliRepoCreate([]string{"demo"}) }, "usage"},
|
||||
{"repo publish invalid", func() error { return cliRepoPublish([]string{"demo", "--public", "--remote"}) }, "--remote requires"},
|
||||
{"repo set invalid", func() error { return cliRepoSet([]string{"alice/demo"}) }, "no settings"},
|
||||
{"repo archive invalid", func() error { return cliRepoArchive([]string{}, true) }, "usage"},
|
||||
{"repo delete invalid", func() error { return cliRepoDelete([]string{}) }, "usage"},
|
||||
{"repo search invalid", func() error { return cliRepoSearch([]string{"one", "two"}) }, "usage"},
|
||||
{"repo fork invalid", func() error { return cliRepoFork([]string{}) }, "usage"},
|
||||
{"repo collaborators invalid", func() error { return cliRepoCollaborators([]string{}) }, "usage"},
|
||||
{"repo collaborator invalid", func() error { return cliRepoCollaborator([]string{"add"}) }, "usage"},
|
||||
{"pr missing", func() error { return cliPR(nil) }, "usage"},
|
||||
{"pr unknown", func() error { return cliPR([]string{"wat"}) }, "unknown pr"},
|
||||
{"pr create invalid", func() error { return cliPRCreate(nil) }, "usage"},
|
||||
{"pr diff invalid", func() error { return cliPRDiff([]string{"alice/demo"}) }, "usage"},
|
||||
{"pr checkout invalid", func() error { return cliPRCheckout([]string{"alice/demo"}) }, "usage"},
|
||||
{"pr comment invalid", func() error { return cliPRComment([]string{"alice/demo", "1"}) }, "usage"},
|
||||
{"pr comments invalid", func() error { return cliPRComments([]string{"alice/demo"}) }, "usage"},
|
||||
{"pr action invalid", func() error { return cliPRAction([]string{"alice/demo"}, "merge") }, "usage"},
|
||||
{"token missing", func() error { return cliToken(nil) }, "usage"},
|
||||
{"admin missing", func() error { return cliAdmin(nil) }, "usage"},
|
||||
{"backup missing", func() error { return cliBackup(nil) }, "usage"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := tc.fn()
|
||||
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("error = %v, want containing %q", err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepoCloneURLAndWrapperHelpers(t *testing.T) {
|
||||
if got := repoCloneURL("http://server/", "alice", "demo"); got != "http://server/alice/demo.git" {
|
||||
t.Fatalf("repoCloneURL = %q", got)
|
||||
}
|
||||
if isReservedName("api") != true || isReservedName("alice") != false {
|
||||
t.Fatal("isReservedName wrapper mismatch")
|
||||
}
|
||||
if _, _, err := mysqlCLIArgs("bad dsn"); err == nil {
|
||||
t.Fatal("expected mysqlCLIArgs wrapper to fail on bad DSN")
|
||||
}
|
||||
if err := copyDir(filepath.Join(t.TempDir(), "missing"), filepath.Join(t.TempDir(), "dst")); err == nil {
|
||||
t.Fatal("expected copyDir wrapper to fail on missing source")
|
||||
}
|
||||
if _, err := gitRefs(filepath.Join(t.TempDir(), "missing.git"), "refs/heads"); err == nil {
|
||||
t.Fatal("expected gitRefs wrapper to fail on missing repo")
|
||||
}
|
||||
if gitBranchExists(filepath.Join(t.TempDir(), "missing.git"), "bad branch") {
|
||||
t.Fatal("invalid branch should not exist")
|
||||
}
|
||||
if err := runGit("", "definitely-not-a-git-command"); err == nil {
|
||||
t.Fatal("expected runGit wrapper to return git error")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
)
|
||||
|
||||
func TestHandleGitHTTPPermissionFailures(t *testing.T) {
|
||||
s, mock, cleanup := newMockServer(t)
|
||||
defer cleanup()
|
||||
owner := User{ID: 1, Email: "alice@example.com", Username: "alice"}
|
||||
privateRepo := Repository{ID: 10, OwnerUserID: owner.ID, Owner: "alice", Name: "demo", Visibility: "private", DefaultBranch: "main"}
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
s.handleGitHTTP(rr, httptest.NewRequest(http.MethodGet, "/bad/path", nil))
|
||||
if rr.Code != http.StatusNotFound {
|
||||
t.Fatalf("invalid path status=%d", rr.Code)
|
||||
}
|
||||
|
||||
expectLoadRepo(mock, "alice", "demo", privateRepo)
|
||||
rr = httptest.NewRecorder()
|
||||
s.handleGitHTTP(rr, httptest.NewRequest(http.MethodGet, "/alice/demo.git/info/refs?service=git-upload-pack", nil))
|
||||
if rr.Code != http.StatusUnauthorized || rr.Header().Get("WWW-Authenticate") == "" {
|
||||
t.Fatalf("private fetch status=%d headers=%#v body=%s", rr.Code, rr.Header(), rr.Body.String())
|
||||
}
|
||||
|
||||
expectLoadRepo(mock, "alice", "demo", privateRepo)
|
||||
rr = httptest.NewRecorder()
|
||||
s.handleGitHTTP(rr, httptest.NewRequest(http.MethodPost, "/alice/demo.git/git-receive-pack", nil))
|
||||
if rr.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("push without auth status=%d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
archived := privateRepo
|
||||
archived.Archived = true
|
||||
expectLoadRepo(mock, "alice", "demo", archived)
|
||||
mock.ExpectQuery("FROM auth_tokens t JOIN users u").WithArgs(hashToken("tok"), "alice").WillReturnRows(sqlmockUserRows(owner))
|
||||
rr = httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/alice/demo.git/git-receive-pack", nil)
|
||||
req.SetBasicAuth("alice", "tok")
|
||||
s.handleGitHTTP(rr, req)
|
||||
if rr.Code != http.StatusForbidden || !strings.Contains(rr.Body.String(), "archived") {
|
||||
t.Fatalf("archived push status=%d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func sqlmockUserRows(user User) *sqlmock.Rows {
|
||||
return sqlmock.NewRows([]string{"id", "email", "username", "is_admin"}).AddRow(user.ID, user.Email, user.Username, user.IsAdmin)
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package app
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
)
|
||||
@@ -17,6 +18,9 @@ func TestParseGitPath(t *testing.T) {
|
||||
if _, _, _, ok := parseGitPath("/bad/path"); ok {
|
||||
t.Fatal("expected invalid path")
|
||||
}
|
||||
if _, _, _, ok := parseGitPath("/too/many/parts.git/info/refs"); ok {
|
||||
t.Fatal("expected invalid owner/repo path")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGitService(t *testing.T) {
|
||||
@@ -28,4 +32,34 @@ func TestGitService(t *testing.T) {
|
||||
if got := gitService(req); got != "git-receive-pack" {
|
||||
t.Fatalf("unexpected receive-pack service: %q", got)
|
||||
}
|
||||
req = &http.Request{Method: http.MethodGet, URL: &url.URL{Path: "/alice/demo.git/info/refs"}}
|
||||
if got := gitService(req); got != "git-upload-pack" {
|
||||
t.Fatalf("default service = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteCGIResponse(t *testing.T) {
|
||||
rr := httptest.NewRecorder()
|
||||
writeCGIResponse(rr, []byte("Status: 201 Created\r\nContent-Type: text/plain\r\nX-Test: one\r\n\r\nbody"))
|
||||
if rr.Code != http.StatusCreated {
|
||||
t.Fatalf("status = %d", rr.Code)
|
||||
}
|
||||
if rr.Header().Get("Content-Type") != "text/plain" || rr.Header().Get("X-Test") != "one" {
|
||||
t.Fatalf("unexpected headers: %#v", rr.Header())
|
||||
}
|
||||
if rr.Body.String() != "body" {
|
||||
t.Fatalf("body = %q", rr.Body.String())
|
||||
}
|
||||
|
||||
rr = httptest.NewRecorder()
|
||||
writeCGIResponse(rr, []byte("Content-Type: text/plain\n\nplain"))
|
||||
if rr.Code != http.StatusOK || rr.Body.String() != "plain" {
|
||||
t.Fatalf("status/body = %d/%q", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
rr = httptest.NewRecorder()
|
||||
writeCGIResponse(rr, []byte("raw"))
|
||||
if rr.Body.String() != "raw" {
|
||||
t.Fatalf("raw body = %q", rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func requireGitForApp(t *testing.T) {
|
||||
t.Helper()
|
||||
if _, err := exec.LookPath("git"); err != nil {
|
||||
t.Skip("git not installed")
|
||||
}
|
||||
}
|
||||
|
||||
func seedRepoWithFeatureBranch(t *testing.T, bare string) {
|
||||
t.Helper()
|
||||
if err := gitInitBare(bare); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
work := filepath.Join(t.TempDir(), "work")
|
||||
if err := runGit("", "clone", bare, work); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, args := range [][]string{{"config", "user.name", "Test User"}, {"config", "user.email", "test@example.com"}} {
|
||||
if err := runGit(work, args...); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(work, "README.md"), []byte("hello\n"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := runGit(work, "add", "README.md"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := runGit(work, "commit", "-m", "initial"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := runGit(work, "push", "origin", "HEAD:main"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := runGit(work, "checkout", "-b", "feature"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(work, "feature.txt"), []byte("feature\n"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := runGit(work, "add", "feature.txt"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := runGit(work, "commit", "-m", "feature"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := runGit(work, "push", "origin", "HEAD:feature"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := runGit(work, "tag", "v1.0.0"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := runGit(work, "push", "origin", "v1.0.0"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppGitRefsDiffAndMerge(t *testing.T) {
|
||||
requireGitForApp(t)
|
||||
s, mock, cleanup := newMockServer(t)
|
||||
defer cleanup()
|
||||
bare := s.repoPath("alice", "demo")
|
||||
if err := os.MkdirAll(filepath.Dir(bare), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seedRepoWithFeatureBranch(t, bare)
|
||||
repo := Repository{ID: 10, OwnerUserID: 1, Owner: "alice", Name: "demo", Visibility: "public", DefaultBranch: "main"}
|
||||
|
||||
expectLoadRepo(mock, "alice", "demo", repo)
|
||||
rr := httptest.NewRecorder()
|
||||
s.handleRepoBranches(rr, httptest.NewRequest(http.MethodGet, "/api/repos/alice/demo/branches", nil), "alice", "demo")
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("branches status=%d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
var refs []RefInfo
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &refs); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(refs) < 2 {
|
||||
t.Fatalf("expected main and feature refs, got %#v", refs)
|
||||
}
|
||||
|
||||
expectLoadRepo(mock, "alice", "demo", repo)
|
||||
rr = httptest.NewRecorder()
|
||||
s.handleRepoTags(rr, httptest.NewRequest(http.MethodGet, "/api/repos/alice/demo/tags", nil), "alice", "demo")
|
||||
if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "v1.0.0") {
|
||||
t.Fatalf("tags status=%d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
pr := PullRequest{Number: 1, TargetOwner: "alice", TargetRepo: "demo", TargetBranch: "main", SourceOwner: "alice", SourceRepo: "demo", SourceBranch: "feature"}
|
||||
diff, err := s.prDiff(pr)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(diff, "feature.txt") {
|
||||
t.Fatalf("diff missing feature file: %s", diff)
|
||||
}
|
||||
if err := s.mergePR(pr); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !gitBranchExists(bare, "main") {
|
||||
t.Fatal("main branch should exist after merge")
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
//go:build integration
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-sql-driver/mysql"
|
||||
)
|
||||
|
||||
func TestIntegrationMigrationsAreIdempotent(t *testing.T) {
|
||||
dsn := os.Getenv("GITOOCEAN_TEST_MYSQL_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("set GITOOCEAN_TEST_MYSQL_DSN to run integration migration tests")
|
||||
}
|
||||
cfg, err := mysql.ParseDSN(dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.DBName == "" || !strings.Contains(cfg.DBName, "test") {
|
||||
t.Fatalf("refusing to run integration test against non-test database %q", cfg.DBName)
|
||||
}
|
||||
db, err := openMySQLAndCreateDatabaseIfMissing(dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
if err := resetIntegrationDB(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := migrate(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := migrate(db); err != nil {
|
||||
t.Fatalf("second migrate should be idempotent: %v", err)
|
||||
}
|
||||
for _, table := range []string{"users", "auth_tokens", "repositories", "repository_collaborators", "pull_requests", "pull_request_comments"} {
|
||||
var name string
|
||||
if err := db.QueryRow("SHOW TABLES LIKE ?", table).Scan(&name); err != nil {
|
||||
t.Fatalf("expected table %s: %v", table, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func resetIntegrationDB(db *sql.DB) error {
|
||||
_, _ = db.Exec("SET FOREIGN_KEY_CHECKS=0")
|
||||
for _, table := range []string{"pull_request_comments", "pull_requests", "repository_collaborators", "auth_tokens", "repositories", "users"} {
|
||||
_, _ = db.Exec("DROP TABLE IF EXISTS " + quoteMySQLIdentifier(table))
|
||||
}
|
||||
_, err := db.Exec("SET FOREIGN_KEY_CHECKS=1")
|
||||
return err
|
||||
}
|
||||
|
||||
func TestIntegrationAuthTokenLifecycle(t *testing.T) {
|
||||
dsn := os.Getenv("GITOOCEAN_TEST_MYSQL_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("set GITOOCEAN_TEST_MYSQL_DSN to run integration auth tests")
|
||||
}
|
||||
db, err := openMySQLAndCreateDatabaseIfMissing(dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
if err := resetIntegrationDB(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := migrate(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := createUserDirect(db, "alice@example.com", "alice", "password123", true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := &Server{db: db, storage: t.TempDir(), publicURL: "http://example.test"}
|
||||
token, expires, err := s.createToken(1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if time.Until(expires) < tokenTTL-time.Minute {
|
||||
t.Fatalf("unexpected expiry: %s", expires)
|
||||
}
|
||||
user, err := s.userFromToken(token, "alice")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if user.Username != "alice" || !user.IsAdmin {
|
||||
t.Fatalf("unexpected user from token: %#v", user)
|
||||
}
|
||||
if _, err := db.Exec("UPDATE auth_tokens SET revoked_at = UTC_TIMESTAMP() WHERE token_hash = ?", hashToken(token)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.userFromToken(token, "alice"); err == nil {
|
||||
t.Fatal("expected revoked token to be rejected")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
)
|
||||
|
||||
func prRows(pr PullRequest) *sqlmock.Rows {
|
||||
created := pr.CreatedAt
|
||||
if created.IsZero() {
|
||||
created = time.Now().UTC()
|
||||
}
|
||||
updated := pr.UpdatedAt
|
||||
if updated.IsZero() {
|
||||
updated = created
|
||||
}
|
||||
closed := sql.NullTime{}
|
||||
if pr.ClosedAt != nil {
|
||||
closed = sql.NullTime{Time: *pr.ClosedAt, Valid: true}
|
||||
}
|
||||
merged := sql.NullTime{}
|
||||
if pr.MergedAt != nil {
|
||||
merged = sql.NullTime{Time: *pr.MergedAt, Valid: true}
|
||||
}
|
||||
return sqlmock.NewRows([]string{"id", "number", "target_repository_id", "source_repository_id", "author_user_id", "author", "source_owner", "source_repo", "source_branch", "target_owner", "target_repo", "target_branch", "title", "description", "status", "created_at", "updated_at", "closed_at", "merged_at"}).
|
||||
AddRow(pr.ID, pr.Number, pr.TargetRepositoryID, pr.SourceRepositoryID, pr.AuthorUserID, pr.Author, pr.SourceOwner, pr.SourceRepo, pr.SourceBranch, pr.TargetOwner, pr.TargetRepo, pr.TargetBranch, pr.Title, pr.Description, pr.Status, created, updated, closed, merged)
|
||||
}
|
||||
|
||||
func TestPRListViewCloseAndComments(t *testing.T) {
|
||||
s, mock, cleanup := newMockServer(t)
|
||||
defer cleanup()
|
||||
owner := User{ID: 1, Email: "alice@example.com", Username: "alice"}
|
||||
token := "tok"
|
||||
repo := Repository{ID: 10, OwnerUserID: owner.ID, Owner: "alice", Name: "demo", Visibility: "public", DefaultBranch: "main"}
|
||||
pr := PullRequest{ID: 30, Number: 2, TargetRepositoryID: repo.ID, SourceRepositoryID: repo.ID, AuthorUserID: owner.ID, Author: "alice", SourceOwner: "alice", SourceRepo: "demo", SourceBranch: "feature", TargetOwner: "alice", TargetRepo: "demo", TargetBranch: "main", Title: "Fix", Description: "desc", Status: "open"}
|
||||
|
||||
expectLoadRepo(mock, "alice", "demo", repo)
|
||||
mock.ExpectQuery("FROM pull_requests pr").WithArgs(repo.ID).WillReturnRows(prRows(pr))
|
||||
rr := httptest.NewRecorder()
|
||||
s.handlePRList(rr, httptest.NewRequest(http.MethodGet, "/api/repos/alice/demo/pulls", nil), "alice", "demo")
|
||||
if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "Fix") {
|
||||
t.Fatalf("list status=%d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
expectLoadRepo(mock, "alice", "demo", repo)
|
||||
mock.ExpectQuery("FROM pull_requests pr").WithArgs(repo.ID, pr.Number).WillReturnRows(prRows(pr))
|
||||
rr = httptest.NewRecorder()
|
||||
s.handlePRView(rr, httptest.NewRequest(http.MethodGet, "/api/repos/alice/demo/pulls/2", nil), "alice", "demo", 2)
|
||||
if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "Fix") {
|
||||
t.Fatalf("view status=%d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
expectBearerUser(mock, token, owner)
|
||||
expectLoadRepo(mock, "alice", "demo", repo)
|
||||
mock.ExpectExec("UPDATE pull_requests SET status = 'closed'").WithArgs(repo.ID, pr.Number).WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
closedPR := pr
|
||||
closedPR.Status = "closed"
|
||||
mock.ExpectQuery("FROM pull_requests pr").WithArgs(repo.ID, pr.Number).WillReturnRows(prRows(closedPR))
|
||||
rr = httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/repos/alice/demo/pulls/2/close", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
s.handlePRClose(rr, req, "alice", "demo", 2)
|
||||
if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "closed") {
|
||||
t.Fatalf("close status=%d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
expectLoadRepo(mock, "alice", "demo", repo)
|
||||
mock.ExpectQuery("FROM pull_requests pr").WithArgs(repo.ID, pr.Number).WillReturnRows(prRows(pr))
|
||||
mock.ExpectQuery("SELECT c.id, u.username").WithArgs(pr.ID).WillReturnRows(sqlmock.NewRows([]string{"id", "username", "body", "created_at", "updated_at"}).AddRow(int64(1), "alice", "hello", time.Now().UTC(), time.Now().UTC()))
|
||||
rr = httptest.NewRecorder()
|
||||
s.handlePRCommentsList(rr, httptest.NewRequest(http.MethodGet, "/api/repos/alice/demo/pulls/2/comments", nil), "alice", "demo", 2)
|
||||
if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "hello") {
|
||||
t.Fatalf("comments list status=%d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
expectBearerUser(mock, token, owner)
|
||||
expectLoadRepo(mock, "alice", "demo", repo)
|
||||
mock.ExpectQuery("FROM pull_requests pr").WithArgs(repo.ID, pr.Number).WillReturnRows(prRows(pr))
|
||||
mock.ExpectExec("INSERT INTO pull_request_comments").WithArgs(pr.ID, owner.ID, "hello").WillReturnResult(sqlmock.NewResult(5, 1))
|
||||
rr = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/repos/alice/demo/pulls/2/comments", strings.NewReader(`{"body":" hello "}`))
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
s.handlePRCommentCreate(rr, req, "alice", "demo", 2)
|
||||
if rr.Code != http.StatusCreated || !strings.Contains(rr.Body.String(), "hello") {
|
||||
t.Fatalf("comment create status=%d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
expectBearerUser(mock, token, owner)
|
||||
expectLoadRepo(mock, "alice", "demo", repo)
|
||||
mock.ExpectQuery("FROM pull_requests pr").WithArgs(repo.ID, pr.Number).WillReturnRows(prRows(pr))
|
||||
rr = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/repos/alice/demo/pulls/2/comments", strings.NewReader(`{"body":" "}`))
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
s.handlePRCommentCreate(rr, req, "alice", "demo", 2)
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Fatalf("empty comment status=%d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
)
|
||||
|
||||
func repoRows(repo Repository) *sqlmock.Rows {
|
||||
var fork any = sql.NullInt64{}
|
||||
if repo.ForkedFromID != nil {
|
||||
fork = sql.NullInt64{Int64: *repo.ForkedFromID, Valid: true}
|
||||
}
|
||||
created := repo.CreatedAt
|
||||
if created.IsZero() {
|
||||
created = time.Now().UTC()
|
||||
}
|
||||
updated := repo.UpdatedAt
|
||||
if updated.IsZero() {
|
||||
updated = created
|
||||
}
|
||||
return sqlmock.NewRows([]string{"id", "owner_user_id", "owner", "name", "visibility", "description", "default_branch", "archived", "forked_from_repository_id", "created_at", "updated_at"}).
|
||||
AddRow(repo.ID, repo.OwnerUserID, repo.Owner, repo.Name, repo.Visibility, repo.Description, repo.DefaultBranch, repo.Archived, fork, created, updated)
|
||||
}
|
||||
|
||||
func expectLoadRepo(mock sqlmock.Sqlmock, owner, name string, repo Repository) {
|
||||
mock.ExpectQuery("FROM repositories r JOIN users u").WithArgs(strings.ToLower(owner), strings.ToLower(name)).WillReturnRows(repoRows(repo))
|
||||
}
|
||||
|
||||
func TestRepoGetSearchAndUpdate(t *testing.T) {
|
||||
s, mock, cleanup := newMockServer(t)
|
||||
defer cleanup()
|
||||
user := User{ID: 1, Email: "alice@example.com", Username: "alice"}
|
||||
token := "tok"
|
||||
repo := Repository{ID: 10, OwnerUserID: user.ID, Owner: "alice", Name: "demo", Visibility: "public", Description: "old", DefaultBranch: "main"}
|
||||
|
||||
expectLoadRepo(mock, "alice", "demo", repo)
|
||||
rr := httptest.NewRecorder()
|
||||
s.handleRepoGet(rr, httptest.NewRequest(http.MethodGet, "/api/repos/alice/demo", nil), "alice", "demo")
|
||||
if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "demo") {
|
||||
t.Fatalf("get status=%d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
mock.ExpectQuery("FROM repositories r JOIN users u").WithArgs("demo", "%demo%", "%demo%").WillReturnRows(repoRows(repo))
|
||||
rr = httptest.NewRecorder()
|
||||
s.handleRepoSearch(rr, httptest.NewRequest(http.MethodGet, "/api/repos/search?q=demo&scope=all", nil))
|
||||
if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "demo") {
|
||||
t.Fatalf("search status=%d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
expectBearerUser(mock, token, user)
|
||||
expectLoadRepo(mock, "alice", "demo", repo)
|
||||
mock.ExpectExec("UPDATE repositories SET description").WithArgs("new", "private", "main", false, repo.ID).WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
desc := "new"
|
||||
body := `{"description":"` + desc + `","visibility":"private"}`
|
||||
rr = httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPatch, "/api/repos/alice/demo", strings.NewReader(body))
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
s.handleRepoUpdate(rr, req, "alice", "demo")
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("update status=%d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
var updated Repository
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &updated); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if updated.Description != "new" || updated.Visibility != "private" {
|
||||
t.Fatalf("unexpected updated repo: %#v", updated)
|
||||
}
|
||||
|
||||
expectBearerUser(mock, token, user)
|
||||
expectLoadRepo(mock, "alice", "demo", repo)
|
||||
rr = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodPatch, "/api/repos/alice/demo", strings.NewReader(`{"visibility":"hidden"}`))
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
s.handleRepoUpdate(rr, req, "alice", "demo")
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Fatalf("bad visibility status=%d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepoDeleteAndCollaborators(t *testing.T) {
|
||||
s, mock, cleanup := newMockServer(t)
|
||||
defer cleanup()
|
||||
owner := User{ID: 1, Email: "alice@example.com", Username: "alice"}
|
||||
token := "tok"
|
||||
repo := Repository{ID: 10, OwnerUserID: owner.ID, Owner: "alice", Name: "demo", Visibility: "private", DefaultBranch: "main"}
|
||||
|
||||
expectBearerUser(mock, token, owner)
|
||||
expectLoadRepo(mock, "alice", "demo", repo)
|
||||
mock.ExpectQuery("SELECT COUNT").WithArgs(repo.ID, repo.ID).WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodDelete, "/api/repos/alice/demo", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
s.handleRepoDelete(rr, req, "alice", "demo")
|
||||
if rr.Code != http.StatusConflict {
|
||||
t.Fatalf("delete open PR status=%d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
expectBearerUser(mock, token, owner)
|
||||
expectLoadRepo(mock, "alice", "demo", repo)
|
||||
mock.ExpectQuery("SELECT COUNT").WithArgs(repo.ID, repo.ID).WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0))
|
||||
mock.ExpectExec("DELETE FROM repositories").WithArgs(repo.ID).WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
rr = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodDelete, "/api/repos/alice/demo?force=true", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
s.handleRepoDelete(rr, req, "alice", "demo")
|
||||
if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "deleted") {
|
||||
t.Fatalf("delete status=%d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
expectBearerUser(mock, token, owner)
|
||||
expectLoadRepo(mock, "alice", "demo", repo)
|
||||
mock.ExpectQuery("SELECT rc.id, u.username").WithArgs(repo.ID).WillReturnRows(sqlmock.NewRows([]string{"id", "username", "role", "created_at"}).AddRow(int64(2), "bob", "write", time.Now().UTC()))
|
||||
rr = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/repos/alice/demo/collaborators", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
s.handleCollaborators(rr, req, "alice", "demo", nil)
|
||||
if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "bob") {
|
||||
t.Fatalf("collab list status=%d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
expectBearerUser(mock, token, owner)
|
||||
expectLoadRepo(mock, "alice", "demo", repo)
|
||||
mock.ExpectQuery("SELECT id FROM users").WithArgs("bob").WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(int64(2)))
|
||||
mock.ExpectExec("INSERT INTO repository_collaborators").WithArgs(repo.ID, int64(2), "read").WillReturnResult(sqlmock.NewResult(3, 1))
|
||||
rr = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/repos/alice/demo/collaborators", strings.NewReader(`{"username":"Bob","role":"read"}`))
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
s.handleCollaborators(rr, req, "alice", "demo", nil)
|
||||
if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "bob") {
|
||||
t.Fatalf("collab add status=%d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
expectBearerUser(mock, token, owner)
|
||||
expectLoadRepo(mock, "alice", "demo", repo)
|
||||
mock.ExpectExec("DELETE rc FROM repository_collaborators").WithArgs(repo.ID, "bob").WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
rr = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodDelete, "/api/repos/alice/demo/collaborators/bob", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
s.handleCollaborators(rr, req, "alice", "demo", []string{"bob"})
|
||||
if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "removed") {
|
||||
t.Fatalf("collab remove status=%d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
)
|
||||
|
||||
func TestHandleRepoForkSuccess(t *testing.T) {
|
||||
requireGitForApp(t)
|
||||
s, mock, cleanup := newMockServer(t)
|
||||
defer cleanup()
|
||||
owner := User{ID: 1, Email: "alice@example.com", Username: "alice"}
|
||||
src := Repository{ID: 10, OwnerUserID: owner.ID, Owner: "alice", Name: "demo", Visibility: "public", Description: "desc", DefaultBranch: "main"}
|
||||
bare := s.repoPath(src.Owner, src.Name)
|
||||
if err := os.MkdirAll(filepath.Dir(bare), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seedRepoWithFeatureBranch(t, bare)
|
||||
expectBearerUser(mock, "tok", owner)
|
||||
expectLoadRepo(mock, "alice", "demo", src)
|
||||
mock.ExpectExec("INSERT INTO repositories").WithArgs(owner.ID, "fork", src.Visibility, src.Description, src.DefaultBranch, src.ID).WillReturnResult(sqlmock.NewResult(30, 1))
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/repos/alice/demo/fork", strings.NewReader(`{"name":"fork"}`))
|
||||
req.Header.Set("Authorization", "Bearer tok")
|
||||
s.handleRepoFork(rr, req, "alice", "demo")
|
||||
if rr.Code != http.StatusCreated {
|
||||
t.Fatalf("fork status=%d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
var repo Repository
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &repo); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if repo.ID != 30 || repo.Name != "fork" || repo.ForkedFromID == nil || *repo.ForkedFromID != src.ID {
|
||||
t.Fatalf("unexpected fork repo: %#v", repo)
|
||||
}
|
||||
if _, err := os.Stat(s.repoPath("alice", "fork")); err != nil {
|
||||
t.Fatalf("expected fork repo on disk: %v", err)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlePRCreateSuccessAndValidation(t *testing.T) {
|
||||
requireGitForApp(t)
|
||||
s, mock, cleanup := newMockServer(t)
|
||||
defer cleanup()
|
||||
owner := User{ID: 1, Email: "alice@example.com", Username: "alice"}
|
||||
repo := Repository{ID: 10, OwnerUserID: owner.ID, Owner: "alice", Name: "demo", Visibility: "public", DefaultBranch: "main"}
|
||||
bare := s.repoPath(repo.Owner, repo.Name)
|
||||
if err := os.MkdirAll(filepath.Dir(bare), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seedRepoWithFeatureBranch(t, bare)
|
||||
pr := PullRequest{ID: 50, Number: 1, TargetRepositoryID: repo.ID, SourceRepositoryID: repo.ID, AuthorUserID: owner.ID, Author: "alice", SourceOwner: "alice", SourceRepo: "demo", SourceBranch: "feature", TargetOwner: "alice", TargetRepo: "demo", TargetBranch: "main", Title: "Fix", Status: "open"}
|
||||
|
||||
expectBearerUser(mock, "tok", owner)
|
||||
expectLoadRepo(mock, "alice", "demo", repo)
|
||||
expectLoadRepo(mock, "alice", "demo", repo)
|
||||
mock.ExpectQuery("SELECT COALESCE").WithArgs(repo.ID).WillReturnRows(sqlmock.NewRows([]string{"number"}).AddRow(1))
|
||||
mock.ExpectExec("INSERT INTO pull_requests").WithArgs(repo.ID, 1, owner.ID, repo.ID, "feature", "main", "Fix", "desc").WillReturnResult(sqlmock.NewResult(50, 1))
|
||||
mock.ExpectQuery("FROM pull_requests pr").WithArgs(repo.ID, 1).WillReturnRows(prRows(pr))
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/repos/alice/demo/pulls", strings.NewReader(`{"source_branch":"feature","target_branch":"main","title":"Fix","description":"desc"}`))
|
||||
req.Header.Set("Authorization", "Bearer tok")
|
||||
s.handlePRCreate(rr, req, "alice", "demo")
|
||||
if rr.Code != http.StatusCreated || !strings.Contains(rr.Body.String(), "Fix") {
|
||||
t.Fatalf("PR create status=%d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
expectBearerUser(mock, "tok", owner)
|
||||
expectLoadRepo(mock, "alice", "demo", repo)
|
||||
rr = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/repos/alice/demo/pulls", strings.NewReader(`{"source_branch":"bad branch","target_branch":"main","title":"Fix"}`))
|
||||
req.Header.Set("Authorization", "Bearer tok")
|
||||
s.handlePRCreate(rr, req, "alice", "demo")
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Fatalf("bad PR status=%d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
)
|
||||
|
||||
type stubScanner struct {
|
||||
values []any
|
||||
err error
|
||||
}
|
||||
|
||||
func (s stubScanner) Scan(dest ...any) error {
|
||||
if s.err != nil {
|
||||
return s.err
|
||||
}
|
||||
for i := range dest {
|
||||
switch d := dest[i].(type) {
|
||||
case *int64:
|
||||
*d = s.values[i].(int64)
|
||||
case *int:
|
||||
*d = s.values[i].(int)
|
||||
case *string:
|
||||
*d = s.values[i].(string)
|
||||
case *time.Time:
|
||||
*d = s.values[i].(time.Time)
|
||||
case *sql.NullTime:
|
||||
*d = s.values[i].(sql.NullTime)
|
||||
default:
|
||||
panic("unsupported scan dest")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestScanOnePR(t *testing.T) {
|
||||
now := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
|
||||
closed := sql.NullTime{Time: now.Add(time.Hour), Valid: true}
|
||||
pr, err := scanOnePR(stubScanner{values: []any{
|
||||
int64(1), 2, int64(3), int64(4), int64(5),
|
||||
"author", "source-owner", "source-repo", "feature", "target-owner", "target-repo", "main",
|
||||
"title", "desc", "closed", now, now, closed, sql.NullTime{},
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if pr.ID != 1 || pr.Number != 2 || pr.Author != "author" || pr.ClosedAt == nil || pr.MergedAt != nil {
|
||||
t.Fatalf("unexpected PR: %#v", pr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanReposAndPRRows(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
now := time.Now().UTC()
|
||||
mock.ExpectQuery("repos").WillReturnRows(sqlmock.NewRows([]string{"id", "owner_user_id", "owner", "name", "visibility", "description", "default_branch", "archived", "forked_from_repository_id", "created_at", "updated_at"}).AddRow(int64(1), int64(2), "alice", "demo", "public", "desc", "main", false, sql.NullInt64{Int64: 9, Valid: true}, now, now))
|
||||
rows, err := db.Query("repos")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repos, err := scanRepos(rows)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(repos) != 1 || repos[0].ForkedFromID == nil || *repos[0].ForkedFromID != 9 {
|
||||
t.Fatalf("unexpected repos: %#v", repos)
|
||||
}
|
||||
|
||||
mock.ExpectQuery("prs").WillReturnRows(sqlmock.NewRows([]string{"id", "number", "target_repository_id", "source_repository_id", "author_user_id", "author", "source_owner", "source_repo", "source_branch", "target_owner", "target_repo", "target_branch", "title", "description", "status", "created_at", "updated_at", "closed_at", "merged_at"}).AddRow(int64(1), 2, int64(3), int64(4), int64(5), "author", "so", "sr", "feature", "to", "tr", "main", "title", "desc", "open", now, now, sql.NullTime{}, sql.NullTime{}))
|
||||
prRows, err := db.Query("prs")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
prs, err := scanPRs(prRows)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(prs) != 1 || prs[0].Number != 2 || prs[0].Status != "open" {
|
||||
t.Fatalf("unexpected prs: %#v", prs)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"github.com/go-sql-driver/mysql"
|
||||
)
|
||||
|
||||
func TestRunServerRequiresDSN(t *testing.T) {
|
||||
t.Setenv("GITOOCEAN_DSN", "")
|
||||
cfgPath := filepath.Join(t.TempDir(), "missing.json")
|
||||
err := runServer([]string{"--config", cfgPath, "--addr", ":0"})
|
||||
if err == nil || !strings.Contains(err.Error(), "mysql_dsn is required") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuoteMySQLIdentifierWrapper(t *testing.T) {
|
||||
if got := quoteMySQLIdentifier("a`b"); got != "`a``b`" {
|
||||
t.Fatalf("quoteMySQLIdentifier = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateSuccessAndDuplicateAlterations(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
for i := 0; i < 6; i++ {
|
||||
mock.ExpectExec("CREATE TABLE IF NOT EXISTS").WillReturnResult(sqlmock.NewResult(0, 0))
|
||||
}
|
||||
for i := 0; i < 4; i++ {
|
||||
mock.ExpectExec("ALTER TABLE").WillReturnError(&mysql.MySQLError{Number: 1060, Message: "Duplicate column"})
|
||||
}
|
||||
if err := migrate(db); err != nil {
|
||||
t.Fatalf("migrate returned error for duplicate column: %v", err)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateReturnsCreateAndAlterErrors(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
mock.ExpectExec("CREATE TABLE IF NOT EXISTS").WillReturnError(assertErr("create failed"))
|
||||
if err := migrate(db); err == nil || !strings.Contains(err.Error(), "create failed") {
|
||||
t.Fatalf("expected create error, got %v", err)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
db, mock, err = sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
for i := 0; i < 6; i++ {
|
||||
mock.ExpectExec("CREATE TABLE IF NOT EXISTS").WillReturnResult(sqlmock.NewResult(0, 0))
|
||||
}
|
||||
mock.ExpectExec("ALTER TABLE").WillReturnError(assertErr("alter failed"))
|
||||
if err := migrate(db); err == nil || !strings.Contains(err.Error(), "alter failed") {
|
||||
t.Fatalf("expected alter error, got %v", err)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
type assertErr string
|
||||
|
||||
func (e assertErr) Error() string { return string(e) }
|
||||
@@ -0,0 +1,131 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
)
|
||||
|
||||
func TestTokenRoutes(t *testing.T) {
|
||||
s, mock, cleanup := newMockServer(t)
|
||||
defer cleanup()
|
||||
user := User{ID: 1, Email: "alice@example.com", Username: "alice"}
|
||||
token := "tok"
|
||||
now := time.Now().UTC()
|
||||
|
||||
expectBearerUser(mock, token, user)
|
||||
mock.ExpectQuery("SELECT id, created_at, expires_at, revoked_at").WithArgs(user.ID).WillReturnRows(sqlmock.NewRows([]string{"id", "created_at", "expires_at", "revoked_at", "active"}).AddRow(int64(9), now, now.Add(time.Hour), sql.NullTime{}, true))
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/tokens", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
s.handleTokenRoutes(rr, req, "")
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("list status=%d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
var infos []TokenInfo
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &infos); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(infos) != 1 || infos[0].ID != 9 || !infos[0].Active {
|
||||
t.Fatalf("unexpected token infos: %#v", infos)
|
||||
}
|
||||
|
||||
expectBearerUser(mock, token, user)
|
||||
mock.ExpectExec("UPDATE auth_tokens SET revoked_at").WithArgs(int64(9), user.ID).WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
rr = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodDelete, "/api/tokens/9", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
s.handleTokenRoutes(rr, req, "/9")
|
||||
if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "revoked") {
|
||||
t.Fatalf("revoke status=%d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
expectBearerUser(mock, token, user)
|
||||
mock.ExpectExec("DELETE FROM auth_tokens WHERE user_id").WithArgs(user.ID).WillReturnResult(sqlmock.NewResult(0, 2))
|
||||
rr = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/tokens/prune", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
s.handleTokenRoutes(rr, req, "/prune")
|
||||
if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "pruned") {
|
||||
t.Fatalf("prune status=%d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
expectBearerUser(mock, token, user)
|
||||
rr = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodDelete, "/api/tokens/nope", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
s.handleTokenRoutes(rr, req, "/nope")
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Fatalf("invalid id status=%d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminRoutes(t *testing.T) {
|
||||
s, mock, cleanup := newMockServer(t)
|
||||
defer cleanup()
|
||||
admin := User{ID: 1, Email: "admin@example.com", Username: "admin", IsAdmin: true}
|
||||
plain := User{ID: 2, Email: "bob@example.com", Username: "bob"}
|
||||
token := "tok"
|
||||
|
||||
expectBearerUser(mock, token, plain)
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/admin/users", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
s.handleAdminRoutes(rr, req, "/users")
|
||||
if rr.Code != http.StatusForbidden {
|
||||
t.Fatalf("non-admin status=%d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
expectBearerUser(mock, token, admin)
|
||||
mock.ExpectQuery("SELECT id, email, username, is_admin FROM users").WillReturnRows(sqlmock.NewRows([]string{"id", "email", "username", "is_admin"}).AddRow(int64(1), "admin@example.com", "admin", true).AddRow(int64(2), "bob@example.com", "bob", false))
|
||||
rr = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/admin/users", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
s.handleAdminRoutes(rr, req, "/users")
|
||||
if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "bob@example.com") {
|
||||
t.Fatalf("users status=%d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
expectBearerUser(mock, token, admin)
|
||||
rr = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodDelete, "/api/admin/users/admin", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
s.handleAdminRoutes(rr, req, "/users/admin")
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Fatalf("delete self status=%d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
expectBearerUser(mock, token, admin)
|
||||
mock.ExpectExec("DELETE FROM users").WithArgs("bob").WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
rr = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodDelete, "/api/admin/users/bob", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
s.handleAdminRoutes(rr, req, "/users/bob")
|
||||
if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "deleted") {
|
||||
t.Fatalf("delete status=%d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
expectBearerUser(mock, token, admin)
|
||||
mock.ExpectExec("DELETE FROM auth_tokens WHERE expires_at").WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
rr = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/admin/tokens/prune", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
s.handleAdminRoutes(rr, req, "/tokens/prune")
|
||||
if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "pruned") {
|
||||
t.Fatalf("admin prune status=%d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMySQLCLIArgsTCP(t *testing.T) {
|
||||
args, db, err := MySQLCLIArgs("user:pass@tcp(127.0.0.1:3307)/gitocean_test?parseTime=true")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []string{"-uuser", "-ppass", "-h127.0.0.1", "-P3307"}
|
||||
if !reflect.DeepEqual(args, want) || db != "gitocean_test" {
|
||||
t.Fatalf("args=%#v db=%q, want %#v %q", args, db, want, "gitocean_test")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMySQLCLIArgsUnix(t *testing.T) {
|
||||
args, db, err := MySQLCLIArgs("user@unix(/tmp/mysql.sock)/gitocean")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []string{"-uuser", "--socket", "/tmp/mysql.sock"}
|
||||
if !reflect.DeepEqual(args, want) || db != "gitocean" {
|
||||
t.Fatalf("args=%#v db=%q, want %#v %q", args, db, want, "gitocean")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMySQLCLIArgsInvalidDSN(t *testing.T) {
|
||||
if _, _, err := MySQLCLIArgs("bad dsn"); err == nil {
|
||||
t.Fatal("expected invalid DSN to fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopyDir(t *testing.T) {
|
||||
src := t.TempDir()
|
||||
dst := filepath.Join(t.TempDir(), "dst")
|
||||
if err := os.MkdirAll(filepath.Join(src, "nested"), 0750); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
file := filepath.Join(src, "nested", "file.txt")
|
||||
if err := os.WriteFile(file, []byte("hello"), 0640); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := CopyDir(src, dst); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := os.ReadFile(filepath.Join(dst, "nested", "file.txt"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(got) != "hello" {
|
||||
t.Fatalf("unexpected copied content: %q", got)
|
||||
}
|
||||
if runtime.GOOS != "windows" {
|
||||
info, err := os.Stat(filepath.Join(dst, "nested", "file.txt"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if info.Mode().Perm() != 0640 {
|
||||
t.Fatalf("mode = %v, want 0640", info.Mode().Perm())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopyDirMissingSource(t *testing.T) {
|
||||
if err := CopyDir(filepath.Join(t.TempDir(), "missing"), filepath.Join(t.TempDir(), "dst")); err == nil {
|
||||
t.Fatal("expected missing source to fail")
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -23,6 +24,41 @@ func TestClientConfigRoundTrip(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadClientFailures(t *testing.T) {
|
||||
t.Setenv("GITOOCEAN_CONFIG", filepath.Join(t.TempDir(), "missing.json"))
|
||||
if _, err := LoadClient(); err == nil {
|
||||
t.Fatal("expected missing client config to fail")
|
||||
}
|
||||
|
||||
path := filepath.Join(t.TempDir(), "client.json")
|
||||
t.Setenv("GITOOCEAN_CONFIG", path)
|
||||
if err := os.WriteFile(path, []byte(`{"server":"http://example.test"}`), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := LoadClient(); err == nil {
|
||||
t.Fatal("expected missing token to fail")
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(`{`), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := LoadClient(); err == nil {
|
||||
t.Fatal("expected invalid JSON to fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnvServerOverrides(t *testing.T) {
|
||||
t.Setenv("GITOOCEAN_SERVER", "http://server.test/")
|
||||
if got := EnvServer(); got != "http://server.test" {
|
||||
t.Fatalf("EnvServer() = %q", got)
|
||||
}
|
||||
|
||||
t.Setenv("GITOOCEAN_SERVER", "")
|
||||
t.Setenv("GITOOCEAN_SERVER_CONFIG", filepath.Join(t.TempDir(), "missing.json"))
|
||||
if got := EnvServer(); got != DefaultServerURL {
|
||||
t.Fatalf("EnvServer() = %q, want default", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerConfigRoundTripAndEnvServer(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "server.json")
|
||||
t.Setenv("GITOOCEAN_SERVER_CONFIG", path)
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
//go:build integration
|
||||
|
||||
package dbutil
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/go-sql-driver/mysql"
|
||||
)
|
||||
|
||||
func TestIntegrationOpenMySQLAndCreateDatabaseIfMissing(t *testing.T) {
|
||||
dsn := os.Getenv("GITOOCEAN_TEST_MYSQL_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("set GITOOCEAN_TEST_MYSQL_DSN to run integration DB tests")
|
||||
}
|
||||
cfg, err := mysql.ParseDSN(dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.DBName == "" || !strings.Contains(cfg.DBName, "test") {
|
||||
t.Fatalf("refusing to run integration test against non-test database %q", cfg.DBName)
|
||||
}
|
||||
db, err := OpenMySQLAndCreateDatabaseIfMissing(dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
if err := db.Ping(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,11 @@
|
||||
package dbutil
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/go-sql-driver/mysql"
|
||||
)
|
||||
|
||||
func TestQuoteMySQLIdentifier(t *testing.T) {
|
||||
got := QuoteMySQLIdentifier("git`ocean")
|
||||
@@ -8,3 +13,15 @@ func TestQuoteMySQLIdentifier(t *testing.T) {
|
||||
t.Fatalf("unexpected quote: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsDuplicateColumnError(t *testing.T) {
|
||||
if !IsDuplicateColumnError(&mysql.MySQLError{Number: 1060, Message: "Duplicate column name"}) {
|
||||
t.Fatal("expected duplicate column mysql error")
|
||||
}
|
||||
if IsDuplicateColumnError(&mysql.MySQLError{Number: 1049, Message: "Unknown database"}) {
|
||||
t.Fatal("did not expect unknown database to be duplicate column")
|
||||
}
|
||||
if IsDuplicateColumnError(errors.New("not mysql")) {
|
||||
t.Fatal("did not expect ordinary error")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
package gitutil
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func requireGit(t *testing.T) {
|
||||
t.Helper()
|
||||
if _, err := exec.LookPath("git"); err != nil {
|
||||
t.Skip("git not installed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunFailureIncludesCommandOutput(t *testing.T) {
|
||||
requireGit(t)
|
||||
err := Run("", "definitely-not-a-git-command")
|
||||
if err == nil {
|
||||
t.Fatal("expected git command to fail")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "git definitely-not-a-git-command failed") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitBareBranchExistsAndRefs(t *testing.T) {
|
||||
requireGit(t)
|
||||
root := t.TempDir()
|
||||
bare := filepath.Join(root, "repo.git")
|
||||
if err := InitBare(bare); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
head, err := os.ReadFile(filepath.Join(bare, "HEAD"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.TrimSpace(string(head)) != "ref: refs/heads/main" {
|
||||
t.Fatalf("HEAD = %q", string(head))
|
||||
}
|
||||
if BranchExists(bare, "main") {
|
||||
t.Fatal("new empty bare repo should not have a main branch commit yet")
|
||||
}
|
||||
if BranchExists(bare, "bad branch") {
|
||||
t.Fatal("invalid branch should not exist")
|
||||
}
|
||||
|
||||
work := filepath.Join(root, "work")
|
||||
if err := Run("", "clone", bare, work); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, args := range [][]string{
|
||||
{"config", "user.name", "Test User"},
|
||||
{"config", "user.email", "test@example.com"},
|
||||
} {
|
||||
if err := Run(work, args...); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(work, "README.md"), []byte("hello\n"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := Run(work, "add", "README.md"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := Run(work, "commit", "-m", "initial commit"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := Run(work, "push", "origin", "HEAD:main"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := Run(work, "tag", "v1.0.0"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := Run(work, "push", "origin", "v1.0.0"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !BranchExists(bare, "main") {
|
||||
t.Fatal("main branch should exist after push")
|
||||
}
|
||||
branches, err := Refs(bare, "refs/heads")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(branches) != 1 || branches[0].Name != "main" || branches[0].Message != "initial commit" {
|
||||
t.Fatalf("unexpected branches: %#v", branches)
|
||||
}
|
||||
tags, err := Refs(bare, "refs/tags")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(tags) != 1 || tags[0].Name != "v1.0.0" {
|
||||
t.Fatalf("unexpected tags: %#v", tags)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefsInvalidRepo(t *testing.T) {
|
||||
requireGit(t)
|
||||
if _, err := Refs(filepath.Join(t.TempDir(), "missing.git"), "refs/heads"); err == nil {
|
||||
t.Fatal("expected missing repo refs to fail")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package httputil
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDecodeJSONStrict(t *testing.T) {
|
||||
t.Run("valid", func(t *testing.T) {
|
||||
var got struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{"name":"demo"}`))
|
||||
rr := httptest.NewRecorder()
|
||||
if !DecodeJSON(rr, req, &got) {
|
||||
t.Fatalf("DecodeJSON returned false: %s", rr.Body.String())
|
||||
}
|
||||
if got.Name != "demo" {
|
||||
t.Fatalf("unexpected decoded value: %#v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unknown field", func(t *testing.T) {
|
||||
var got struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{"name":"demo","extra":true}`))
|
||||
rr := httptest.NewRecorder()
|
||||
if DecodeJSON(rr, req, &got) {
|
||||
t.Fatal("DecodeJSON returned true for unknown field")
|
||||
}
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want %d", rr.Code, http.StatusBadRequest)
|
||||
}
|
||||
if !strings.Contains(rr.Body.String(), "unknown field") {
|
||||
t.Fatalf("unexpected body: %s", rr.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid json", func(t *testing.T) {
|
||||
var got map[string]string
|
||||
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{`))
|
||||
rr := httptest.NewRecorder()
|
||||
if DecodeJSON(rr, req, &got) {
|
||||
t.Fatal("DecodeJSON returned true for invalid JSON")
|
||||
}
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want %d", rr.Code, http.StatusBadRequest)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestWriteJSONAndError(t *testing.T) {
|
||||
rr := httptest.NewRecorder()
|
||||
WriteJSON(rr, http.StatusCreated, map[string]string{"status": "ok"})
|
||||
if rr.Code != http.StatusCreated {
|
||||
t.Fatalf("status = %d", rr.Code)
|
||||
}
|
||||
if ct := rr.Header().Get("Content-Type"); ct != "application/json" {
|
||||
t.Fatalf("content-type = %q", ct)
|
||||
}
|
||||
var body map[string]string
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body["status"] != "ok" {
|
||||
t.Fatalf("unexpected body: %#v", body)
|
||||
}
|
||||
|
||||
rr = httptest.NewRecorder()
|
||||
WriteError(rr, http.StatusTeapot, "short and stout")
|
||||
if rr.Code != http.StatusTeapot {
|
||||
t.Fatalf("status = %d", rr.Code)
|
||||
}
|
||||
body = map[string]string{}
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body["error"] != "short and stout" {
|
||||
t.Fatalf("unexpected error body: %#v", body)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user