check / check (push) Waiting to run
The HTTP server ran ListenAndServe in a goroutine, so a failed bind was only logged and neoircd kept running without its HTTP API. The start hook now binds the listen address itself and returns the bind error, so fx stops startup and neoircd exits 1 with an error naming the address. The bound listener is then served in a goroutine; if serving ends with anything other than a normal shutdown, the server stops the app through fx.Shutdowner with exit code 1. A new test holds a port and checks that starting the app on it fails with an error naming the address. Model: opus-5-5
297 lines
6.3 KiB
Go
297 lines
6.3 KiB
Go
package server_test
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"go.uber.org/fx"
|
|
"go.uber.org/fx/fxtest"
|
|
"sneak.berlin/go/neoirc/internal/broker"
|
|
"sneak.berlin/go/neoirc/internal/config"
|
|
"sneak.berlin/go/neoirc/internal/db"
|
|
"sneak.berlin/go/neoirc/internal/globals"
|
|
"sneak.berlin/go/neoirc/internal/handlers"
|
|
"sneak.berlin/go/neoirc/internal/healthcheck"
|
|
"sneak.berlin/go/neoirc/internal/logger"
|
|
"sneak.berlin/go/neoirc/internal/middleware"
|
|
"sneak.berlin/go/neoirc/internal/server"
|
|
"sneak.berlin/go/neoirc/internal/service"
|
|
"sneak.berlin/go/neoirc/internal/stats"
|
|
)
|
|
|
|
const (
|
|
testTimeout = 5 * time.Second
|
|
|
|
// checkStep is how often waitUntil checks again.
|
|
checkStep = 10 * time.Millisecond
|
|
|
|
// stopHoldTime is how long a test waits to see that the
|
|
// app has not stopped.
|
|
stopHoldTime = 200 * time.Millisecond
|
|
)
|
|
|
|
// TestStopWaitsForRequestInProgress checks that stopping the
|
|
// app, which runs the stop hook server.New registers, waits
|
|
// for a long poll the HTTP server is serving to finish, and
|
|
// that the poll gets its response.
|
|
func TestStopWaitsForRequestInProgress(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
port := freePort(t)
|
|
addr := net.JoinHostPort("127.0.0.1", strconv.Itoa(port))
|
|
|
|
app, brk, database := startApp(t, port)
|
|
|
|
sessionID, _, token, err := database.CreateSession(
|
|
t.Context(), "poller", "poller", "", "",
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("create session: %v", err)
|
|
}
|
|
|
|
waitUntil(t, "the HTTP server to listen", func() bool {
|
|
return canConnect(t, addr)
|
|
})
|
|
|
|
statuses := make(chan int, 1)
|
|
|
|
go func() {
|
|
statuses <- getAuthenticated(
|
|
t, "http://"+addr+"/api/v1/messages?timeout=30", token,
|
|
)
|
|
}()
|
|
|
|
waitUntil(t, "the long poll to wait for a message", func() bool {
|
|
return brk.ListenerCount(sessionID) == 1
|
|
})
|
|
|
|
var stopErr error
|
|
|
|
stopped := make(chan struct{})
|
|
|
|
go func() {
|
|
stopErr = app.Stop(t.Context())
|
|
|
|
close(stopped)
|
|
}()
|
|
|
|
select {
|
|
case <-stopped:
|
|
t.Error("the app stopped while a request was in progress")
|
|
case <-time.After(stopHoldTime):
|
|
}
|
|
|
|
brk.Notify(sessionID)
|
|
|
|
status := <-statuses
|
|
if status != http.StatusOK {
|
|
t.Errorf("status %d, want %d", status, http.StatusOK)
|
|
}
|
|
|
|
select {
|
|
case <-stopped:
|
|
if stopErr != nil {
|
|
t.Errorf("stop: %v", stopErr)
|
|
}
|
|
case <-time.After(testTimeout):
|
|
t.Fatal("the app did not stop after the request finished")
|
|
}
|
|
}
|
|
|
|
// TestStartFailsWhenHTTPPortIsTaken checks that starting the
|
|
// app, which runs the start hook server.New registers,
|
|
// returns an error naming the HTTP listen address when
|
|
// something else is already listening on its port.
|
|
func TestStartFailsWhenHTTPPortIsTaken(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var listenConfig net.ListenConfig
|
|
|
|
taken, err := listenConfig.Listen(t.Context(), "tcp", ":0")
|
|
if err != nil {
|
|
t.Fatalf("listen: %v", err)
|
|
}
|
|
|
|
t.Cleanup(func() { _ = taken.Close() })
|
|
|
|
tcpAddr, ok := taken.Addr().(*net.TCPAddr)
|
|
if !ok {
|
|
t.Fatalf("listener address %v is not TCP", taken.Addr())
|
|
}
|
|
|
|
app, _, _ := newApp(t, tcpAddr.Port)
|
|
|
|
err = app.Start(t.Context())
|
|
if err == nil {
|
|
app.RequireStop()
|
|
t.Fatal("the app started while its HTTP port was taken")
|
|
}
|
|
|
|
listenAddr := fmt.Sprintf(":%d", tcpAddr.Port)
|
|
if !strings.Contains(err.Error(), listenAddr) {
|
|
t.Errorf("start error %q does not name %s", err, listenAddr)
|
|
}
|
|
}
|
|
|
|
// startApp starts the app newApp builds, with the HTTP server
|
|
// listening on port.
|
|
func startApp(
|
|
t *testing.T,
|
|
port int,
|
|
) (*fxtest.App, *broker.Broker, *db.Database) {
|
|
t.Helper()
|
|
|
|
app, brk, database := newApp(t, port)
|
|
|
|
app.RequireStart()
|
|
|
|
// Stops the app if the test ends before stopping it.
|
|
t.Cleanup(app.RequireStop)
|
|
|
|
return app, brk, database
|
|
}
|
|
|
|
// newApp builds, without starting it, an app that runs the
|
|
// server with the dependencies cmd/neoircd gives it, on a
|
|
// fresh in-memory database, with the HTTP port set to port.
|
|
func newApp(
|
|
t *testing.T,
|
|
port int,
|
|
) (*fxtest.App, *broker.Broker, *db.Database) {
|
|
t.Helper()
|
|
|
|
var (
|
|
brk *broker.Broker
|
|
database *db.Database
|
|
)
|
|
|
|
app := fxtest.New(t,
|
|
fx.Supply(&globals.Globals{
|
|
Appname: "neoirc-test",
|
|
Version: "test",
|
|
StartTime: time.Now(),
|
|
}),
|
|
fx.Provide(
|
|
broker.New,
|
|
config.New,
|
|
db.New,
|
|
handlers.New,
|
|
healthcheck.New,
|
|
logger.New,
|
|
middleware.New,
|
|
server.New,
|
|
service.New,
|
|
stats.New,
|
|
),
|
|
fx.Decorate(func(cfg *config.Config) *config.Config {
|
|
// A random name, so that no other test can share it.
|
|
cfg.DBURL = fmt.Sprintf(
|
|
"file:%s?mode=memory&cache=shared", rand.Text(),
|
|
)
|
|
cfg.Port = port
|
|
|
|
return cfg
|
|
}),
|
|
fx.Invoke(func(*server.Server) {}),
|
|
fx.Populate(&brk, &database),
|
|
)
|
|
|
|
return app, brk, database
|
|
}
|
|
|
|
// freePort returns a TCP port that nothing was listening on a
|
|
// moment ago.
|
|
func freePort(t *testing.T) int {
|
|
t.Helper()
|
|
|
|
var listenConfig net.ListenConfig
|
|
|
|
listener, err := listenConfig.Listen(
|
|
t.Context(), "tcp", "127.0.0.1:0",
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("listen: %v", err)
|
|
}
|
|
|
|
tcpAddr, ok := listener.Addr().(*net.TCPAddr)
|
|
if !ok {
|
|
t.Fatalf("listener address %v is not TCP", listener.Addr())
|
|
}
|
|
|
|
err = listener.Close()
|
|
if err != nil {
|
|
t.Fatalf("close listener: %v", err)
|
|
}
|
|
|
|
return tcpAddr.Port
|
|
}
|
|
|
|
// canConnect reports whether addr accepts a TCP connection.
|
|
func canConnect(t *testing.T, addr string) bool {
|
|
t.Helper()
|
|
|
|
var dialer net.Dialer
|
|
|
|
conn, err := dialer.DialContext(t.Context(), "tcp", addr)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
|
|
_ = conn.Close()
|
|
|
|
return true
|
|
}
|
|
|
|
// waitUntil calls done every checkStep until it returns true,
|
|
// and fails the test if it does not within testTimeout.
|
|
func waitUntil(t *testing.T, what string, done func() bool) {
|
|
t.Helper()
|
|
|
|
deadline := time.Now().Add(testTimeout)
|
|
|
|
for !done() {
|
|
if time.Now().After(deadline) {
|
|
t.Fatalf("timed out waiting for %s", what)
|
|
}
|
|
|
|
time.Sleep(checkStep)
|
|
}
|
|
}
|
|
|
|
// getAuthenticated sends a GET request to url as the session
|
|
// whose auth token is token, and returns the response status,
|
|
// or 0 if there was no response.
|
|
func getAuthenticated(t *testing.T, url, token string) int {
|
|
t.Helper()
|
|
|
|
request, err := http.NewRequestWithContext(
|
|
t.Context(), http.MethodGet, url, nil,
|
|
)
|
|
if err != nil {
|
|
t.Errorf("new request: %v", err)
|
|
|
|
return 0
|
|
}
|
|
|
|
request.Header.Set("Cookie", "neoirc_auth="+token)
|
|
|
|
client := http.Client{Timeout: testTimeout}
|
|
|
|
response, err := client.Do(request)
|
|
if err != nil {
|
|
t.Errorf("get %s: %v", url, err)
|
|
|
|
return 0
|
|
}
|
|
|
|
_ = response.Body.Close()
|
|
|
|
return response.StatusCode
|
|
}
|