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.
534 lines
17 KiB
C#
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
|