Implement Phase 4 test coverage

This commit is contained in:
2026-06-08 15:03:41 -05:00
parent fd81f443d6
commit bba767c821
27 changed files with 2780 additions and 2 deletions
+382
View File
@@ -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)
}
}
+58
View File
@@ -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)
}
}
+186
View File
@@ -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)
}
})
}
}
+107
View File
@@ -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)
}
}
+127
View File
@@ -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)
}
}
+71
View File
@@ -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")
}
}
+56
View File
@@ -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)
}
+34
View File
@@ -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())
}
}
+119
View File
@@ -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")
}
}
+108
View File
@@ -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)
}
}
+159
View File
@@ -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)
}
}
+90
View File
@@ -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)
}
}
+90
View File
@@ -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)
}
}
+80
View File
@@ -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) }
+131
View File
@@ -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)
}
}