package api_test import ( "archive/zip" "bytes" "context" "encoding/base64" "io" "net/http" "strings" "testing" ) func TestMeChatHTMLZipExport(t *testing.T) { e := setup(t) student, studentID := register(t, e, "Export Student", "me-export@test.dev") other, otherID := register(t, e, "Other Student", "me-export-other@test.dev") admin := login(t, e, bootstrapAdminEmail) code, body := e.do(t, "POST", "/api/admin/users", admin, map[string]any{ "name": "Mentor Export", "email": "mentor-export@test.dev", "username": "mentorexport", "password": "password123", "role": "mentor", "securityQuestion": "نام حیوان خانگی؟", "securityAnswer": "rex", }) if code != http.StatusCreated { t.Fatalf("create mentor: %d %v", code, body) } mentorID := int64(body["user"].(map[string]any)["id"].(float64)) mentor := login(t, e, "mentor-export@test.dev") png, err := base64.StdEncoding.DecodeString("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==") if err != nil || len(png) < 8 { t.Fatalf("png: %v len=%d", err, len(png)) } wav := make([]byte, 44) copy(wav, []byte("RIFF")) copy(wav[8:], []byte("WAVE")) note := []byte("note-bytes") code, upImg := uploadChatFile(t, e, student, "shot.png", png) if code != http.StatusCreated { t.Fatalf("png upload: %d %v", code, upImg) } code, upNote := uploadChatFile(t, e, student, "notes.txt", note) if code != http.StatusCreated { t.Fatalf("txt upload: %d %v", code, upNote) } code, upVoice := uploadChatFile(t, e, student, "voice-note.wav", wav) if code != http.StatusCreated { t.Fatalf("wav upload: %d %v", code, upVoice) } code, sent := e.do(t, "POST", "/api/chats/"+itoa(mentorID)+"/messages", student, map[string]any{ "body": "راز\nخط دوم", "attachments": []any{upImg["attachment"], upNote["attachment"]}, }) if code != http.StatusCreated { t.Fatalf("send: %d %v", code, sent) } msgID := int64(sent["message"].(map[string]any)["id"].(float64)) if _, err := e.pool.Exec(context.Background(), `UPDATE chat_messages SET created_at = $1 WHERE id = $2`, "2026-03-21 12:00:00+00", msgID); err != nil { t.Fatal(err) } code, voice := e.do(t, "POST", "/api/chats/"+itoa(mentorID)+"/messages", student, map[string]any{ "body": "صدا", "attachment": upVoice["attachment"], }) if code != http.StatusCreated { t.Fatalf("voice: %d %v", code, voice) } if _, err := e.pool.Exec(context.Background(), ` INSERT INTO chat_messages (user_id, mentor_id, sender_role, body, attachment_type, attachment_url, attachment_name, attachment_size) VALUES ($1, $2, 'student', 'گمشده', 'audio', 'http://localhost:8080/media/chats/999-missing.wav', 'gone.wav', 5000)`, studentID, mentorID); err != nil { t.Fatal(err) } code, saved := e.do(t, "POST", "/api/chats/"+itoa(studentID)+"/messages", student, map[string]any{"body": "یادداشت خصوصی"}) if code != http.StatusCreated { t.Fatalf("saved: %d %v", code, saved) } code, reply := e.do(t, "POST", "/api/chats/"+itoa(studentID)+"/messages", mentor, map[string]any{"body": "از منتور"}) if code != http.StatusCreated { t.Fatalf("reply: %d %v", code, reply) } code, _ = e.do(t, "GET", "/api/me/export?meta=1", student, nil) if code != http.StatusBadRequest { t.Fatalf("meta without chat: %d", code) } code, _, raw := e.doRaw(t, "GET", "/api/me/export", student, nil) if code != http.StatusBadRequest || (len(raw) >= 2 && raw[0] == 'P' && raw[1] == 'K') { t.Fatalf("export without chat should not be a zip: %d", code) } code, meta := e.do(t, "GET", "/api/me/export?meta=1&chat="+itoa(mentorID), student, nil) if code != http.StatusOK { t.Fatalf("meta: %d %v", code, meta) } approx := int64(meta["bytes"].(float64)) floor := int64(len(png) + len(wav) + len(note) + 5000) if approx < floor { t.Fatalf("estimate %d < media floor %d", approx, floor) } if int64(meta["chat"].(float64)) != mentorID { t.Fatalf("meta chat: %v", meta["chat"]) } code, savedMeta := e.do(t, "GET", "/api/me/export?meta=1&chat="+itoa(studentID), student, nil) if code != http.StatusOK || int64(savedMeta["bytes"].(float64)) >= approx { t.Fatalf("saved meta should be only that chat: %d %v", code, savedMeta) } code, ct, raw := e.doRaw(t, "GET", "/api/me/export?chat="+itoa(mentorID), student, nil) if code != http.StatusOK { t.Fatalf("zip: %d %s", code, ct) } if ct != "application/zip" || len(raw) < 2 || raw[0] != 'P' || raw[1] != 'K' { t.Fatalf("not a zip: %s %d", ct, len(raw)) } zr, err := zip.NewReader(bytes.NewReader(raw), int64(len(raw))) if err != nil { t.Fatal(err) } var htmlDoc string var sawImage, sawAudio, sawNote bool for _, f := range zr.File { if strings.Contains(f.Name, "gone.wav") || strings.Contains(f.Name, "missing") { t.Fatalf("missing media was packed: %s", f.Name) } rc, err := f.Open() if err != nil { t.Fatal(err) } blob, _ := io.ReadAll(rc) _ = rc.Close() switch { case f.Name == "index.html": htmlDoc = string(blob) case strings.HasSuffix(f.Name, ".png"): sawImage = bytes.Equal(blob, png) case strings.HasSuffix(f.Name, ".wav"): sawAudio = bytes.Equal(blob, wav) case strings.HasSuffix(f.Name, ".txt"): sawNote = bytes.Equal(blob, note) } } if htmlDoc == "" || !sawImage || !sawAudio || !sawNote { t.Fatalf("zip parts image=%v audio=%v note=%v html=%d", sawImage, sawAudio, sawNote, len(htmlDoc)) } if !strings.Contains(htmlDoc, `lang="fa"`) || !strings.Contains(htmlDoc, `dir="rtl"`) { t.Fatal("html is not a persian rtl document") } if strings.Contains(htmlDoc, "راز") || !strings.Contains(htmlDoc, "<b>راز</b>") { t.Fatal("user text was not escaped") } if !strings.Contains(htmlDoc, "<b>راز</b>\nخط دوم") { t.Fatal("newline was not kept in the bubble") } if !strings.Contains(htmlDoc, "white-space: pre-wrap") { t.Fatal("bubble text does not keep whitespace") } if !strings.Contains(htmlDoc, `class="msg mine"`) || !strings.Contains(htmlDoc, `class="msg theirs"`) { t.Fatal("own and partner bubbles are missing") } if !strings.Contains(htmlDoc, "#6d63f0") { t.Fatal("own bubble is not the chat primary color") } if !strings.Contains(htmlDoc, ">Mentor Export") || !strings.Contains(htmlDoc, "Export Student") || !strings.Contains(htmlDoc, "از منتور") { t.Fatal("single conversation header or sender names missing") } if strings.Contains(htmlDoc, "یادداشت خصوصی") || strings.Contains(htmlDoc, "گفتگوهای پرگار") { t.Fatal("export included another conversation") } if !strings.Contains(htmlDoc, "