Files
Memoh/internal/channel/observer_test.go
T
BBQ bc374fe8cd refactor: content-addressed assets, cross-channel multimodal, infra simplification (#63)
* refactor(attachment): multimodal attachment refactor with snapshot schema and storage layer

- Add snapshot schema migration (0008) and update init/versions/snapshots
- Add internal/attachment and internal/channel normalize for unified attachment handling
- Move containerfs provider from internal/media to internal/storage
- Update agent types, channel adapters (Telegram/Feishu), inbound and handlers
- Add containerd snapshot lineage and local_channel tests
- Regenerate sqlc, swagger and SDK

* refactor(media): content-addressed asset system with unified naming

- Replace asset_id foreign key with content_hash as sole identifier
  for bot_history_message_assets (pure soft-link model)
- Remove mime, size_bytes, storage_key from DB; derive at read time
  via media.Resolve from actual storage
- Merge migrations 0008/0009 into single 0008; keep 0001 as canonical schema
- Add Docker initdb script for deterministic migration execution order
- Fix cross-channel real-time image display (Telegram → WebUI SSE)
- Fix message disappearing on refresh (null assets fallback)
- Fix file icon instead of image preview (mime derivation from storage)
- Unify AssetID → ContentHash naming across Go, Agent, and Frontend
- Change storage key prefix from 4-char to 2-char for directory sharding
- Add server-entrypoint.sh for Docker deployment migration handling

* refactor(infra): embedded migrations, Docker simplification, and config consolidation

- Embed SQL migrations into Go binary, removing shell-based migration scripts
- Consolidate config files into conf/ directory (app.example.toml, app.docker.toml, app.dev.toml)
- Simplify Docker setup: remove initdb.d scripts, streamline nginx config and entrypoint
- Remove legacy CLI, feishu-echo commands, and obsolete incremental migration files
- Update install script and docs to require sudo for one-click install
- Add mise tasks for dev environment orchestration

* chore: recover migrations

---------

Co-authored-by: Acbox <acbox0328@gmail.com>
2026-02-19 00:20:27 +08:00

151 lines
3.7 KiB
Go

package channel
import (
"context"
"sync"
"testing"
)
type recordingObserver struct {
mu sync.Mutex
events []observedEvent
}
type observedEvent struct {
BotID string
Source ChannelType
Event StreamEvent
}
func (r *recordingObserver) OnStreamEvent(_ context.Context, botID string, source ChannelType, event StreamEvent) {
r.mu.Lock()
defer r.mu.Unlock()
r.events = append(r.events, observedEvent{BotID: botID, Source: source, Event: event})
}
func (r *recordingObserver) recorded() []observedEvent {
r.mu.Lock()
defer r.mu.Unlock()
cp := make([]observedEvent, len(r.events))
copy(cp, r.events)
return cp
}
// stubStream records pushed events.
type stubStream struct {
mu sync.Mutex
events []StreamEvent
closed bool
}
func (s *stubStream) Push(_ context.Context, event StreamEvent) error {
s.mu.Lock()
defer s.mu.Unlock()
s.events = append(s.events, event)
return nil
}
func (s *stubStream) Close(_ context.Context) error {
s.mu.Lock()
defer s.mu.Unlock()
s.closed = true
return nil
}
func TestNewTeeStream_NilObserver(t *testing.T) {
primary := &stubStream{}
result := NewTeeStream(primary, nil, "bot1", "telegram")
if result != primary {
t.Fatal("expected primary stream returned when observer is nil")
}
}
func TestTeeStream_Push(t *testing.T) {
primary := &stubStream{}
obs := &recordingObserver{}
stream := NewTeeStream(primary, obs, "bot1", "telegram")
event := StreamEvent{Type: StreamEventDelta, Delta: "hello"}
if err := stream.Push(context.Background(), event); err != nil {
t.Fatalf("unexpected error: %v", err)
}
primary.mu.Lock()
if len(primary.events) != 1 {
t.Fatalf("expected 1 primary event, got %d", len(primary.events))
}
if primary.events[0].Delta != "hello" {
t.Fatalf("unexpected primary delta: %s", primary.events[0].Delta)
}
primary.mu.Unlock()
recorded := obs.recorded()
if len(recorded) != 1 {
t.Fatalf("expected 1 observed event, got %d", len(recorded))
}
if recorded[0].BotID != "bot1" {
t.Fatalf("unexpected botID: %s", recorded[0].BotID)
}
if recorded[0].Source != "telegram" {
t.Fatalf("unexpected source: %s", recorded[0].Source)
}
if recorded[0].Event.Delta != "hello" {
t.Fatalf("unexpected observed delta: %s", recorded[0].Event.Delta)
}
}
func TestTeeStream_Close(t *testing.T) {
primary := &stubStream{}
obs := &recordingObserver{}
stream := NewTeeStream(primary, obs, "bot1", "telegram")
if err := stream.Close(context.Background()); err != nil {
t.Fatalf("unexpected error: %v", err)
}
primary.mu.Lock()
if !primary.closed {
t.Fatal("expected primary stream to be closed")
}
primary.mu.Unlock()
if len(obs.recorded()) != 0 {
t.Fatal("close should not produce observer events")
}
}
func TestTeeStream_MultipleEvents(t *testing.T) {
primary := &stubStream{}
obs := &recordingObserver{}
stream := NewTeeStream(primary, obs, "bot1", "feishu")
events := []StreamEvent{
{Type: StreamEventStatus, Status: StreamStatusStarted},
{Type: StreamEventDelta, Delta: "chunk1"},
{Type: StreamEventDelta, Delta: "chunk2"},
{Type: StreamEventFinal, Final: &StreamFinalizePayload{Message: Message{Text: "done"}}},
{Type: StreamEventStatus, Status: StreamStatusCompleted},
}
for _, event := range events {
if err := stream.Push(context.Background(), event); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
primary.mu.Lock()
if len(primary.events) != len(events) {
t.Fatalf("expected %d primary events, got %d", len(events), len(primary.events))
}
primary.mu.Unlock()
recorded := obs.recorded()
if len(recorded) != len(events) {
t.Fatalf("expected %d observed events, got %d", len(events), len(recorded))
}
for i, r := range recorded {
if r.Source != "feishu" {
t.Fatalf("event %d: unexpected source %s", i, r.Source)
}
}
}