internal/app/transcription/service_test.go
Rama main · réplica de lectura del repositorio original.
package transcription
import (
"context"
"io"
"net/http"
"net/http/httptest"
"sync"
"sync/atomic"
"testing"
"time"
"go.uber.org/zap/zaptest"
"telesrv/internal/domain"
)
type fakeTranscriptionStore struct {
mu sync.Mutex
rows map[int64]domain.MessageTranscription
}
func newFakeTranscriptionStore() *fakeTranscriptionStore {
return &fakeTranscriptionStore{rows: map[int64]domain.MessageTranscription{}}
}
func (s *fakeTranscriptionStore) GetTranscription(_ context.Context, documentID int64) (domain.MessageTranscription, bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
row, ok := s.rows[documentID]
return row, ok, nil
}
func (s *fakeTranscriptionStore) CreateTranscriptionPending(_ context.Context, req domain.PendingTranscriptionRequest) (domain.MessageTranscription, bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.rows[req.DocumentID]; ok {
return domain.MessageTranscription{}, false, nil
}
row := domain.MessageTranscription{
DocumentID: req.DocumentID,
TranscriptionID: req.TranscriptionID,
Status: domain.TranscriptionStatusPending,
RequestedBy: req.RequestedBy,
Peer: req.Peer,
MsgID: req.MsgID,
}
s.rows[req.DocumentID] = row
return row, true, nil
}
func (s *fakeTranscriptionStore) ResetTranscriptionPending(_ context.Context, req domain.PendingTranscriptionRequest) (domain.MessageTranscription, bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
row, ok := s.rows[req.DocumentID]
if !ok || row.Status != domain.TranscriptionStatusFailed {
return domain.MessageTranscription{}, false, nil
}
row.Status = domain.TranscriptionStatusPending
row.TranscriptionID = req.TranscriptionID
row.RequestedBy = req.RequestedBy
row.Peer = req.Peer
row.MsgID = req.MsgID
row.Failure = ""
s.rows[req.DocumentID] = row
return row, true, nil
}
func (s *fakeTranscriptionStore) MarkTranscriptionDone(_ context.Context, documentID int64, text string) error {
s.mu.Lock()
defer s.mu.Unlock()
row := s.rows[documentID]
row.Status = domain.TranscriptionStatusDone
row.Text = text
s.rows[documentID] = row
return nil
}
func (s *fakeTranscriptionStore) MarkTranscriptionFailed(_ context.Context, documentID int64, failure string) error {
s.mu.Lock()
defer s.mu.Unlock()
row := s.rows[documentID]
if row.Status == domain.TranscriptionStatusDone {
return nil
}
row.Status = domain.TranscriptionStatusFailed
row.Failure = failure
s.rows[documentID] = row
return nil
}
func (s *fakeTranscriptionStore) row(t *testing.T, documentID int64) domain.MessageTranscription {
t.Helper()
row, ok, err := s.GetTranscription(context.Background(), documentID)
if err != nil || !ok {
t.Fatalf("store row: ok=%v err=%v", ok, err)
}
return row
}
type fakeFileSource struct {
data []byte
total int64
}
func (f *fakeFileSource) GetFile(_ context.Context, req domain.FileDownloadRequest) (domain.FileChunk, bool, error) {
return domain.FileChunk{Bytes: f.data, Total: f.total}, true, nil
}
func newASRServer(t *testing.T, status int, body string, hits *atomic.Int32) *httptest.Server {
t.Helper()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hits.Add(1)
if r.URL.Path != "/inference" {
http.NotFound(w, r)
return
}
if err := r.ParseMultipartForm(1 << 20); err != nil {
http.Error(w, "bad multipart", http.StatusBadRequest)
return
}
if _, _, err := r.FormFile("file"); err != nil {
http.Error(w, "missing file", http.StatusBadRequest)
return
}
w.WriteHeader(status)
io.WriteString(w, body)
}))
t.Cleanup(server.Close)
return server
}
func waitForDone(t *testing.T, store *fakeTranscriptionStore, documentID int64) domain.MessageTranscription {
t.Helper()
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
row, ok, err := store.GetTranscription(context.Background(), documentID)
if err == nil && ok && row.Status == domain.TranscriptionStatusDone {
return row
}
time.Sleep(10 * time.Millisecond)
}
t.Fatalf("transcription %d did not finish", documentID)
return domain.MessageTranscription{}
}
func TestTranscribeStartsJobAndPushesResult(t *testing.T) {
store := newFakeTranscriptionStore()
var hits atomic.Int32
server := newASRServer(t, http.StatusOK, `{"text":"hello from asr"}`, &hits)
var pushed struct {
mu sync.Mutex
user int64
msg int
text string
}
svc := NewService(store, &fakeFileSource{data: []byte("ogg"), total: 3}, server.URL, 5*time.Second, zaptest.NewLogger(t))
svc.SetCompletionHandler(func(_ context.Context, userID int64, _ domain.Peer, msgID int, _ int64, text string) {
pushed.mu.Lock()
defer pushed.mu.Unlock()
pushed.user, pushed.msg, pushed.text = userID, msgID, text
})
document := domain.Document{ID: 555}
peer := domain.Peer{Type: domain.PeerTypeUser, ID: 1002}
first, err := svc.Transcribe(context.Background(), 1001, peer, 7, document)
if err != nil {
t.Fatalf("transcribe: %v", err)
}
if !first.Pending || first.TranscriptionID == 0 {
t.Fatalf("first = %+v, want pending with id", first)
}
row := waitForDone(t, store, 555)
if row.Text != "hello from asr" {
t.Fatalf("stored text = %q", row.Text)
}
pushed.mu.Lock()
user, msg, text := pushed.user, pushed.msg, pushed.text
pushed.mu.Unlock()
if user != 1001 || msg != 7 || text != "hello from asr" {
t.Fatalf("push = user %d msg %d text %q", user, msg, text)
}
again, err := svc.Transcribe(context.Background(), 1001, peer, 7, document)
if err != nil {
t.Fatalf("second transcribe: %v", err)
}
if again.Pending || again.Text != "hello from asr" || again.TranscriptionID != row.TranscriptionID {
t.Fatalf("second = %+v, want cached done", again)
}
if hits.Load() != 1 {
t.Fatalf("asr hits = %d, want 1", hits.Load())
}
}
func TestTranscribeWithoutBackendIsUnavailable(t *testing.T) {
svc := NewService(newFakeTranscriptionStore(), &fakeFileSource{}, "", time.Second, zaptest.NewLogger(t))
if _, err := svc.Transcribe(context.Background(), 1001, domain.Peer{Type: domain.PeerTypeUser, ID: 1002}, 7, domain.Document{ID: 1}); err != ErrUnavailable {
t.Fatalf("err = %v, want ErrUnavailable", err)
}
}
func TestTranscribeServesCachedDoneWithoutASR(t *testing.T) {
store := newFakeTranscriptionStore()
store.rows[9] = domain.MessageTranscription{
DocumentID: 9, TranscriptionID: 42, Status: domain.TranscriptionStatusDone, Text: "cached",
}
var hits atomic.Int32
server := newASRServer(t, http.StatusOK, `{"text":"should not be called"}`, &hits)
svc := NewService(store, &fakeFileSource{data: []byte("x"), total: 1}, server.URL, 5*time.Second, zaptest.NewLogger(t))
got, err := svc.Transcribe(context.Background(), 1001, domain.Peer{Type: domain.PeerTypeUser, ID: 1002}, 7, domain.Document{ID: 9})
if err != nil {
t.Fatalf("transcribe: %v", err)
}
if got.Pending || got.Text != "cached" || got.TranscriptionID != 42 {
t.Fatalf("got = %+v, want cached done", got)
}
if hits.Load() != 0 {
t.Fatalf("asr hits = %d, want 0", hits.Load())
}
}
func TestTranscribeRetriesFailedRow(t *testing.T) {
store := newFakeTranscriptionStore()
store.rows[11] = domain.MessageTranscription{
DocumentID: 11, TranscriptionID: 7, Status: domain.TranscriptionStatusFailed, Failure: "asr down",
}
var hits atomic.Int32
server := newASRServer(t, http.StatusOK, `{"text":"recovered"}`, &hits)
svc := NewService(store, &fakeFileSource{data: []byte("ogg"), total: 3}, server.URL, 5*time.Second, zaptest.NewLogger(t))
got, err := svc.Transcribe(context.Background(), 1001, domain.Peer{Type: domain.PeerTypeUser, ID: 1002}, 7, domain.Document{ID: 11})
if err != nil {
t.Fatalf("transcribe: %v", err)
}
if !got.Pending {
t.Fatalf("got = %+v, want pending retry", got)
}
row := waitForDone(t, store, 11)
if row.Text != "recovered" || row.TranscriptionID == 7 {
t.Fatalf("row = %+v, want recovered text with new id", row)
}
}
func TestTranscribeMarksFailedWhenASRRejects(t *testing.T) {
store := newFakeTranscriptionStore()
var hits atomic.Int32
server := newASRServer(t, http.StatusInternalServerError, "boom", &hits)
svc := NewService(store, &fakeFileSource{data: []byte("ogg"), total: 3}, server.URL, 5*time.Second, zaptest.NewLogger(t))
if _, err := svc.Transcribe(context.Background(), 1001, domain.Peer{Type: domain.PeerTypeUser, ID: 1002}, 7, domain.Document{ID: 13}); err != nil {
t.Fatalf("transcribe: %v", err)
}
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
row, ok, err := store.GetTranscription(context.Background(), 13)
if err == nil && ok && row.Status == domain.TranscriptionStatusFailed {
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatalf("row never marked failed")
}
func TestTranscribeRejectsOversizedDocument(t *testing.T) {
store := newFakeTranscriptionStore()
svc := NewService(store, &fakeFileSource{data: nil, total: maxDocumentBytes + 1}, "http://127.0.0.1:1", 5*time.Second, zaptest.NewLogger(t))
if _, err := svc.Transcribe(context.Background(), 1001, domain.Peer{Type: domain.PeerTypeUser, ID: 1002}, 7, domain.Document{ID: 15}); err != nil {
t.Fatalf("transcribe: %v", err)
}
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
row, ok, err := store.GetTranscription(context.Background(), 15)
if err == nil && ok && row.Status == domain.TranscriptionStatusFailed {
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatalf("oversized row never marked failed")
}
func TestTranscribeBoundsJobsAndFansOutSharedRetry(t *testing.T) {
st := newFakeTranscriptionStore()
st.rows[1] = domain.MessageTranscription{DocumentID: 1, TranscriptionID: 42, Status: domain.TranscriptionStatusFailed}
release := make(chan struct{})
var once sync.Once
unblock := func() { once.Do(func() { close(release) }) }
var hits atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hits.Add(1)
select {
case <-release:
case <-r.Context().Done():
return
}
io.WriteString(w, `{"text":"shared result"}`)
}))
defer server.Close()
defer unblock()
svc := NewService(st, &fakeFileSource{data: []byte("ogg"), total: 3}, server.URL, 5*time.Second, zaptest.NewLogger(t))
completed := make(chan int64, maxTranscriptionSubscribers+maxConcurrentTranscriptions)
svc.SetCompletionHandler(func(_ context.Context, userID int64, _ domain.Peer, _ int, _ int64, _ string) { completed <- userID })
peer := domain.Peer{Type: domain.PeerTypeUser, ID: 999}
var retryID int64
for i := 1; i <= maxTranscriptionSubscribers; i++ {
got, err := svc.Transcribe(context.Background(), int64(i), peer, 1, domain.Document{ID: 1})
if err != nil {
t.Fatal(err)
}
if i == 1 {
retryID = got.TranscriptionID
}
if !got.Pending || got.TranscriptionID != retryID || retryID == 42 {
t.Fatalf("shared retry = %+v", got)
}
}
if _, err := svc.Transcribe(context.Background(), 1000, peer, 1, domain.Document{ID: 1}); err != ErrUnavailable {
t.Fatalf("subscriber limit: %v", err)
}
for i := 2; i <= maxConcurrentTranscriptions; i++ {
if _, err := svc.Transcribe(context.Background(), int64(1000+i), peer, 1, domain.Document{ID: int64(i)}); err != nil {
t.Fatal(err)
}
}
if _, err := svc.Transcribe(context.Background(), 2000, peer, 1, domain.Document{ID: 99}); err != ErrUnavailable {
t.Fatalf("job limit: %v", err)
}
unblock()
seen := map[int64]bool{}
for i := 0; i < maxTranscriptionSubscribers+maxConcurrentTranscriptions-1; i++ {
select {
case id := <-completed:
if seen[id] {
t.Fatalf("duplicate completion %d", id)
}
seen[id] = true
case <-time.After(5 * time.Second):
t.Fatal("missing shared completion")
}
}
if hits.Load() != maxConcurrentTranscriptions {
t.Fatalf("ASR calls=%d", hits.Load())
}
if got, err := svc.Transcribe(context.Background(), 1, peer, 1, domain.Document{ID: 1}); err != nil || got.Pending || got.TranscriptionID != retryID {
t.Fatalf("cache=%+v %v", got, err)
}
}