refactor: use mock transport in watch order tests and harden server timeouts

This commit is contained in:
2026-05-18 13:58:17 +02:00
parent a01207323f
commit a097410f40
3 changed files with 98 additions and 58 deletions

View File

@@ -3,20 +3,23 @@ package watchorder
import ( import (
"context" "context"
"errors" "errors"
"io"
"net/http" "net/http"
"net/http/httptest"
"strings" "strings"
"testing" "testing"
"time" "time"
) )
func testServer(body string) *httptest.Server { func mockResponse(status int, headers map[string]string, body string) *http.Response {
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { h := make(http.Header, len(headers))
w.Header().Set("Content-Type", "text/html; charset=utf-8") for k, v := range headers {
_, _ = w.Write([]byte(body)) h.Set(k, v)
}) }
return &http.Response{
return httptest.NewServer(handler) StatusCode: status,
Header: h,
Body: io.NopCloser(strings.NewReader(body)),
}
} }
func testHTMLWithMetadata() string { func testHTMLWithMetadata() string {
@@ -55,11 +58,18 @@ func testHTMLEmptyRows() string {
} }
func TestFetchWatchOrder_OutputShape(t *testing.T) { func TestFetchWatchOrder_OutputShape(t *testing.T) {
server := testServer(testHTMLWithMetadata()) client := &http.Client{
defer server.Close() Timeout: time.Second,
Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) {
if request.URL.RawQuery == "/tools/watch_order/id/442" {
return mockResponse(http.StatusOK, map[string]string{"Content-Type": "text/html; charset=utf-8"}, testHTMLWithMetadata()), nil
}
return mockResponse(http.StatusNotFound, nil, "not found"), nil
}),
}
url := server.URL + "/?/tools/watch_order/id/442" url := "https://chiaki.site/?/tools/watch_order/id/442"
result, err := FetchWatchOrder(context.Background(), &http.Client{Timeout: time.Second}, url) result, err := FetchWatchOrder(context.Background(), client, url)
if err != nil { if err != nil {
t.Fatalf("expected no error, got %v", err) t.Fatalf("expected no error, got %v", err)
} }
@@ -88,11 +98,18 @@ func TestFetchWatchOrder_OutputShape(t *testing.T) {
} }
func TestFetchWatchOrder_NoRowsReturnsEmpty(t *testing.T) { func TestFetchWatchOrder_NoRowsReturnsEmpty(t *testing.T) {
server := testServer(testHTMLEmptyRows()) client := &http.Client{
defer server.Close() Timeout: time.Second,
Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) {
if request.URL.RawQuery == "/tools/watch_order/id/1535" {
return mockResponse(http.StatusOK, map[string]string{"Content-Type": "text/html; charset=utf-8"}, testHTMLEmptyRows()), nil
}
return mockResponse(http.StatusNotFound, nil, "not found"), nil
}),
}
url := server.URL + "/?/tools/watch_order/id/1535" url := "https://chiaki.site/?/tools/watch_order/id/1535"
result, err := FetchWatchOrder(context.Background(), &http.Client{Timeout: time.Second}, url) result, err := FetchWatchOrder(context.Background(), client, url)
if err != nil { if err != nil {
t.Fatalf("expected no error, got %v", err) t.Fatalf("expected no error, got %v", err)
} }
@@ -121,37 +138,18 @@ Jujutsu Kaisen 0
Dec 24, 2021 | Movie | 1ep × 1hr. 44min. | ★8.36 | [](https://myanimelist.net/anime/48561) Dec 24, 2021 | Movie | 1ep × 1hr. 44min. | ★8.36 | [](https://myanimelist.net/anime/48561)
` `
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/http/") {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(proxyPayload))
return
}
w.WriteHeader(http.StatusForbidden)
_, _ = w.Write([]byte("blocked"))
}))
defer server.Close()
transport := http.DefaultTransport
testClient := &http.Client{ testClient := &http.Client{
Timeout: time.Second, Timeout: time.Second,
Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) { Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) {
if strings.HasPrefix(request.URL.Host, "r.jina.ai") { switch {
proxyURL := server.URL + "/http/" + strings.TrimPrefix(request.URL.Path, "/") case request.URL.Host == "chiaki.site":
proxyRequest, err := http.NewRequestWithContext(request.Context(), request.Method, proxyURL, nil) return mockResponse(http.StatusForbidden, map[string]string{"Content-Type": "text/html; charset=utf-8"}, "blocked"), nil
if err != nil { case request.URL.Host == "r.jina.ai":
return nil, err // Proxy response is plain text/markdown.
return mockResponse(http.StatusOK, map[string]string{"Content-Type": "text/plain; charset=utf-8"}, proxyPayload), nil
default:
return mockResponse(http.StatusNotFound, nil, "not found"), nil
} }
return transport.RoundTrip(proxyRequest)
}
blockedURL := server.URL + request.URL.Path
blockedRequest, err := http.NewRequestWithContext(request.Context(), request.Method, blockedURL, nil)
if err != nil {
return nil, err
}
return transport.RoundTrip(blockedRequest)
}), }),
} }
@@ -174,17 +172,19 @@ Jujutsu Kaisen 0
} }
func TestFetchWatchOrder_HTTPStatusErrorIncludesContext(t *testing.T) { func TestFetchWatchOrder_HTTPStatusErrorIncludesContext(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { client := &http.Client{
w.Header().Set("Server", "cloudflare") Timeout: time.Second,
w.Header().Set("CF-Ray", "abc123") Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) {
w.Header().Set("Content-Type", "text/html; charset=utf-8") return mockResponse(http.StatusForbidden, map[string]string{
w.WriteHeader(http.StatusForbidden) "Server": "cloudflare",
_, _ = w.Write([]byte("<html><body>access denied</body></html>")) "CF-Ray": "abc123",
})) "Content-Type": "text/html; charset=utf-8",
defer server.Close() }, "<html><body>access denied</body></html>"), nil
}),
}
url := server.URL + "/?/tools/watch_order/id/1" url := "https://chiaki.site/?/tools/watch_order/id/1"
_, err := fetchDocument(context.Background(), &http.Client{Timeout: time.Second}, url) _, err := fetchDocument(context.Background(), client, url)
if err == nil { if err == nil {
t.Fatalf("expected error, got nil") t.Fatalf("expected error, got nil")
} }

