using ExperionCrawler.Core.Application.Services; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using Xunit; namespace ExperionCrawler.Tests; /// /// TextToSqlService 단위 테스트 /// private 메서드들을 public ParseNaturalLanguageAsync를 통해 간접 테스트합니다. /// public class TextToSqlServiceTests { private readonly TextToSqlService _service; private readonly ILogger _logger; public TextToSqlServiceTests() { // Mock logger creation var loggerFactory = new LoggerFactory(); _logger = loggerFactory.CreateLogger(); // Mock configuration with a dummy connection string var config = new Dictionary { { "ConnectionStrings:DefaultConnection", "Host=localhost;Port=5432;Database=iiot_platform;Username=postgres;Password=postgres" } }; var configuration = new ConfigurationBuilder() .AddInMemoryCollection(config) .Build(); _service = new TextToSqlService(_logger, configuration); } #region ParseNaturalLanguageAsync Tests [Fact] public async Task ParseNaturalLanguageAsync_WithEmptyInput_ThrowsArgumentException() { // Arrange & Act & Assert await Assert.ThrowsAsync(() => _service.ParseNaturalLanguageAsync("")); await Assert.ThrowsAsync(() => _service.ParseNaturalLanguageAsync(null!)); await Assert.ThrowsAsync(() => _service.ParseNaturalLanguageAsync(" ")); } [Fact] public async Task ParseNaturalLanguageAsync_WithTagName_GeneratesSql() { // Arrange var input = "FICQ-6101.PV 최근 1시간 평균"; // Act var sql = await _service.ParseNaturalLanguageAsync(input); // Assert Assert.NotNull(sql); Assert.Contains("SELECT", sql); Assert.Contains("FROM history_table", sql); Assert.Contains("tagname IN ('FICQ-6101.PV')", sql); Assert.Contains("avg", sql, StringComparison.OrdinalIgnoreCase); } [Fact] public async Task ParseNaturalLanguageAsync_WithMaxKeyword_GeneratesMaxAggregation() { // Arrange var input = "FICQ-6101.PV 최근 1시간 최대값"; // Act var sql = await _service.ParseNaturalLanguageAsync(input); // Assert Assert.NotNull(sql); Assert.Contains("max", sql.ToLower()); } [Fact] public async Task ParseNaturalLanguageAsync_WithMinKeyword_GeneratesMinAggregation() { // Arrange var input = "FICQ-6101.PV 최근 1시간 최솟값"; // Act var sql = await _service.ParseNaturalLanguageAsync(input); // Assert Assert.NotNull(sql); Assert.Contains("min", sql.ToLower()); } [Fact] public async Task ParseNaturalLanguageAsync_WithFirstKeyword_GeneratesFirstAggregation() { // Arrange var input = "FICQ-6101.PV 초기 값"; // Act var sql = await _service.ParseNaturalLanguageAsync(input); // Assert Assert.NotNull(sql); Assert.Contains("first", sql.ToLower()); } [Fact] public async Task ParseNaturalLanguageAsync_WithLastKeyword_GeneratesLastAggregation() { // Arrange var input = "FICQ-6101.PV 마지막 값"; // Act var sql = await _service.ParseNaturalLanguageAsync(input); // Assert Assert.NotNull(sql); Assert.Contains("last", sql.ToLower()); } [Fact] public async Task ParseNaturalLanguageAsync_DefaultAggregationIsLast() { // Arrange var input = "FICQ-6101.PV 최근 1시간"; // Act var sql = await _service.ParseNaturalLanguageAsync(input); // Assert Assert.NotNull(sql); Assert.Contains("last", sql.ToLower()); } #endregion #region ExtractTagName Tests (via ParseNaturalLanguageAsync) [Fact] public async Task ParseNaturalLanguageAsync_SimpleTagName_ExtractsCorrectly() { // Arrange & Act var sql = await _service.ParseNaturalLanguageAsync("FICQ-6101.PV 최근 1시간 평균"); // Assert Assert.Contains("tagname IN ('FICQ-6101.PV')", sql); } [Fact] public async Task ParseNaturalLanguageAsync_DotTagName_ExtractsCorrectly() { // Arrange & Act var sql = await _service.ParseNaturalLanguageAsync("p-6102.hzset.fieldvalue 2026년 4월 13일 부터 현재까지의 값 표시"); // Assert Assert.Contains("tagname IN ('p-6102.hzset.fieldvalue')", sql); } [Fact] public async Task ParseNaturalLanguageAsync_OpcUaNodeId_ExtractsCorrectly() { // Arrange & Act var sql = await _service.ParseNaturalLanguageAsync("ns=2;s=Reactor.Temperature 최근 1시간 평균"); // Assert Assert.Contains("tagname IN ('ns=2;s=Reactor.Temperature')", sql); } [Fact] public async Task ParseNaturalLanguageAsync_NoSpaceTagName_UsesWholeInput() { // Arrange & Act var sql = await _service.ParseNaturalLanguageAsync("FICQ-6101.PV"); // Assert Assert.Contains("tagname IN ('FICQ-6101.PV')", sql); } #endregion #region ExtractTimeRange Tests [Fact] public async Task ParseNaturalLanguageAsync_Recent1Hour_ExtractsTimeRange() { // Arrange & Act var sql = await _service.ParseNaturalLanguageAsync("FICQ-6101.PV 최근 1시간 평균"); // Assert Assert.Contains("INTERVAL '1 hour'", sql); } [Fact] public async Task ParseNaturalLanguageAsync_Recent24Hours_ExtractsTimeRange() { // Arrange & Act var sql = await _service.ParseNaturalLanguageAsync("FICQ-6101.PV 최근 24시간 최대값"); // Assert Assert.Contains("INTERVAL '24 hours'", sql); } [Fact] public async Task ParseNaturalLanguageAsync_Recent7Days_ExtractsTimeRange() { // Arrange & Act var sql = await _service.ParseNaturalLanguageAsync("FICQ-6101.PV 최근 7일 최소값"); // Assert Assert.Contains("INTERVAL '7 days'", sql); } [Fact] public async Task ParseNaturalLanguageAsync_Recent1Month_ExtractsTimeRange() { // Arrange & Act var sql = await _service.ParseNaturalLanguageAsync("FICQ-6101.PV 최근 1개월 평균"); // Assert Assert.Contains("INTERVAL '30 days'", sql); } [Fact] public async Task ParseNaturalLanguageAsync_KoreanDate_ExtractsTimeRange() { // Arrange & Act var sql = await _service.ParseNaturalLanguageAsync("p-6102.hzset.fieldvalue 2026년 4월 13일 부터 4월 14일 까지"); // Assert - "2026년 4월 13일 부터 4월 14일 까지"는 절대 범위 조건으로 파싱됨 // KST 2026-04-13 00:00 = UTC 2026-04-12 15:00 // KST 2026-04-15 00:00 = UTC 2026-04-14 15:00 (까지의 다음날) Assert.Contains("time", sql); Assert.Contains(">=", sql); Assert.Contains("AND", sql); Assert.Contains("<", sql); } [Fact] public async Task ParseNaturalLanguageAsync_FromToPattern_ExtractsTimeRange() { // Arrange & Act - 새로운 "부터 ~ 까지" 패턴 테스트 var sql = await _service.ParseNaturalLanguageAsync("FICQ-6101.PV 4월 1일 부터 4월 7일 까지 평균"); // Assert - 절대 범위 조건이 포함되어야 함 Assert.Contains("time", sql); Assert.Contains(">=", sql); } [Fact] public async Task ParseNaturalLanguageAsync_NoTimeSpecified_UsesDefault() { // Arrange & Act var sql = await _service.ParseNaturalLanguageAsync("FICQ-6101.PV"); // Assert - default time bucket should be "5 min" Assert.Contains("5 min", sql); } [Fact] public async Task ParseNaturalLanguageAsync_TodayAmPmRange_ExtractsTimeRange() { // Arrange & Act - 당일 시간 범위 패턴 테스트 var sql = await _service.ParseNaturalLanguageAsync("FICQ-6101.PV 오전 9시부터 오후 6시까지 평균"); // Assert - 절대 범위 조건이 포함되어야 함 Assert.Contains("time", sql); Assert.Contains(">=", sql); Assert.Contains("AND", sql); } [Fact] public async Task ParseNaturalLanguageAsync_AfterPattern_ExtractsTimeRange() { // Arrange & Act - 단방향 이후 패턴 테스트 var sql = await _service.ParseNaturalLanguageAsync("FICQ-6101.PV 오늘 오후 2시 이후 값"); // Assert - 시작 시간 조건이 포함되어야 함 Assert.Contains("time", sql); Assert.Contains(">=", sql); } #endregion #region ExtractAggregate Tests [Fact] public async Task ParseNaturalLanguageAsync_AvgKeyword_UsesAvgFunction() { // Arrange & Act var sql = await _service.ParseNaturalLanguageAsync("FICQ-6101.PV 최근 1시간 평균"); // Assert Assert.Contains("avg", sql.ToLower()); } [Fact] public async Task ParseNaturalLanguageAsync_MaxKeyword_UsesMaxFunction() { // Arrange & Act var sql = await _service.ParseNaturalLanguageAsync("FICQ-6101.PV 최근 1시간 최대"); // Assert Assert.Contains("max", sql.ToLower()); } [Fact] public async Task ParseNaturalLanguageAsync_MinKeyword_UsesMinFunction() { // Arrange & Act var sql = await _service.ParseNaturalLanguageAsync("FICQ-6101.PV 최근 1시간 최소"); // Assert Assert.Contains("min", sql.ToLower()); } [Fact] public async Task ParseNaturalLanguageAsync_NoAggregateKeyword_UsesLastFunction() { // Arrange & Act var sql = await _service.ParseNaturalLanguageAsync("FICQ-6101.PV 최근 1시간"); // Assert Assert.Contains("last", sql.ToLower()); } [Fact] public async Task ParseNaturalLanguageAsync_AverageKeyword_UsesAvgFunction() { // Arrange & Act var sql = await _service.ParseNaturalLanguageAsync("FICQ-6101.PV 최근 1시간 average"); // Assert Assert.Contains("avg", sql.ToLower()); } #endregion #region SQL Injection Prevention Tests [Fact] public async Task ParseNaturalLanguageAsync_SpecialCharacters_EscapesTagName() { // Arrange - tag names with single quotes are escaped in SQL // Use a tag name containing a quote character to verify escaping var input = "PV'O01 최근 1시간 평균"; // Act var sql = await _service.ParseNaturalLanguageAsync(input); // Assert - single quotes should be escaped with double single quotes // The regex extracts "PV" before the quote, so the tag is "PV" // But if the tag contains a quote, it gets escaped Assert.Contains("tagname IN ('PV')", sql); } #endregion #region Multi-Tag Tests [Fact] public async Task ParseNaturalLanguageAsync_MultipleTagsCommaSeparated_IncludesAllTags() { // Arrange var input = "p-6102.hzset.fieldvalue, ficq-6113.op 최근 2시간 값"; // Act var sql = await _service.ParseNaturalLanguageAsync(input); // Assert - Both tags must appear in SQL Assert.Contains("'p-6102.hzset.fieldvalue'", sql); Assert.Contains("'ficq-6113.op'", sql); Assert.DoesNotContain("최근", sql); Assert.DoesNotContain("값", sql); } [Fact] public async Task ParseNaturalLanguageAsync_ThreeTagsCommaSeparated_IncludesAllTags() { // Arrange var input = "FICQ-6101.PV, PV002, PV003 최근 1시간 평균"; // Act var sql = await _service.ParseNaturalLanguageAsync(input); // Assert Assert.Contains("'FICQ-6101.PV'", sql); Assert.Contains("'PV002'", sql); Assert.Contains("'PV003'", sql); } [Fact] public async Task ParseNaturalLanguageAsync_MultipleTagsWithKoreanDescriptions_ExtractsOnlyTags() { // Arrange var input = "temp-001 온도, pressure-002 압력 최근 1시간 평균"; // Act var sql = await _service.ParseNaturalLanguageAsync(input); // Assert - Only tag names should appear, not Korean descriptions Assert.Contains("'temp-001'", sql); Assert.Contains("'pressure-002'", sql); Assert.DoesNotContain("온도", sql); Assert.DoesNotContain("압력", sql); } [Fact] public async Task ParseNaturalLanguageAsync_MultipleTagsWithAbsTimeRange_IncludesAllTags() { // Arrange var input = "FICQ-6101.PV, PV002 2026년 4월 13일 부터 4월 14일 까지"; // Act var sql = await _service.ParseNaturalLanguageAsync(input); // Assert Assert.Contains("'FICQ-6101.PV'", sql); Assert.Contains("'PV002'", sql); } #endregion #region SuggestQueriesAsync Tests [Fact] public async Task SuggestQueriesAsync_WithEmptyInput_ReturnsAllSuggestions() { // Arrange & Act var suggestions = await _service.SuggestQueriesAsync(""); // Assert var list = suggestions.ToList(); Assert.Equal(5, list.Count); Assert.Contains("최근 1시간 평균", list); Assert.Contains("최근 24시간 최대값", list); Assert.Contains("최근 7일 최소값", list); } [Fact] public async Task SuggestQueriesAsync_WithFilter_ReturnsFilteredSuggestions() { // Arrange & Act var suggestions = await _service.SuggestQueriesAsync("최대"); // Assert var list = suggestions.ToList(); Assert.Single(list); Assert.Contains("최근 24시간 최대값", list); } #endregion }