diff --git a/src/AgileConfig.Server.Apisite/Controllers/AppController.cs b/src/AgileConfig.Server.Apisite/Controllers/AppController.cs index 04e4c560..5e1a04a0 100644 --- a/src/AgileConfig.Server.Apisite/Controllers/AppController.cs +++ b/src/AgileConfig.Server.Apisite/Controllers/AppController.cs @@ -14,6 +14,7 @@ using AgileConfig.Server.Event; using AgileConfig.Server.IService; using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Newtonsoft.Json; @@ -382,6 +383,243 @@ public async Task Export([FromBody] AppExportRequest model) return File(Encoding.UTF8.GetBytes(json), "application/json", fileName); } + [TypeFilter(typeof(PermissionCheckAttribute), Arguments = new object[] { Functions.App_Add })] + [HttpPost] + public async Task PreviewImport(IFormFile file) + { + var importFile = await ReadImportFileAsync(file); + var preview = await BuildImportPreviewAsync(importFile); + return Json(new + { + success = !preview.Errors.Any(), + data = preview, + message = preview.Errors.FirstOrDefault() + }); + } + + [TypeFilter(typeof(PermissionCheckAttribute), Arguments = new object[] { Functions.App_Add })] + [HttpPost] + public async Task Import([FromBody] AppImportRequest model) + { + ArgumentNullException.ThrowIfNull(model); + ArgumentNullException.ThrowIfNull(model.File); + + var preview = await BuildImportPreviewAsync(model.File); + if (preview.Errors.Any()) + return Json(new + { + success = false, + data = preview, + message = string.Join(Environment.NewLine, preview.Errors) + }); + + var currentUserId = await this.GetCurrentUserId(_userService); + var now = DateTime.Now; + + foreach (var previewItem in preview.Apps.OrderBy(x => x.Order)) + { + var importItem = model.File.Apps.First(x => string.Equals(x.App?.Id, previewItem.AppId, StringComparison.OrdinalIgnoreCase)); + var app = new App + { + Id = importItem.App.Id, + Name = importItem.App.Name, + Group = importItem.App.Group, + Secret = importItem.App.Secret, + Enabled = importItem.App.Enabled, + Type = importItem.App.Inheritanced ? AppType.Inheritance : AppType.PRIVATE, + CreateTime = now, + Creator = currentUserId + }; + + var inheritanceApps = BuildInheritanceLinks(importItem.App.InheritancedApps, app.Id); + await _appService.AddAsync(app, inheritanceApps); + + foreach (var envConfigs in importItem.Envs) + { + foreach (var configVm in envConfigs.Value ?? new List()) + { + var config = new Config + { + Id = Guid.NewGuid().ToString("N"), + AppId = app.Id, + Env = envConfigs.Key, + Group = configVm.Group, + Key = configVm.Key, + Value = configVm.Value, + Description = configVm.Description, + CreateTime = now, + Status = ConfigStatus.Enabled, + OnlineStatus = OnlineStatus.WaitPublish, + EditStatus = EditStatus.Add + }; + await _configService.AddAsync(config, envConfigs.Key); + } + } + } + + return Json(new + { + success = true, + data = preview + }); + } + + private static List BuildInheritanceLinks(List parentIds, string appId) + { + var inheritanceApps = new List(); + if (parentIds == null) return inheritanceApps; + + var sort = 0; + foreach (var parentId in parentIds.Where(x => !string.IsNullOrWhiteSpace(x)).Distinct(StringComparer.OrdinalIgnoreCase)) + inheritanceApps.Add(new AppInheritanced + { + Id = Guid.NewGuid().ToString("N"), + AppId = appId, + InheritancedAppId = parentId, + Sort = sort++ + }); + + return inheritanceApps; + } + + private async Task ReadImportFileAsync(IFormFile file) + { + if (file == null || file.Length == 0) throw new ArgumentException("file"); + + using var stream = file.OpenReadStream(); + using var reader = new System.IO.StreamReader(stream, Encoding.UTF8); + var content = await reader.ReadToEndAsync(); + var importFile = JsonConvert.DeserializeObject(content); + if (importFile == null) throw new ArgumentException("file"); + + return importFile; + } + + private async Task BuildImportPreviewAsync(AppExportFileVM importFile) + { + var preview = new AppImportPreviewVM(); + if (importFile?.Apps == null || !importFile.Apps.Any()) + { + preview.Errors.Add("Import file does not contain any apps."); + return preview; + } + + var appItems = importFile.Apps + .Where(x => x?.App != null) + .ToList(); + if (!appItems.Any()) + { + preview.Errors.Add("Import file does not contain any valid app entries."); + return preview; + } + + var duplicateIds = appItems + .GroupBy(x => x.App.Id ?? string.Empty, StringComparer.OrdinalIgnoreCase) + .Where(x => !string.IsNullOrWhiteSpace(x.Key) && x.Count() > 1) + .Select(x => x.Key) + .OrderBy(x => x, StringComparer.OrdinalIgnoreCase) + .ToList(); + preview.Errors.AddRange(duplicateIds.Select(x => $"Duplicate AppId in import file: {x}.")); + + var duplicateNames = appItems + .GroupBy(x => x.App.Name ?? string.Empty, StringComparer.OrdinalIgnoreCase) + .Where(x => !string.IsNullOrWhiteSpace(x.Key) && x.Count() > 1) + .Select(x => x.Key) + .OrderBy(x => x, StringComparer.OrdinalIgnoreCase) + .ToList(); + preview.Errors.AddRange(duplicateNames.Select(x => $"Duplicate app name in import file: {x}.")); + + foreach (var item in appItems) + { + if (string.IsNullOrWhiteSpace(item.App.Id)) preview.Errors.Add("Imported app is missing AppId."); + if (string.IsNullOrWhiteSpace(item.App.Name)) preview.Errors.Add("Imported app is missing Name."); + } + + var importedAppIds = new HashSet(appItems.Select(x => x.App.Id).Where(x => !string.IsNullOrWhiteSpace(x)), StringComparer.OrdinalIgnoreCase); + var existingApps = await _appService.GetAllAppsAsync(); + + foreach (var item in appItems.Where(x => x.App != null && !string.IsNullOrWhiteSpace(x.App.Id))) + { + if (existingApps.Any(x => string.Equals(x.Id, item.App.Id, StringComparison.OrdinalIgnoreCase))) + preview.Errors.Add($"AppId already exists: {item.App.Id}."); + if (!string.IsNullOrWhiteSpace(item.App.Name) && existingApps.Any(x => string.Equals(x.Name, item.App.Name, StringComparison.OrdinalIgnoreCase))) + preview.Errors.Add($"App name already exists: {item.App.Name}."); + } + + foreach (var item in appItems) + { + foreach (var parentId in item.App.InheritancedApps?.Where(x => !string.IsNullOrWhiteSpace(x)).Distinct(StringComparer.OrdinalIgnoreCase) ?? new List()) + { + if (importedAppIds.Contains(parentId)) continue; + if (existingApps.Any(x => string.Equals(x.Id, parentId, StringComparison.OrdinalIgnoreCase))) continue; + preview.Errors.Add($"App '{item.App.Id}' references missing parent '{parentId}'. Parent must already exist or be included in the import file."); + } + } + + var orderLookup = TryTopologicalSort(appItems, importedAppIds, preview.Errors); + if (preview.Errors.Any()) return preview; + + preview.Apps = appItems + .OrderBy(x => orderLookup[x.App.Id]) + .Select(x => new AppImportPreviewItemVM + { + AppId = x.App.Id, + Name = x.App.Name, + Group = x.App.Group, + Enabled = x.App.Enabled, + Inheritanced = x.App.Inheritanced, + InheritancedApps = x.App.InheritancedApps?.Where(v => !string.IsNullOrWhiteSpace(v)).Distinct(StringComparer.OrdinalIgnoreCase).ToList() ?? new List(), + EnvCount = x.Envs?.Count ?? 0, + ConfigCount = x.Envs?.Sum(env => env.Value?.Count ?? 0) ?? 0, + Order = orderLookup[x.App.Id] + }) + .ToList(); + + return preview; + } + + private static Dictionary TryTopologicalSort(List appItems, HashSet importedAppIds, List errors) + { + var dependencyMap = appItems.ToDictionary( + x => x.App.Id, + x => (x.App.InheritancedApps ?? new List()) + .Where(parentId => !string.IsNullOrWhiteSpace(parentId) && importedAppIds.Contains(parentId)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(), + StringComparer.OrdinalIgnoreCase); + + var inDegree = dependencyMap.ToDictionary(x => x.Key, _ => 0, StringComparer.OrdinalIgnoreCase); + var childMap = dependencyMap.Keys.ToDictionary(x => x, _ => new List(), StringComparer.OrdinalIgnoreCase); + + foreach (var entry in dependencyMap) + { + inDegree[entry.Key] = entry.Value.Count; + foreach (var parentId in entry.Value) childMap[parentId].Add(entry.Key); + } + + var queue = new Queue(inDegree.Where(x => x.Value == 0).Select(x => x.Key).OrderBy(x => x, StringComparer.OrdinalIgnoreCase)); + var ordered = new List(); + while (queue.Any()) + { + var next = queue.Dequeue(); + ordered.Add(next); + + foreach (var child in childMap[next].OrderBy(x => x, StringComparer.OrdinalIgnoreCase)) + { + inDegree[child]--; + if (inDegree[child] == 0) queue.Enqueue(child); + } + } + + if (ordered.Count != dependencyMap.Count) + { + var cyclicApps = inDegree.Where(x => x.Value > 0).Select(x => x.Key).OrderBy(x => x, StringComparer.OrdinalIgnoreCase); + errors.Add($"Cyclic inheritance detected among imported apps: {string.Join(", ", cyclicApps)}."); + } + + return ordered.Select((appId, index) => new { appId, index }).ToDictionary(x => x.appId, x => x.index + 1, StringComparer.OrdinalIgnoreCase); + } + /// /// Get all applications that can be inherited. /// @@ -462,4 +700,4 @@ public async Task GetAppGroups() data = groups.OrderBy(x => x) }); } -} \ No newline at end of file +} diff --git a/src/AgileConfig.Server.Apisite/Models/AppExportVM.cs b/src/AgileConfig.Server.Apisite/Models/AppExportVM.cs index f6645b9c..73f963ae 100644 --- a/src/AgileConfig.Server.Apisite/Models/AppExportVM.cs +++ b/src/AgileConfig.Server.Apisite/Models/AppExportVM.cs @@ -10,6 +10,12 @@ public class AppExportRequest public List AppIds { get; set; } } +public class AppImportRequest +{ + [JsonProperty("file")] + public AppExportFileVM File { get; set; } +} + public class AppExportFileVM { [JsonProperty("schemaVersion")] @@ -72,3 +78,42 @@ public class AppExportConfigVM [JsonProperty("description")] public string Description { get; set; } } + +public class AppImportPreviewVM +{ + [JsonProperty("apps")] + public List Apps { get; set; } = new(); + + [JsonProperty("errors")] + public List Errors { get; set; } = new(); +} + +public class AppImportPreviewItemVM +{ + [JsonProperty("appId")] + public string AppId { get; set; } + + [JsonProperty("name")] + public string Name { get; set; } + + [JsonProperty("group")] + public string Group { get; set; } + + [JsonProperty("enabled")] + public bool Enabled { get; set; } + + [JsonProperty("inheritanced")] + public bool Inheritanced { get; set; } + + [JsonProperty("inheritancedApps")] + public List InheritancedApps { get; set; } = new(); + + [JsonProperty("envCount")] + public int EnvCount { get; set; } + + [JsonProperty("configCount")] + public int ConfigCount { get; set; } + + [JsonProperty("order")] + public int Order { get; set; } +} diff --git a/src/AgileConfig.Server.UI/react-ui-antd/src/locales/en-US/pages.ts b/src/AgileConfig.Server.UI/react-ui-antd/src/locales/en-US/pages.ts index 79e3988f..2dc51677 100644 --- a/src/AgileConfig.Server.UI/react-ui-antd/src/locales/en-US/pages.ts +++ b/src/AgileConfig.Server.UI/react-ui-antd/src/locales/en-US/pages.ts @@ -132,6 +132,22 @@ export default { 'pages.app.table.cols.admin': 'Administrator', 'pages.app.table.cols.action.auth': 'Authorization', 'pages.app.table.group_aggregation': 'Group Aggregation', + 'pages.app.import.button': 'Import', + 'pages.app.import.title': 'Import Apps', + 'pages.app.import.tip': 'Import only creates new apps and config records. Parent apps must already exist or be included in the same file.', + 'pages.app.import.select_file': 'Select JSON File', + 'pages.app.import.preview.success': 'Import preview generated successfully', + 'pages.app.import.preview.failed': 'Import preview failed', + 'pages.app.import.preview.order': 'Order', + 'pages.app.import.preview.parents': 'Parents', + 'pages.app.import.preview.envCount': 'Envs', + 'pages.app.import.preview.configCount': 'Configs', + 'pages.app.import.validation_failed': 'Import validation failed', + 'pages.app.import.no_valid_preview': 'Resolve preview errors before importing', + 'pages.app.import.importing': 'Importing apps...', + 'pages.app.import.success': 'Apps imported successfully', + 'pages.app.import.failed': 'App import failed', + 'pages.app.import.empty': 'Upload a file to preview the import', 'pages.app.auth.title': 'User Authorization', 'pages.app.auth.bind_users': 'Bind Users', diff --git a/src/AgileConfig.Server.UI/react-ui-antd/src/locales/zh-CN/pages.ts b/src/AgileConfig.Server.UI/react-ui-antd/src/locales/zh-CN/pages.ts index 50b9ba97..d8cdefcc 100644 --- a/src/AgileConfig.Server.UI/react-ui-antd/src/locales/zh-CN/pages.ts +++ b/src/AgileConfig.Server.UI/react-ui-antd/src/locales/zh-CN/pages.ts @@ -129,6 +129,22 @@ export default { 'pages.app.table.cols.admin': '管理员', 'pages.app.table.cols.action.auth': '授权', 'pages.app.table.group_aggregation': '分组聚合', + 'pages.app.import.button': '导入', + 'pages.app.import.title': '导入应用', + 'pages.app.import.tip': '导入仅创建新应用和新的配置记录。父应用必须已存在于目标系统中,或同时包含在导入文件内。', + 'pages.app.import.select_file': '选择 JSON 文件', + 'pages.app.import.preview.success': '导入预览生成成功', + 'pages.app.import.preview.failed': '导入预览失败', + 'pages.app.import.preview.order': '顺序', + 'pages.app.import.preview.parents': '父应用', + 'pages.app.import.preview.envCount': '环境数', + 'pages.app.import.preview.configCount': '配置数', + 'pages.app.import.validation_failed': '导入校验失败', + 'pages.app.import.no_valid_preview': '请先解决预览中的错误再导入', + 'pages.app.import.importing': '正在导入应用...', + 'pages.app.import.success': '应用导入成功', + 'pages.app.import.failed': '应用导入失败', + 'pages.app.import.empty': '上传文件后可预览导入内容', 'pages.app.auth.title': '用户授权', 'pages.app.auth.bind_users': '绑定用户', diff --git a/src/AgileConfig.Server.UI/react-ui-antd/src/pages/Apps/comps/AppImport.tsx b/src/AgileConfig.Server.UI/react-ui-antd/src/pages/Apps/comps/AppImport.tsx new file mode 100644 index 00000000..f479762c --- /dev/null +++ b/src/AgileConfig.Server.UI/react-ui-antd/src/pages/Apps/comps/AppImport.tsx @@ -0,0 +1,169 @@ +import { getIntl, getLocale, useIntl } from 'umi'; +import { UploadOutlined } from '@ant-design/icons'; +import { Alert, Button, message, Modal, Space, Table, Upload } from 'antd'; +import React, { useMemo, useState } from 'react'; +import { AppImportFile, AppImportPreviewItem, AppImportPreviewResult } from '../data'; +import { importApps, previewImportApps } from '../service'; + +export type AppImportProps = { + visible: boolean; + onCancel: () => void; + onSuccess: () => void; +}; + +const AppImport: React.FC = ({ visible, onCancel, onSuccess }) => { + const intl = useIntl(); + const [preview, setPreview] = useState(); + const [importFile, setImportFile] = useState(); + const [uploading, setUploading] = useState(false); + const [submitting, setSubmitting] = useState(false); + + const reset = () => { + setPreview(undefined); + setImportFile(undefined); + setUploading(false); + setSubmitting(false); + }; + + const columns = useMemo( + () => [ + { + title: intl.formatMessage({ id: 'pages.app.import.preview.order' }), + dataIndex: 'order', + width: 80, + }, + { + title: intl.formatMessage({ id: 'pages.app.table.cols.appid' }), + dataIndex: 'appId', + }, + { + title: intl.formatMessage({ id: 'pages.app.table.cols.appname' }), + dataIndex: 'name', + }, + { + title: intl.formatMessage({ id: 'pages.app.table.cols.group' }), + dataIndex: 'group', + }, + { + title: intl.formatMessage({ id: 'pages.app.import.preview.parents' }), + dataIndex: 'inheritancedApps', + render: (value: string[]) => value?.join(', ') || '-', + }, + { + title: intl.formatMessage({ id: 'pages.app.import.preview.envCount' }), + dataIndex: 'envCount', + width: 90, + }, + { + title: intl.formatMessage({ id: 'pages.app.import.preview.configCount' }), + dataIndex: 'configCount', + width: 110, + }, + ], + [intl], + ); + + const handlePreview = async (file: File) => { + setUploading(true); + try { + const text = await file.text(); + const parsedFile = JSON.parse(text) as AppImportFile; + setImportFile(parsedFile); + + const result = await previewImportApps(file); + if (result.success) { + setPreview(result.data); + message.success(intl.formatMessage({ id: 'pages.app.import.preview.success' })); + } else { + setPreview(result.data); + message.error(result.message || intl.formatMessage({ id: 'pages.app.import.preview.failed' })); + } + } catch (error) { + setPreview(undefined); + setImportFile(undefined); + message.error(intl.formatMessage({ id: 'pages.app.import.preview.failed' })); + } finally { + setUploading(false); + } + + return false; + }; + + const handleImport = async () => { + if (!importFile || !preview?.apps?.length || preview.errors?.length) { + message.warning(intl.formatMessage({ id: 'pages.app.import.no_valid_preview' })); + return; + } + + setSubmitting(true); + const hide = message.loading( + getIntl(getLocale()).formatMessage({ + id: 'pages.app.import.importing', + }), + ); + try { + const result = await importApps(importFile); + hide(); + if (result.success) { + message.success(intl.formatMessage({ id: 'pages.app.import.success' })); + reset(); + onSuccess(); + } else { + message.error(result.message || intl.formatMessage({ id: 'pages.app.import.failed' })); + } + } catch (error) { + hide(); + message.error(intl.formatMessage({ id: 'pages.app.import.failed' })); + } finally { + setSubmitting(false); + } + }; + + return ( + { + reset(); + onCancel(); + }} + onOk={handleImport} + okButtonProps={{ disabled: !preview?.apps?.length || !!preview?.errors?.length, loading: submitting }} + destroyOnClose + > + + + + + + {!!preview?.errors?.length && ( + + {preview.errors.map((item) => ( +
{item}
+ ))} + + } + /> + )} + + rowKey="appId" + pagination={false} + columns={columns} + dataSource={preview?.apps || []} + locale={{ emptyText: intl.formatMessage({ id: 'pages.app.import.empty' }) }} + /> +
+
+ ); +}; + +export default AppImport; diff --git a/src/AgileConfig.Server.UI/react-ui-antd/src/pages/Apps/data.d.ts b/src/AgileConfig.Server.UI/react-ui-antd/src/pages/Apps/data.d.ts index e478aae1..444c0017 100644 --- a/src/AgileConfig.Server.UI/react-ui-antd/src/pages/Apps/data.d.ts +++ b/src/AgileConfig.Server.UI/react-ui-antd/src/pages/Apps/data.d.ts @@ -33,3 +33,38 @@ export type UserAppAuth = { appId: string, authorizedUsers?: string[] } + +export type AppImportPreviewItem = { + appId: string; + name: string; + group?: string; + enabled: boolean; + inheritanced: boolean; + inheritancedApps: string[]; + envCount: number; + configCount: number; + order: number; +} + +export type AppImportPreviewResult = { + apps: AppImportPreviewItem[]; + errors: string[]; +} + +export type AppImportFile = { + schemaVersion: number; + exportedAt: string; + apps: { + app: { + id: string; + name: string; + group?: string; + secret?: string; + enabled: boolean; + type: number; + inheritanced: boolean; + inheritancedApps: string[]; + }; + envs: Record; + }[]; +} diff --git a/src/AgileConfig.Server.UI/react-ui-antd/src/pages/Apps/index.tsx b/src/AgileConfig.Server.UI/react-ui-antd/src/pages/Apps/index.tsx index c98092a2..94087f83 100644 --- a/src/AgileConfig.Server.UI/react-ui-antd/src/pages/Apps/index.tsx +++ b/src/AgileConfig.Server.UI/react-ui-antd/src/pages/Apps/index.tsx @@ -1,4 +1,4 @@ -import { DownloadOutlined, ExclamationCircleOutlined, PlusOutlined } from '@ant-design/icons'; +import { DownloadOutlined, ExclamationCircleOutlined, PlusOutlined, UploadOutlined } from '@ant-design/icons'; import { ModalForm, ProFormDependency, @@ -23,6 +23,7 @@ import { import React, { Key, useState, useRef, useEffect } from 'react'; import { getIntl, getLocale, Link, useIntl } from 'umi'; import UpdateForm from './comps/updateForm'; +import AppImport from './comps/AppImport'; import { AppListItem, AppListParams, AppListResult, UserAppAuth } from './data'; import { addApp, @@ -224,6 +225,7 @@ const appList: React.FC = (props) => { const [createModalVisible, setCreateModalVisible] = useState(false); const [updateModalVisible, setUpdateModalVisible] = useState(false); const [userAuthModalVisible, setUserAuthModalVisible] = useState(false); + const [importModalVisible, setImportModalVisible] = useState(false); const [currentRow, setCurrentRow] = useState(); const [dataSource, setDataSource] = useState(); const [selectedRowKeysState, setSelectedRowKeys] = useState([]); @@ -544,6 +546,17 @@ const appList: React.FC = (props) => { 导出 , + + + , ]; }} //dataSource={dataSource} @@ -727,6 +740,18 @@ const appList: React.FC = (props) => { }} > )} + {importModalVisible && ( + { + setImportModalVisible(false); + }} + onSuccess={() => { + setImportModalVisible(false); + actionRef.current?.reload(); + }} + /> + )} ); }; diff --git a/src/AgileConfig.Server.UI/react-ui-antd/src/pages/Apps/service.ts b/src/AgileConfig.Server.UI/react-ui-antd/src/pages/Apps/service.ts index 23b1e1ab..546e526e 100644 --- a/src/AgileConfig.Server.UI/react-ui-antd/src/pages/Apps/service.ts +++ b/src/AgileConfig.Server.UI/react-ui-antd/src/pages/Apps/service.ts @@ -1,5 +1,5 @@ import request from '@/utils/request'; -import { AppListItem, AppListParams, AppListResult, UserAppAuth } from './data'; +import { AppImportFile, AppImportPreviewResult, AppListItem, AppListParams, AppListResult, UserAppAuth } from './data'; export async function queryApps(params:AppListParams) { return request('app/search', { @@ -79,4 +79,23 @@ export async function exportApps(appIds: string[]) { }, responseType: 'blob', }); -} \ No newline at end of file +} + +export async function previewImportApps(file: File) { + const formData = new FormData(); + formData.append('file', file); + return request<{ success: boolean; data: AppImportPreviewResult; message?: string }>('app/PreviewImport', { + method: 'POST', + data: formData, + requestType: 'form', + }); +} + +export async function importApps(file: AppImportFile) { + return request('app/Import', { + method: 'POST', + data: { + file, + }, + }); +} diff --git a/test/ApiSiteTests/TestAppController.cs b/test/ApiSiteTests/TestAppController.cs index 2a532960..1a08017c 100644 --- a/test/ApiSiteTests/TestAppController.cs +++ b/test/ApiSiteTests/TestAppController.cs @@ -1,6 +1,9 @@ using System; using System.Collections.Generic; using System.Globalization; +using System.IO; +using System.Linq; +using System.Text; using System.Threading.Tasks; using AgileConfig.Server.Apisite.Controllers; using AgileConfig.Server.Apisite.Models; @@ -11,12 +14,30 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.VisualStudio.TestTools.UnitTesting; using Moq; +using Newtonsoft.Json; namespace ApiSiteTests; [TestClass] public class TestAppController { + private static AppController BuildController(Mock appService, Mock configService, + Mock settingService, Mock userService) + { + var eventBus = new Mock(); + var ctl = new AppController(appService.Object, userService.Object, configService.Object, settingService.Object, + eventBus.Object); + ctl.ControllerContext.HttpContext = new DefaultHttpContext(); + return ctl; + } + + private static IFormFile BuildImportFile(AppExportFileVM file) + { + var json = JsonConvert.SerializeObject(file); + var bytes = Encoding.UTF8.GetBytes(json); + return new FormFile(new MemoryStream(bytes), 0, bytes.Length, "file", "apps.json"); + } + [TestMethod] public async Task TestAdd() { @@ -77,4 +98,119 @@ public async Task TestAdd() Console.WriteLine(jr.Value.ToString()); Assert.IsTrue(jr.Value.ToString().Contains("success = True")); } -} \ No newline at end of file + + [TestMethod] + public async Task PreviewImport_ShouldRejectDuplicatesMissingParentsAndCycles() + { + var appService = new Mock(); + var userService = new Mock(); + var configService = new Mock(); + var settingService = new Mock(); + appService.Setup(x => x.GetAllAppsAsync()).ReturnsAsync(new List + { + new() { Id = "existing-id", Name = "Existing Name" } + }); + + var controller = BuildController(appService, configService, settingService, userService); + var file = new AppExportFileVM + { + Apps = new List + { + new() + { + App = new AppExportAppVM { Id = "existing-id", Name = "new-name", InheritancedApps = new List { "missing-parent" } }, + Envs = new Dictionary>() + }, + new() + { + App = new AppExportAppVM { Id = "cycle-a", Name = "Existing Name", InheritancedApps = new List { "cycle-b" } }, + Envs = new Dictionary>() + }, + new() + { + App = new AppExportAppVM { Id = "cycle-b", Name = "cycle-b", InheritancedApps = new List { "cycle-a" } }, + Envs = new Dictionary>() + } + } + }; + + var result = await controller.PreviewImport(BuildImportFile(file)); + var json = result as JsonResult; + var payload = JsonConvert.SerializeObject(json?.Value); + + Assert.IsNotNull(json); + Assert.IsTrue(payload.Contains("\"success\":false")); + Assert.IsTrue(payload.Contains("AppId already exists: existing-id")); + Assert.IsTrue(payload.Contains("Existing Name")); + Assert.IsTrue(payload.Contains("missing parent 'missing-parent'")); + Assert.IsTrue(payload.Contains("Cyclic inheritance detected")); + } + + [TestMethod] + public async Task Import_ShouldCreateAppsInTopologicalOrderAndAddConfigsAsNew() + { + var appService = new Mock(); + var userService = new Mock(); + var configService = new Mock(); + var settingService = new Mock(); + appService.Setup(x => x.GetAllAppsAsync()).ReturnsAsync(new List()); + userService.Setup(x => x.GetUserRolesAsync(It.IsAny())).ReturnsAsync(new List()); + + var addedApps = new List(); + var addedInheritance = new List>(); + var addedConfigs = new List(); + appService.Setup(x => x.AddAsync(It.IsAny(), It.IsAny>())) + .Callback>((app, links) => + { + addedApps.Add(app); + addedInheritance.Add(links); + }) + .ReturnsAsync(true); + configService.Setup(x => x.AddAsync(It.IsAny(), It.IsAny())) + .Callback((config, _) => addedConfigs.Add(config)) + .ReturnsAsync(true); + + var controller = BuildController(appService, configService, settingService, userService); + controller.ControllerContext.HttpContext.User = new System.Security.Claims.ClaimsPrincipal( + new System.Security.Claims.ClaimsIdentity(new[] + { + new System.Security.Claims.Claim("id", "tester") + }, "mock")); + + var importFile = new AppExportFileVM + { + Apps = new List + { + new() + { + App = new AppExportAppVM { Id = "child", Name = "Child", InheritancedApps = new List { "parent" } }, + Envs = new Dictionary> + { + ["DEV"] = new() { new AppExportConfigVM { Key = "child-key", Value = "child-value" } } + } + }, + new() + { + App = new AppExportAppVM { Id = "parent", Name = "Parent", Inheritanced = true, InheritancedApps = new List() }, + Envs = new Dictionary> + { + ["DEV"] = new() { new AppExportConfigVM { Key = "parent-key", Value = "parent-value" } } + } + } + } + }; + + var result = await controller.Import(new AppImportRequest { File = importFile }); + var json = result as JsonResult; + var payload = JsonConvert.SerializeObject(json?.Value); + + Assert.IsNotNull(json); + Assert.IsTrue(payload.Contains("\"success\":true")); + CollectionAssert.AreEqual(new[] { "parent", "child" }, addedApps.Select(x => x.Id).ToArray()); + Assert.AreEqual("parent", addedInheritance.Last().Single().InheritancedAppId); + Assert.AreEqual(2, addedConfigs.Count); + Assert.IsTrue(addedConfigs.All(x => x.EditStatus == EditStatus.Add)); + Assert.IsTrue(addedConfigs.All(x => x.OnlineStatus == OnlineStatus.WaitPublish)); + Assert.IsTrue(addedConfigs.All(x => x.Status == ConfigStatus.Enabled)); + } +}