Files
Memoh/internal/channel/outbound_prepare_test.go
T
BBQ d3bf6bc90a fix(channel,attachment): channel quality refactor & attachment pipeline fixes (#349)
* feat(channel): add DingTalk channel adapter

- Add DingTalk channel adapter (`internal/channel/adapters/dingtalk/`) using dingtalk-stream-sdk-go, supporting inbound message receiving and outbound text/markdown reply
- Register DingTalk adapter in cmd/agent and cmd/memoh
- Add go.mod dependency: github.com/memohai/dingtalk-stream-sdk-go
- Add Dingtalk and Wecom SVG icons and Vue components to @memohai/icon
- Refactor existing icon components to remove redundant inline wrappers
- Add `channelTypeDisplayName` util for consistent channel label resolution
- Add DingTalk/WeCom i18n entries (en/zh) for types and typesShort
- Extend channel-icon, bot-channels, channel-settings-panel to support dingtalk/wecom
- Use channelTypeDisplayName in profile page to replace ad-hoc i18n lookup

* fix(channel,attachment): channel quality refactor & attachment pipeline fixes

Channel module:
- Fix RemoveAdapter not cleaning connectionMeta (stale status leak)
- Fix preparedAttachmentTypeFromMime misclassifying image/gif
- Fix sleepWithContext time.After goroutine/timer leak
- Export IsDataURL/IsHTTPURL/IsDataPath, dedup across packages
- Cache OutboundPolicy in managerOutboundStream to avoid repeated lookups
- Split OutboundAttachmentStore: extract ContainerAttachmentIngester interface
- Add ManagerOption funcs (WithInboundQueueSize, WithInboundWorkers, WithRefreshInterval)
- Add thread-safety docs on OutboundStream / managerOutboundStream
- Add debug logs on successful send/edit paths
- Expand outbound_prepare_test.go with 21 new cases
- Convert no-receiver adapter helpers to package-level funcs; drop unused params

DingTalk adapter:
- Implement AttachmentResolver: download inbound media via /v1.0/robot/messageFiles/download
- Fix pure-image inbound messages failing due to missing resolver

Attachment pipeline:
- Fix images invisible to LLM in pipeline (DCP) path: inject InlineImages into
  last user message when cfg.Query is empty
- Fix public_url fallback: skip direct URL-to-LLM when ContentHash is set,
  always prefer inlined persisted asset
- Inject path: carry ImageParts through agent.InjectMessage; inline persisted
  attachments in resolver inject goroutine so mid-stream images reach the model
- Fix ResolveMime for images: prefer content-sniffed MIME over platform-declared
  MIME (fixes Feishu sending image/png header for actual JPEG content → API 400)
2026-04-09 14:36:11 +08:00

725 lines
20 KiB
Go

package channel
import (
"context"
"encoding/base64"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/memohai/memoh/internal/channel/channeltest"
"github.com/memohai/memoh/internal/media"
)
func TestPrepareOutboundMessage_ContainerPathFallsBackToIngestContainerFile(t *testing.T) {
t.Parallel()
store := channeltest.NewMemoryAttachmentStore()
const sourcePath = "/data/media/26da/missing.png"
store.SeedContainerFile("bot-1", sourcePath, []byte("image-bytes"), "image/png", "missing.png")
prepared, err := PrepareOutboundMessage(context.Background(), store, ChannelConfig{
BotID: "bot-1",
ChannelType: ChannelType("qq"),
}, OutboundMessage{
Target: "chat-1",
Message: Message{
Attachments: []Attachment{{
Type: AttachmentImage,
URL: sourcePath,
}},
},
})
if err != nil {
t.Fatalf("PrepareOutboundMessage failed: %v", err)
}
if len(prepared.Message.Attachments) != 1 {
t.Fatalf("expected 1 prepared attachment, got %d", len(prepared.Message.Attachments))
}
if len(prepared.Message.Message.Attachments) != 1 {
t.Fatalf("expected 1 logical attachment, got %d", len(prepared.Message.Message.Attachments))
}
logical := prepared.Message.Message.Attachments[0]
if logical.ContentHash == "" {
t.Fatal("expected content hash after container fallback ingest")
}
if logical.URL == sourcePath {
t.Fatalf("expected prepared access path, got original path %q", logical.URL)
}
if logical.Metadata["source_path"] != sourcePath {
t.Fatalf("expected source_path metadata, got %#v", logical.Metadata["source_path"])
}
reader, err := prepared.Message.Attachments[0].Open(context.Background())
if err != nil {
t.Fatalf("open prepared attachment: %v", err)
}
defer func() { _ = reader.Close() }()
data, err := io.ReadAll(reader)
if err != nil {
t.Fatalf("read prepared attachment: %v", err)
}
if string(data) != "image-bytes" {
t.Fatalf("unexpected prepared attachment bytes: %q", string(data))
}
}
func TestPrepareStreamEvent_FailsFastOnAttachmentPreparationError(t *testing.T) {
t.Parallel()
_, err := PrepareStreamEvent(context.Background(), nil, ChannelConfig{
BotID: "bot-1",
ChannelType: ChannelType("qq"),
}, StreamEvent{
Type: StreamEventAttachment,
Attachments: []Attachment{{
Type: AttachmentImage,
URL: "https://example.com/image.png",
}},
})
if err == nil {
t.Fatal("expected attachment preparation error")
}
if !strings.Contains(err.Error(), "attachment store is not configured") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestPrepareStreamEvent_FailsFastOnFinalAttachmentPreparationError(t *testing.T) {
t.Parallel()
_, err := PrepareStreamEvent(context.Background(), nil, ChannelConfig{
BotID: "bot-1",
ChannelType: ChannelType("qq"),
}, StreamEvent{
Type: StreamEventFinal,
Final: &StreamFinalizePayload{
Message: Message{
Attachments: []Attachment{{
Type: AttachmentImage,
URL: "https://example.com/image.png",
}},
},
},
})
if err == nil {
t.Fatal("expected final attachment preparation error")
}
if !strings.Contains(err.Error(), "attachment store is not configured") {
t.Fatalf("unexpected error: %v", err)
}
}
// --- NativeRef ---
func TestPrepareOutboundMessage_TelegramNativeRef(t *testing.T) {
t.Parallel()
store := channeltest.NewMemoryAttachmentStore()
prepared, err := PrepareOutboundMessage(context.Background(), store, ChannelConfig{
BotID: "bot-1",
ChannelType: ChannelTypeTelegram,
}, OutboundMessage{
Target: "chat-1",
Message: Message{
Attachments: []Attachment{{
Type: AttachmentImage,
PlatformKey: "AgACAgIAAxkB",
SourcePlatform: "telegram",
}},
},
})
if err != nil {
t.Fatalf("PrepareOutboundMessage failed: %v", err)
}
att := prepared.Message.Attachments[0]
if att.Kind != PreparedAttachmentNativeRef {
t.Fatalf("expected native_ref, got %s", att.Kind)
}
if att.NativeRef != "AgACAgIAAxkB" {
t.Fatalf("unexpected native ref: %q", att.NativeRef)
}
}
func TestPrepareOutboundMessage_DingtalkNativeRefSkipsImageAndGIF(t *testing.T) {
t.Parallel()
store := channeltest.NewMemoryAttachmentStore()
_, seedErr := store.SeedAsset("bot-1", []byte("gif-data"), "image/gif", ".gif")
if seedErr != nil {
t.Fatalf("seed asset: %v", seedErr)
}
for _, attType := range []AttachmentType{AttachmentImage, AttachmentGIF} {
attType := attType
t.Run(string(attType), func(t *testing.T) {
t.Parallel()
asset, err := store.SeedAsset("bot-1", []byte("data-"+string(attType)), "image/png", ".png")
if err != nil {
t.Fatalf("seed: %v", err)
}
prepared, err := PrepareOutboundMessage(context.Background(), store, ChannelConfig{
BotID: "bot-1",
ChannelType: ChannelTypeDingtalk,
}, OutboundMessage{
Target: "chat-1",
Message: Message{
Attachments: []Attachment{{
Type: attType,
ContentHash: asset.ContentHash,
PlatformKey: "dingtalk-key",
SourcePlatform: "dingtalk",
}},
},
})
if err != nil {
t.Fatalf("PrepareOutboundMessage failed: %v", err)
}
att := prepared.Message.Attachments[0]
if att.Kind == PreparedAttachmentNativeRef {
t.Fatalf("DingTalk image/gif should NOT use native ref, got native_ref")
}
})
}
}
func TestPrepareOutboundMessage_MatrixNativeRefFromMXCURL(t *testing.T) {
t.Parallel()
store := channeltest.NewMemoryAttachmentStore()
prepared, err := PrepareOutboundMessage(context.Background(), store, ChannelConfig{
BotID: "bot-1",
ChannelType: ChannelTypeMatrix,
}, OutboundMessage{
Target: "!room:matrix.org",
Message: Message{
Attachments: []Attachment{{
Type: AttachmentImage,
URL: "mxc://matrix.org/AbcDef",
}},
},
})
if err != nil {
t.Fatalf("PrepareOutboundMessage failed: %v", err)
}
att := prepared.Message.Attachments[0]
if att.Kind != PreparedAttachmentNativeRef {
t.Fatalf("expected native_ref for mxc:// URL, got %s", att.Kind)
}
if att.NativeRef != "mxc://matrix.org/AbcDef" {
t.Fatalf("unexpected native ref: %q", att.NativeRef)
}
}
// --- PublicURL ---
func TestPrepareOutboundMessage_TelegramPublicURLPassthrough(t *testing.T) {
t.Parallel()
store := channeltest.NewMemoryAttachmentStore()
const rawURL = "https://example.com/photo.jpg"
prepared, err := PrepareOutboundMessage(context.Background(), store, ChannelConfig{
BotID: "bot-1",
ChannelType: ChannelTypeTelegram,
}, OutboundMessage{
Target: "chat-1",
Message: Message{
Attachments: []Attachment{{
Type: AttachmentImage,
URL: rawURL,
}},
},
})
if err != nil {
t.Fatalf("PrepareOutboundMessage failed: %v", err)
}
att := prepared.Message.Attachments[0]
if att.Kind != PreparedAttachmentPublicURL {
t.Fatalf("expected public_url, got %s", att.Kind)
}
if att.PublicURL != rawURL {
t.Fatalf("unexpected public URL: %q", att.PublicURL)
}
}
func TestPrepareOutboundMessage_DingtalkPublicURLOnlyForImageAndGIF(t *testing.T) {
t.Parallel()
store := channeltest.NewMemoryAttachmentStore()
t.Run("image allowed", func(t *testing.T) {
t.Parallel()
prepared, err := PrepareOutboundMessage(context.Background(), store, ChannelConfig{
BotID: "bot-1",
ChannelType: ChannelTypeDingtalk,
}, OutboundMessage{
Target: "chat-1",
Message: Message{
Attachments: []Attachment{{
Type: AttachmentImage,
URL: "https://example.com/img.png",
}},
},
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if prepared.Message.Attachments[0].Kind != PreparedAttachmentPublicURL {
t.Fatalf("expected public_url for DingTalk image, got %s", prepared.Message.Attachments[0].Kind)
}
})
t.Run("file not allowed", func(t *testing.T) {
t.Parallel()
_, err := PrepareOutboundMessage(context.Background(), store, ChannelConfig{
BotID: "bot-1",
ChannelType: ChannelTypeDingtalk,
}, OutboundMessage{
Target: "chat-1",
Message: Message{
Attachments: []Attachment{{
Type: AttachmentFile,
URL: "https://example.com/doc.pdf",
}},
},
})
// File with HTTP URL on DingTalk without store support should fail.
if err == nil {
t.Fatal("expected error for DingTalk file with public URL")
}
})
}
// --- Base64 ---
func TestPrepareOutboundMessage_Base64Attachment(t *testing.T) {
t.Parallel()
store := channeltest.NewMemoryAttachmentStore()
raw := []byte("png-data")
encoded := base64.StdEncoding.EncodeToString(raw)
dataURL := "data:image/png;base64," + encoded
prepared, err := PrepareOutboundMessage(context.Background(), store, ChannelConfig{
BotID: "bot-1",
ChannelType: ChannelTypeDiscord,
}, OutboundMessage{
Target: "chan-1",
Message: Message{
Attachments: []Attachment{{
Type: AttachmentImage,
URL: dataURL,
}},
},
})
if err != nil {
t.Fatalf("PrepareOutboundMessage failed: %v", err)
}
att := prepared.Message.Attachments[0]
if att.Kind != PreparedAttachmentUpload {
t.Fatalf("expected upload, got %s", att.Kind)
}
if att.Mime != "image/png" {
t.Fatalf("expected image/png mime, got %q", att.Mime)
}
reader, err := att.Open(context.Background())
if err != nil {
t.Fatalf("open: %v", err)
}
defer func() { _ = reader.Close() }()
got, err := io.ReadAll(reader)
if err != nil {
t.Fatalf("read: %v", err)
}
if string(got) != string(raw) {
t.Fatalf("unexpected bytes: %q", got)
}
}
func TestPrepareOutboundMessage_Base64FieldAttachment(t *testing.T) {
t.Parallel()
store := channeltest.NewMemoryAttachmentStore()
raw := []byte("audio-data")
encoded := base64.StdEncoding.EncodeToString(raw)
prepared, err := PrepareOutboundMessage(context.Background(), store, ChannelConfig{
BotID: "bot-1",
ChannelType: ChannelTypeDiscord,
}, OutboundMessage{
Target: "chan-1",
Message: Message{
Attachments: []Attachment{{
Type: AttachmentAudio,
Base64: encoded,
Mime: "audio/mpeg",
}},
},
})
if err != nil {
t.Fatalf("PrepareOutboundMessage failed: %v", err)
}
att := prepared.Message.Attachments[0]
if att.Kind != PreparedAttachmentUpload {
t.Fatalf("expected upload, got %s", att.Kind)
}
}
// --- HTTP download ---
func TestPrepareOutboundMessage_HTTPAttachmentDownload(t *testing.T) {
t.Parallel()
imgData := []byte("fake-image-content")
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "image/jpeg")
w.Header().Set("Content-Disposition", `attachment; filename="photo.jpg"`)
_, _ = w.Write(imgData)
}))
defer srv.Close()
store := channeltest.NewMemoryAttachmentStore()
prepared, err := PrepareOutboundMessage(context.Background(), store, ChannelConfig{
BotID: "bot-1",
ChannelType: ChannelTypeDiscord,
}, OutboundMessage{
Target: "chan-1",
Message: Message{
Attachments: []Attachment{{
Type: AttachmentImage,
URL: srv.URL + "/photo.jpg",
}},
},
})
if err != nil {
t.Fatalf("PrepareOutboundMessage failed: %v", err)
}
att := prepared.Message.Attachments[0]
if att.Kind != PreparedAttachmentUpload {
t.Fatalf("expected upload after HTTP download, got %s", att.Kind)
}
if att.Mime != "image/jpeg" {
t.Fatalf("expected image/jpeg, got %q", att.Mime)
}
if att.Name != "photo.jpg" {
t.Fatalf("expected filename from Content-Disposition, got %q", att.Name)
}
reader, err := att.Open(context.Background())
if err != nil {
t.Fatalf("open: %v", err)
}
defer func() { _ = reader.Close() }()
got, err := io.ReadAll(reader)
if err != nil {
t.Fatalf("read: %v", err)
}
if string(got) != string(imgData) {
t.Fatalf("unexpected bytes: %q", got)
}
}
func TestPrepareOutboundMessage_HTTPAttachmentBadStatus(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
http.Error(w, "not found", http.StatusNotFound)
}))
defer srv.Close()
store := channeltest.NewMemoryAttachmentStore()
_, err := PrepareOutboundMessage(context.Background(), store, ChannelConfig{
BotID: "bot-1",
ChannelType: ChannelTypeDiscord,
}, OutboundMessage{
Target: "chan-1",
Message: Message{
Attachments: []Attachment{{
Type: AttachmentImage,
URL: srv.URL + "/missing.jpg",
}},
},
})
if err == nil {
t.Fatal("expected error for HTTP 404")
}
if !strings.Contains(err.Error(), "404") {
t.Fatalf("unexpected error: %v", err)
}
}
// --- ContentHash (persisted asset) ---
func TestPrepareOutboundMessage_PersistedContentHash(t *testing.T) {
t.Parallel()
store := channeltest.NewMemoryAttachmentStore()
asset, err := store.SeedAsset("bot-1", []byte("video-data"), "video/mp4", ".mp4")
if err != nil {
t.Fatalf("seed: %v", err)
}
prepared, err := PrepareOutboundMessage(context.Background(), store, ChannelConfig{
BotID: "bot-1",
ChannelType: ChannelTypeDiscord,
}, OutboundMessage{
Target: "chan-1",
Message: Message{
Attachments: []Attachment{{
Type: AttachmentVideo,
ContentHash: asset.ContentHash,
}},
},
})
if err != nil {
t.Fatalf("PrepareOutboundMessage failed: %v", err)
}
att := prepared.Message.Attachments[0]
if att.Kind != PreparedAttachmentUpload {
t.Fatalf("expected upload for persisted hash, got %s", att.Kind)
}
logical := prepared.Message.Message.Attachments[0]
if logical.ContentHash != asset.ContentHash {
t.Fatalf("content hash mismatch: got %q, want %q", logical.ContentHash, asset.ContentHash)
}
}
// --- Container path (already stored) ---
func TestPrepareOutboundMessage_ContainerPathHitStorageKey(t *testing.T) {
t.Parallel()
store := channeltest.NewMemoryAttachmentStore()
asset, err := store.SeedAsset("bot-1", []byte("already-ingested"), "image/png", ".png")
if err != nil {
t.Fatalf("seed: %v", err)
}
accessPath := store.AccessPath(asset)
prepared, err := PrepareOutboundMessage(context.Background(), store, ChannelConfig{
BotID: "bot-1",
ChannelType: ChannelTypeDiscord,
}, OutboundMessage{
Target: "chan-1",
Message: Message{
Attachments: []Attachment{{
Type: AttachmentImage,
URL: accessPath,
}},
},
})
if err != nil {
t.Fatalf("PrepareOutboundMessage failed: %v", err)
}
if prepared.Message.Attachments[0].Kind != PreparedAttachmentUpload {
t.Fatalf("expected upload, got %s", prepared.Message.Attachments[0].Kind)
}
if prepared.Message.Message.Attachments[0].ContentHash != asset.ContentHash {
t.Fatal("content hash should match the already-ingested asset")
}
}
// --- GIF type inference ---
func TestPreparedAttachmentTypeFromMime_GIFIsDistinctFromImage(t *testing.T) {
t.Parallel()
got := preparedAttachmentTypeFromMime("image/gif")
if got != AttachmentGIF {
t.Fatalf("expected AttachmentGIF for image/gif, got %s", got)
}
got = preparedAttachmentTypeFromMime("image/png")
if got != AttachmentImage {
t.Fatalf("expected AttachmentImage for image/png, got %s", got)
}
}
// --- Helper utilities ---
func TestExtractPreparedStorageKey(t *testing.T) {
t.Parallel()
cases := []struct {
path string
want string
}{
{"/data/media/ab/abcdef.png", "ab/abcdef.png"},
{"/other/media/ab/abcdef.png", ""},
{"/data/media/", ""},
{"/data/media", ""},
{"ab/abcdef.png", ""},
}
for _, tc := range cases {
got := extractPreparedStorageKey(tc.path)
if got != tc.want {
t.Errorf("extractPreparedStorageKey(%q) = %q, want %q", tc.path, got, tc.want)
}
}
}
func TestIsDataURL(t *testing.T) {
t.Parallel()
if !IsDataURL("data:image/png;base64,abc") {
t.Error("expected true for data: URL")
}
if !IsDataURL("DATA:image/png;base64,abc") {
t.Error("expected true for uppercase DATA: URL")
}
if IsDataURL("https://example.com") {
t.Error("expected false for https URL")
}
if IsDataURL("") {
t.Error("expected false for empty string")
}
}
func TestIsHTTPURL(t *testing.T) {
t.Parallel()
if !IsHTTPURL("http://example.com") {
t.Error("expected true for http://")
}
if !IsHTTPURL("HTTPS://example.com") {
t.Error("expected true for HTTPS://")
}
if IsHTTPURL("data:image/png;base64,abc") {
t.Error("expected false for data: URL")
}
if IsHTTPURL("/data/media/file.png") {
t.Error("expected false for local path")
}
}
func TestIsDataPath(t *testing.T) {
t.Parallel()
if !IsDataPath("/data/media/file.png") {
t.Error("expected true for /data/ path")
}
if IsDataPath("/tmp/file.png") {
t.Error("expected false for /tmp/ path")
}
}
// --- PrepareStreamEvent happy path ---
func TestPrepareStreamEvent_FinalWithAttachment(t *testing.T) {
t.Parallel()
store := channeltest.NewMemoryAttachmentStore()
asset, err := store.SeedAsset("bot-1", []byte("img"), "image/png", ".png")
if err != nil {
t.Fatalf("seed: %v", err)
}
ev, err := PrepareStreamEvent(context.Background(), store, ChannelConfig{
BotID: "bot-1",
ChannelType: ChannelTypeDiscord,
}, StreamEvent{
Type: StreamEventFinal,
Final: &StreamFinalizePayload{
Message: Message{
Text: "done",
Attachments: []Attachment{{
Type: AttachmentImage,
ContentHash: asset.ContentHash,
}},
},
},
})
if err != nil {
t.Fatalf("PrepareStreamEvent failed: %v", err)
}
if ev.Final == nil {
t.Fatal("expected non-nil Final")
}
if len(ev.Final.Message.Attachments) != 1 {
t.Fatalf("expected 1 prepared attachment in Final, got %d", len(ev.Final.Message.Attachments))
}
if ev.Final.Message.Attachments[0].Kind != PreparedAttachmentUpload {
t.Fatalf("expected upload kind, got %s", ev.Final.Message.Attachments[0].Kind)
}
}
func TestPrepareStreamEvent_AttachmentEventHappyPath(t *testing.T) {
t.Parallel()
store := channeltest.NewMemoryAttachmentStore()
asset, err := store.SeedAsset("bot-1", []byte("audio"), "audio/mpeg", ".mp3")
if err != nil {
t.Fatalf("seed: %v", err)
}
ev, err := PrepareStreamEvent(context.Background(), store, ChannelConfig{
BotID: "bot-1",
ChannelType: ChannelTypeDiscord,
}, StreamEvent{
Type: StreamEventAttachment,
Attachments: []Attachment{{
Type: AttachmentAudio,
ContentHash: asset.ContentHash,
}},
})
if err != nil {
t.Fatalf("PrepareStreamEvent failed: %v", err)
}
if len(ev.Attachments) != 1 {
t.Fatalf("expected 1 prepared attachment, got %d", len(ev.Attachments))
}
}
// --- ContainerAttachmentIngester optional interface ---
// noContainerIngesterStore satisfies OutboundAttachmentStore but deliberately
// does NOT implement ContainerAttachmentIngester. GetByStorageKey always
// returns not-found so prepareContainerAttachment reaches the type assertion.
type noContainerIngesterStore struct{}
func (noContainerIngesterStore) Stat(_ context.Context, _, _ string) (media.Asset, error) {
return media.Asset{}, media.ErrAssetNotFound
}
func (noContainerIngesterStore) Open(_ context.Context, _, _ string) (io.ReadCloser, media.Asset, error) {
return nil, media.Asset{}, media.ErrAssetNotFound
}
func (noContainerIngesterStore) Ingest(_ context.Context, _ media.IngestInput) (media.Asset, error) {
return media.Asset{}, nil
}
func (noContainerIngesterStore) GetByStorageKey(_ context.Context, _, _ string) (media.Asset, error) {
return media.Asset{}, media.ErrAssetNotFound
}
func (noContainerIngesterStore) AccessPath(_ media.Asset) string { return "" }
func TestPrepareContainerAttachment_StoreWithoutIngesterFails(t *testing.T) {
t.Parallel()
var store OutboundAttachmentStore = noContainerIngesterStore{}
_, err := PrepareOutboundMessage(context.Background(), store, ChannelConfig{
BotID: "bot-1",
ChannelType: ChannelTypeDiscord,
}, OutboundMessage{
Target: "chan-1",
Message: Message{
Attachments: []Attachment{{
Type: AttachmentImage,
// Path that won't match any storage key so it falls through to IngestContainerFile.
URL: "/data/nonexistent/unique/file.png",
}},
},
})
if err == nil {
t.Fatal("expected error when store does not implement ContainerAttachmentIngester")
}
if !strings.Contains(err.Error(), "does not support container file ingestion") {
t.Fatalf("unexpected error: %v", err)
}
}