using ExperionCrawler.Core.Application.DTOs; using ExperionCrawler.Core.Application.Services; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using Xunit; namespace ExperionCrawler.Tests; /// /// TextToSqlService 통합 테스트 /// task_state.md의 매핑표 기반으로 작성된 테스트 프로그램 /// public class TextToSqlTest { private readonly TextToSqlService _service; private readonly ILogger _logger; public TextToSqlTest() { // 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 1. SQL 생성 요청 → 응답 형식 검증 [Fact] public void ParseNaturalLanguageAsync_WithValidInput_ReturnsValidSqlFormat() { // Arrange var input = "FICQ-6101.PV 최근 1시간 평균"; // Act var sql = _service.ParseNaturalLanguageAsync(input).GetAwaiter().GetResult(); // Assert - 응답 형식 검증 Assert.NotNull(sql); Assert.StartsWith("SELECT", sql, StringComparison.OrdinalIgnoreCase); Assert.Contains("FROM history_table", sql, StringComparison.OrdinalIgnoreCase); Assert.Contains("date_trunc", sql, StringComparison.OrdinalIgnoreCase); Assert.Contains("GROUP BY", sql, StringComparison.OrdinalIgnoreCase); Assert.Contains("ORDER BY", sql, StringComparison.OrdinalIgnoreCase); } [Fact] public void ParseNaturalLanguageAsync_WithMaxKeyword_ReturnsMaxFunction() { // Arrange var input = "FICQ-6101.PV 최근 1시간 최대값"; // Act var sql = _service.ParseNaturalLanguageAsync(input).GetAwaiter().GetResult(); // Assert Assert.Contains("max", sql, StringComparison.OrdinalIgnoreCase); } [Fact] public void ParseNaturalLanguageAsync_WithMinKeyword_ReturnsMinFunction() { // Arrange var input = "FICQ-6101.PV 최근 1시간 최솟값"; // Act var sql = _service.ParseNaturalLanguageAsync(input).GetAwaiter().GetResult(); // Assert Assert.Contains("min", sql, StringComparison.OrdinalIgnoreCase); } [Fact] public void ParseNaturalLanguageAsync_WithFirstKeyword_ReturnsFirstFunction() { // Arrange var input = "FICQ-6101.PV 초기 값"; // Act var sql = _service.ParseNaturalLanguageAsync(input).GetAwaiter().GetResult(); // Assert Assert.Contains("first", sql, StringComparison.OrdinalIgnoreCase); } [Fact] public void ParseNaturalLanguageAsync_WithLastKeyword_ReturnsLastFunction() { // Arrange var input = "FICQ-6101.PV 마지막 값"; // Act var sql = _service.ParseNaturalLanguageAsync(input).GetAwaiter().GetResult(); // Assert Assert.Contains("last", sql, StringComparison.OrdinalIgnoreCase); } [Fact] public void ParseNaturalLanguageAsync_WithAvgKeyword_ReturnsAvgFunction() { // Arrange var input = "FICQ-6101.PV 최근 1시간 평균"; // Act var sql = _service.ParseNaturalLanguageAsync(input).GetAwaiter().GetResult(); // Assert Assert.Contains("avg", sql, StringComparison.OrdinalIgnoreCase); } [Fact] public void ParseNaturalLanguageAsync_WithMultipleTags_ReturnsAllTagsInSql() { // Arrange var input = "p-6102.hzset.fieldvalue, ficq-6113.op 최근 2시간 값"; // Act var sql = _service.ParseNaturalLanguageAsync(input).GetAwaiter().GetResult(); // Assert Assert.Contains("'p-6102.hzset.fieldvalue'", sql); Assert.Contains("'ficq-6113.op'", sql); } [Fact] public void ParseNaturalLanguageAsync_WithOpcUaNodeId_ReturnsOpcUaFormat() { // Arrange var input = "ns=2;s=Reactor.Temperature 최근 1시간 평균"; // Act var sql = _service.ParseNaturalLanguageAsync(input).GetAwaiter().GetResult(); // Assert Assert.Contains("'ns=2;s=Reactor.Temperature'", sql); } #endregion #region 2. 생성된 SQL 실행 → TimescaleDB 결과 반환 확인 [Fact] public async Task ExecuteQueryAsync_WithValidSql_ReturnsResultWithColumnsAndRows() { // Arrange var sql = "SELECT date_trunc('minute', recorded_at) AS bucket, tagname, last(value::double precision, recorded_at) AS result FROM history_table WHERE tagname IN ('FICQ-6101.PV') AND recorded_at > NOW() - INTERVAL '1 hour' GROUP BY 1, 2 ORDER BY 1, 2 LIMIT 10"; // Act var result = await _service.ExecuteQueryAsync(sql, 10); // Assert - TimescaleDB 결과 반환 확인 Assert.True(result.Success); Assert.NotNull(result.Columns); Assert.True(result.Columns.Count > 0); Assert.Contains("bucket", result.Columns[0], StringComparison.OrdinalIgnoreCase); Assert.Contains("tagname", result.Columns[1], StringComparison.OrdinalIgnoreCase); Assert.Contains("result", result.Columns[2], StringComparison.OrdinalIgnoreCase); Assert.NotNull(result.Rows); } [Fact] public async Task ExecuteQueryAsync_WithLimit_ReturnsLimitedRows() { // Arrange var sql = "SELECT date_trunc('minute', recorded_at) AS bucket, tagname, last(value::double precision, recorded_at) AS result FROM history_table WHERE tagname IN ('FICQ-6101.PV') AND recorded_at > NOW() - INTERVAL '1 hour' GROUP BY 1, 2 ORDER BY 1, 2"; // Act var result = await _service.ExecuteQueryAsync(sql, 5); // Assert Assert.True(result.Success); Assert.True(result.Rows.Count <= 5); } [Fact] public async Task ExecuteQueryAsync_WithInvalidSql_ReturnsError() { // Arrange var sql = "SELECT * FROM invalid_table_that_does_not_exist"; // Act var result = await _service.ExecuteQueryAsync(sql); // Assert Assert.False(result.Success); Assert.NotNull(result.Error); Assert.Contains("PostgreSQL 오류", result.Error); } [Fact] public async Task ExecuteQueryAsync_WithSqlInjectionAttempt_ReturnsError() { // Arrange var sql = "SELECT * FROM history_table WHERE tagname = 'FICQ-6101.PV' OR '1'='1'"; // Act var result = await _service.ExecuteQueryAsync(sql); // Assert Assert.False(result.Success); Assert.NotNull(result.Error); } #endregion #region 3. 빈 입력 / 잘못된 입력 → 에러 핸들링 [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_WithWhitespaceOnly_ThrowsArgumentException() { // Arrange & Act & Assert await Assert.ThrowsAsync(() => _service.ParseNaturalLanguageAsync(" ")); } [Fact] public async Task ParseNaturalLanguageAsync_WithOnlyTimeKeyword_ThrowsArgumentException() { // Arrange & Act & Assert await Assert.ThrowsAsync(() => _service.ParseNaturalLanguageAsync("최근 1시간")); } [Fact] public async Task ParseNaturalLanguageAsync_WithOnlyDescription_ThrowsArgumentException() { // Arrange & Act & Assert await Assert.ThrowsAsync(() => _service.ParseNaturalLanguageAsync("온도 값")); } [Fact] public async Task ExecuteQueryAsync_WithEmptySql_ThrowsException() { // Arrange var sql = ""; // Act & Assert await Assert.ThrowsAsync(() => _service.ExecuteQueryAsync(sql)); } [Fact] public async Task ExecuteQueryAsync_WithNullSql_ThrowsException() { // Arrange string? sql = null; // Act & Assert await Assert.ThrowsAsync(() => _service.ExecuteQueryAsync(sql!)); } [Fact] public async Task ExecuteQueryAsync_WithInvalidTagInSql_ReturnsError() { // Arrange var sql = "SELECT date_trunc('minute', recorded_at) AS bucket, tagname, last(value::double precision, recorded_at) AS result FROM history_table WHERE tagname IN ('INVALID_TAG_12345') AND recorded_at > NOW() - INTERVAL '1 hour' GROUP BY 1, 2 ORDER BY 1, 2 LIMIT 10"; // Act var result = await _service.ExecuteQueryAsync(sql, 10); // Assert Assert.False(result.Success); Assert.NotNull(result.Error); } #endregion #region 4. 한국어 자연어 입력 → SQL 변환 정확도 [Fact] public async Task ParseNaturalLanguageAsync_KoreanRecent1Hour_ReturnsCorrectInterval() { // Arrange var input = "FICQ-6101.PV 최근 1시간 평균"; // Act var sql = _service.ParseNaturalLanguageAsync(input).GetAwaiter().GetResult(); // Assert Assert.Contains("INTERVAL '1 hour'", sql); } [Fact] public async Task ParseNaturalLanguageAsync_KoreanRecent24Hours_ReturnsCorrectInterval() { // Arrange var input = "FICQ-6101.PV 최근 24시간 최대값"; // Act var sql = _service.ParseNaturalLanguageAsync(input).GetAwaiter().GetResult(); // Assert Assert.Contains("INTERVAL '24 hours'", sql); } [Fact] public async Task ParseNaturalLanguageAsync_KoreanRecent7Days_ReturnsCorrectInterval() { // Arrange var input = "FICQ-6101.PV 최근 7일 최소값"; // Act var sql = _service.ParseNaturalLanguageAsync(input).GetAwaiter().GetResult(); // Assert Assert.Contains("INTERVAL '7 days'", sql); } [Fact] public async Task ParseNaturalLanguageAsync_KoreanRecent1Month_ReturnsCorrectInterval() { // Arrange var input = "FICQ-6101.PV 최근 1개월 평균"; // Act var sql = _service.ParseNaturalLanguageAsync(input).GetAwaiter().GetResult(); // Assert Assert.Contains("INTERVAL '30 days'", sql); } [Fact] public async Task ParseNaturalLanguageAsync_KoreanDateRange_ReturnsTimeCondition() { // Arrange var input = "p-6102.hzset.fieldvalue 2026년 4월 13일 부터 4월 14일 까지"; // Act var sql = _service.ParseNaturalLanguageAsync(input).GetAwaiter().GetResult(); // Assert - 절대 범위 조건이 포함되어야 함 Assert.Contains("time", sql, StringComparison.OrdinalIgnoreCase); Assert.Contains(">=", sql, StringComparison.OrdinalIgnoreCase); Assert.Contains("AND", sql, StringComparison.OrdinalIgnoreCase); Assert.Contains("<", sql, StringComparison.OrdinalIgnoreCase); } [Fact] public async Task ParseNaturalLanguageAsync_KoreanFromToPattern_ReturnsTimeCondition() { // Arrange var input = "FICQ-6101.PV 4월 1일 부터 4월 7일 까지 평균"; // Act var sql = _service.ParseNaturalLanguageAsync(input).GetAwaiter().GetResult(); // Assert - 절대 범위 조건이 포함되어야 함 Assert.Contains("time", sql, StringComparison.OrdinalIgnoreCase); Assert.Contains(">=", sql, StringComparison.OrdinalIgnoreCase); } [Fact] public async Task ParseNaturalLanguageAsync_KoreanAmPmRange_ReturnsTimeCondition() { // Arrange var input = "FICQ-6101.PV 오전 9시부터 오후 6시까지 평균"; // Act var sql = _service.ParseNaturalLanguageAsync(input).GetAwaiter().GetResult(); // Assert - 절대 범위 조건이 포함되어야 함 Assert.Contains("time", sql, StringComparison.OrdinalIgnoreCase); Assert.Contains(">=", sql, StringComparison.OrdinalIgnoreCase); Assert.Contains("AND", sql, StringComparison.OrdinalIgnoreCase); } [Fact] public async Task ParseNaturalLanguageAsync_KoreanAfterPattern_ReturnsTimeCondition() { // Arrange var input = "FICQ-6101.PV 오늘 오후 2시 이후 값"; // Act var sql = _service.ParseNaturalLanguageAsync(input).GetAwaiter().GetResult(); // Assert - 시작 시간 조건이 포함되어야 함 Assert.Contains("time", sql, StringComparison.OrdinalIgnoreCase); Assert.Contains(">=", sql, StringComparison.OrdinalIgnoreCase); } [Fact] public async Task ParseNaturalLanguageAsync_KoreanWithDescription_ExtractsOnlyTagName() { // Arrange var input = "temp-001 온도, pressure-002 압력 최근 1시간 평균"; // Act var sql = _service.ParseNaturalLanguageAsync(input).GetAwaiter().GetResult(); // Assert - 한국어 설명은 제거되고 태그명만 포함되어야 함 Assert.Contains("'temp-001'", sql); Assert.Contains("'pressure-002'", sql); Assert.DoesNotContain("온도", sql); Assert.DoesNotContain("압력", sql); } [Fact] public async Task ParseNaturalLanguageAsync_KoreanWithTimeKeyword_ExtractsOnlyTagName() { // Arrange var input = "FICQ-6101.PV 최근 1시간 평균"; // Act var sql = _service.ParseNaturalLanguageAsync(input).GetAwaiter().GetResult(); // Assert - "최근" 키워드는 제거되고 태그명만 포함되어야 함 Assert.Contains("'FICQ-6101.PV'", sql); Assert.DoesNotContain("최근", sql); } [Fact] public async Task ParseNaturalLanguageAsync_KoreanWithTimePattern_ExtractsOnlyTagName() { // Arrange var input = "FICQ-6101.PV 1시간 평균"; // Act var sql = _service.ParseNaturalLanguageAsync(input).GetAwaiter().GetResult(); // Assert - "1시간" 패턴은 제거되고 태그명만 포함되어야 함 Assert.Contains("'FICQ-6101.PV'", sql); Assert.DoesNotContain("1시간", sql); } [Fact] public async Task ParseNaturalLanguageAsync_KoreanWithTagKeyword_ExtractsOnlyTagName() { // Arrange var input = "데이터 중 aia-131.sp 최근 1시간 평균"; // Act var sql = _service.ParseNaturalLanguageAsync(input).GetAwaiter().GetResult(); // Assert - "데이터 중" 키워드는 제거되고 태그명만 포함되어야 함 Assert.Contains("'aia-131.sp'", sql); Assert.DoesNotContain("데이터", sql); } [Fact] public async Task ParseNaturalLanguageAsync_KoreanWithMiddleKeyword_ExtractsOnlyTagName() { // Arrange var input = "데이터 중 aia-131.sp 최근 1시간 평균"; // Act var sql = _service.ParseNaturalLanguageAsync(input).GetAwaiter().GetResult(); // Assert - "중" 키워드 이후 태그명만 추출되어야 함 Assert.Contains("'aia-131.sp'", sql); } [Fact] public async Task ParseNaturalLanguageAsync_KoreanWithDotTagName_ExtractsCorrectly() { // Arrange var input = "p-6102.hzset.fieldvalue 2026년 4월 13일 부터 현재까지의 값 표시"; // Act var sql = _service.ParseNaturalLanguageAsync(input).GetAwaiter().GetResult(); // Assert Assert.Contains("'p-6102.hzset.fieldvalue'", sql); } [Fact] public async Task ParseNaturalLanguageAsync_KoreanWithNoSpaceTagName_UsesWholeInput() { // Arrange var input = "FICQ-6101.PV"; // Act var sql = _service.ParseNaturalLanguageAsync(input).GetAwaiter().GetResult(); // Assert Assert.Contains("'FICQ-6101.PV'", sql); } [Fact] public async Task ParseNaturalLanguageAsync_KoreanWithNoTimeSpecified_UsesDefault() { // Arrange var input = "FICQ-6101.PV"; // Act var sql = _service.ParseNaturalLanguageAsync(input).GetAwaiter().GetResult(); // Assert - default time bucket should be "5 min" Assert.Contains("5 min", sql); } [Fact] public async Task ParseNaturalLanguageAsync_KoreanWithAverageKeyword_UsesAvgFunction() { // Arrange var input = "FICQ-6101.PV 최근 1시간 average"; // Act var sql = _service.ParseNaturalLanguageAsync(input).GetAwaiter().GetResult(); // Assert Assert.Contains("avg", sql, StringComparison.OrdinalIgnoreCase); } [Fact] public async Task ParseNaturalLanguageAsync_KoreanWithMaxKeyword_UsesMaxFunction() { // Arrange var input = "FICQ-6101.PV 최근 1시간 최대"; // Act var sql = _service.ParseNaturalLanguageAsync(input).GetAwaiter().GetResult(); // Assert Assert.Contains("max", sql, StringComparison.OrdinalIgnoreCase); } } #endregion