package api
import (
"context"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"craftdesign.group/quarry/internal/oltp"
"craftdesign.group/quarry/internal/ops"
)
// bytesFile is an ops.File over a string, counting how often it is
// opened — the 304 test's whole point is that it is not.
type bytesFile struct {
body string
etag string
err error
opens *int
}
func (b bytesFile) Name() string { return "thing.zip" }
func (b bytesFile) ContentType() string { return "application/zip" }
func (b bytesFile) ETag() string { return b.etag }
func (b bytesFile) Open() (io.ReadCloser, int64, error) {
*b.opens++
if b.err != nil {
return nil, 0, b.err
}
return io.NopCloser(strings.NewReader(b.body)), int64(len(b.body)), nil
}
type fileIn struct {
Repo string `json:"repo"`
}
func fileRouter(t *testing.T, f bytesFile) http.Handler {
t.Helper()
r := ops.NewRegistry()
if err := ops.Register(r, ops.Op[fileIn, bytesFile]{
Name: "file.test", Kind: ops.Query, Auth: ops.Anonymous,
Route: "GET /api/v1/filetest", Doc: "test double: answers bytes",
Run: func(context.Context, fileIn) (bytesFile, error) { return f, nil },
}); err != nil {
t.Fatal(err)
}
h, err := RouterWithCaps(r, "test", func(_, _ string) CapSet { return oltp.CapSet("s") }, nil)
if err != nil {
t.Fatal(err)
}
return h
}
func getFile(h http.Handler, ifNoneMatch string) *httptest.ResponseRecorder {
req := httptest.NewRequest("GET", "/api/v1/filetest", nil)
if ifNoneMatch != "" {
req.Header.Set("If-None-Match", ifNoneMatch)
}
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
return w
}
// An op that answers an ops.File is served as the bytes: content type,
// disposition, length and ETag from the file, no JSON envelope. This is
// what replaced read-into-memory, base64-into-JSON, decode-in-the-web-
// route (6dbaeb21, a0770478).
func TestAFileResultIsServedAsBytes(t *testing.T) {
opens := 0
w := getFile(fileRouter(t, bytesFile{body: "PK...", etag: "abc-zip", opens: &opens}), "")
if w.Code != 200 || w.Body.String() != "PK..." {
t.Fatalf("FAIL: %d %q", w.Code, w.Body.String())
}
h := w.Header()
if h.Get("Content-Type") != "application/zip" || h.Get("Content-Length") != "5" ||
!strings.Contains(h.Get("Content-Disposition"), "thing.zip") || h.Get("ETag") != `"abc-zip"` {
t.Fatalf("FAIL: headers %v", h)
}
if opens != 1 {
t.Fatalf("FAIL: opened %d times, want once", opens)
}
t.Logf("ok: %s %s bytes=%s etag=%s", h.Get("Content-Type"), h.Get("Content-Length"), w.Body.String(), h.Get("ETag"))
}
// A reader who already has the bytes gets a 304 and Open is never
// called — so an archive that would have to be built is not built.
func TestAMatchingETagAnswers304WithoutOpening(t *testing.T) {
opens := 0
w := getFile(fileRouter(t, bytesFile{body: "PK...", etag: "abc-zip", opens: &opens}), `"abc-zip"`)
if w.Code != http.StatusNotModified {
t.Fatalf("FAIL: status %d, want 304", w.Code)
}
if opens != 0 {
t.Fatalf("FAIL: Open was called %d times on a 304; the build ran for nothing", opens)
}
t.Log("ok: 304, and Open never ran")
}
// Open's error is an op error and is mapped like one — a missing
// version is a 404 with a JSON body, not a truncated download.
func TestAFileOpenErrorIsMappedLikeAnOpError(t *testing.T) {
opens := 0
w := getFile(fileRouter(t, bytesFile{etag: "x", opens: &opens, err: fmt.Errorf("no version %q: %w", "zzz", ops.ErrNotFound)}), "")
if w.Code != 404 || !strings.Contains(w.Body.String(), `"error"`) {
t.Fatalf("FAIL: %d %q", w.Code, w.Body.String())
}
t.Logf("ok: %d %s", w.Code, strings.TrimSpace(w.Body.String()))
}
// A temporary archive lives exactly as long as the reader that asked
// for it: removed on Close, not before the bytes are sent.
func TestATemporaryArchiveIsRemovedOnClose(t *testing.T) {
dir := t.TempDir()
path := dir + "/a.zip"
if err := os.WriteFile(path, []byte("zip"), 0o600); err != nil {
t.Fatal(err)
}
f := archiveFile{name: "a.zip", ctype: "application/zip", version: "v", format: "zip",
build: func() (string, bool, error) { return path, true, nil }}
rc, size, err := f.Open()
if err != nil || size != 3 {
t.Fatalf("FAIL: open: %v size=%d", err, size)
}
if _, err := os.Stat(path); err != nil {
t.Fatal("FAIL: the temporary file was removed before it was read")
}
io.ReadAll(rc)
rc.Close()
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatal("FAIL: the temporary file survived Close")
}
t.Log("ok: present while open, gone after Close")
}