blob: 52dfe92a5e92a02c352f00fd89e23761dc09b013 [file] [log] [blame]
// Copyright 2020 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package relui
import (
"context"
"database/sql"
"embed"
"fmt"
"io/ioutil"
"log"
"net/http"
"net/http/httptest"
"net/url"
"os"
"strings"
"sync"
"testing"
"time"
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
"github.com/google/uuid"
"github.com/jackc/pgx/v4"
"github.com/jackc/pgx/v4/pgxpool"
"golang.org/x/build/internal/relui/db"
)
// testStatic is our static web server content.
//go:embed testing
var testStatic embed.FS
func TestFileServerHandler(t *testing.T) {
h := fileServerHandler(testStatic, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("Home"))
}))
cases := []struct {
desc string
path string
wantCode int
wantBody string
wantHeaders map[string]string
}{
{
desc: "fallback to next handler",
path: "/",
wantCode: http.StatusOK,
wantBody: "Home",
},
{
desc: "sets headers and returns file",
path: "/testing/test.css",
wantCode: http.StatusOK,
wantBody: ".Header { font-size: 10rem; }\n",
wantHeaders: map[string]string{
"Content-Type": "text/css; charset=utf-8",
"Cache-Control": "no-cache, private, max-age=0",
},
},
{
desc: "handles missing file",
path: "/foo.js",
wantCode: http.StatusNotFound,
wantBody: "404 page not found\n",
wantHeaders: map[string]string{
"Content-Type": "text/plain; charset=utf-8",
},
},
}
for _, c := range cases {
t.Run(c.desc, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, c.path, nil)
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
resp := w.Result()
defer resp.Body.Close()
if resp.StatusCode != c.wantCode {
t.Errorf("rep.StatusCode = %d, wanted %d", resp.StatusCode, c.wantCode)
}
b, err := ioutil.ReadAll(resp.Body)
if err != nil {
t.Errorf("resp.Body = _, %v, wanted no error", err)
}
if string(b) != c.wantBody {
t.Errorf("resp.Body = %q, %v, wanted %q, %v", b, err, c.wantBody, nil)
}
for k, v := range c.wantHeaders {
if resp.Header.Get(k) != v {
t.Errorf("resp.Header.Get(%q) = %q, wanted %q", k, resp.Header.Get(k), v)
}
}
})
}
}
func TestServerHomeHandler(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
p := testDB(ctx, t)
q := db.New(p)
wf := db.CreateWorkflowParams{ID: uuid.New()}
if _, err := q.CreateWorkflow(ctx, wf); err != nil {
t.Fatalf("CreateWorkflow(_, %v) = _, %v, wanted no error", wf, err)
}
tp := db.CreateTaskParams{WorkflowID: wf.ID, Name: "TestTask"}
if _, err := q.CreateTask(ctx, tp); err != nil {
t.Fatalf("CreateTask(_, %v) = _, %v, wanted no error", tp, err)
}
req := httptest.NewRequest(http.MethodGet, "/", nil)
w := httptest.NewRecorder()
s := NewServer(p, NewWorker(p, &PGListener{p}), nil)
s.homeHandler(w, req)
resp := w.Result()
if resp.StatusCode != http.StatusOK {
t.Errorf("resp.StatusCode = %d, wanted %d", resp.StatusCode, http.StatusOK)
}
}
func TestServerNewWorkflowHandler(t *testing.T) {
cases := []struct {
desc string
params url.Values
wantCode int
}{
{
desc: "No selection",
wantCode: http.StatusOK,
},
{
desc: "valid workflow",
params: url.Values{"workflow.name": []string{"echo"}},
wantCode: http.StatusOK,
},
{
desc: "invalid workflow",
params: url.Values{"workflow.name": []string{"this workflow does not exist"}},
wantCode: http.StatusOK,
},
}
for _, c := range cases {
t.Run(c.desc, func(t *testing.T) {
u := url.URL{Path: "/workflows/new", RawQuery: c.params.Encode()}
req := httptest.NewRequest(http.MethodGet, u.String(), nil)
w := httptest.NewRecorder()
s := NewServer(nil, nil, nil)
s.newWorkflowHandler(w, req)
resp := w.Result()
if resp.StatusCode != http.StatusOK {
t.Errorf("rep.StatusCode = %d, wanted %d", resp.StatusCode, http.StatusOK)
}
})
}
}
func TestServerCreateWorkflowHandler(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
cases := []struct {
desc string
params url.Values
wantCode int
wantHeaders map[string]string
wantWorkflows []db.Workflow
}{
{
desc: "no params",
wantCode: http.StatusBadRequest,
},
{
desc: "invalid workflow name",
params: url.Values{"workflow.name": []string{"invalid"}},
wantCode: http.StatusBadRequest,
},
{
desc: "missing workflow params",
params: url.Values{"workflow.name": []string{"echo"}},
wantCode: http.StatusBadRequest,
},
{
desc: "successful creation",
params: url.Values{
"workflow.name": []string{"echo"},
"workflow.params.greeting": []string{"hello"},
"workflow.params.farewell": []string{"bye"},
},
wantCode: http.StatusSeeOther,
wantHeaders: map[string]string{
"Location": "/",
},
wantWorkflows: []db.Workflow{
{
ID: uuid.New(), // SameUUIDVariant
Params: nullString(`{"farewell": "bye", "greeting": "hello"}`),
Name: nullString(`echo`),
Output: "{}",
CreatedAt: time.Now(), // cmpopts.EquateApproxTime
UpdatedAt: time.Now(), // cmpopts.EquateApproxTime
},
},
},
}
for _, c := range cases {
t.Run(c.desc, func(t *testing.T) {
p := testDB(ctx, t)
req := httptest.NewRequest(http.MethodPost, "/workflows/create", strings.NewReader(c.params.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
q := db.New(p)
s := NewServer(p, NewWorker(p, &PGListener{p}), nil)
s.createWorkflowHandler(rec, req)
resp := rec.Result()
if resp.StatusCode != c.wantCode {
t.Errorf("rep.StatusCode = %d, wanted %d", resp.StatusCode, c.wantCode)
}
for k, v := range c.wantHeaders {
if resp.Header.Get(k) != v {
t.Errorf("resp.Header.Get(%q) = %q, wanted %q", k, resp.Header.Get(k), v)
}
}
if c.wantCode == http.StatusBadRequest {
return
}
wfs, err := q.Workflows(ctx)
if err != nil {
t.Fatalf("q.Workflows() = %v, %v, wanted no error", wfs, err)
}
if diff := cmp.Diff(c.wantWorkflows, wfs, SameUUIDVariant(), cmpopts.EquateApproxTime(time.Minute)); diff != "" {
t.Fatalf("q.Workflows() mismatch (-want +got):\n%s", diff)
}
})
}
}
// resetDB truncates the db connected to in the pgxpool.Pool
// connection.
//
// All tables in the public schema of the connected database will be
// truncated except for the migrations table.
func resetDB(ctx context.Context, t *testing.T, p *pgxpool.Pool) {
t.Helper()
tableQuery := `SELECT table_name FROM information_schema.tables WHERE table_schema='public'`
rows, err := p.Query(ctx, tableQuery)
if err != nil {
t.Fatalf("p.Query(_, %q, %q) = %v, %v, wanted no error", tableQuery, "public", rows, err)
}
defer rows.Close()
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
t.Fatalf("rows.Scan() = %v, wanted no error", err)
}
if name == "migrations" {
continue
}
truncQ := fmt.Sprintf("TRUNCATE %s CASCADE", pgx.Identifier{name}.Sanitize())
c, err := p.Exec(ctx, truncQ)
if err != nil {
t.Fatalf("p.Exec(_, %q) = %v, %v", truncQ, c, err)
}
}
if err := rows.Err(); err != nil {
log.Fatalf("rows.Err() = %v, wanted no error", err)
}
}
var testPoolOnce sync.Once
var testPool *pgxpool.Pool
// testDB connects, creates, and migrates a database in preparation
// for testing, and returns a connection pool to the prepared
// database.
//
// The connection pool is closed as part of a t.Cleanup handler.
// Database connections are expected to be configured through libpq
// compatible environment variables. If no PGDATABASE is specified,
// relui-test will be used.
//
// https://www.postgresql.org/docs/current/libpq-envars.html
func testDB(ctx context.Context, t *testing.T) *pgxpool.Pool {
t.Helper()
if testing.Short() {
t.Skip("Skipping database tests in short mode.")
}
testPoolOnce.Do(func() {
pgdb := url.QueryEscape(os.Getenv("PGDATABASE"))
if pgdb == "" {
pgdb = "relui-test"
}
if err := InitDB(ctx, fmt.Sprintf("database=%v", pgdb)); err != nil {
t.Skipf("Skipping database integration test: %v", err)
}
p, err := pgxpool.Connect(ctx, fmt.Sprintf("database=%v", pgdb))
if err != nil {
t.Skipf("Skipping database integration test: %v", err)
}
testPool = p
})
if testPool == nil {
t.Skip("Skipping database integration test: testdb = nil. See first error for details.")
return nil
}
t.Cleanup(func() {
resetDB(context.Background(), t, testPool)
})
return testPool
}
// SameUUIDVariant considers UUIDs equal if they are both the same
// uuid.Variant. Zero-value uuids are considered equal.
func SameUUIDVariant() cmp.Option {
return cmp.Transformer("SameVariant", func(v uuid.UUID) uuid.Variant {
return v.Variant()
})
}
func TestSameUUIDVariant(t *testing.T) {
cases := []struct {
desc string
x uuid.UUID
y uuid.UUID
want bool
}{
{
desc: "both set",
x: uuid.New(),
y: uuid.New(),
want: true,
},
{
desc: "both unset",
want: true,
},
{
desc: "just x",
x: uuid.New(),
want: false,
},
{
desc: "just y",
y: uuid.New(),
want: false,
},
}
for _, c := range cases {
t.Run(c.desc, func(t *testing.T) {
if got := cmp.Equal(c.x, c.y, SameUUIDVariant()); got != c.want {
t.Fatalf("cmp.Equal(%v, %v, SameUUIDVariant()) = %t, wanted %t", c.x, c.y, got, c.want)
}
})
}
}
// nullString returns a sql.NullString for a string.
func nullString(val string) sql.NullString {
return sql.NullString{String: val, Valid: true}
}
func TestServerBaseLink(t *testing.T) {
cases := []struct {
desc string
baseURL string
target string
want string
}{
{
desc: "no baseURL, relative",
target: "/workflows",
want: "/workflows",
},
{
desc: "no baseURL, absolute",
target: "https://example.test/something",
want: "https://example.test/something",
},
{
desc: "absolute baseURL, relative",
baseURL: "https://example.test/releases",
target: "/workflows",
want: "https://example.test/releases/workflows",
},
{
desc: "relative baseURL, relative",
baseURL: "/releases",
target: "/workflows",
want: "/releases/workflows",
},
{
desc: "absolute baseURL, absolute",
baseURL: "https://example.test/releases",
target: "https://example.test/something",
want: "https://example.test/something",
},
}
for _, c := range cases {
t.Run(c.desc, func(t *testing.T) {
base, err := url.Parse(c.baseURL)
if err != nil {
t.Fatalf("url.Parse(%q) = %v, %v, wanted no error", c.baseURL, base, err)
}
s := NewServer(nil, nil, base)
got := s.BaseLink(c.target)
if got != c.want {
t.Errorf("s.BaseLink(%q) = %q, wanted %q", c.target, got, c.want)
}
})
}
}