Files
ExperionCrawler/ExperionCrawler.Tests/TextToSqlTest.cs
windpacer 77bdcf1f7f feat: ExperionCrawler IIoT OPC UA Data Bridge Infrastructure
Major project initialization and feature implementation:

**Core Features:**
- OPC UA client for Honeywell Experion HS R530 integration
- Real-time data streaming and history data retrieval
- Text-to-SQL query engine with TimeScaleDB
- JSON-based node configuration system
- SQLite database with migration support

**Architecture:**
- Clean architecture with Domain, Application, Infrastructure layers
- ASP.NET Core Web API frontend
- Web UI with real-time visualization
- PKI-based OPC UA authentication (TLS)

**Infrastructure Components:**
- ExperionOpcClient: OPC UA connection management
- ExperionRealtimeService: Real-time data streaming
- ExperionHistoryService: Historical data queries
- TextToSqlService: Natural language to SQL queries
- SqlValidator: SQL injection prevention

**Database:**
- TimescaleDB integration (recommended) or SQLite fallback
- Entity Framework Core with Extenstion methods
- OPCTag, KeyValue tables for data storage

**Security:**
- Certificate-based OPC UA endpoint security
- SSL/TLS encryption for database connections
- Output param binding injection prevention

**Testing:**
- Unit tests for TextToSqlService and SqlValidator
- Integration tests for Korean time range extraction

See REVIEW_REQUEST.md for detailed code review information.
2026-04-26 19:28:56 +09:00

534 lines
17 KiB
C#

using ExperionCrawler.Core.Application.DTOs;
using ExperionCrawler.Core.Application.Services;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Xunit;
namespace ExperionCrawler.Tests;
/// <summary>
/// TextToSqlService 통합 테스트
/// task_state.md의 매핑표 기반으로 작성된 테스트 프로그램
/// </summary>
public class TextToSqlTest
{
private readonly TextToSqlService _service;
private readonly ILogger<TextToSqlService> _logger;
public TextToSqlTest()
{
// Mock logger creation
var loggerFactory = new LoggerFactory();
_logger = loggerFactory.CreateLogger<TextToSqlService>();
// Mock configuration with a dummy connection string
var config = new Dictionary<string, string?>
{
{ "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<ArgumentException>(() => _service.ParseNaturalLanguageAsync(""));
await Assert.ThrowsAsync<ArgumentException>(() => _service.ParseNaturalLanguageAsync(null!));
await Assert.ThrowsAsync<ArgumentException>(() => _service.ParseNaturalLanguageAsync(" "));
}
[Fact]
public async Task ParseNaturalLanguageAsync_WithWhitespaceOnly_ThrowsArgumentException()
{
// Arrange & Act & Assert
await Assert.ThrowsAsync<ArgumentException>(() => _service.ParseNaturalLanguageAsync(" "));
}
[Fact]
public async Task ParseNaturalLanguageAsync_WithOnlyTimeKeyword_ThrowsArgumentException()
{
// Arrange & Act & Assert
await Assert.ThrowsAsync<ArgumentException>(() => _service.ParseNaturalLanguageAsync("최근 1시간"));
}
[Fact]
public async Task ParseNaturalLanguageAsync_WithOnlyDescription_ThrowsArgumentException()
{
// Arrange & Act & Assert
await Assert.ThrowsAsync<ArgumentException>(() => _service.ParseNaturalLanguageAsync("온도 값"));
}
[Fact]
public async Task ExecuteQueryAsync_WithEmptySql_ThrowsException()
{
// Arrange
var sql = "";
// Act & Assert
await Assert.ThrowsAsync<Exception>(() => _service.ExecuteQueryAsync(sql));
}
[Fact]
public async Task ExecuteQueryAsync_WithNullSql_ThrowsException()
{
// Arrange
string? sql = null;
// Act & Assert
await Assert.ThrowsAsync<Exception>(() => _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