package llamaswap import ( "context" "encoding/base64" "io" "mime" "mime/multipart" "net/http" "net/http/httptest" "strings" "testing" "gitea.stevedudenhoeffer.com/steve/majordomo/imagegen" "gitea.stevedudenhoeffer.com/steve/majordomo/llm" ) func swapImg(t *testing.T) imagegen.Image { t.Helper() raw, err := base64.StdEncoding.DecodeString(onePixelPNG) if err != nil { t.Fatalf("decode fixture: %v", err) } return imagegen.Image{MIME: "image/png", Data: raw} } // parseParts pulls the multipart form a handler received. func parseParts(t *testing.T, r *http.Request) (files map[string][]byte, fields map[string]string) { t.Helper() _, params, err := mime.ParseMediaType(r.Header.Get("Content-Type")) if err != nil { t.Fatalf("content type: %v", err) } mr := multipart.NewReader(r.Body, params["boundary"]) files, fields = map[string][]byte{}, map[string]string{} for { p, err := mr.NextPart() if err == io.EOF { break } if err != nil { t.Fatalf("next part: %v", err) } body, _ := io.ReadAll(p) if p.FileName() != "" { files[p.FormName()] = body } else { fields[p.FormName()] = string(body) } } return files, fields } // TestFaceSwapSendsBothFiles pins the two-file wire shape. A face swap is the // first endpoint in this provider taking more than one file, so buildMultipart // grew a sibling; getting the field NAMES wrong would reach the shim as a // missing-argument 422 rather than anything self-explanatory. func TestFaceSwapSendsBothFiles(t *testing.T) { var gotPath string var files map[string][]byte var fields map[string]string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotPath = r.URL.Path files, fields = parseParts(t, r) w.Header().Set("Content-Type", "image/png") raw, _ := base64.StdEncoding.DecodeString(onePixelPNG) _, _ = w.Write(raw) })) defer srv.Close() p := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client())) m, err := p.FaceSwapModel("faceswap") if err != nil { t.Fatalf("model: %v", err) } img := swapImg(t) res, err := m.FaceSwap(context.Background(), imagegen.FaceSwapRequest{Target: img, Source: img}, imagegen.WithFaceIndex(2)) if err != nil { t.Fatalf("faceswap: %v", err) } if len(res.Images) != 1 { t.Fatalf("images = %d, want 1", len(res.Images)) } if !strings.HasSuffix(gotPath, "/upstream/faceswap/v1/faceswap") { t.Errorf("path = %q", gotPath) } for _, want := range []string{"target", "source"} { if len(files[want]) == 0 { t.Errorf("no %q file part — the shim requires both", want) } } if fields["index"] != "2" { t.Errorf("index = %q, want 2", fields["index"]) } if _, ok := fields["all"]; ok { t.Error("all sent alongside index — the shim ignores index under all=true, so sending both implies a precedence the caller cannot see") } } // TestFaceSwapAllSuppressesIndex: same reasoning from the other side. func TestFaceSwapAllSuppressesIndex(t *testing.T) { var fields map[string]string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, fields = parseParts(t, r) w.Header().Set("Content-Type", "image/png") raw, _ := base64.StdEncoding.DecodeString(onePixelPNG) _, _ = w.Write(raw) })) defer srv.Close() p := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client())) m, _ := p.FaceSwapModel("faceswap") img := swapImg(t) if _, err := m.FaceSwap(context.Background(), imagegen.FaceSwapRequest{Target: img, Source: img, Index: new(int), All: true}); err != nil { t.Fatalf("faceswap: %v", err) } if fields["all"] != "true" { t.Errorf("all = %q, want true", fields["all"]) } if _, ok := fields["index"]; ok { t.Error("index sent under all=true") } } // TestFaceSwapRejectsMissingImages: both are required, and the error should // name which one rather than surfacing a shim 422. func TestFaceSwapRejectsMissingImages(t *testing.T) { p := New(WithBaseURL("http://example.invalid")) m, _ := p.FaceSwapModel("faceswap") img := swapImg(t) for _, tc := range []struct { name string req imagegen.FaceSwapRequest want string }{ {"no target", imagegen.FaceSwapRequest{Source: img}, "target"}, {"no source", imagegen.FaceSwapRequest{Target: img}, "source"}, } { _, err := m.FaceSwap(context.Background(), tc.req) if err == nil || !strings.Contains(err.Error(), tc.want) { t.Errorf("%s: err = %v, want one naming %q", tc.name, err, tc.want) } } } // TestFaceSwapRejectsNonImageResponse: the shim answers JSON on a semantic // miss (no face found). Returning those bytes as an "image" would hand the // caller a file that is not a picture and call it success. func TestFaceSwapRejectsNonImageResponse(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"detail":{"error":"no_face_in_source"}}`)) })) defer srv.Close() p := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client())) m, _ := p.FaceSwapModel("faceswap") img := swapImg(t) _, err := m.FaceSwap(context.Background(), imagegen.FaceSwapRequest{Target: img, Source: img}) if err == nil { t.Fatal("a JSON body was accepted as an image") } var apiErr *llm.APIError if !strings.Contains(err.Error(), "no_face_in_source") { t.Errorf("err = %v, want it to relay the shim's reason", err) } _ = apiErr } // TestListFacesParsesOrdering: the shim's left-to-right index is the contract // callers select against, so it must survive decoding intact. func TestListFacesParsesOrdering(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if !strings.HasSuffix(r.URL.Path, "/upstream/faceswap/v1/faces") { t.Errorf("path = %q", r.URL.Path) } w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"count":2,"faces":[ {"index":0,"box":[10,20,30,40],"score":0.9,"width":20,"height":20}, {"index":1,"box":[50,20,90,60],"score":0.8,"width":40,"height":40}]}`)) })) defer srv.Close() p := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client())) m, _ := p.FaceSwapModel("faceswap") faces, err := m.ListFaces(context.Background(), swapImg(t)) if err != nil { t.Fatalf("list: %v", err) } if len(faces) != 2 || faces[0].Index != 0 || faces[1].Index != 1 { t.Fatalf("faces = %+v", faces) } if faces[1].Box != [4]int{50, 20, 90, 60} { t.Errorf("box = %v", faces[1].Box) } } // TestListFacesRejectsShortBox: a malformed box would send a caller at the // wrong face, which is worse than an error. func TestListFacesRejectsShortBox(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"count":1,"faces":[{"index":0,"box":[1,2],"score":0.9}]}`)) })) defer srv.Close() p := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client())) m, _ := p.FaceSwapModel("faceswap") if _, err := m.ListFaces(context.Background(), swapImg(t)); err == nil { t.Fatal("a 2-element box was accepted") } }