- SubmitChain rejects NaN/±Inf segment seconds with ErrUnsupported (previously an obscure json.Marshal error; NaN fails every comparison and +Inf passed the >= 0 check). - ChainStatus skips segment entries with no usable id — JSON null (which no-op-unmarshals into a string, previously appending ""), empty strings, and id-less objects; the unfiltered list survives in Raw. ChainJob.SegmentIDs doc now also says ChainSegmentResult takes the segment index, not an id string. - jobPath rejects '%' in job ids — %2F/%2E%2E percent-escapes decode back into path structure server-side, bypassing the literal check on this upstream-echoed value. - singleVideoResult moves to video.go next to videoMIME, and the two remaining hand-rolled copies of the video-result validation (videoModel.Generate, Interpolate) now use it — one validation, one message shape. - videogen.LipSyncer renamed to videogen.Lipsyncer for consistency with the rest of the surface's Lipsync* naming (LipsyncProvider, LipsyncModel, LipsyncRequest); not yet consumed downstream, so the rename is free now and never again. Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01WWCQcYStWXBYUy5sZWnbLT
323 lines
11 KiB
Go
323 lines
11 KiB
Go
package llamaswap
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"math"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"reflect"
|
|
"testing"
|
|
|
|
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
|
|
"gitea.stevedudenhoeffer.com/steve/majordomo/videogen"
|
|
)
|
|
|
|
func TestSubmitChain(t *testing.T) {
|
|
var gotPath string
|
|
var gotBody map[string]any
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
gotPath = r.URL.Path
|
|
_ = json.NewDecoder(r.Body).Decode(&gotBody)
|
|
_, _ = w.Write([]byte(`{"job_id":"job-123"}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
p := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client()))
|
|
ch, err := p.ChainerModel("videoutils")
|
|
if err != nil {
|
|
t.Fatalf("ChainerModel: %v", err)
|
|
}
|
|
jobID, err := ch.SubmitChain(context.Background(), videogen.ChainRequest{
|
|
Segments: []videogen.ChainSegment{
|
|
{Prompt: "a cat walks in", Seconds: 5},
|
|
{Prompt: "the cat sits down", Seconds: 5},
|
|
},
|
|
InitImage: []byte("IMG"),
|
|
SmoothJoins: true,
|
|
Size: "1280x704",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("SubmitChain: %v", err)
|
|
}
|
|
if jobID != "job-123" {
|
|
t.Errorf("jobID = %q", jobID)
|
|
}
|
|
if gotPath != "/upstream/videoutils/v1/video/chain" {
|
|
t.Errorf("path = %q", gotPath)
|
|
}
|
|
segments, _ := gotBody["segments"].([]any)
|
|
if len(segments) != 2 {
|
|
t.Fatalf("segments = %v", gotBody["segments"])
|
|
}
|
|
first, _ := segments[0].(map[string]any)
|
|
if first["prompt"] != "a cat walks in" || first["seconds"] != 5.0 {
|
|
t.Errorf("segment[0] = %v", first)
|
|
}
|
|
if gotBody["init_image_b64"] != base64.StdEncoding.EncodeToString([]byte("IMG")) {
|
|
t.Errorf("init_image_b64 = %v", gotBody["init_image_b64"])
|
|
}
|
|
if gotBody["smooth_joins"] != true || gotBody["size"] != "1280x704" {
|
|
t.Errorf("smooth_joins/size = %v/%v", gotBody["smooth_joins"], gotBody["size"])
|
|
}
|
|
}
|
|
|
|
func TestSubmitChainOmitsUnsetFields(t *testing.T) {
|
|
var gotBody map[string]any
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
_ = json.NewDecoder(r.Body).Decode(&gotBody)
|
|
_, _ = w.Write([]byte(`{"data":{"job_id":"job-9"}}`)) // data-wrapped envelope
|
|
}))
|
|
defer srv.Close()
|
|
|
|
p := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client()))
|
|
ch, _ := p.ChainerModel("videoutils")
|
|
jobID, err := ch.SubmitChain(context.Background(), videogen.ChainRequest{
|
|
Segments: []videogen.ChainSegment{{Prompt: "a dog"}},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("SubmitChain: %v", err)
|
|
}
|
|
if jobID != "job-9" {
|
|
t.Errorf("jobID = %q, want data-wrapped id", jobID)
|
|
}
|
|
for _, k := range []string{"init_image_b64", "smooth_joins", "size"} {
|
|
if v, ok := gotBody[k]; ok {
|
|
t.Errorf("unset request sent %q = %v, want omitted", k, v)
|
|
}
|
|
}
|
|
seg, _ := gotBody["segments"].([]any)
|
|
if first, _ := seg[0].(map[string]any); first == nil {
|
|
t.Fatalf("segments = %v", gotBody["segments"])
|
|
} else if _, ok := first["seconds"]; ok {
|
|
t.Error("zero seconds sent; want omitted")
|
|
}
|
|
}
|
|
|
|
func TestSubmitChainRejectsBadArgs(t *testing.T) {
|
|
p := New(WithBaseURL("http://unused"))
|
|
ch, _ := p.ChainerModel("videoutils")
|
|
if _, err := ch.SubmitChain(context.Background(), videogen.ChainRequest{}); !errors.Is(err, llm.ErrUnsupported) {
|
|
t.Errorf("no segments: err = %v, want ErrUnsupported", err)
|
|
}
|
|
if _, err := ch.SubmitChain(context.Background(), videogen.ChainRequest{
|
|
Segments: []videogen.ChainSegment{{Prompt: " "}},
|
|
}); !errors.Is(err, llm.ErrUnsupported) {
|
|
t.Errorf("empty prompt: err = %v, want ErrUnsupported", err)
|
|
}
|
|
if _, err := ch.SubmitChain(context.Background(), videogen.ChainRequest{
|
|
Segments: []videogen.ChainSegment{{Prompt: "x", Seconds: -1}},
|
|
}); !errors.Is(err, llm.ErrUnsupported) {
|
|
t.Errorf("negative seconds: err = %v, want ErrUnsupported", err)
|
|
}
|
|
for name, bad := range map[string]float64{
|
|
"NaN": math.NaN(),
|
|
"+Inf": math.Inf(1),
|
|
"-Inf": math.Inf(-1),
|
|
} {
|
|
if _, err := ch.SubmitChain(context.Background(), videogen.ChainRequest{
|
|
Segments: []videogen.ChainSegment{{Prompt: "x", Seconds: bad}},
|
|
}); !errors.Is(err, llm.ErrUnsupported) {
|
|
t.Errorf("%s seconds: err = %v, want ErrUnsupported", name, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSubmitChainRejectsMissingJobID(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
_, _ = w.Write([]byte(`{"ok":true}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
p := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client()))
|
|
ch, _ := p.ChainerModel("videoutils")
|
|
_, err := ch.SubmitChain(context.Background(), videogen.ChainRequest{
|
|
Segments: []videogen.ChainSegment{{Prompt: "x"}},
|
|
})
|
|
var apiErr *llm.APIError
|
|
if !errors.As(err, &apiErr) {
|
|
t.Fatalf("err = %v, want APIError for missing job_id", err)
|
|
}
|
|
}
|
|
|
|
func TestChainStatus(t *testing.T) {
|
|
var gotPath string
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
gotPath = r.URL.Path
|
|
_, _ = w.Write([]byte(`{"status":"running","segment":2,"total":3,"segments":["seg-0"]}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
p := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client()))
|
|
ch, _ := p.ChainerModel("videoutils")
|
|
job, err := ch.ChainStatus(context.Background(), "job-123")
|
|
if err != nil {
|
|
t.Fatalf("ChainStatus: %v", err)
|
|
}
|
|
if gotPath != "/upstream/videoutils/v1/jobs/job-123" {
|
|
t.Errorf("path = %q", gotPath)
|
|
}
|
|
if job.Status != "running" || job.Segment != 2 || job.Total != 3 {
|
|
t.Errorf("job = %+v", job)
|
|
}
|
|
if !reflect.DeepEqual(job.SegmentIDs, []string{"seg-0"}) {
|
|
t.Errorf("SegmentIDs = %v", job.SegmentIDs)
|
|
}
|
|
if job.Raw == nil {
|
|
t.Error("Raw = nil, want raw payload")
|
|
}
|
|
}
|
|
|
|
func TestChainStatusToleratesObjectSegments(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
_, _ = w.Write([]byte(`{"status":"done","segment":2,"total":2,"segments":[{"id":"seg-0"},{"segment_id":"seg-1"}]}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
p := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client()))
|
|
ch, _ := p.ChainerModel("videoutils")
|
|
job, err := ch.ChainStatus(context.Background(), "job-123")
|
|
if err != nil {
|
|
t.Fatalf("ChainStatus: %v", err)
|
|
}
|
|
if !reflect.DeepEqual(job.SegmentIDs, []string{"seg-0", "seg-1"}) {
|
|
t.Errorf("SegmentIDs = %v", job.SegmentIDs)
|
|
}
|
|
}
|
|
|
|
func TestChainStatusSkipsIDLessSegments(t *testing.T) {
|
|
// null, "", an id-less object, and a mistyped entry must all be
|
|
// skipped — never appended as "" (SegmentIDs promises fetchable
|
|
// artifacts; the unfiltered list stays in Raw).
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
_, _ = w.Write([]byte(`{"status":"running","segment":3,"total":5,` +
|
|
`"segments":[null,"seg-0","",{},{"id":"seg-1"},{"segment_id":""},42]}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
p := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client()))
|
|
ch, _ := p.ChainerModel("videoutils")
|
|
job, err := ch.ChainStatus(context.Background(), "job-123")
|
|
if err != nil {
|
|
t.Fatalf("ChainStatus: %v", err)
|
|
}
|
|
if !reflect.DeepEqual(job.SegmentIDs, []string{"seg-0", "seg-1"}) {
|
|
t.Errorf("SegmentIDs = %v, want [seg-0 seg-1]", job.SegmentIDs)
|
|
}
|
|
}
|
|
|
|
func TestChainStatusRejectsStatuslessPayload(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
_, _ = w.Write([]byte(`{"detail":"no such job"}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
p := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client()))
|
|
ch, _ := p.ChainerModel("videoutils")
|
|
_, err := ch.ChainStatus(context.Background(), "job-123")
|
|
var apiErr *llm.APIError
|
|
if !errors.As(err, &apiErr) {
|
|
t.Fatalf("err = %v, want APIError for statusless payload", err)
|
|
}
|
|
}
|
|
|
|
func TestChainJobPathRejectsHostileIDs(t *testing.T) {
|
|
p := New(WithBaseURL("http://unused"))
|
|
ch, _ := p.ChainerModel("videoutils")
|
|
for _, bad := range []string{"", "a/b", "a?b", "a#b", "..", "a..b", "a%2Fb", "%2e%2e", "a%b"} {
|
|
if _, err := ch.ChainStatus(context.Background(), bad); err == nil {
|
|
t.Errorf("ChainStatus(%q) succeeded; want error", bad)
|
|
}
|
|
if _, err := ch.ChainResult(context.Background(), bad); err == nil {
|
|
t.Errorf("ChainResult(%q) succeeded; want error", bad)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestChainResult(t *testing.T) {
|
|
var gotPath string
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
gotPath = r.URL.Path
|
|
w.Header().Set("Content-Type", "video/mp4")
|
|
_, _ = w.Write(mp4Fixture())
|
|
}))
|
|
defer srv.Close()
|
|
|
|
p := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client()))
|
|
ch, _ := p.ChainerModel("videoutils")
|
|
res, err := ch.ChainResult(context.Background(), "job-123")
|
|
if err != nil {
|
|
t.Fatalf("ChainResult: %v", err)
|
|
}
|
|
if gotPath != "/upstream/videoutils/v1/jobs/job-123/result" {
|
|
t.Errorf("path = %q", gotPath)
|
|
}
|
|
if res.Video.MIME != "video/mp4" || len(res.Video.Data) == 0 {
|
|
t.Fatalf("video = %q/%d bytes", res.Video.MIME, len(res.Video.Data))
|
|
}
|
|
}
|
|
|
|
func TestChainSegmentResult(t *testing.T) {
|
|
var gotPath string
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
gotPath = r.URL.Path
|
|
w.Header().Set("Content-Type", "video/mp4")
|
|
_, _ = w.Write(mp4Fixture())
|
|
}))
|
|
defer srv.Close()
|
|
|
|
p := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client()))
|
|
ch, _ := p.ChainerModel("videoutils")
|
|
res, err := ch.ChainSegmentResult(context.Background(), "job-123", 1)
|
|
if err != nil {
|
|
t.Fatalf("ChainSegmentResult: %v", err)
|
|
}
|
|
if gotPath != "/upstream/videoutils/v1/jobs/job-123/segments/1" {
|
|
t.Errorf("path = %q", gotPath)
|
|
}
|
|
if res.Video.MIME != "video/mp4" {
|
|
t.Fatalf("video = %q", res.Video.MIME)
|
|
}
|
|
|
|
if _, err := ch.ChainSegmentResult(context.Background(), "job-123", -1); !errors.Is(err, llm.ErrUnsupported) {
|
|
t.Errorf("negative segment: err = %v, want ErrUnsupported", err)
|
|
}
|
|
}
|
|
|
|
func TestChainResultRejectsNonVideoResponse(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"status":"running"}`)) // a status page, not the clip
|
|
}))
|
|
defer srv.Close()
|
|
|
|
p := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client()))
|
|
ch, _ := p.ChainerModel("videoutils")
|
|
_, err := ch.ChainResult(context.Background(), "job-123")
|
|
var apiErr *llm.APIError
|
|
if !errors.As(err, &apiErr) {
|
|
t.Fatalf("err = %v, want APIError for non-video body", err)
|
|
}
|
|
}
|
|
|
|
func TestChainSurfacesAPIError(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
_, _ = w.Write([]byte(`{"error":{"message":"job not found"}}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
p := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client()))
|
|
ch, _ := p.ChainerModel("videoutils")
|
|
_, err := ch.ChainStatus(context.Background(), "job-void")
|
|
var apiErr *llm.APIError
|
|
if !errors.As(err, &apiErr) {
|
|
t.Fatalf("err = %T %v, want *llm.APIError", err, err)
|
|
}
|
|
if apiErr.Status != http.StatusNotFound || apiErr.Message != "job not found" || apiErr.Model != "videoutils" {
|
|
t.Errorf("apiErr = %+v", apiErr)
|
|
}
|
|
}
|