import XCTest @testable import ShadowsocksX_NG2 @MainActor final class CustomRuleEditingWorkflowTests: XCTestCase { func testIPPreviewNormalizesWithoutSavingAndSavePublishesNewRow() async throws { let workflow = RulesWorkflow( preset: .gfwlist, loadDocument: { CustomRuleDocument(rules: []) }, commitDocument: { RuleDocumentCommit(outcome: .saved, document: $0) }, builtinSnapshots: BuiltinRuleSnapshots(loader: { rulesFixture($0) })) await workflow.refresh() let version = workflow.snapshot.version var draft = try XCTUnwrap(workflow.makeCustomRuleDraft()) draft.kind = .ipAddress draft.content = " 2001:DB8::1 " let preview = await workflow.previewCustomRule(draft) XCTAssertNil(preview.failure) XCTAssertEqual(preview.rule?.match, try RuleMatch(ipv6CIDR: "2001:db8::1/128")) XCTAssertEqual(preview.displayContent, "2001:db8::1") XCTAssertEqual(workflow.snapshot.version, version) XCTAssertFalse(workflow.snapshot.rows.contains { $0.customIDs.contains(draft.id) }) let outcome = await workflow.saveCustomRule(draft) XCTAssertEqual(outcome, .committed(.saved)) let row = try XCTUnwrap(workflow.snapshot.rows.first { $0.customIDs.contains(draft.id) }) XCTAssertEqual(row.identity?.match, try RuleMatch(ipv6CIDR: "2001:db8::1/128")) XCTAssertEqual(row.sources, [.custom]) XCTAssertNotEqual(workflow.snapshot.version, version) } func testEditingDisabledMergedRuleMovesOnlyCustomMembershipAndKeepsUUID() async throws { let original = CustomRule( action: .proxy, match: try RuleMatch(domainSuffix: "old.example")) let builtin = ProxyRule(action: .proxy, match: original.match) var saved = CustomRuleDocument(rules: [original], disabledIdentities: [original.identity]) let workflow = RulesWorkflow( preset: .gfwlist, loadDocument: { saved }, commitDocument: { saved = $0 return RuleDocumentCommit(outcome: .saved, document: saved) }, builtinSnapshots: BuiltinRuleSnapshots(loader: { rulesFixture($0, rules: $0 == .gfwlist ? [builtin] : []) })) await workflow.refresh() var draft = try XCTUnwrap(workflow.makeCustomRuleDraft(editing: original.id)) let unchanged = await workflow.previewCustomRule(draft) XCTAssertNil(unchanged.failure, "Editing excludes its own UUID") draft.content = "new.example" let preview = await workflow.previewCustomRule(draft) XCTAssertTrue(preview.inheritsDisablement) XCTAssertEqual(preview.row?.isEnabled, false) let result = await workflow.saveCustomRule(draft) XCTAssertEqual(result, .committed(.saved)) XCTAssertEqual(saved.rules.count, 1) XCTAssertEqual(saved.rules.first?.id, original.id) XCTAssertEqual( saved.disabledIdentities, [ original.identity, ProxyRule(action: .proxy, match: try RuleMatch(domainSuffix: "new.example")), ]) workflow.query(RulesQuery(source: .gfwlist)) let oldRow = try XCTUnwrap(workflow.snapshot.rows.first { $0.identity == original.identity }) XCTAssertEqual(oldRow.sources, [.gfwlist]) XCTAssertTrue(oldRow.customIDs.isEmpty) XCTAssertFalse(oldRow.isEnabled) workflow.query(RulesQuery(source: .custom)) let newRow = try XCTUnwrap(workflow.snapshot.rows.first { $0.customIDs.contains(original.id) }) XCTAssertEqual(newRow.sources, [.custom]) XCTAssertFalse(newRow.isEnabled) } func testCIDRRequiresAddressAndPrefixAndShowsMaskedNetwork() async throws { let workflow = RulesWorkflow( preset: .gfwlist, loadDocument: { CustomRuleDocument(rules: []) }, commitDocument: { RuleDocumentCommit(outcome: .saved, document: $0) }, builtinSnapshots: BuiltinRuleSnapshots(loader: { rulesFixture($0) })) await workflow.refresh() var draft = try XCTUnwrap(workflow.makeCustomRuleDraft()) draft.kind = .cidr for invalid in ["/8.8.8.8", "8.8.8.8/", "8.8.8.8", "8.8.8.8/33", "2001:db8::1/129"] { draft.content = invalid let preview = await workflow.previewCustomRule(draft) XCTAssertEqual(preview.failure, .invalidInput(.cidr), invalid) } for (input, expected) in [ ("8.8.8.9/24", "8.8.8.0/24"), ("2001:db8:1::123/48", "2001:db8:1::/48"), ] { draft.content = input let preview = await workflow.previewCustomRule(draft) XCTAssertNil(preview.failure) XCTAssertEqual(preview.displayContent, expected) } } func testDuplicateAndFixedLocalConflictAreRejectedWithoutCommit() async throws { let existing = CustomRule(action: .proxy, match: try RuleMatch(ipv4CIDR: "8.8.8.8/32")) var commits = 0 let workflow = RulesWorkflow( preset: .gfwlist, loadDocument: { CustomRuleDocument(rules: [existing]) }, commitDocument: { commits += 1 return RuleDocumentCommit(outcome: .saved, document: $0) }, builtinSnapshots: BuiltinRuleSnapshots(loader: { rulesFixture($0) })) await workflow.refresh() var draft = try XCTUnwrap(workflow.makeCustomRuleDraft()) draft.kind = .ipAddress draft.content = "8.8.8.8" let duplicate = await workflow.previewCustomRule(draft) XCTAssertEqual(duplicate.failure, .duplicate) let duplicateSave = await workflow.saveCustomRule(draft) XCTAssertEqual(duplicateSave, .unavailable(.duplicate)) draft.kind = .cidr draft.content = "8.8.8.8/32" let cidr = await workflow.previewCustomRule(draft) XCTAssertEqual(cidr.failure, .duplicate, "IP and full CIDR have one identity") draft.kind = .ipAddress for input in ["192.168.1.1", "::ffff:192.168.1.1", "::1"] { draft.content = input let conflict = await workflow.previewCustomRule(draft) XCTAssertEqual(conflict.failure, .fixedLocalConflict, input) let save = await workflow.saveCustomRule(draft) XCTAssertEqual(save, .unavailable(.fixedLocalConflict)) } XCTAssertEqual(commits, 0) } func testEquivalentBuiltinAndDisabledOrphanCanBeAddedWithoutEnabling() async throws { let builtin = ProxyRule( action: .proxy, match: try RuleMatch(domainSuffix: "merge.example")) let orphan = ProxyRule(action: .proxy, match: try RuleMatch(domainExact: "orphan.example")) let workflow = RulesWorkflow( preset: .gfwlist, loadDocument: { CustomRuleDocument(rules: [], disabledIdentities: [builtin, orphan]) }, commitDocument: { RuleDocumentCommit(outcome: .saved, document: $0) }, builtinSnapshots: BuiltinRuleSnapshots(loader: { rulesFixture($0, rules: $0 == .gfwlist ? [builtin] : []) })) await workflow.refresh() var draft = try XCTUnwrap(workflow.makeCustomRuleDraft()) draft.content = ".MERGE.Example" let preview = await workflow.previewCustomRule(draft) XCTAssertNil(preview.failure) XCTAssertEqual(preview.displayContent, "merge.example") XCTAssertEqual(preview.row?.sources, [.custom, .gfwlist]) XCTAssertEqual(preview.row?.isEnabled, false) let result = await workflow.saveCustomRule(draft) XCTAssertEqual(result, .committed(.saved)) let row = try XCTUnwrap(workflow.snapshot.rows.first { $0.customIDs.contains(draft.id) }) XCTAssertEqual(row.sources, [.custom, .gfwlist]) XCTAssertFalse(row.isEnabled) var next = try XCTUnwrap(workflow.makeCustomRuleDraft()) next.kind = .domainExact next.content = "orphan.example" let orphanPreview = await workflow.previewCustomRule(next) XCTAssertEqual(orphanPreview.row?.isEnabled, false) let orphanSave = await workflow.saveCustomRule(next) XCTAssertEqual(orphanSave, .committed(.saved)) XCTAssertFalse( try XCTUnwrap(workflow.snapshot.rows.first { $0.customIDs.contains(next.id) }).isEnabled) } func testDomainBoundaryAndNationalSuffixUseExistingNormalization() async throws { let workflow = RulesWorkflow( preset: .chinaDirect, loadDocument: { CustomRuleDocument(rules: []) }, commitDocument: { RuleDocumentCommit(outcome: .saved, document: $0) }, builtinSnapshots: BuiltinRuleSnapshots(loader: { rulesFixture($0) })) await workflow.refresh() var draft = try XCTUnwrap(workflow.makeCustomRuleDraft()) for kind in [CustomRuleDraft.Kind.domainExact, .domainSuffix] { draft.kind = kind for input in [ "", "例子.cn", "*.example.com", "https://example.com", "example.com/path", "^example$", ] { draft.content = input let preview = await workflow.previewCustomRule(draft) XCTAssertEqual(preview.failure, .invalidInput(kind), input) } draft.content = " XN--FIQS8S.Example " let valid = await workflow.previewCustomRule(draft) XCTAssertNil(valid.failure) XCTAssertEqual(valid.displayContent, "xn--fiqs8s.example") } draft.kind = .domainSuffix draft.content = "CN" let national = await workflow.previewCustomRule(draft) XCTAssertNil(national.failure) XCTAssertEqual(national.rule?.match, try RuleMatch(nationalDomainSuffix: "cn")) draft.kind = .ipAddress for input in ["8.8.8.8/32", "[2001:db8::1]", "8.8.8.8:80", "example.com"] { draft.content = input let invalid = await workflow.previewCustomRule(draft) XCTAssertEqual(invalid.failure, .invalidInput(.ipAddress)) } } func testPreviewExplainsSameActionAbsorptionAndAllowsSave() async throws { let broad = CustomRule( action: .proxy, match: try RuleMatch(domainSuffix: "coverage.example")) let workflow = RulesWorkflow( preset: .gfwlist, loadDocument: { CustomRuleDocument(rules: [broad]) }, commitDocument: { RuleDocumentCommit(outcome: .saved, document: $0) }, builtinSnapshots: BuiltinRuleSnapshots(loader: { rulesFixture($0) })) await workflow.refresh() var draft = try XCTUnwrap(workflow.makeCustomRuleDraft()) draft.kind = .domainExact draft.content = "a.coverage.example" let preview = await workflow.previewCustomRule(draft) XCTAssertNil(preview.failure) XCTAssertTrue( preview.row?.relationships.contains { $0.kind == .absorption && $0.extent == .full && $0.covering == [broad.identity] } == true) let saved = await workflow.saveCustomRule(draft) XCTAssertEqual(saved, .committed(.saved)) await workflow.analysisTask?.value XCTAssertEqual( workflow.snapshot.rows.first { $0.customIDs.contains(draft.id) }?.relationships, preview.row?.relationships) } } extension CustomRuleEditingWorkflowTests { func testPendingSaveBlocksRepeatsAndFailureKeepsSnapshotAvailableForRetry() async throws { let original = CustomRuleDocument(rules: []) let started = expectation(description: "save awaiting adapter") var continuation: CheckedContinuation? var commits = 0 let workflow = RulesWorkflow( preset: .gfwlist, loadDocument: { original }, commitDocument: { _ in commits += 1 await withCheckedContinuation { continuation = $0 started.fulfill() } return RuleDocumentCommit(outcome: .persistenceFailed, document: original) }, builtinSnapshots: BuiltinRuleSnapshots(loader: { rulesFixture($0) })) await workflow.refresh() let version = workflow.snapshot.version var draft = try XCTUnwrap(workflow.makeCustomRuleDraft()) draft.content = "pending.example" let pending = Task { await workflow.saveCustomRule(draft) } await fulfillment(of: [started], timeout: 3) XCTAssertTrue(workflow.snapshot.isCommitting) XCTAssertEqual(workflow.snapshot.operationStatus, .updating) let repeated = await workflow.saveCustomRule(draft) XCTAssertEqual(repeated, .unavailable(.busy)) XCTAssertNil(workflow.makeCustomRuleDraft()) continuation?.resume() let result = await pending.value XCTAssertEqual(result, .committed(.persistenceFailed)) XCTAssertEqual(commits, 1) XCTAssertEqual(workflow.snapshot.version, version) XCTAssertEqual(workflow.snapshot.commitOutcome, .persistenceFailed) XCTAssertFalse(workflow.snapshot.rows.contains { $0.customIDs.contains(draft.id) }) let retry = await workflow.previewCustomRule(draft) XCTAssertNil(retry.failure) } func testSavedEditRejectsStaleDraftAndIncompleteCollection() async throws { let rule = CustomRule( action: .proxy, match: try RuleMatch(domainSuffix: "old.example")) let original = CustomRuleDocument(rules: [rule]) let outcome = CustomRuleUpdateOutcome.saved let workflow = RulesWorkflow( preset: .gfwlist, loadDocument: { original }, commitDocument: { RuleDocumentCommit(outcome: outcome, document: $0) }, builtinSnapshots: BuiltinRuleSnapshots(loader: { rulesFixture($0) })) XCTAssertNil(workflow.makeCustomRuleDraft()) await workflow.refresh() var draft = try XCTUnwrap(workflow.makeCustomRuleDraft(editing: rule.id)) draft.content = "new.example" let result = await workflow.saveCustomRule(draft) XCTAssertEqual(result, .committed(outcome)) XCTAssertEqual(workflow.snapshot.commitOutcome, outcome) XCTAssertEqual( workflow.snapshot.rows.first { $0.customIDs.contains(rule.id) }?.content, "new.example") let stale = await workflow.previewCustomRule(draft) XCTAssertEqual(stale.failure, .staleDraft) let incomplete = RulesWorkflow( preset: .gfwlist, loadDocument: { throw CustomRuleStoreError.corrupt(detail: "fixture error") }, commitDocument: { RuleDocumentCommit(outcome: .saved, document: $0) }, builtinSnapshots: BuiltinRuleSnapshots(loader: { rulesFixture($0) })) await incomplete.refresh() XCTAssertNil(incomplete.makeCustomRuleDraft()) let refused = await incomplete.saveCustomRule(draft) XCTAssertEqual(refused, .unavailable(.incompleteCollection)) } } extension CustomRuleEditingWorkflowTests { func testDisabledEditCannotInheritDisablementOntoImmutableFixedIdentity() async throws { let original = CustomRule( action: .direct, match: try RuleMatch(domainExact: "disabled.example")) var commits = 0 let workflow = RulesWorkflow( preset: .chinaDirect, loadDocument: { CustomRuleDocument(rules: [original], disabledIdentities: [original.identity]) }, commitDocument: { commits += 1 return RuleDocumentCommit(outcome: .saved, document: $0) }, builtinSnapshots: BuiltinRuleSnapshots(loader: { rulesFixture($0) })) await workflow.refresh() var draft = try XCTUnwrap(workflow.makeCustomRuleDraft(editing: original.id)) draft.kind = .cidr draft.content = "127.0.0.0/8" let preview = await workflow.previewCustomRule(draft) XCTAssertEqual(preview.failure, .fixedPolicyDisablement) XCTAssertNil(preview.document) let result = await workflow.saveCustomRule(draft) guard case .unavailable = result else { return XCTFail("Must reject fixed-policy disablement") } XCTAssertEqual(commits, 0) XCTAssertFalse(workflow.snapshot.rows.filter(\.isFixed).contains { !$0.isEnabled }) } } extension CustomRuleEditingWorkflowTests { func testStaleDraftCannotOverwriteAnotherSavedChangeAndDiscardingDraftDoesNothing() async throws { let rule = CustomRule(action: .proxy, match: try RuleMatch(domainExact: "kept.example")) var commits = 0 let workflow = RulesWorkflow( preset: .gfwlist, loadDocument: { CustomRuleDocument(rules: [rule]) }, commitDocument: { commits += 1 return RuleDocumentCommit(outcome: .saved, document: $0) }, builtinSnapshots: BuiltinRuleSnapshots(loader: { rulesFixture($0) })) await workflow.refresh() var discarded = try XCTUnwrap(workflow.makeCustomRuleDraft(editing: rule.id)) discarded.content = "discarded.example" _ = await workflow.previewCustomRule(discarded) XCTAssertEqual(commits, 0) XCTAssertEqual( workflow.snapshot.rows.first { $0.customIDs.contains(rule.id) }?.identity, rule.identity) var stale = try XCTUnwrap(workflow.makeCustomRuleDraft()) stale.content = "new.example" await workflow.setEnabled(false, identities: [rule.identity]) let result = await workflow.saveCustomRule(stale) XCTAssertEqual(result, .unavailable(.staleDraft)) XCTAssertEqual(commits, 1) XCTAssertFalse(workflow.snapshot.rows.contains { $0.customIDs.contains(stale.id) }) } }