package agent
import (
"archive/zip"
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"image"
"image/png"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
)
func uploadFixture(t *testing.T, s *Service, id, name string, data []byte) Attachment {
t.Helper()
a, err := s.UploadAttachment(context.Background(), id, AttachmentInput{Name: name, Data: base64.StdEncoding.EncodeToString(data)})
if err != nil {
t.Fatal(err)
}
return a
}
func officeFixture(t *testing.T, files map[string]string) []byte {
t.Helper()
var buf bytes.Buffer
z := zip.NewWriter(&buf)
for name, text := range files {
w, err := z.Create(name)
if err != nil {
t.Fatal(err)
}
if _, err = w.Write([]byte(text)); err != nil {
t.Fatal(err)
}
}
if err := z.Close(); err != nil {
t.Fatal(err)
}
return buf.Bytes()
}
func pdfFixture() []byte {
stream := "BT /F1 12 Tf 40 70 Td (Budget 3500 quantity 500) Tj ET"
objects := []string{"<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 144] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>", "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>", fmt.Sprintf("<< /Length %d >>\nstream\n%s\nendstream", len(stream), stream)}
var buf bytes.Buffer
buf.WriteString("%PDF-1.4\n")
var offsets []int
for i, obj := range objects {
offsets = append(offsets, buf.Len())
fmt.Fprintf(&buf, "%d 0 obj\n%s\nendobj\n", i+1, obj)
}
xref := buf.Len()
fmt.Fprintf(&buf, "xref\n0 %d\n0000000000 65535 f \n", len(objects)+1)
for _, offset := range offsets {
fmt.Fprintf(&buf, "%010d 00000 n \n", offset)
}
fmt.Fprintf(&buf, "trailer\n<< /Size %d /Root 1 0 R >>\nstartxref\n%d\n%%%%EOF\n", len(objects)+1, xref)
return buf.Bytes()
}
func TestAttachmentFormatsAndEncoding(t *testing.T) {
docx := officeFixture(t, map[string]string{"word/document.xml": `预算3500元数量500个`})
xlsx := officeFixture(t, map[string]string{"xl/sharedStrings.xml": `数量`, "xl/worksheets/sheet1.xml": `0500预算3500
`})
for _, tc := range []struct {
name string
data []byte
want string
}{{"资料.txt", []byte("预算3500元"), "预算3500"}, {"utf16.txt", []byte{0xff, 0xfe, '5', 0, '0', 0, '0', 0}, "500"}, {"报价.docx", docx, "数量500"}, {"客户.xlsx", xlsx, "B1=500"}, {"报价.pdf", pdfFixture(), "3500"}} {
t.Run(tc.name, func(t *testing.T) {
result, err := extractAttachment(context.Background(), tc.name, tc.data)
if err != nil || !strings.Contains(result.Text, tc.want) {
t.Fatalf("extract %s: %q %v", tc.name, result.Text, err)
}
})
}
var picture bytes.Buffer
png.Encode(&picture, image.NewRGBA(image.Rect(0, 0, 2, 2)))
result, err := extractAttachment(context.Background(), "图.png", picture.Bytes())
if err != nil || result.Kind != "image" || result.MIMEType != "image/png" {
t.Fatalf("image: %+v %v", result, err)
}
for _, tc := range []struct {
name string
data []byte
}{{"a.exe", []byte("hello")}, {"a.txt", []byte{0, 1, 2}}, {"a.png", []byte("not image")}, {"a.pdf", []byte("invalid PDF")}, {"a.docx", []byte("not zip")}} {
if _, err := extractAttachment(context.Background(), tc.name, tc.data); err == nil {
t.Fatalf("invalid file accepted: %s", tc.name)
}
}
result, err = extractAttachment(context.Background(), "long.txt", []byte(strings.Repeat("内容", 40000)))
if err != nil || !result.Truncated || len(result.Text) > maxAttachmentText {
t.Fatal("long text limit", err)
}
}
func TestAttachmentsPersistAndStayWithinConversation(t *testing.T) {
s, secrets := testService(t)
c, _ := s.CreateConversation()
other, _ := s.CreateConversation()
a := uploadFixture(t, s, c.ID, "需求.md", []byte("预算3500,数量500,只起草不发送"))
c.DraftAttachments = []Attachment{a}
c.Draft = "分析这份资料"
if _, err := s.SaveConversation(c); err != nil {
t.Fatal(err)
}
restarted := newService(s.configPath, secrets)
loaded, err := restarted.GetConversation(c.ID)
if err != nil || len(loaded.DraftAttachments) != 1 || loaded.DraftAttachments[0].Name != a.Name {
t.Fatalf("restore: %+v %v", loaded, err)
}
other.DraftAttachments = []Attachment{a}
if _, err := s.SaveConversation(other); err == nil {
t.Fatal("cross-conversation file reference accepted")
}
forged := a
forged.Name = "forged"
forged.Size = -1
c.DraftAttachments = []Attachment{forged}
s.SaveConversation(c)
loaded, _ = s.GetConversation(c.ID)
if loaded.DraftAttachments[0].Name != a.Name || loaded.DraftAttachments[0].Size <= 0 {
t.Fatal("client metadata was trusted")
}
if err := s.RemoveAttachment(c.ID, a.ID); err == nil {
t.Fatal("removed referenced draft")
}
c.DraftAttachments = nil
c.Messages = []ConversationMessage{{ID: "u", Role: "user", Content: "分析附件", Attachments: []Attachment{a}}, {ID: "a", Role: "assistant", Content: "已读取", Status: "complete"}}
s.SaveConversation(c)
if err := s.RemoveAttachment(c.ID, a.ID); err == nil {
t.Fatal("removed sent file")
}
message, err := s.messageForModel(c.ID, c.Messages[0])
if err != nil || !strings.Contains(fmt.Sprint(message.Content), "预算3500") {
t.Fatalf("missing file context: %+v %v", message, err)
}
source, err := s.memorySource(context.Background(), c.ID, c.Messages)
if err != nil || !strings.Contains(source, "预算3500") {
t.Fatal("file missing in memory source", err)
}
dir, _ := s.attachmentDir(c.ID, "")
if err := s.DeleteConversation(c.ID); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(dir); !os.IsNotExist(err) {
t.Fatal("conversation deletion left attachments")
}
if _, err := s.UploadAttachment(context.Background(), "../escape", AttachmentInput{Name: "safe.txt", Data: base64.StdEncoding.EncodeToString([]byte("text"))}); err == nil {
t.Fatal("invalid conversation accepted")
}
}
func TestChatStreamReceivesAttachmentContentAndImageParts(t *testing.T) {
s, _ := testService(t)
c, _ := s.CreateConversation()
a := uploadFixture(t, s, c.ID, "budget.csv", []byte("budget,quantity\n3500,500"))
var received []modelMessage
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body struct {
Messages []modelMessage `json:"messages"`
}
json.NewDecoder(r.Body).Decode(&body)
received = body.Messages
w.Header().Set("Content-Type", "text/event-stream")
fmt.Fprint(w, "data: {\"choices\":[{\"delta\":{\"content\":\"预算3500,数量500\"},\"finish_reason\":\"stop\"}]}\n\ndata: [DONE]\n\n")
}))
defer server.Close()
s.SaveModelConfig(ModelConfigInput{Config: testConfig(server.URL), APIKey: "key"})
result, err := s.ChatStreamWithFiles(context.Background(), "files-test", c.ID, "分析预算", []string{a.ID})
if err != nil || !strings.Contains(result["content"], "3500") || !strings.Contains(fmt.Sprint(received[len(received)-1].Content), "3500,500") {
t.Fatalf("model file context: %v %v", received, err)
}
var picture bytes.Buffer
png.Encode(&picture, image.NewRGBA(image.Rect(0, 0, 2, 2)))
img := uploadFixture(t, s, c.ID, "图.png", picture.Bytes())
message, err := s.messageForModel(c.ID, ConversationMessage{Role: "user", Content: "看图", Attachments: []Attachment{img}})
if err != nil {
t.Fatal(err)
}
raw, _ := json.Marshal(message)
if !strings.Contains(string(raw), "data:image/png;base64,") {
t.Fatal("image not adapted for model")
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
if _, err := s.UploadAttachment(ctx, c.ID, AttachmentInput{Name: "cancel.txt", Data: base64.StdEncoding.EncodeToString([]byte("cancel"))}); err == nil {
t.Fatal("cancelled upload continued")
}
}