244 lines
18 KiB
C#
244 lines
18 KiB
C#
using System.Collections.Concurrent;
|
|
using SimpleAdmin.Core;
|
|
using SimpleAdmin.Application;
|
|
using SqlSugar;
|
|
|
|
var passed = 0;
|
|
void Check(bool condition, string name) {
|
|
if (!condition) throw new Exception("FAIL: " + name);
|
|
passed++;
|
|
Console.WriteLine("PASS: " + name);
|
|
}
|
|
void Reject(Action action, string name) {
|
|
try { action(); } catch (InvalidOperationException) { Check(true, name); return; }
|
|
throw new Exception("FAIL: expected rejection: " + name);
|
|
}
|
|
|
|
// Uses the real BasePageInput and real ORM SQL generation; never opens a DB connection.
|
|
using var db = new SqlSugarClient(new ConnectionConfig {
|
|
ConnectionString = "Server=127.0.0.1;Database=never_connected;Uid=unused;Pwd=unused;",
|
|
DbType = SqlSugar.DbType.MySql, IsAutoCloseConnection = true
|
|
});
|
|
foreach (var order in new[] { "asc, CASE WHEN 1=1 THEN 0 ELSE 1 END", "desc;DROP TABLE x", "asc--", "asc\tLIMIT 1", "random()" })
|
|
Reject(() => new BasePageInput { SortField = "Id", SortOrder = order }.GetSafeOrderBy("Id"), "reject sort direction: " + order);
|
|
foreach (var field in new[] { "Id,(CASE\tWHEN\t1=1\tTHEN\t0\tELSE\t1\tEND)", "Id\tDESC", "Id/**/", "Id;SELECT", "Id\n", "u.Id", "`Id`", "Id,Name", new string('a', 65) })
|
|
Reject(() => new BasePageInput { SortField = field }.GetSafeOrderBy("Id"), "reject sort field: " + field.Replace("\n", "<newline>"));
|
|
Reject(() => new BasePageInput { SortField = "NotAllowed" }.GetSafeOrderBy("Id"), "column whitelist enforced");
|
|
foreach (var sample in new[] { ("ascend", "asc"), ("descend", "desc"), (" ASC ", "asc"), ("", "desc"), ((string)null, "desc") }) {
|
|
var input = new BasePageInput { SortField = "id", SortOrder = sample.Item1 };
|
|
var orderBy = input.GetSafeOrderBy("Id", "Name");
|
|
Check(orderBy == "Id " + sample.Item2, "normalizes direction and maps column: " + sample.Item1);
|
|
var sql = db.Queryable<TestRow>().OrderBy(orderBy).ToSql().Key;
|
|
Check(sql.Contains("ORDER BY", StringComparison.OrdinalIgnoreCase) && !sql.Contains("CASE"), "safe ORM SQL generated");
|
|
}
|
|
Check(new BasePageInput().GetSafeOrderBy("Id") == "", "no requested sort preserves default");
|
|
|
|
// Uses the real state guard and production status constants.
|
|
Check(OnboardingSignStateGuard.Resolve(ElectronicSignStatus.PendingEmployeeSign, ElectronicSignStatus.PendingCompanySeal)
|
|
== ElectronicSignStatus.PendingCompanySeal, "employee signed -> company seal");
|
|
Check(OnboardingSignStateGuard.Resolve(ElectronicSignStatus.PendingCompanySeal, ElectronicSignStatus.Completed)
|
|
== ElectronicSignStatus.Completed, "company seal -> completed");
|
|
Check(OnboardingSignStateGuard.Resolve(ElectronicSignStatus.Archived, ElectronicSignStatus.Completed)
|
|
== ElectronicSignStatus.Archived, "completed provider result preserves local archive");
|
|
foreach (var terminal in new[] { ElectronicSignStatus.Completed, ElectronicSignStatus.Archived, ElectronicSignStatus.Invalid, ElectronicSignStatus.Expired, ElectronicSignStatus.Rejected }) {
|
|
Check(OnboardingSignStateGuard.Resolve(terminal, terminal) == terminal, "terminal replay allowed: " + terminal);
|
|
Reject(() => OnboardingSignStateGuard.Resolve(terminal, ElectronicSignStatus.PendingEmployeeSign), "terminal rollback rejected: " + terminal);
|
|
}
|
|
foreach (var backward in new[] { ElectronicSignStatus.PendingEmployeeSign, ElectronicSignStatus.Draft, ElectronicSignStatus.Creating, ElectronicSignStatus.Failed })
|
|
Reject(() => OnboardingSignStateGuard.Resolve(ElectronicSignStatus.PendingCompanySeal, backward), "signed progress cannot regress: " + backward);
|
|
|
|
|
|
// Uses the real signing-material validity rule. End time is exclusive.
|
|
var validityNow = new DateTime(2026, 9, 17, 12, 0, 0);
|
|
var availableVersion = new ObContractTemplateVersion { VersionStatus = ContractTemplateVersionStatus.Enabled };
|
|
Check(ContractTemplateVersionValidity.IsAvailable(availableVersion, validityNow), "enabled version without time bounds is available");
|
|
availableVersion.EffectiveDate = validityNow;
|
|
availableVersion.ExpiryDate = validityNow.AddSeconds(1);
|
|
Check(ContractTemplateVersionValidity.IsAvailable(availableVersion, validityNow), "effective boundary is inclusive");
|
|
availableVersion.ExpiryDate = validityNow;
|
|
Check(!ContractTemplateVersionValidity.IsAvailable(availableVersion, validityNow), "expiry boundary is exclusive");
|
|
availableVersion.EffectiveDate = validityNow.AddSeconds(1);
|
|
availableVersion.ExpiryDate = validityNow.AddMinutes(1);
|
|
Check(!ContractTemplateVersionValidity.IsAvailable(availableVersion, validityNow), "future version is unavailable");
|
|
availableVersion.EffectiveDate = validityNow.AddMinutes(-1);
|
|
availableVersion.ExpiryDate = validityNow.AddMinutes(1);
|
|
availableVersion.VersionStatus = ContractTemplateVersionStatus.Disabled;
|
|
Check(!ContractTemplateVersionValidity.IsAvailable(availableVersion, validityNow), "disabled version is unavailable");
|
|
ContractTemplateVersionValidity.ValidateRange(validityNow, validityNow.AddSeconds(1));
|
|
Reject(() => ContractTemplateVersionValidity.ValidateRange(validityNow, validityNow), "equal start/end rejected");
|
|
Reject(() => ContractTemplateVersionValidity.ValidateRange(validityNow, validityNow.AddSeconds(-1)), "end before start rejected");
|
|
|
|
// Uses the real partial-update builder and ORM SQL generation, never executes the SQL.
|
|
Reject(() => ContractTemplateVersionSealConfiguration.BuildUpdate(db, new() { Id = 42 }),
|
|
"seal update rejects an empty patch instead of resetting both flags");
|
|
Reject(() => ContractTemplateVersionSealConfiguration.BuildUpdate(db, new() { Id = 0, CompanySealEnabled = false }),
|
|
"seal update requires a specific version");
|
|
foreach (var enabled in new[] { false, true })
|
|
{
|
|
foreach (var companySeal in new[] { true, false })
|
|
{
|
|
var patch = new OnboardingContractVersionSealInput { Id = 42 };
|
|
var changedField = companySeal ? "CompanySealEnabled" : "AcrossPageSealEnabled";
|
|
var untouchedField = companySeal ? "AcrossPageSealEnabled" : "CompanySealEnabled";
|
|
if (companySeal) patch.CompanySealEnabled = enabled;
|
|
else patch.AcrossPageSealEnabled = enabled;
|
|
var generated = ContractTemplateVersionSealConfiguration.BuildUpdate(db, patch).ToSql();
|
|
var setClause = System.Text.RegularExpressions.Regex.Match(generated.Key, @"\bSET\b([\s\S]*?)\bWHERE\b",
|
|
System.Text.RegularExpressions.RegexOptions.IgnoreCase).Groups[1].Value;
|
|
Check(setClause.Contains(changedField) && !setClause.Contains(untouchedField)
|
|
&& !setClause.Contains("VersionStatus") && !System.Text.RegularExpressions.Regex.IsMatch(setClause, @"\bId\b"),
|
|
$"{changedField}={enabled} changes only its own column, never the other flag, status or primary key");
|
|
Check(generated.Value.Any(item => item.Value is bool flag && flag == enabled),
|
|
$"{changedField}={enabled} preserves the explicit boolean value in parameterized SQL");
|
|
Check(System.Text.RegularExpressions.Regex.IsMatch(generated.Key, @"\bWHERE\b[\s\S]*\bId\b",
|
|
System.Text.RegularExpressions.RegexOptions.IgnoreCase)
|
|
&& generated.Value.Any(item => item.Value is long id && id == 42),
|
|
$"{changedField} update is scoped to the requested version ID");
|
|
}
|
|
}
|
|
var bothSealUpdates = ContractTemplateVersionSealConfiguration.BuildUpdate(db,
|
|
new() { Id = 42, CompanySealEnabled = false, AcrossPageSealEnabled = true }).ToSql().Key;
|
|
Check(bothSealUpdates.Contains("CompanySealEnabled") && bothSealUpdates.Contains("AcrossPageSealEnabled"),
|
|
"both seal flags can be explicitly updated without overwriting unrelated version data");
|
|
|
|
// Exercises the real async gate with a fake external lease. Does NOT claim MySQL GET_LOCK integration.
|
|
var leases = new FakeLeases();
|
|
Task<int> Gate(string key, Func<Task<int>> action) => OnboardingOperationGate.Run(key, () => leases.Acquire(key), action);
|
|
var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
var first = Gate("task:1", async () => { entered.SetResult(); await release.Task; return 1; });
|
|
await entered.Task;
|
|
var contenders = Enumerable.Range(0, 64).Select(async _ => {
|
|
try { await Gate("task:1", () => Task.FromResult(2)); return false; }
|
|
catch (InvalidOperationException) { return true; }
|
|
}).ToArray();
|
|
Check((await Task.WhenAll(contenders)).All(x => x), "64 independent same-task operations rejected while held");
|
|
Check(await Gate("task:2", () => Task.FromResult(7)) == 7, "different tasks proceed independently");
|
|
release.SetResult();
|
|
await first;
|
|
Check(await Gate("task:1", () => Task.FromResult(3)) == 3, "lock released after completion");
|
|
var before = leases.AcquisitionCount;
|
|
Check(await Gate("nested", () => Gate("nested", () => Gate("identity", () => Gate("nested", () => Task.FromResult(9))))) == 9,
|
|
"nested task/snapshot call is reentrant");
|
|
Check(leases.AcquisitionCount - before == 2, "reentrant gate acquires each resource only once");
|
|
try { await Gate("failure", () => throw new ApplicationException("synthetic fault")); } catch (ApplicationException) {}
|
|
Check(await Gate("failure", () => Task.FromResult(4)) == 4, "lock released on exception");
|
|
Check(leases.ActiveCount == 0, "no leaked leases or caller async context");
|
|
// Uses the real request builder and provider models. No provider or database requests.
|
|
var sealSettings = new QiyuesuoSettingOptions { EnableAcrossPageSeal = true, AcrossPageSealType = "SINGLE_DOCUMENT" };
|
|
var signDocuments = new[]
|
|
{
|
|
new OnboardingSigningDocument("labor", "员工签字", "公司盖章", true, true),
|
|
new OnboardingSigningDocument("confidentiality", "员工签字", "公司盖章", true, true),
|
|
new OnboardingSigningDocument("notice", "员工签字", null, false, false),
|
|
new OnboardingSigningDocument("no_release_certificate", "员工签字", null, false, false)
|
|
};
|
|
var positions = QiyuesuoContractRequestBuilder.BuildStampers(signDocuments, "person", "company", "123", sealSettings);
|
|
Check(positions.Count(item => item.Type == "PERSONAL") == 4, "all four materials require employee signatures");
|
|
Check(positions.Where(item => item.Type == "PERSONAL").Select(item => item.DocumentId).SequenceEqual(signDocuments.Select(item => item.DocumentId)),
|
|
"employee signatures retain each distinct material and its order");
|
|
Check(positions.Count(item => item.Type == "COMPANY") == 2, "only labor contract and confidentiality agreement receive company seals");
|
|
Check(positions.Count(item => item.Type == "ACROSS_PAGE") == 2, "selected materials receive separate across-page seals");
|
|
Check(positions.Where(item => item.Type != "PERSONAL").All(item => item.DocumentId is "labor" or "confidentiality"),
|
|
"employee-only materials never receive any company signing position");
|
|
Check(positions.Where(item => item.Type == "COMPANY").All(item => item.ActionId == "company" && item.SealId == "123"),
|
|
"company positions use the intended action and company seal");
|
|
|
|
sealSettings.AcrossPageSealType = "ALL_DOCUMENT";
|
|
var reorderedDocuments = new[] { signDocuments[2], signDocuments[0], signDocuments[3], signDocuments[1] };
|
|
positions = QiyuesuoContractRequestBuilder.BuildStampers(reorderedDocuments, "person", "company", "123", sealSettings);
|
|
var mergedAcross = positions.Single(item => item.Type == "ACROSS_PAGE");
|
|
Check(mergedAcross.DocumentId == "labor", "merged across-page anchor is the first selected material, not the first uploaded file");
|
|
Check(mergedAcross.DocumentIds == "labor,confidentiality", "merged across-page seal excludes both employee-only materials");
|
|
|
|
sealSettings.EnableAcrossPageSeal = false;
|
|
var legacyDocuments = new[] { new OnboardingSigningDocument("legacy", "员工签字", "公司盖章", null, null) };
|
|
positions = QiyuesuoContractRequestBuilder.BuildStampers(legacyDocuments, "person", "company", "123", sealSettings);
|
|
Check(positions.Count(item => item.Type == "COMPANY") == 1 && positions.All(item => item.Type != "ACROSS_PAGE"),
|
|
"old versions retain company seals and the disabled global across-page setting");
|
|
sealSettings.EnableAcrossPageSeal = true;
|
|
positions = QiyuesuoContractRequestBuilder.BuildStampers(legacyDocuments, "person", "company", "123", sealSettings);
|
|
Check(positions.Count(item => item.Type == "ACROSS_PAGE") == 1, "old versions retain the enabled global across-page setting");
|
|
sealSettings.EnableAcrossPageSeal = false;
|
|
positions = QiyuesuoContractRequestBuilder.BuildStampers(signDocuments, "person", "company", "123", sealSettings);
|
|
Check(positions.Count(item => item.Type == "ACROSS_PAGE") == 1, "new explicit across-page switch overrides legacy global fallback");
|
|
|
|
var acrossOnly = new[] { new OnboardingSigningDocument("across_only", "员工签字", null, false, true) };
|
|
positions = QiyuesuoContractRequestBuilder.BuildStampers(acrossOnly, "person", "company", "123", sealSettings);
|
|
Check(positions.Count(item => item.Type == "COMPANY") == 0 && positions.Count(item => item.Type == "ACROSS_PAGE") == 1,
|
|
"company seal and across-page seal switches operate independently");
|
|
Check(QiyuesuoContractRequestBuilder.RequiresCompanySign(false, true, sealSettings),
|
|
"across-page-only materials still require a company signing action");
|
|
var companyOnly = new[] { new OnboardingSigningDocument("company_only", "员工签字", "公司盖章", true, false) };
|
|
positions = QiyuesuoContractRequestBuilder.BuildStampers(companyOnly, "person", "company", "123", sealSettings);
|
|
Check(positions.Count(item => item.Type == "COMPANY") == 1 && positions.All(item => item.Type != "ACROSS_PAGE"),
|
|
"company seal without across-page seal is supported");
|
|
var personalOnly = new[] { new OnboardingSigningDocument("personal_only", "员工签字", null, false, false) };
|
|
positions = QiyuesuoContractRequestBuilder.BuildStampers(personalOnly, "person", null, null, sealSettings);
|
|
Check(positions.Count == 1 && positions[0].Type == "PERSONAL", "all switches off produces only personal signature positions");
|
|
Check(!QiyuesuoContractRequestBuilder.RequiresCompanySign(false, false, sealSettings),
|
|
"employee-only signing needs no company action");
|
|
Reject(() => QiyuesuoContractRequestBuilder.BuildStampers(signDocuments, "person", null, "123", sealSettings),
|
|
"company action cannot be silently omitted for materials requiring seals");
|
|
Reject(() => QiyuesuoContractRequestBuilder.BuildStampers(
|
|
new[] { new OnboardingSigningDocument(null, "员工签字", "公司盖章", true, true) }, "person", "company", "123", sealSettings),
|
|
"missing document ID is rejected instead of broadening seal scope");
|
|
sealSettings.AcrossPageSealType = "INVALID";
|
|
Reject(() => QiyuesuoContractRequestBuilder.BuildStampers(acrossOnly, "person", "company", "123", sealSettings),
|
|
"invalid across-page mode is checked even when the global fallback is off");
|
|
|
|
var draftInput = new QiyuesuoCreateDraftInput
|
|
{
|
|
RequiresCompanySign = false, CompanyName = "测试企业", EmployeeName = "测试员工",
|
|
EmployeeMobile = "13800138000", CreatorName = "测试经办人", CreatorMobile = "13900139000",
|
|
CategoryId = "456", CallbackUrl = " https://example.invalid/callback "
|
|
};
|
|
var employeeOnlyDraft = System.Text.Json.JsonSerializer.SerializeToElement(QiyuesuoContractRequestBuilder.BuildDraft(draftInput));
|
|
Check(employeeOnlyDraft.GetProperty("signatories").GetArrayLength() == 1
|
|
&& employeeOnlyDraft.GetProperty("signatories")[0].GetProperty("tenantType").GetString() == "PERSONAL",
|
|
"employee-only draft never creates a company signature node");
|
|
Check(employeeOnlyDraft.GetProperty("tenantName").GetString() == "测试企业",
|
|
"employee-only workflow retains its sponsoring company");
|
|
Check(employeeOnlyDraft.GetProperty("callbackUrl").GetString() == "https://example.invalid/callback",
|
|
"draft builder preserves normalized server callback");
|
|
draftInput.RequiresCompanySign = true;
|
|
Reject(() => QiyuesuoContractRequestBuilder.BuildDraft(draftInput), "company signing draft requires a valid seal");
|
|
draftInput.SealId = "123";
|
|
var companyDraft = System.Text.Json.JsonSerializer.SerializeToElement(QiyuesuoContractRequestBuilder.BuildDraft(draftInput));
|
|
Check(companyDraft.GetProperty("signatories").GetArrayLength() == 2
|
|
&& companyDraft.GetProperty("signatories")[1].GetProperty("actions")[0].GetProperty("type").GetString() == "COMPANY",
|
|
"mixed materials keep one company signing action after employee signing");
|
|
Check(!companyDraft.GetProperty("signatories")[1].GetProperty("actions")[0].GetProperty("autoSign").GetBoolean(),
|
|
"company seal remains manually triggered");
|
|
|
|
var employeeOnlyProvider = new QiyuesuoContractResult { Signatories = new()
|
|
{
|
|
new QiyuesuoSignatoryResult { TenantType = "PERSONAL", Status = "SIGNED" }
|
|
}};
|
|
Check(QiyuesuoContractRequestBuilder.ResolvePendingStatus(employeeOnlyProvider) != ElectronicSignStatus.PendingCompanySeal,
|
|
"employee-only workflow cannot become stuck in a nonexistent company-seal stage");
|
|
employeeOnlyProvider.Signatories.Add(new QiyuesuoSignatoryResult { TenantType = "COMPANY", Actions = new()
|
|
{
|
|
new QiyuesuoActionResult { Type = "COMPANY", Status = "START" }
|
|
}});
|
|
Check(QiyuesuoContractRequestBuilder.ResolvePendingStatus(employeeOnlyProvider) == ElectronicSignStatus.PendingCompanySeal,
|
|
"mixed workflow waits for company seal after employee signing");
|
|
Console.WriteLine("ALL " + passed + " CHECKS PASSED (offline; no business database or provider contacted)");
|
|
|
|
[SugarTable("test_row")]
|
|
public class TestRow { public long Id { get; set; } public string Name { get; set; } }
|
|
public sealed class FakeLeases {
|
|
private readonly ConcurrentDictionary<string, byte> held = new();
|
|
private int acquisitions;
|
|
public int AcquisitionCount => acquisitions;
|
|
public int ActiveCount => held.Count;
|
|
public Task<IAsyncDisposable> Acquire(string key) {
|
|
if (!held.TryAdd(key, 0)) throw new InvalidOperationException("busy");
|
|
Interlocked.Increment(ref acquisitions);
|
|
return Task.FromResult<IAsyncDisposable>(new Lease(() => held.TryRemove(key, out _)));
|
|
}
|
|
private sealed class Lease(Action release) : IAsyncDisposable {
|
|
public ValueTask DisposeAsync() { release(); return ValueTask.CompletedTask; }
|
|
}
|
|
}
|