From 99923470049e2696b64e990494196163f403b2eb Mon Sep 17 00:00:00 2001 From: cct0831 Date: Thu, 19 Mar 2026 11:43:24 +0800 Subject: [PATCH] feat(analysis): add Saved Queries and CRUD tests (#656) --- .../Analysis/AnalysisSavedQuery.cs | 31 ++++ .../Analysis/AnalysisSavedQueryDtos.cs | 35 +++++ src/WalkingTec.Mvvm.Core/DataContext.cs | 3 + .../_AnalysisController.cs | 121 +++++++++++++++ .../Analysis/AnalysisControllerTests.cs | 145 ++++++++++++++++++ 5 files changed, 335 insertions(+) create mode 100644 src/WalkingTec.Mvvm.Core/Analysis/AnalysisSavedQuery.cs create mode 100644 src/WalkingTec.Mvvm.Core/Analysis/AnalysisSavedQueryDtos.cs diff --git a/src/WalkingTec.Mvvm.Core/Analysis/AnalysisSavedQuery.cs b/src/WalkingTec.Mvvm.Core/Analysis/AnalysisSavedQuery.cs new file mode 100644 index 000000000..cd06659fa --- /dev/null +++ b/src/WalkingTec.Mvvm.Core/Analysis/AnalysisSavedQuery.cs @@ -0,0 +1,31 @@ +#nullable enable +using System.ComponentModel.DataAnnotations; + +namespace WalkingTec.Mvvm.Core.Analysis +{ + /// + /// 儲存的 Analysis 查詢設定(維度、度量、篩選條件),支援 private/public 共享。 + /// + public class AnalysisSavedQuery : BasePoco + { + [Required] + [StringLength(100, ErrorMessage = "Validate.{0}stringmax{1}")] + public string Name { get; set; } = string.Empty; + + /// 對應的 ListVM FullName(在 AnalysisVmRegistry 白名單中查找)。 + [Required] + [StringLength(500)] + public string ListVmType { get; set; } = string.Empty; + + /// JSON-serialized AnalysisQueryRequest(dims、msrs、filters、hierarchies)。 + [Required] + public string ConfigJson { get; set; } = string.Empty; + + /// 擁有者的 ITCode(登入代碼)。 + [StringLength(50)] + public string? OwnerCode { get; set; } + + /// 是否公開(其他使用者可載入,但不能刪除)。 + public bool IsPublic { get; set; } = false; + } +} diff --git a/src/WalkingTec.Mvvm.Core/Analysis/AnalysisSavedQueryDtos.cs b/src/WalkingTec.Mvvm.Core/Analysis/AnalysisSavedQueryDtos.cs new file mode 100644 index 000000000..ec6870a77 --- /dev/null +++ b/src/WalkingTec.Mvvm.Core/Analysis/AnalysisSavedQueryDtos.cs @@ -0,0 +1,35 @@ +#nullable enable +using System; +using System.ComponentModel.DataAnnotations; + +namespace WalkingTec.Mvvm.Core.Analysis +{ + /// + /// 儲存查詢請求,包含查詢設定與顯示名稱。 + /// + public class SaveQueryRequest + { + [Required] + [StringLength(100)] + public string Name { get; set; } = string.Empty; + + public bool IsPublic { get; set; } = false; + + [Required] + public AnalysisQueryRequest Config { get; set; } = new(); + } + + /// + /// 儲存查詢清單項目 DTO(不含 ConfigJson,節省傳輸量)。 + /// + public class SavedQuerySummaryDto + { + public Guid Id { get; set; } + public string Name { get; set; } = string.Empty; + public string ListVmType { get; set; } = string.Empty; + public string OwnerCode { get; set; } = string.Empty; + public bool IsPublic { get; set; } + public bool IsOwner { get; set; } + public DateTime? CreatedAt { get; set; } + } +} diff --git a/src/WalkingTec.Mvvm.Core/DataContext.cs b/src/WalkingTec.Mvvm.Core/DataContext.cs index 934e9599b..ebca25d7a 100644 --- a/src/WalkingTec.Mvvm.Core/DataContext.cs +++ b/src/WalkingTec.Mvvm.Core/DataContext.cs @@ -22,6 +22,7 @@ using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; +using WalkingTec.Mvvm.Core.Analysis; using WalkingTec.Mvvm.Core.Extensions; using WalkingTec.Mvvm.Core.Models; using WalkingTec.Mvvm.Core.Support.Json; @@ -53,6 +54,7 @@ public partial class FrameworkContext : EmptyContext, IDataContext // public DbSet Elsa_WorkflowExecutionLogRecords { get; set; } // public DbSet Elsa_WorkflowInstances { get; set; } public DbSet FrameworkRefreshTokens { get; set; } = null!; + public DbSet AnalysisSavedQueries { get; set; } = null!; /// /// FrameworkContext @@ -448,6 +450,7 @@ public string? TenantCode public string? Version { get; set; } public CS ConnectionString { get; set; } = null!; + public DbSet AnalysisSavedQueries { get; set; } = null!; /// diff --git a/src/WalkingTec.Mvvm.Mvc/_AnalysisController.cs b/src/WalkingTec.Mvvm.Mvc/_AnalysisController.cs index 9f59b84e7..b64367e5e 100644 --- a/src/WalkingTec.Mvvm.Mvc/_AnalysisController.cs +++ b/src/WalkingTec.Mvvm.Mvc/_AnalysisController.cs @@ -290,6 +290,127 @@ public async Task PivotExport([FromBody] AnalysisPivotRequest? re "analysis_pivot.xlsx"); } + // ─── Saved Queries ───────────────────────────────────────────────── + + /// + /// GET /_analysis/savedqueries?listVmType=Foo.BarListVM + /// 回傳目前使用者的私有查詢 + 所有公開查詢(依建立時間降序)。 + /// + [HttpGet("savedqueries")] + [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] + public IActionResult ListSavedQueries([FromQuery] string listVmType) + { + if (string.IsNullOrWhiteSpace(listVmType)) + return BadRequest("listVmType is required."); + + var userCode = Wtm?.LoginUserInfo?.ITCode ?? string.Empty; + + var rows = Wtm!.DC.Set() + .Where(q => q.ListVmType == listVmType && (q.OwnerCode == userCode || q.IsPublic)) + .OrderByDescending(q => q.CreateTime) + .Select(q => new SavedQuerySummaryDto + { + Id = q.ID, + Name = q.Name, + ListVmType = q.ListVmType, + OwnerCode = q.OwnerCode ?? string.Empty, + IsPublic = q.IsPublic, + IsOwner = q.OwnerCode == userCode, + CreatedAt = q.CreateTime + }) + .ToList(); + + return Ok(rows); + } + + /// + /// POST /_analysis/savedqueries + /// 儲存一個查詢設定,回傳新建立的記錄 ID。 + /// + [HttpPost("savedqueries")] + [ProducesResponseType(typeof(object), StatusCodes.Status201Created)] + [ProducesResponseType(typeof(string), StatusCodes.Status400BadRequest)] + public IActionResult SaveQuery([FromBody] SaveQueryRequest? req) + { + if (req == null) return BadRequest("Request body is required."); + if (string.IsNullOrWhiteSpace(req.Name)) return BadRequest("查詢名稱不可為空。"); + if (string.IsNullOrWhiteSpace(req.Config?.ListVmType)) return BadRequest("Config.ListVmType is required."); + + try { _registry.Resolve(req.Config.ListVmType); } + catch (AnalysisVmNotFoundException ex) { return NotFound(ex.Message); } + + var userCode = Wtm?.LoginUserInfo?.ITCode ?? string.Empty; + var configJson = JsonSerializer.Serialize(req.Config, _camelCase); + + var entity = new AnalysisSavedQuery + { + Name = req.Name.Trim(), + ListVmType = req.Config.ListVmType, + ConfigJson = configJson, + OwnerCode = userCode, + IsPublic = req.IsPublic, + CreateTime = DateTime.Now, + CreateBy = userCode + }; + + Wtm!.DC.Set().Add(entity); + Wtm.DC.SaveChanges(); + + _logger.LogInformation("Analysis saved query created Id={Id} Name={Name} ListVm={ListVm} Owner={Owner} IsPublic={IsPublic}", + entity.ID, entity.Name, entity.ListVmType, entity.OwnerCode, entity.IsPublic); + + return CreatedAtAction(nameof(GetSavedQuery), new { id = entity.ID }, + new { id = entity.ID, name = entity.Name }); + } + + /// + /// GET /_analysis/savedqueries/{id} + /// 載入指定 ID 的查詢設定(回傳 AnalysisQueryRequest)。 + /// + [HttpGet("savedqueries/{id:guid}")] + [ProducesResponseType(typeof(AnalysisQueryRequest), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public IActionResult GetSavedQuery(Guid id) + { + var entity = Wtm!.DC.Set().FirstOrDefault(q => q.ID == id); + if (entity == null) return NotFound(); + + var userCode = Wtm.LoginUserInfo?.ITCode ?? string.Empty; + if (!entity.IsPublic && entity.OwnerCode != userCode) return Forbid(); + + AnalysisQueryRequest? config; + try { config = JsonSerializer.Deserialize(entity.ConfigJson, _camelCase); } + catch (JsonException) { return BadRequest("儲存的查詢格式無效。"); } + + if (config == null) return BadRequest("儲存的查詢格式無效。"); + + return new JsonResult(config, _camelCase); + } + + /// + /// DELETE /_analysis/savedqueries/{id} + /// 刪除儲存的查詢(只有擁有者可刪除)。 + /// + [HttpDelete("savedqueries/{id:guid}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public IActionResult DeleteSavedQuery(Guid id) + { + var entity = Wtm!.DC.Set().FirstOrDefault(q => q.ID == id); + if (entity == null) return NotFound(); + + var userCode = Wtm.LoginUserInfo?.ITCode ?? string.Empty; + if (entity.OwnerCode != userCode) return Forbid(); + + Wtm.DC.Set().Remove(entity); + Wtm.DC.SaveChanges(); + + _logger.LogInformation("Analysis saved query deleted Id={Id} Owner={Owner}", id, userCode); + return NoContent(); + } + // ─── Helpers ─────────────────────────────────────────────────────── /// diff --git a/test/WalkingTec.Mvvm.Core.Test/Analysis/AnalysisControllerTests.cs b/test/WalkingTec.Mvvm.Core.Test/Analysis/AnalysisControllerTests.cs index c1feb19ff..dd58c035d 100644 --- a/test/WalkingTec.Mvvm.Core.Test/Analysis/AnalysisControllerTests.cs +++ b/test/WalkingTec.Mvvm.Core.Test/Analysis/AnalysisControllerTests.cs @@ -2410,6 +2410,7 @@ public async Task Export_happy_path_writes_ActionLog() StringAssert.Contains(logMsg, "Export", "ActionLog 應記錄 ActionName 包含 Export"); } + [TestMethod] public async Task Query_validation_failure_does_not_write_ActionLog() { @@ -2432,5 +2433,149 @@ public async Task Query_validation_failure_does_not_write_ActionLog() Assert.AreEqual(0, actionLogger.Messages.Count, "驗證失敗時不應寫入 ActionLog"); } + + // ─── Saved Queries CRUD & 權限測試 ─────────────────────────────────────── + + [TestMethod] + public void SaveQuery_creates_new_record() + { + var controller = CreateController(); + controller.Wtm.LoginUserInfo.ITCode = "testuser"; + + var req = new SaveQueryRequest + { + Name = "My Sales Query", + IsPublic = true, + Config = new AnalysisQueryRequest + { + ListVmType = typeof(SaleRecordListVM).FullName, + Dimensions = new List { "Region" }, + Measures = new List { new MeasureRequest { Field = "Amount", Func = AggregateFunc.Sum } } + } + }; + + var result = controller.SaveQuery(req) as CreatedAtActionResult; + Assert.IsNotNull(result); + + // Verify DB + var query = controller.Wtm.DC.Set().FirstOrDefault(); + Assert.IsNotNull(query); + Assert.AreEqual("My Sales Query", query.Name); + Assert.AreEqual(typeof(SaleRecordListVM).FullName, query.ListVmType); + Assert.AreEqual("testuser", query.OwnerCode); + Assert.IsTrue(query.IsPublic); + } + + [TestMethod] + public void ListSavedQueries_returns_public_and_owned_queries() + { + var controller = CreateController(); + controller.Wtm.LoginUserInfo.ITCode = "userA"; + + var vmType = typeof(SaleRecordListVM).FullName; + var configJson = "{}"; + + controller.Wtm.DC.Set().AddRange( + new AnalysisSavedQuery { Name = "A_Private", ListVmType = vmType, OwnerCode = "userA", IsPublic = false, ConfigJson = configJson }, + new AnalysisSavedQuery { Name = "A_Public", ListVmType = vmType, OwnerCode = "userA", IsPublic = true, ConfigJson = configJson }, + new AnalysisSavedQuery { Name = "B_Private", ListVmType = vmType, OwnerCode = "userB", IsPublic = false, ConfigJson = configJson }, + new AnalysisSavedQuery { Name = "B_Public", ListVmType = vmType, OwnerCode = "userB", IsPublic = true, ConfigJson = configJson } + ); + controller.Wtm.DC.SaveChanges(); + + var result = controller.ListSavedQueries(vmType) as OkObjectResult; + Assert.IsNotNull(result); + + var list = result.Value as IEnumerable; + Assert.IsNotNull(list); + + var names = list.Select(x => x.Name).ToList(); + Assert.IsTrue(names.Contains("A_Private"), "UserA 應能看到自己的 Private 查詢"); + Assert.IsTrue(names.Contains("A_Public")); + Assert.IsFalse(names.Contains("B_Private"), "UserA 不應看到 UserB 的 Private 查詢"); + Assert.IsTrue(names.Contains("B_Public"), "UserA 應能看到 UserB 的 Public 查詢"); + } + + [TestMethod] + public void GetSavedQuery_returns_config_for_authorized_user() + { + var controller = CreateController(); + controller.Wtm.LoginUserInfo.ITCode = "userA"; + var vmType = typeof(SaleRecordListVM).FullName; + + var id = Guid.NewGuid(); + controller.Wtm.DC.Set().Add( + new AnalysisSavedQuery + { + ID = id, + Name = "Test", + ListVmType = vmType, + OwnerCode = "userB", + IsPublic = true, + ConfigJson = "{\"listVmType\":\"" + vmType + "\"}" + } + ); + controller.Wtm.DC.SaveChanges(); + + var result = controller.GetSavedQuery(id) as JsonResult; + Assert.IsNotNull(result, "存取公開查詢應回傳 200"); + var config = result.Value as AnalysisQueryRequest; + Assert.IsNotNull(config); + Assert.AreEqual(vmType, config.ListVmType); + } + + [TestMethod] + public void GetSavedQuery_returns_403_for_unauthorized_user() + { + var controller = CreateController(); + controller.Wtm.LoginUserInfo.ITCode = "userA"; + + var id = Guid.NewGuid(); + controller.Wtm.DC.Set().Add( + new AnalysisSavedQuery { ID = id, Name = "Test", ListVmType = "VM", OwnerCode = "userB", IsPublic = false, ConfigJson = "{}" } + ); + controller.Wtm.DC.SaveChanges(); + + var result = controller.GetSavedQuery(id) as ForbidResult; + Assert.IsNotNull(result, "存取他人的私有查詢應回傳 403 Forbid"); + } + + [TestMethod] + public void DeleteSavedQuery_removes_record_if_owner() + { + var controller = CreateController(); + controller.Wtm.LoginUserInfo.ITCode = "userA"; + + var id = Guid.NewGuid(); + controller.Wtm.DC.Set().Add( + new AnalysisSavedQuery { ID = id, Name = "Test", ListVmType = "VM", OwnerCode = "userA", ConfigJson = "{}" } + ); + controller.Wtm.DC.SaveChanges(); + + var result = controller.DeleteSavedQuery(id) as NoContentResult; + Assert.IsNotNull(result); + + var query = controller.Wtm.DC.Set().FirstOrDefault(q => q.ID == id); + Assert.IsNull(query, "查詢應被刪除"); + } + + [TestMethod] + public void DeleteSavedQuery_returns_403_if_not_owner() + { + var controller = CreateController(); + controller.Wtm.LoginUserInfo.ITCode = "userA"; + + var id = Guid.NewGuid(); + controller.Wtm.DC.Set().Add( + new AnalysisSavedQuery { ID = id, Name = "Test", ListVmType = "VM", OwnerCode = "userB", ConfigJson = "{}" } + ); + controller.Wtm.DC.SaveChanges(); + + var result = controller.DeleteSavedQuery(id) as ForbidResult; + Assert.IsNotNull(result, "刪除他人查詢應回傳 403 Forbid"); + + var query = controller.Wtm.DC.Set().FirstOrDefault(q => q.ID == id); + Assert.IsNotNull(query, "查詢不應被刪除"); + } } }