package http import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "io/fs" "log/slog" "mime/multipart" "net/http" "net/http/httptest" "net/netip" "regexp" "strings" "sync" "testing" "testing/fstest" "time" "github.com/prometheus/client_golang/prometheus" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "git.vakhrushev.me/av/transcriber/internal/adapter/recognizer" sqliterepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/sqlite" "git.vakhrushev.me/av/transcriber/internal/clock" "git.vakhrushev.me/av/transcriber/internal/contract" "git.vakhrushev.me/av/transcriber/internal/entity" "git.vakhrushev.me/av/transcriber/internal/ident" "git.vakhrushev.me/av/transcriber/internal/service" ) // Подставные адаптеры вместо ffprobe и ffmpeg. Проверки судят приём — что // запись сохранена, задача заведена и ответ такой, какой обещан, — а не // способность внешней программы разобрать звук. Внешних программ здесь нет // ни одной, и видно это по списку импортов. // stubMetaViewer отдаёт заданную длительность либо заданную ошибку. type stubMetaViewer struct { seconds int err error } func (m *stubMetaViewer) GetInfo(ctx context.Context, _ string) (*contract.AudioInfo, error) { // Настоящий `ffprobe` заведён с контекстом и по отмене умирает; стаб, // который контекст игнорирует, сделал бы проверку приёма неспособной упасть. if err := ctx.Err(); err != nil { return nil, err } if m.err != nil { return nil, m.err } return &contract.AudioInfo{Seconds: m.seconds}, nil } // stubConverter молчалив: приём конвертацию не делает, и ни одна проверка // этого файла её не зовёт. type stubConverter struct{} func (c *stubConverter) Convert(context.Context, string, string) error { return nil } // readableMetaViewer — источник метаданных, который читает любую запись. func readableMetaViewer() *stubMetaViewer { return &stubMetaViewer{seconds: 42} } const testMarkup = `приложение` // builtDist — собранное приложение, каким его видит раздача. func builtDist() fs.FS { return fstest.MapFS{ "index.html": {Data: []byte(testMarkup)}, "assets/index-abc123.js": {Data: []byte("console.log(1)")}, "favicon.ico": {Data: []byte("значок")}, } } // testEnv — собранное окружение одной проверки. Каталог данных свой у каждой: // рабочий каталог процесса проверки не трогают. type testEnv struct { mux http.Handler handler *AppHandler db *sqliterepo.DB store *sqliterepo.Store users *sqliterepo.UserRepository files *sqliterepo.FileRepository journal *journalBuffer // login — логин у провайдера, которым доверенный источник называет // пришедшего. Адреса приложения закрыты узнаванием, и проверка, судящая их // по существу, обязана назваться ровно так же, как это делает прокси. login string // account — учётная запись, которой принадлежит этот логин. account *contract.UserAccount } // Доверенный источник проверок. // // Подсеть и адрес пира разведены намеренно: проверка недоверенного источника // берёт адрес **вне** подсети, и обе стороны условия видны рядом. const ( trustedPeer = "10.1.2.3:34567" untrustedPeer = "203.0.113.9:34567" ) func testTrustedNetworks(t *testing.T) []netip.Prefix { t.Helper() // Подсетей две: своя для запросов через `httptest.NewRecorder` и петлевая // для тех проверок, которым нужен **настоящий** HTTP-запрос через поднятую // цепочку слоёв — у них адрес пира ставит ядро, и он петлевой. networks := make([]netip.Prefix, 0, 2) for _, raw := range []string{"10.0.0.0/8", "127.0.0.0/8"} { prefix, err := netip.ParsePrefix(raw) require.NoError(t, err) networks = append(networks, prefix) } return networks } // liveServer поднимает настоящий HTTP-сервер на этой поверхности. // // Нужен там, где проверка судит поведение, которого у вызова обработчика // напрямую не бывает: разбор заголовка диапазона идёт по настоящему запросу, а // адрес пира ставит ядро. func (e *testEnv) liveServer(t *testing.T) *httptest.Server { t.Helper() server := httptest.NewServer(e.mux) t.Cleanup(server.Close) return server } // liveResponse — готовый ответ настоящего запроса: тело прочитано и закрыто, и // проверке остаётся судить значения. type liveResponse struct { StatusCode int Header http.Header ContentLength int64 Body []byte } // liveRequest шлёт настоящий запрос от имени учётной записи проверки. func (e *testEnv) liveRequest( t *testing.T, server *httptest.Server, path string, headers map[string]string, ) liveResponse { t.Helper() req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, server.URL+path, nil) require.NoError(t, err) req.Header.Set(LoginHeader, e.login) for name, value := range headers { req.Header.Set(name, value) } res, err := server.Client().Do(req) require.NoError(t, err) defer func() { require.NoError(t, res.Body.Close()) }() body, err := io.ReadAll(res.Body) require.NoError(t, err) return liveResponse{ StatusCode: res.StatusCode, Header: res.Header, ContentLength: res.ContentLength, Body: body, } } // asUser делает запрос запросом названного человека: ставит заголовок и адрес // пира из доверенной подсети — ровно то, что делает обратный прокси. func asUser(req *http.Request, login string) *http.Request { req.Header.Set(LoginHeader, login) req.RemoteAddr = trustedPeer return req } // serve шлёт запрос от имени узнанного. Проверки, судящие отказ неузнанному, // зовут `mux` напрямую: запрос без заголовка и есть запрос неузнанного. func (e *testEnv) serve(w http.ResponseWriter, req *http.Request) { e.mux.ServeHTTP(w, asUser(req, e.login)) } // get шлёт запрос **неузнанным**: заголовка на нём нет вовсе. func (e *testEnv) get(path string) *httptest.ResponseRecorder { w := httptest.NewRecorder() e.mux.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil)) return w } // getAs шлёт запрос от имени названного логина. func (e *testEnv) getAs(login, path string) *httptest.ResponseRecorder { w := httptest.NewRecorder() e.mux.ServeHTTP(w, asUser(httptest.NewRequest(http.MethodGet, path, nil), login)) return w } // getOwn шлёт запрос от имени учётной записи проверки. func (e *testEnv) getOwn(path string) *httptest.ResponseRecorder { return e.getAs(e.login, path) } // journalBuffer — перехваченный журнал одной проверки. Свой на случай: общий на // пакет сделал бы исход функцией от соседних случаев — «поля на месте» прошло бы // на чужой строке, а «маркера нет» покраснело бы от чужой. Замок нужен потому, // что пишущих в него потоков два: логгер сервиса и логгер обработчика. type journalBuffer struct { mu sync.Mutex text strings.Builder } func (b *journalBuffer) Write(p []byte) (int, error) { b.mu.Lock() defer b.mu.Unlock() return b.text.Write(p) } // String отдаёт весь перехваченный текст. Проверки ищут в нём значение, а не имя // поля: имя, вернувшееся под другим ключом, поиск по ключу не разбудил бы. func (b *journalBuffer) String() string { b.mu.Lock() defer b.mu.Unlock() return b.text.String() } // newTestStorage поднимает базу на пустом каталоге и накатывает схему — ровно // тем же путём, каким это делает сервис при старте. func newTestStorage(t *testing.T) (*sqliterepo.DB, *sqliterepo.Store, string) { t.Helper() dir := t.TempDir() db, err := sqliterepo.Open(dir, sqliterepo.Settings{BusyTimeoutMs: 5000, ReadConnections: 4}) require.NoError(t, err) t.Cleanup(func() { if err := db.Close(); err != nil { t.Logf("не удалось закрыть базу: %v", err) } }) require.NoError(t, sqliterepo.Migrate( context.Background(), db, dir, slog.New(slog.DiscardHandler), )) return db, sqliterepo.NewStore(dir), dir } // envOptions — чем одна проверка отличается от другой. type envOptions struct { metaviewer contract.AudioMetaViewer dist fs.FS built bool // substitution — имитация заголовков входа отладочного запуска. Пустая // значит «предохранитель выключен»: слой подстановки в цепочку не встаёт, // и поверхность собирается ровно та же, что в бою. substitution map[string]string } func setupTestEnv(t *testing.T, metaviewer contract.AudioMetaViewer) *testEnv { return setupEnv(t, envOptions{metaviewer: metaviewer, dist: builtDist(), built: true}) } // setupEnv собирает поверхность сервиса тем же способом, что и точка входа: // адреса порождает перечень адресного пространства, слои одевает та же цепочка. // Собери проверка поверхность иначе — и судила бы она не то. func setupEnv(t *testing.T, opts envOptions) *testEnv { t.Helper() db, store, _ := newTestStorage(t) recordRepo := sqliterepo.NewAudioRecordRepository(db) textRepo := sqliterepo.NewTextRepository(db) structureRepo := sqliterepo.NewStructureRepository(db) fileRepo := sqliterepo.NewFileRepository(db, store) users := sqliterepo.NewUserRepository(db) repos := service.Repositories{ Records: recordRepo, Files: fileRepo, Texts: textRepo, Structures: structureRepo, Recognitions: sqliterepo.NewRecognitionRepository(db, store), Events: sqliterepo.NewRecordEventRepository(db), } // Журнал уходит в буфер, а не в никуда: по нему судит проверка запрета на // имя отправителя. Вывод прогона от этого не меняется — ERROR-строки ветки // отказа по-прежнему не попадают на экран. journal := &journalBuffer{} logger := slog.New(slog.NewTextHandler(journal, nil)) metaviewer := opts.metaviewer if metaviewer == nil { metaviewer = readableMetaViewer() } trsService := service.NewTranscribeService( repos, metaviewer, &stubConverter{}, &recognizer.MemoryAudioRecognizer{}, entity.StuckLimits{Own: time.Hour, Foreign: 24 * time.Hour}, logger, ) handler := NewAppHandler(recordRepo, textRepo, structureRepo, fileRepo, trsService, logger) mounts := ServiceMounts( AppChain(handler.Routes(), users, testTrustedNetworks(t), opts.substitution, logger), http.NotFoundHandler(), ) webapp := NewWebappHandler(opts.dist, opts.built, logger) mux := BuildHandler(mounts, webapp, logger) account, _, err := users.EnsureUser(contract.Identity{ Login: "person", Name: "Человек", Email: "person@example.com", }) require.NoError(t, err) return &testEnv{ mux: mux, handler: handler, db: db, store: store, users: users, files: fileRepo, journal: journal, login: "person", account: account, } } // createMultipartRequest собирает запрос из имени и содержимого. Файла на диске // для этого не нужно: имя проверяет выбор расширения, содержимое — сохранение. func createMultipartRequest(t *testing.T, fileName string, content []byte) *http.Request { return createMultipartRequestWithField(t, "audio", fileName, content) } // createMultipartRequestWithField кладёт запись в поле с заданным именем — // нужно, чтобы построить форму без поля `audio`. func createMultipartRequestWithField(t *testing.T, field, fileName string, content []byte) *http.Request { return createMultipartRequestAtWithField(t, "/app/audiorecords", field, fileName, content) } // createMultipartRequestAt собирает тот же запрос по названному адресу — нужно // проверке, судящей убранные адреса. func createMultipartRequestAt(t *testing.T, path, fileName string, content []byte) *http.Request { return createMultipartRequestAtWithField(t, path, "audio", fileName, content) } func createMultipartRequestAtWithField(t *testing.T, path, field, fileName string, content []byte) *http.Request { var buf bytes.Buffer writer := multipart.NewWriter(&buf) part, err := writer.CreateFormFile(field, fileName) require.NoError(t, err) _, err = part.Write(content) require.NoError(t, err) err = writer.Close() require.NoError(t, err) req := httptest.NewRequest("POST", path, &buf) req.Header.Set("Content-Type", writer.FormDataContentType()) return req } // intakeItemOf разбирает ответ приёма и отдаёт единственный его элемент. Ответ // списком всегда, даже на один файл: форма согласована один раз и вперёд. func intakeItemOf(t *testing.T, w *httptest.ResponseRecorder) IntakeItem { t.Helper() var items []IntakeItem require.NoError(t, json.Unmarshal(w.Body.Bytes(), &items)) require.Len(t, items, 1) return items[0] } // storedFileNames отдаёт имена, под которыми копии легли в каталог данных. func storedFileNames(t *testing.T, env *testEnv) []string { t.Helper() rows, err := env.db.Reader().QueryContext(context.Background(), "SELECT file_name FROM files") require.NoError(t, err) defer func() { require.NoError(t, rows.Close()) }() var names []string for rows.Next() { var name string require.NoError(t, rows.Scan(&name)) names = append(names, name) } require.NoError(t, rows.Err()) return names } // countRows считает строки названной таблицы. func countRows(t *testing.T, env *testEnv, table string) int { t.Helper() var total int require.NoError(t, env.db.Reader().QueryRowContext(context.Background(), "SELECT COUNT(*) FROM "+table).Scan(&total)) return total } // countFiles считает строки о копиях записей. func countFiles(t *testing.T, env *testEnv) int { return countRows(t, env, "files") } // countJobs считает заведённые аудиозаписи. func countJobs(t *testing.T, env *testEnv) int { return countRows(t, env, "audio_records") } // jobWithFile заводит запись вместе с её принятой копией: без ссылки на файл // запись не прошла бы ни одного шага. func jobWithFile(t *testing.T, env *testEnv) *entity.AudioRecord { t.Helper() return jobWithFileOf(t, env, env.account.ID) } // jobWithFileOf — то же, но от имени названного владельца. func jobWithFileOf(t *testing.T, env *testEnv, ownerID string) *entity.AudioRecord { t.Helper() recordID := ident.New() work, err := env.files.Stage(".mp3", strings.NewReader("запись")) require.NoError(t, err) defer func() { require.NoError(t, work.Close()) }() // Владелец — учётная запись проверки: ничьей записи в хранилище не бывает, и // фикстура без владельца описывала бы состояние, которого в проде нет. file, err := env.files.Create( recordID, "sample.mp3", work, contract.FileMeta{Format: "mp3"}, ownerID, ) require.NoError(t, err) record := &entity.AudioRecord{ Id: recordID, State: entity.StateUploaded, StateEnteredAt: clock.Now(), OwnerID: ownerID, OriginalFileID: &file.Id, } require.NoError(t, env.handler.recordRepo.Create(record)) return record } // newTopic заводит тему в словаре названного владельца. // // Запросом к базе, а не адресом сервиса: адреса, которым заводят тему, у сервиса // нет — словарь пишет задача языковой модели, которой ещё нет. Проверка обязана // пройти тот же путь разрешения тем, каким пойдёт она. func newTopic(t *testing.T, env *testEnv, ownerID, name string) string { t.Helper() id := ident.New() now := clock.Now().UTC().Format("2006-01-02T15:04:05Z") _, err := env.db.Writer().ExecContext(context.Background(), "INSERT INTO topics (id, owner_id, name, created_at, updated_at) VALUES (?, ?, ?, ?, ?)", id, ownerID, name, now, now, ) require.NoError(t, err) return id } // attachTopic связывает тему с записью. func attachTopic(t *testing.T, env *testEnv, recordID, topicID string) { t.Helper() _, err := env.db.Writer().ExecContext(context.Background(), "INSERT INTO record_topics (record_id, topic_id) VALUES (?, ?)", recordID, topicID, ) require.NoError(t, err) } // storedContent читает содержимое копии из каталога данных. func storedContent(t *testing.T, env *testEnv, fileID string) []byte { t.Helper() reader, err := env.files.Open(fileID) require.NoError(t, err) defer func() { require.NoError(t, reader.Close()) }() var buf bytes.Buffer _, err = buf.ReadFrom(reader) require.NoError(t, err) return buf.Bytes() } func TestCreateTranscribeJob_Success(t *testing.T) { env := setupTestEnv(t, readableMetaViewer()) content := []byte("содержимое записи, которое обязано доехать до диска целиком") req := createMultipartRequest(t, "sample.m4a", content) w := httptest.NewRecorder() env.serve(w, req) require.Equal(t, http.StatusCreated, w.Code) // Имена полей ответа нормативны: контракт объявлен необратимым, а экраны // строятся на этих именах. Судим по сырому JSON — разбор в структуру // переименовал бы тег вместе с ожиданием, и проверка не смогла бы упасть. // // Ответ — **список**, даже когда файл в запросе один: форма согласована // вперёд, чтобы приём нескольких файлов и распознавание повтора её не // переписывали. var rawItems []map[string]json.RawMessage err := json.Unmarshal(w.Body.Bytes(), &rawItems) require.NoError(t, err) require.Len(t, rawItems, 1) assert.Contains(t, rawItems[0], "id") assert.Contains(t, rawItems[0], "state") assert.Contains(t, rawItems[0], "duplicate", "место под признак повтора заведено вперёд") assert.NotContains(t, rawItems[0], "job_id", "прежние имена полей убраны вместе с опросом") response := intakeItemOf(t, w) assert.NotEmpty(t, response.ID) assert.Equal(t, entity.StateUploaded, response.State) // Задача действительно заведена, а не только названа в ответе: иначе // отправитель получит идентификатор записи, которой не будет никогда. require.Equal(t, 1, countJobs(t, env)) job, err := env.handler.recordRepo.GetByID(response.ID, env.account.ID) require.NoError(t, err) assert.Equal(t, entity.StateUploaded, job.State) require.NotNil(t, job.OriginalFileID) assert.NotEmpty(t, *job.OriginalFileID) // Содержимое лежит в хранилище одним файлом и целиком. require.Equal(t, 1, countFiles(t, env)) assert.Equal(t, content, storedContent(t, env, *job.OriginalFileID)) } func TestCreateTranscribeJob_NoFile(t *testing.T) { // Две ветки одного сценария: тела нет вовсе и форма есть, а поля в ней нет. // Вторая — та, что описана требованием; первая ходит тем же путём. testCases := []struct { name string req func(t *testing.T) *http.Request }{ { name: "no body at all", req: func(t *testing.T) *http.Request { // Запрос строится так, как его видит сервер: у пришедшего по // проводу тело не бывает пустым указателем. return httptest.NewRequest("POST", "/app/audiorecords", http.NoBody) }, }, { name: "form without audio field", req: func(t *testing.T) *http.Request { return createMultipartRequestWithField(t, "attachment", "sample.m4a", []byte("запись")) }, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { env := setupTestEnv(t, readableMetaViewer()) w := httptest.NewRecorder() env.serve(w, tc.req(t)) require.Equal(t, http.StatusBadRequest, w.Code) var response ErrorBody err := json.Unmarshal(w.Body.Bytes(), &response) require.NoError(t, err) assert.Equal(t, CodeBadRequest, response.Code) assert.NotEmpty(t, response.Message, "рядом с кодом стоит фраза для человека") assert.Equal(t, 0, countFiles(t, env)) assert.Equal(t, 0, countJobs(t, env)) }) } } func TestCreateTranscribeJob_EmptyFile(t *testing.T) { env := setupTestEnv(t, readableMetaViewer()) // Собственного порога по размеру у приёма нет: годность записи судит // источник метаданных, а не приём. req := createMultipartRequest(t, "empty.m4a", nil) w := httptest.NewRecorder() env.serve(w, req) require.Equal(t, http.StatusCreated, w.Code) response := intakeItemOf(t, w) assert.NotEmpty(t, response.ID) assert.Equal(t, entity.StateUploaded, response.State) } func TestCreateTranscribeJob_DifferentFileExtensions(t *testing.T) { testCases := []struct { name string fileName string expectExt string }{ { name: "m4a file", fileName: "test.m4a", expectExt: ".m4a", }, { name: "mp3 file", fileName: "test.mp3", expectExt: ".mp3", }, { name: "wav file", fileName: "test.wav", expectExt: ".wav", }, { name: "file without extension", fileName: "test", expectExt: ".audio", }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { env := setupTestEnv(t, readableMetaViewer()) req := createMultipartRequest(t, tc.fileName, []byte("запись")) w := httptest.NewRecorder() env.serve(w, req) require.Equal(t, http.StatusCreated, w.Code) names := storedFileNames(t, env) require.Len(t, names, 1) // Имя отправителя в хранилище не попадает: имя файла — свой // идентификатор, от отправителя взято только расширение. Суффикс // дописывает само хранилище, поэтому сверяем хвост, а не Ext. assert.True(t, strings.HasSuffix(names[0], tc.expectExt), "имя в хранилище %q оканчивается на %q", names[0], tc.expectExt) assert.NotContains(t, names[0], strings.TrimSuffix(tc.fileName, tc.expectExt)) }) } } // Имя, данное отправителем, в хранилище не попадает целиком — умолчание // библиотеки, строящее имя файла из него, не применяется. Проверка отдельная от // перебора расширений: там сверяется хвост, здесь — что основы имени нет. func TestCreateTranscribeJob_SenderFileNameNotStored(t *testing.T) { env := setupTestEnv(t, readableMetaViewer()) req := createMultipartRequest(t, "секретное-слово.mp3", []byte("запись")) w := httptest.NewRecorder() env.serve(w, req) require.Equal(t, http.StatusCreated, w.Code) names := storedFileNames(t, env) require.Len(t, names, 1) assert.NotContains(t, names[0], "секретное-слово", "имя, данное отправителем, в имя файла не попадает") assert.True(t, strings.HasSuffix(names[0], ".mp3"), "расширение при этом сохраняется: %q", names[0]) // И в **путь** к файлу оно не попадает тоже: подкаталог назван // идентификатором записи, а не именем отправителя. var recordID, storedRecord string require.NoError(t, env.db.Reader(). QueryRow("SELECT id FROM audio_records").Scan(&recordID)) require.NoError(t, env.db.Reader(). QueryRow("SELECT record_id FROM files").Scan(&storedRecord)) assert.Equal(t, recordID, storedRecord, "подкаталог копии назван не записью") assert.NotContains(t, storedRecord, "секретное-слово", "имя, данное отправителем, попало в путь к файлу") } func TestCreateTranscribeJob_MetaViewerFailure(t *testing.T) { env := setupTestEnv(t, &stubMetaViewer{err: errors.New("не удалось прочитать запись")}) req := createMultipartRequest(t, "broken.m4a", []byte("не запись вовсе")) w := httptest.NewRecorder() env.serve(w, req) // Негодная запись — отказ по причине, а не по месту: прежде здесь стоял // `500`, и «файл не читается» приходило человеку как «сломался сервер». require.Equal(t, http.StatusBadRequest, w.Code) var response ErrorBody err := json.Unmarshal(w.Body.Bytes(), &response) require.NoError(t, err) // Причина отказа принадлежит журналу, а не отправителю. assert.Equal(t, CodeBadRequest, response.Code) assert.NotEmpty(t, response.Message) assert.NotContains(t, w.Body.String(), "не удалось прочитать запись") assert.NotContains(t, w.Body.String(), "transcibe", "опечатка ушла вместе с прежним текстом") assert.Equal(t, 0, countJobs(t, env)) } // senderNameMarker — метка внутри имени, которое даёт отправитель. ASCII и // заведомо уникальна: в остальном выводе прогона такой строки нет, поэтому // находка означает утечку, а не совпадение. Ищется она **значением**, а не // именем журнального поля: имя, вернувшееся под другим ключом, поиск по ключу // пропустил бы. const senderNameMarker = "SENDERNAMELEAKMARKER7Q2" // Тексты, по которым проверки находят журнальные строки. Первый — записанный // долг `docs/conventions/logging.md`: `msg` обязан стать короткой категорией. // Когда долг закроют, правка будет здесь и одна. const ( msgIntake = "Creating audio record" // Отказ пишет доменная граница — приём, — а не транспорт: конвенция просит // логировать ошибку один раз, и повторная запись транспорта снята. msgIntakeErr = "Failed to get file info" ) func TestCreateTranscribeJob_SenderFileNameNotLogged(t *testing.T) { env := setupTestEnv(t, readableMetaViewer()) // Метка стоит в основе имени, а расширение обычное: расширение запретом не // накрыто и в журнале остаётся законно. req := createMultipartRequest(t, senderNameMarker+".mp3", []byte("запись")) w := httptest.NewRecorder() env.serve(w, req) require.Equal(t, http.StatusCreated, w.Code) journal := env.journal.String() // Сперва — что журнал приёма вообще перехвачен. Без этого утверждения // пустой буфер сделал бы проверку запрета зелёной, ничего не прочитав. require.Contains(t, journal, msgIntake, "строка приёма попадает в перехваченный журнал") assert.NotContains(t, journal, senderNameMarker, "имя, данное отправителем, не пишется в журнал: инвариант приватности") } func TestCreateTranscribeJob_SenderFileNameNotLoggedOnFailure(t *testing.T) { // Отказ — тот путь, где имя приехало бы в журнал текстом ошибки: приём // назван конвенцией логирующей границей, и цепочка `%w` осядет полем error. env := setupTestEnv(t, &stubMetaViewer{err: errors.New("не удалось прочитать запись")}) req := createMultipartRequest(t, senderNameMarker+".mp3", []byte("не запись вовсе")) w := httptest.NewRecorder() env.serve(w, req) require.Equal(t, http.StatusBadRequest, w.Code) journal := env.journal.String() // Сперва — что журнал ветки отказа вообще перехвачен: обработчик пишет свою // строку, и без неё оракул молча сузился бы вдвое. require.Contains(t, journal, msgIntakeErr, "строка приёма об отказе попадает в перехваченный журнал") assert.NotContains(t, journal, senderNameMarker, "имя отправителя не пишется в журнал и на пути отказа") } // Имя, под которым файл лёг в хранилище, — это последняя часть ссылки // `/api/files/...`, по которой запись скачивают. Попав в журнал, строка стала бы // бессрочным ключом к чужому аудио, поэтому в журнал идёт имя, заданное // сервисом, а суффикс хранилища — нет. func TestCreateTranscribeJob_StorageFileNameNotLogged(t *testing.T) { env := setupTestEnv(t, readableMetaViewer()) req := createMultipartRequest(t, "sample.mp3", []byte("запись")) w := httptest.NewRecorder() env.serve(w, req) require.Equal(t, http.StatusCreated, w.Code) names := storedFileNames(t, env) require.Len(t, names, 1) journal := env.journal.String() require.Contains(t, journal, msgIntake, "журнал приёма перехвачен") assert.NotContains(t, journal, names[0], "имени файла в хранилище в журнале нет: по нему собирается ссылка на скачивание") } func TestCreateTranscribeJob_JournalTracesRecord(t *testing.T) { env := setupTestEnv(t, readableMetaViewer()) content := []byte("содержимое записи") req := createMultipartRequest(t, "sample.mp3", content) w := httptest.NewRecorder() env.serve(w, req) require.Equal(t, http.StatusCreated, w.Code) response := intakeItemOf(t, w) job, err := env.handler.recordRepo.GetByID(response.ID, env.account.ID) require.NoError(t, err) require.NotNil(t, job.OriginalFileID) // Отбор по идентификатору **этого** прогона: иначе утверждение прошло бы по // строке, оставленной соседней проверкой, и прослеживаемость числилась бы // сохранённой при пустом журнале. journal := env.journal.String() assert.Contains(t, journal, *job.OriginalFileID, "по журналу видно, какой файл заведён") assert.Contains(t, journal, ".mp3", "расширение принятой записи в журнале остаётся") // Разделитель ключа и значения задаёт обработчик: сегодня текстовый, по // конвенции — JSON. Утверждение держится на значении и переживёт замену. assert.Regexp(t, fmt.Sprintf(`size["=:\s]+%d`, len(content)), journal, "размер принятой записи в байтах в журнале остаётся") // Запись о приёме не сменила адресата: на DEBUG её в боевой настройке не // будет вовсе, и разбор постфактум опереться будет не на что. Уровень // ищется в строке самого приёма — соседние строки тоже идут на INFO, и // поиск по всему журналу не упал бы от понижения этой. assert.Regexp(t, `(?m)^.*level["=:\s]+INFO.*`+regexp.QuoteMeta(msgIntake)+`.*$`, journal, "строка приёма остаётся на уровне INFO") // И она ровно одна: вторая строка приёма означала бы второй путь заведения // задачи мимо общей точки, то есть место, куда запрет не доехал. assert.Equal(t, 1, strings.Count(journal, msgIntake), "на принятую запись приходится одна журнальная строка приёма") } // metricLabelValues собирает значения меток названного семейства метрик из // общего реестра процесса. Проверка судит реестр, а не функцию приведения: // приведение, снятое в точке употребления, функцию не ломает, а хвост имени // уходит на страницу метрик, которая отдаётся без проверки отправителя. func metricLabelValues(t *testing.T, family string) []string { families, err := prometheus.DefaultGatherer.Gather() require.NoError(t, err) var values []string for _, mf := range families { if mf.GetName() != family { continue } for _, m := range mf.GetMetric() { for _, label := range m.GetLabel() { values = append(values, label.GetValue()) } } } return values } func TestCreateTranscribeJob_MetricLabelCarriesNoSenderName(t *testing.T) { env := setupTestEnv(t, readableMetaViewer()) // Хвост после последней точки — это тоже кусок имени, данного отправителем. req := createMultipartRequest(t, "sample."+senderNameMarker, []byte("запись")) w := httptest.NewRecorder() env.serve(w, req) require.Equal(t, http.StatusCreated, w.Code) values := metricLabelValues(t, "transcriber_input_file_size_bytes") require.NotEmpty(t, values, "метрика размера принятой записи заполняется приёмом") assert.NotContains(t, values, "."+senderNameMarker, "метка метрики не несёт хвоста имени, данного отправителем") assert.NotContains(t, values, senderNameMarker, "метка метрики не несёт хвоста имени и без ведущей точки") assert.Contains(t, values, "other", "незнакомое расширение приведено к общему значению") // А в хранилище расширение остаётся пришедшим: приведение сюда не // распространяется. names := storedFileNames(t, env) require.Len(t, names, 1) assert.True(t, strings.HasSuffix(names[0], "."+senderNameMarker), "имя в хранилище сохраняет пришедшее расширение: %q", names[0]) } func TestGetTranscribeJobStatus_Success(t *testing.T) { env := setupTestEnv(t, readableMetaViewer()) job := jobWithFile(t, env) req := httptest.NewRequest("GET", "/app/audiorecords/"+job.Id, http.NoBody) w := httptest.NewRecorder() env.serve(w, req) require.Equal(t, http.StatusOK, w.Code) var response RecordView require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response)) assert.Equal(t, job.Id, response.ID) assert.Equal(t, entity.StateUploaded, response.State) assert.NotEmpty(t, response.CreatedAt) } func TestGetTranscribeJobStatus_NoTranscriptionText(t *testing.T) { env := setupTestEnv(t, readableMetaViewer()) job := jobWithFile(t, env) req := httptest.NewRequest("GET", "/app/audiorecords/"+job.Id, http.NoBody) w := httptest.NewRecorder() env.serve(w, req) require.Equal(t, http.StatusOK, w.Code) // Судим по сырому JSON: имена полей карточки нормативны, а разбор в структуру // переименовал бы тег вместе с ожиданием — и проверка не смогла бы упасть. var raw map[string]json.RawMessage require.NoError(t, json.Unmarshal(w.Body.Bytes(), &raw)) assert.Contains(t, raw, "id") assert.Contains(t, raw, "state") assert.Contains(t, raw, "created_at") assert.Contains(t, raw, "original_filename") assert.Contains(t, raw, "duration_ms") assert.Contains(t, raw, "size_bytes") assert.NotContains(t, raw, "transcription_text", "текст читается своим адресом") assert.NotContains(t, raw, "job_id", "прежние имена ушли вместе с опросом готовности") } func TestGetTranscribeJobStatus_NotFound(t *testing.T) { env := setupTestEnv(t, readableMetaViewer()) req := httptest.NewRequest("GET", "/app/audiorecords/non-existent-id", http.NoBody) w := httptest.NewRecorder() env.serve(w, req) require.Equal(t, http.StatusNotFound, w.Code) var response ErrorBody require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response)) assert.Equal(t, CodeNotFound, response.Code, "код отказа машиночитаем") assert.NotEmpty(t, response.Message, "и рядом с ним фраза для человека") } // Отправитель, у которого соединение оборвалось после полной загрузки, задачу // всё равно получает: запись доехала целиком, а результат он заберёт позже по // карточкой записи. Приём на контексте запроса терял бы такую запись молча — // решение владельца от 2026-08-13. func TestAcceptedRecordSurvivesSenderDisconnect(t *testing.T) { env := setupTestEnv(t, readableMetaViewer()) req := createMultipartRequest(t, "sample.m4a", []byte("аудио")) // Так выглядит ушедший отправитель: контекст запроса отменяется сервером, // когда соединение закрылось. ctx, cancel := context.WithCancel(req.Context()) cancel() req = req.WithContext(ctx) w := httptest.NewRecorder() env.serve(w, req) require.Equal(t, http.StatusCreated, w.Result().StatusCode, "тело ответа: %s", w.Body.String()) assert.Equal(t, 1, countJobs(t, env), "задача заведена, несмотря на ушедшего отправителя") }