View File

@@ -5,6 +5,7 @@ import (
"log" "log"
"net/http" "net/http"
"os" "os"
"time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/gin-gonic/gin/render" "github.com/gin-gonic/gin/render"
@@ -34,28 +35,39 @@ func RunServer(lifecycle fx.Lifecycle, r *gin.Engine) {
port = "3000" port = "3000"
} }
srv := &http.Server{ srv := newHTTPServer(":"+port, r)
Addr: ":" + port,
Handler: r,
}
lifecycle.Append(fx.Hook{ lifecycle.Append(fx.Hook{
OnStart: func(context.Context) error { OnStart: func(context.Context) error {
log.Printf("Starting server on http://localhost:%s", port) log.Printf("Starting server on http://localhost:%s", port)
go func() { go func() {
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("listen: %s\n", err) // Avoid exiting the process from a goroutine; let the process supervisor handle restarts.
log.Printf("server listen error: %s", err)
} }
}() }()
return nil return nil
}, },
OnStop: func(ctx context.Context) error { OnStop: func(ctx context.Context) error {
log.Println("Shutting down server...") log.Println("Shutting down server...")
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
return srv.Shutdown(ctx) return srv.Shutdown(ctx)
}, },
}) })
} }
func newHTTPServer(addr string, handler http.Handler) *http.Server {
return &http.Server{
Addr: addr,
Handler: handler,
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 30 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 2 * time.Minute,
}
}
// RouteRegister is an interface that modules can implement to register their routes. // RouteRegister is an interface that modules can implement to register their routes.
type RouteRegister interface { type RouteRegister interface {
Register(r *gin.Engine) Register(r *gin.Engine)

View File

@@ -0,0 +1,28 @@
package server
import (
"net/http"
"testing"
"time"
)
func TestNewHTTPServer_TimeoutsAndAddr(t *testing.T) {
srv := newHTTPServer(":1234", http.NewServeMux())
if srv.Addr != ":1234" {
t.Fatalf("Addr: got %q want %q", srv.Addr, ":1234")
}
if srv.ReadHeaderTimeout != 5*time.Second {
t.Fatalf("ReadHeaderTimeout: got %s want %s", srv.ReadHeaderTimeout, 5*time.Second)
}
if srv.ReadTimeout != 30*time.Second {
t.Fatalf("ReadTimeout: got %s want %s", srv.ReadTimeout, 30*time.Second)
}
if srv.WriteTimeout != 30*time.Second {
t.Fatalf("WriteTimeout: got %s want %s", srv.WriteTimeout, 30*time.Second)
}
if srv.IdleTimeout != 2*time.Minute {
t.Fatalf("IdleTimeout: got %s want %s", srv.IdleTimeout, 2*time.Minute)
}
}