package main import ( "encoding/json" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" "time" "git.cin.su/m/Assistant/contracts/capability" "git.cin.su/m/Assistant/contracts/epistemics" "git.cin.su/m/Assistant/contracts/interaction" "git.cin.su/m/Assistant/contracts/work" interactiondomain "git.cin.su/m/Assistant/modules/interaction/domain" ) func TestHumanResearchExcerptRemovesSearchResultHeading(t *testing.T) { got := humanResearchExcerpt("погода в Санкт-Петербурге", "Погода в Санкт-Петербурге — Прогноз погоды — Сейчас пасмурно, температура +14°") if got != "Сейчас пасмурно, температура +14°" || strings.Contains(got, "Прогноз погоды") { t.Fatalf("human research excerpt = %q", got) } } func TestNearestFlightEvidenceDoesNotClaimMissingDepartureTime(t *testing.T) { if !nearestFlightQuery("ближайший рейс Москва Казань сегодня") { t.Fatal("nearest flight query was not recognized") } if containsFlightTimeEvidence("Казань составляет 1 ч 25 мин. График вылетов доступен онлайн") { t.Fatal("duration was mistaken for a departure time") } if !containsFlightTimeEvidence("Ближайший вылет в 18:40") { t.Fatal("departure time was not recognized") } } func TestNearestFlightReplyFiltersUnrelatedSearchSnippets(t *testing.T) { result := epistemics.ResearchResult{Findings: []epistemics.ResearchFinding{ {State: epistemics.FindingSupported, Source: epistemics.Source{Locator: "https://naidy.example", Title: "Мебельная фурнитура"}, Excerpt: epistemics.SourceExcerpt{Text: "Комплектующие для мебели оптом и в розницу."}}, {State: epistemics.FindingSupported, Source: epistemics.Source{Locator: "https://rasp.example", Title: "Рейсы Москва — Казань"}, Excerpt: epistemics.SourceExcerpt{Text: "Расписание самолётов Москва — Казань, время вылета и прилёта."}}, }} reply, backed := renderResearchReply("ближайшие авиарейсы Москва Казань сегодня", result, nil) if !backed || !strings.Contains(reply, "Рейсы Москва") || strings.Contains(reply, "Мебельная") { t.Fatalf("unrelated source leaked into flight reply: %q", reply) } } func TestNearestFlightReplyDoesNotAppendWeakerSecondMatch(t *testing.T) { result := epistemics.ResearchResult{Findings: []epistemics.ResearchFinding{ {State: epistemics.FindingSupported, Source: epistemics.Source{Locator: "https://schedule.example", Title: "Рейсы Москва — Казань"}, Excerpt: epistemics.SourceExcerpt{Text: "Расписание авиарейсов Москва — Казань, время вылета и прилёта."}}, {State: epistemics.FindingSupported, Source: epistemics.Source{Locator: "https://category.example", Title: "Категория направлений"}, Excerpt: epistemics.SourceExcerpt{Text: "Список близлежащих городов и ориентировочные цены."}}, }} reply, backed := renderResearchReply("ближайшие авиарейсы Москва Казань сегодня", result, nil) if !backed || !strings.Contains(reply, "Рейсы Москва") || strings.Contains(reply, "Категория направлений") { t.Fatalf("weaker second finding leaked into nearest-flight reply: %q", reply) } } func TestNearestFlightEvidenceRequiresBothRouteEndpointsWhenKnown(t *testing.T) { query := "ближайшие авиарейсы Москва Казань сегодня" matched := epistemics.ResearchFinding{Source: epistemics.Source{Title: "Рейсы Москва — Казань"}, Excerpt: epistemics.SourceExcerpt{Text: "Расписание авиарейсов из Москвы в Казань."}} if !researchFindingMatchesIntent(query, matched) { t.Fatal("route evidence with both endpoints was rejected") } unrelated := epistemics.ResearchFinding{Source: epistemics.Source{Title: "Рейсы Москва — Казань", Locator: "https://example.test/moscow-kazan"}, Excerpt: epistemics.SourceExcerpt{Text: "Рейсы Москва — Казань — Ближайшие к Казани города; цена указана ориентировочно."}} if researchFindingMatchesIntent(query, unrelated) { t.Fatal("one-ended travel evidence was accepted") } } func TestTicketReplyRejectsGenericCategoryPageWithoutDestination(t *testing.T) { query := "найди билеты до Ялты сегодня" generic := epistemics.ResearchFinding{Source: epistemics.Source{Title: "Купить билеты", Locator: "https://tickets.example"}, Excerpt: epistemics.SourceExcerpt{Text: "Поиск авиабилетов среди множества направлений."}} if researchFindingMatchesIntent(query, generic) { t.Fatal("generic ticket category was presented as destination result") } relevant := epistemics.ResearchFinding{Source: epistemics.Source{Title: "Билеты"}, Excerpt: epistemics.SourceExcerpt{Text: "Билеты до Ялты: варианты на сегодня."}} if !researchFindingMatchesIntent(query, relevant) { t.Fatal("destination-specific ticket result was rejected") } } func TestDistanceReplyRejectsCalculatorPageWithoutDistance(t *testing.T) { query := "расстояние между Москве и Сочи в километрах по прямой и по дороге" calculator := epistemics.ResearchFinding{Source: epistemics.Source{Title: "Расчет расстояний"}, Excerpt: epistemics.SourceExcerpt{Text: "Калькулятор рассчитывает точное расстояние между городами."}} if researchFindingMatchesIntent(query, calculator) { t.Fatal("distance calculator landing page was presented without a measured distance") } measured := epistemics.ResearchFinding{Source: epistemics.Source{Title: "Маршрут"}, Excerpt: epistemics.SourceExcerpt{Text: "Расстояние по дороге составляет 1 620 км."}} if !researchFindingMatchesIntent(query, measured) { t.Fatal("measured distance evidence was rejected") } } func TestResearchReplyRejectsGenericLandingPageForConcreteIdentifier(t *testing.T) { result := epistemics.ResearchResult{Findings: []epistemics.ResearchFinding{{ State: epistemics.FindingSupported, Source: epistemics.Source{Locator: "https://market.example", Title: "Интернет-магазин"}, Excerpt: epistemics.SourceExcerpt{Text: "Широкий ассортимент товаров и услуг."}, }}} reply, backed := renderResearchReply("на WB бренд marka-wear", result, nil) if backed || !strings.Contains(reply, "marka-wear") || strings.Contains(reply, "wildberries") || !strings.Contains(reply, "уточните написание") { t.Fatalf("generic page was presented as a brand result: %q", reply) } } func TestLocalPlaceReplyRejectsDictionaryFinding(t *testing.T) { result := epistemics.ResearchResult{Findings: []epistemics.ResearchFinding{{ State: epistemics.FindingSupported, Source: epistemics.Source{Locator: "https://dictionary.example", Title: "Ближайший — словарь"}, Excerpt: epistemics.SourceExcerpt{Text: "Ближайший — значение слова и пример употребления."}, }}} reply, backed := renderResearchReply("места ресторан в Сочи адрес отзывы меню", result, nil) if backed || !strings.Contains(reply, "Проверяемых данных") { t.Fatalf("dictionary finding was presented as local place result: %q", reply) } } func TestResearchReplyKeepsFindingContainingConcreteIdentifier(t *testing.T) { result := epistemics.ResearchResult{Findings: []epistemics.ResearchFinding{{ State: epistemics.FindingSupported, Source: epistemics.Source{Locator: "https://market.example/marka-wear", Title: "Marka-wear"}, Excerpt: epistemics.SourceExcerpt{Text: "Бренд marka-wear: товары и карточки."}, }}} reply, backed := renderResearchReply("на WB бренд marka-wear", result, nil) if !backed || !strings.Contains(strings.ToLower(reply), "marka-wear") { t.Fatalf("concrete identifier finding was rejected: %q", reply) } } func TestResearchIdentifiersExcludeSearchDate(t *testing.T) { identifiers := requestedResearchIdentifiers("ближайший поезд в Петербург сегодня 26.08.2026") if len(identifiers) != 0 { t.Fatalf("search date was treated as a concrete identifier: %#v", identifiers) } } func TestResearchQueryKeepsDistanceRelationBeforeSearchNormalization(t *testing.T) { history := []interaction.HumanUtterance{{Content: "я сейчас в Москве"}} query := interactiondomain.TravelResearchQuery("сколько километров до Сочи?", history, time.Time{}) query = interactiondomain.SearchQueryWithLexicon(query, interactiondomain.TaskLexicon{}) if query != "расстояние между Москве и Сочи в километрах по прямой и по дороге" { t.Fatalf("distance query lost remembered origin: %q", query) } } func TestResponseReviewPreservesAttemptedResearchOutcome(t *testing.T) { if reviewMayReplacePlanningReply(false, false, true, false, "Уточните аэропорт.", "Пока не смог получить проверяемый ответ.") { t.Fatal("review replaced an honest unbacked research outcome") } if !reviewMayReplacePlanningReply(false, false, true, true, "Расписание. Источник: https://example.test", "Расписание. Источник: https://example.test") { t.Fatal("review should be able to improve a source-backed research outcome") } if reviewMayReplacePlanningReply(false, false, true, true, "Расписание без ссылки", "Расписание. Источник: https://example.test") { t.Fatal("review dropped the research evidence") } if !reviewMayReplacePlanningReply(false, false, false, false, "Нормальный ответ", "Черновик") { t.Fatal("ordinary conversation review was unexpectedly blocked") } if !reviewMayReplacePlanningReply(true, true, false, false, "Санкт-Петербург — город в России.", "Меня зовут Домовой. Санкт-Петербург — город в России.") { t.Fatal("stable-knowledge draft must be eligible for semantic quality review") } // The caller must not mark a source-backed research result as immutable // semantic state: it may be rewritten, but only with its evidence retained. if !reviewMayReplacePlanningReply(false, false, true, true, "Из Москвы в Сочи можно добраться самолётом или поездом. Источник: https://example.test", "Сырые результаты. Источник: https://example.test") { t.Fatal("source-backed research draft must be eligible for human-facing review") } } func TestNonMutatingTextDirectiveCanUseCognitiveReply(t *testing.T) { semantic := interactiondomain.SemanticResult{Interpretation: interaction.UtteranceInterpretation{SpeechAct: interaction.SpeechActDirective}} if !shouldGenerateNonMutatingTextDirective(semantic, nil, work.HelpPath{}, false) { t.Fatal("ordinary formatting directive was not eligible for a cognitive reply") } if !shouldGenerateNonMutatingTextDirective(semantic, nil, work.HelpPath{Kind: work.HelpPathCognition}, false) { t.Fatal("reply-only cognition path was incorrectly rejected") } if shouldGenerateNonMutatingTextDirective(semantic, nil, work.HelpPath{Kind: work.HelpPathDirectCapability}, false) { t.Fatal("direct capability was incorrectly replaced by a textual reply") } if shouldGenerateNonMutatingTextDirective(semantic, []chatRoute{{Kind: chatRouteMedia}}, work.HelpPath{}, false) { t.Fatal("media route was incorrectly replaced by a textual reply") } if shouldGenerateNonMutatingTextDirective(semantic, nil, work.HelpPath{NeedsDurableWork: true}, false) { t.Fatal("durable work was incorrectly replaced by a textual reply") } } func TestBearerTokenRequiresBearerScheme(t *testing.T) { request := httptest.NewRequest("GET", "/", nil) request.Header.Set("Authorization", "Basic value") if token := bearerToken(request); token != "" { t.Fatalf("unexpected token %q", token) } request.Header.Set("Authorization", "Bearer creator-session") if token := bearerToken(request); token != "creator-session" { t.Fatalf("token = %q", token) } } func TestLatestAssistantNameUsesNewestDurablePreference(t *testing.T) { history := []interaction.HumanUtterance{ {Content: "я назову тебя Дед", ReceivedAtUnixMillis: 20}, {Content: "теперь тебя зовут Фея", ReceivedAtUnixMillis: 30}, } if got := latestAssistantName(history); got != "Фея" { t.Fatalf("latest assistant name = %q", got) } } func TestHumanMemoryInventoryUsesNewestPreference(t *testing.T) { history := []interaction.HumanUtterance{ {Content: "мне нравится синий цвет", ReceivedAtUnixMillis: 20}, {Content: "мне нравится красный цвет", ReceivedAtUnixMillis: 30}, } got := humanMemoryInventory(history, "Фея") if !strings.Contains(got, "красный") || strings.Contains(got, "синий") { t.Fatalf("memory inventory did not select newest preference: %q", got) } } func TestContainsScheduleTimeEvidence(t *testing.T) { if containsScheduleTimeEvidence("расписание поездов и цена билета") { t.Fatal("a price or schedule label is not a departure time") } if !containsScheduleTimeEvidence("Отправление в 07:45") { t.Fatal("expected an explicit departure time to be recognized") } } func TestInternetStatusQueryRecognizesConnectionChecks(t *testing.T) { for _, query := range []string{"проверь, работает ли интернет", "есть доступ к сети?"} { if !isInternetStatusQuery(query) { t.Fatalf("connection check was not recognized: %q", query) } } if isInternetStatusQuery("найди интернет-магазин") { t.Fatal("ordinary search must not be treated as a connectivity check") } } func TestInternetStatusReplyDoesNotLeakIncidentalSearchResults(t *testing.T) { result := epistemics.ResearchResult{Findings: []epistemics.ResearchFinding{{ State: epistemics.FindingSupported, Source: epistemics.Source{Locator: "https://example.test", Title: "Incidental page"}, Excerpt: epistemics.SourceExcerpt{Text: "A response was received."}, }}} reply, backed := renderResearchReply("проверь, работает ли интернет", result, nil) if !backed || reply != "Интернет работает: Домовой получил ответ от внешнего источника." { t.Fatalf("unexpected internet status reply: %q", reply) } } func TestCognitiveChannelQuestionUsesHumanSafeHealthReply(t *testing.T) { reply := humanCognitiveChannelReply(interaction.InformationRequestCognitiveChannelStatus, interaction.SpeechActQuestion, capability.Health{State: capability.HealthAvailable, CapabilityID: "provider-secret-model"}, nil) if !strings.Contains(reply, "доступен") || strings.Contains(reply, "provider-secret-model") { t.Fatalf("unexpected safe reply: %q", reply) } } func TestCurrentModelReplyDoesNotInventProvider(t *testing.T) { reply := humanCurrentCognitiveModelReply(capability.Health{State: capability.HealthDegraded, CapabilityID: "provider-secret-model"}, nil) if !strings.Contains(reply, "встроенным режимом") || !strings.Contains(reply, "ограничениями") || strings.Contains(reply, "provider-secret-model") { t.Fatalf("unexpected current-model reply: %q", reply) } } func TestCognitiveConfigurationRequestDoesNotPretendToMutate(t *testing.T) { reply := humanCognitiveChannelReply(interaction.InformationRequestCognitiveRecovery, interaction.SpeechActDirective, capability.Health{State: capability.HealthUnavailable}, nil) if reply != "Канал мышления недоступен. Домовой остаётся на связи." { t.Fatalf("unexpected configuration reply: %q", reply) } } func TestCognitiveDegradedReplyKeepsDomovoyAlive(t *testing.T) { reply := humanCognitiveDegradedReply("Помоги настроить локальную модель") if strings.Contains(reply, "когнитивный канал") || strings.Contains(reply, "Повторю") || !strings.Contains(reply, "не выдумываю") { t.Fatalf("unexpected degraded reply: %q", reply) } } func TestModelInventoryQuestionUsesReadOnlyFastPath(t *testing.T) { for _, message := range []string{"какие модели доступны?", "напиши список настроек ИИ", "какие есть подключения ИИ"} { result, err := interactiondomain.InterpretUtterance(interaction.HumanUtterance{Content: message}, nil, nil, time.Now()) if err != nil { t.Fatalf("interpretation failed for %q: %v", message, err) } if result.InformationRequestKind != interaction.InformationRequestCognitiveModelInventory { t.Fatalf("semantic inventory request %q was not recognized: kind=%q act=%q hints=%v", message, result.InformationRequestKind, result.Interpretation.SpeechAct, result.CapabilityHints) } } ordinary, err := interactiondomain.InterpretUtterance(interaction.HumanUtterance{Content: "настрой локальную модель"}, nil, nil, time.Now()) if err != nil { t.Fatal(err) } if ordinary.InformationRequestKind == interaction.InformationRequestCognitiveModelInventory { t.Fatal("configuration mutation must not use read-only model inventory path") } } func TestHumanCognitiveModelInventoryDoesNotExposeSecrets(t *testing.T) { cost := int64(25) text := humanCognitiveModelInventory([]capability.ResolvedCognitiveBinding{{ Binding: capability.CognitiveBinding{DataPolicy: capability.DataPolicyExternalMessageOnly, Activation: capability.ActivationActive}, Provider: capability.ProviderInstance{Kind: "openai-compatible-chat-v1", CredentialRef: "secret-ref", AuthMode: capability.AuthKey, BillingMode: capability.BillingPerRequest, RequestCostMicros: &cost, Health: capability.HealthAvailable}, Model: capability.ModelDescriptor{ProviderModelID: "example-model", Health: capability.HealthAvailable}, }}, nil) if strings.Contains(text, "secret-ref") || strings.Contains(text, "25") || !strings.Contains(text, "example-model") || !strings.Contains(text, "активна") { t.Fatalf("unsafe or incomplete inventory: %q", text) } } func TestHumanCognitiveModelInventoryDeduplicatesAndPrioritizesAvailable(t *testing.T) { text := humanCognitiveModelInventory([]capability.ResolvedCognitiveBinding{ {Binding: capability.CognitiveBinding{Activation: capability.ActivationInactive}, Provider: capability.ProviderInstance{Health: capability.HealthUnavailable}, Model: capability.ModelDescriptor{ProviderModelID: "same-model", Health: capability.HealthUnavailable}}, {Binding: capability.CognitiveBinding{Activation: capability.ActivationActive}, Provider: capability.ProviderInstance{Health: capability.HealthAvailable}, Model: capability.ModelDescriptor{ProviderModelID: "same-model", Health: capability.HealthAvailable}}, }, nil) if strings.Count(text, "same-model") != 1 || !strings.Contains(text, "same-model — активна") { t.Fatalf("expected one active model entry, got %q", text) } } func TestCreatorSessionHandlerAcceptsConfiguredCredentials(t *testing.T) { handler := creatorSessionHandler("recovery", "m", "password", []byte("01234567890123456789012345678901"), func() time.Time { return time.Unix(1000, 0) }) request := httptest.NewRequest(http.MethodPost, "/v1/creator/sessions", strings.NewReader(`{"username":"m","password":"password"}`)) response := httptest.NewRecorder() handler(response, request) if response.Code != http.StatusOK { t.Fatalf("status = %d body=%s", response.Code, response.Body.String()) } var issued struct { Token string } if err := json.NewDecoder(response.Body).Decode(&issued); err != nil || issued.Token == "" { t.Fatalf("session response = %+v, err=%v", issued, err) } } func TestCreatorSessionHandlerFailsClosed(t *testing.T) { handler := creatorSessionHandler("recovery", "m", "password", []byte("01234567890123456789012345678901"), time.Now) tests := []string{ `{"username":"wrong","password":"password"}`, `{"username":"m","password":"wrong"}`, `{"username":"","password":""}`, `{"username":"m","password":"password","actor":"attacker"}`, `{`, `{"username":"m","password":"password"}{}`, } for _, body := range tests { request := httptest.NewRequest(http.MethodPost, "/v1/creator/sessions", strings.NewReader(body)) response := httptest.NewRecorder() handler(response, request) if response.Code != http.StatusUnauthorized || strings.TrimSpace(response.Body.String()) != "creator authentication denied" { t.Fatalf("body %q: status=%d response=%q", body, response.Code, response.Body.String()) } } } func TestCreatorSessionHandlerPreservesBootstrapRecovery(t *testing.T) { handler := creatorSessionHandler("recovery", "", "", []byte("01234567890123456789012345678901"), time.Now) request := httptest.NewRequest(http.MethodPost, "/v1/creator/sessions", nil) request.Header.Set("Authorization", "Bearer recovery") response := httptest.NewRecorder() handler(response, request) if response.Code != http.StatusOK { t.Fatalf("bootstrap recovery status = %d", response.Code) } } func TestReadRequiredSecretFromEnvironment(t *testing.T) { t.Setenv("TEST_CREATOR_SECRET", "direct-value") value, err := readRequiredSecret("TEST_CREATOR_SECRET") if err != nil || value != "direct-value" { t.Fatalf("value=%q err=%v", value, err) } } func TestReadRequiredSecretFromFile(t *testing.T) { fileName := filepath.Join(t.TempDir(), "secret") if err := os.WriteFile(fileName, []byte("file-value\r\n"), 0o600); err != nil { t.Fatal(err) } t.Setenv("TEST_CREATOR_SECRET_FILE", fileName) value, err := readRequiredSecret("TEST_CREATOR_SECRET") if err != nil || value != "file-value" { t.Fatalf("value=%q err=%v", value, err) } } func TestReadRequiredSecretFailsClosed(t *testing.T) { t.Run("both sources", func(t *testing.T) { t.Setenv("TEST_CREATOR_SECRET", "direct") t.Setenv("TEST_CREATOR_SECRET_FILE", filepath.Join(t.TempDir(), "secret")) if _, err := readRequiredSecret("TEST_CREATOR_SECRET"); err == nil { t.Fatal("expected ambiguous configuration to fail") } }) t.Run("empty file", func(t *testing.T) { fileName := filepath.Join(t.TempDir(), "secret") if err := os.WriteFile(fileName, nil, 0o600); err != nil { t.Fatal(err) } t.Setenv("TEST_CREATOR_SECRET_FILE", fileName) if _, err := readRequiredSecret("TEST_CREATOR_SECRET"); err == nil { t.Fatal("expected empty secret to fail") } }) t.Run("multiline file", func(t *testing.T) { fileName := filepath.Join(t.TempDir(), "secret") if err := os.WriteFile(fileName, []byte("first\nsecond\n"), 0o600); err != nil { t.Fatal(err) } t.Setenv("TEST_CREATOR_SECRET_FILE", fileName) if _, err := readRequiredSecret("TEST_CREATOR_SECRET"); err == nil { t.Fatal("expected multiline secret to fail") } }) t.Run("missing file", func(t *testing.T) { t.Setenv("TEST_CREATOR_SECRET_FILE", filepath.Join(t.TempDir(), "missing")) if _, err := readRequiredSecret("TEST_CREATOR_SECRET"); err == nil { t.Fatal("expected missing secret file to fail") } }) }