// Unit tests for PackageManagerImpl — PackageManagerLib is mocked // (mock_package_manager_lib.cpp). // // Structured lib returns (installed packages, dependency tree, dependents) // are registered via setMock*() helpers declared in mock_package_manager_lib.h. // The registries reset automatically on each LogosTestContext, so tests just // construct a context, set the mocks, and instantiate the impl. #include #include "package_manager_impl.h" #include "mocks/mock_package_manager_lib.h" #include #include #include #include #include #include #include // Qt-free event capture lives in the test framework (logos_test_events.h, // pulled in by ); the module-specific event-method bodies these // drive are in package_manager_events_test.cpp. using logos_test::EventCapture; using logos_test::ScopedEventSink; LOGOS_TEST(onInit_does_not_throw) { auto t = LogosTestContext("package_manager"); PackageManagerImpl impl; LOGOS_ASSERT_FALSE(t.moduleCalled("any_module", "any_method")); } LOGOS_TEST(installPlugin_success_core_emits_core_event) { auto t = LogosTestContext("package_manager"); t.mockCFunction("installPluginFile_result").returns("/installed/core.dylib"); t.mockCFunction("installPluginFile_installedPath").returns("/installed/core.dylib"); t.mockCFunction("installPluginFile_error").returns(""); t.mockCFunction("installPluginFile_isCore").returns(true); std::string lastEvent; std::string lastEventData; PackageManagerImpl impl; ScopedEventSink _sink([&](const std::string& name, const std::string& data) { lastEvent = name; lastEventData = data; }); LogosMap m = impl.installPlugin("/path/to/foo.lgx", false); LOGOS_ASSERT_EQ(m["path"].get(), std::string("/installed/core.dylib")); LOGOS_ASSERT_TRUE(m["isCoreModule"].get()); LOGOS_ASSERT_FALSE(m.contains("error")); LOGOS_ASSERT_EQ(m["name"].get(), std::string("foo")); LOGOS_ASSERT_EQ(m["signatureStatus"].get(), std::string("unsigned")); LOGOS_ASSERT_TRUE(t.cFunctionCalled("verifyPackageSignature")); LOGOS_ASSERT_EQ(lastEvent, std::string("corePluginFileInstalled")); LOGOS_ASSERT_EQ(lastEventData, std::string("/installed/core.dylib")); LOGOS_ASSERT_TRUE(t.cFunctionCalled("installPluginFile")); } LOGOS_TEST(installPlugin_success_ui_emits_ui_event) { auto t = LogosTestContext("package_manager"); t.mockCFunction("installPluginFile_result").returns("/ui/plugin.qml"); t.mockCFunction("installPluginFile_installedPath").returns("/ui/plugin.qml"); t.mockCFunction("installPluginFile_error").returns(""); t.mockCFunction("installPluginFile_isCore").returns(false); std::string lastEvent; PackageManagerImpl impl; ScopedEventSink _sink([&](const std::string& name, const std::string&) { lastEvent = name; }); LogosMap m = impl.installPlugin("/path/bar.lgx", false); LOGOS_ASSERT_FALSE(m["isCoreModule"].get()); LOGOS_ASSERT_EQ(lastEvent, std::string("uiPluginFileInstalled")); } // A QML-only ui_qml package has no backend library, so the library reports the // installed module DIRECTORY rather than a main file. That must still be a // success: event emitted, non-empty "path", no "error" key. Before the fix the // library handed back an empty string here and the impl gated its event on it, // so uiPluginFileInstalled never fired and logos-package-manager-ui — which // treats an empty "path" as failure — rendered a red RETRY on a package that // had installed perfectly. LOGOS_TEST(installPlugin_ui_qml_without_main_emits_event_with_directory_path) { auto t = LogosTestContext("package_manager"); t.mockCFunction("installPluginFile_result").returns("/user/ui_plugins"); t.mockCFunction("installPluginFile_installedPath").returns("/user/ui_plugins/hello_ui"); t.mockCFunction("installPluginFile_error").returns(""); t.mockCFunction("installPluginFile_isCore").returns(false); std::string lastEvent; std::string lastEventData; PackageManagerImpl impl; ScopedEventSink _sink([&](const std::string& name, const std::string& data) { lastEvent = name; lastEventData = data; }); LogosMap m = impl.installPlugin("/path/hello_ui.lgx", false); LOGOS_ASSERT_EQ(m["path"].get(), std::string("/user/ui_plugins/hello_ui")); LOGOS_ASSERT_FALSE(m.contains("error")); LOGOS_ASSERT_EQ(lastEvent, std::string("uiPluginFileInstalled")); LOGOS_ASSERT_EQ(lastEventData, std::string("/user/ui_plugins/hello_ui")); } // Defence in depth against an OLDER logos-package-manager that still leaves // installedPluginPath empty for a QML-only package: the impl must not take the // empty value as failure. It falls back to the library's return value (the // install root), which keeps the event firing and "path" non-empty. LOGOS_TEST(installPlugin_empty_installedPath_still_succeeds_via_result_fallback) { auto t = LogosTestContext("package_manager"); t.mockCFunction("installPluginFile_result").returns("/user/ui_plugins"); t.mockCFunction("installPluginFile_installedPath").returns(""); t.mockCFunction("installPluginFile_error").returns(""); t.mockCFunction("installPluginFile_isCore").returns(false); std::string lastEvent; std::string lastEventData; PackageManagerImpl impl; ScopedEventSink _sink([&](const std::string& name, const std::string& data) { lastEvent = name; lastEventData = data; }); LogosMap m = impl.installPlugin("/path/hello_ui.lgx", false); LOGOS_ASSERT_EQ(m["path"].get(), std::string("/user/ui_plugins")); LOGOS_ASSERT_FALSE(m.contains("error")); LOGOS_ASSERT_EQ(lastEvent, std::string("uiPluginFileInstalled")); LOGOS_ASSERT_EQ(lastEventData, std::string("/user/ui_plugins")); } LOGOS_TEST(installPlugin_failure_sets_error_no_event) { auto t = LogosTestContext("package_manager"); t.mockCFunction("installPluginFile_result").returns(""); t.mockCFunction("installPluginFile_error").returns("invalid lgx"); t.mockCFunction("installPluginFile_installedPath").returns(""); std::string lastEvent; PackageManagerImpl impl; ScopedEventSink _sink([&](const std::string& name, const std::string&) { lastEvent = name; }); LogosMap m = impl.installPlugin("/bad.lgx", false); LOGOS_ASSERT_TRUE(m["path"].get().empty()); LOGOS_ASSERT_EQ(m["error"].get(), std::string("invalid lgx")); LOGOS_ASSERT_TRUE(lastEvent.empty()); } LOGOS_TEST(installPlugin_skipIfNotNewerVersion_passed_to_mock) { auto t = LogosTestContext("package_manager"); t.mockCFunction("installPluginFile_result").returns("/ok"); t.mockCFunction("installPluginFile_installedPath").returns("/ok"); t.mockCFunction("installPluginFile_error").returns(""); PackageManagerImpl impl; impl.installPlugin("/x.lgx", true); LOGOS_ASSERT_TRUE(t.cFunctionCalled("installPluginFile_skipIfNotNewer_true")); impl.installPlugin("/y.lgx", false); LOGOS_ASSERT_TRUE(t.cFunctionCalled("installPluginFile_skipIfNotNewer_false")); } LOGOS_TEST(setEmbeddedModulesDirectory_forwards_to_lib) { auto t = LogosTestContext("package_manager"); PackageManagerImpl impl; impl.setEmbeddedModulesDirectory("/emb/mod"); LOGOS_ASSERT_TRUE(t.cFunctionCalled("setEmbeddedModulesDirectory")); } LOGOS_TEST(addEmbeddedModulesDirectory_forwards_to_lib) { auto t = LogosTestContext("package_manager"); PackageManagerImpl impl; impl.addEmbeddedModulesDirectory("/emb/m2"); LOGOS_ASSERT_TRUE(t.cFunctionCalled("addEmbeddedModulesDirectory")); } LOGOS_TEST(setEmbeddedUiPluginsDirectory_forwards_to_lib) { auto t = LogosTestContext("package_manager"); PackageManagerImpl impl; impl.setEmbeddedUiPluginsDirectory("/emb/ui"); LOGOS_ASSERT_TRUE(t.cFunctionCalled("setEmbeddedUiPluginsDirectory")); } LOGOS_TEST(addEmbeddedUiPluginsDirectory_forwards_to_lib) { auto t = LogosTestContext("package_manager"); PackageManagerImpl impl; impl.addEmbeddedUiPluginsDirectory("/emb/ui2"); LOGOS_ASSERT_TRUE(t.cFunctionCalled("addEmbeddedUiPluginsDirectory")); } LOGOS_TEST(setUserModulesDirectory_forwards_to_lib) { auto t = LogosTestContext("package_manager"); PackageManagerImpl impl; impl.setUserModulesDirectory("/user/mod"); LOGOS_ASSERT_TRUE(t.cFunctionCalled("setUserModulesDirectory")); } LOGOS_TEST(setUserUiPluginsDirectory_forwards_to_lib) { auto t = LogosTestContext("package_manager"); PackageManagerImpl impl; impl.setUserUiPluginsDirectory("/user/ui"); LOGOS_ASSERT_TRUE(t.cFunctionCalled("setUserUiPluginsDirectory")); } LOGOS_TEST(getInstalledPackages_returns_struct_registry) { auto t = LogosTestContext("package_manager"); InstalledPackage pkg; pkg.name = "pkg1"; pkg.version = "1.0.0"; setMockInstalledPackages({pkg}); PackageManagerImpl impl; LogosList list = impl.getInstalledPackages(); LOGOS_ASSERT_EQ(list.size(), static_cast(1)); LOGOS_ASSERT_EQ(list[0]["name"].get(), std::string("pkg1")); LOGOS_ASSERT_EQ(list[0]["version"].get(), std::string("1.0.0")); LOGOS_ASSERT_TRUE(t.cFunctionCalled("getInstalledPackages")); } LOGOS_TEST(getInstalledModules_returns_struct_registry) { auto t = LogosTestContext("package_manager"); InstalledPackage mod; mod.name = "mod_a"; mod.type = "core"; setMockInstalledModules({mod}); PackageManagerImpl impl; LogosList list = impl.getInstalledModules(); LOGOS_ASSERT_EQ(list.size(), static_cast(1)); LOGOS_ASSERT_EQ(list[0]["name"].get(), std::string("mod_a")); LOGOS_ASSERT_EQ(list[0]["type"].get(), std::string("core")); } LOGOS_TEST(getInstalledUiPlugins_returns_struct_registry) { auto t = LogosTestContext("package_manager"); InstalledPackage ui; ui.name = "ui_z"; ui.type = "ui"; setMockInstalledUiPlugins({ui}); PackageManagerImpl impl; LogosList list = impl.getInstalledUiPlugins(); LOGOS_ASSERT_EQ(list.size(), static_cast(1)); LOGOS_ASSERT_EQ(list[0]["name"].get(), std::string("ui_z")); } LOGOS_TEST(getInstalledPackages_empty_registry) { auto t = LogosTestContext("package_manager"); PackageManagerImpl impl; LOGOS_ASSERT_TRUE(impl.getInstalledPackages().empty()); } LOGOS_TEST(getValidVariants_uses_platformVariantsToTry) { auto t = LogosTestContext("package_manager"); t.mockCFunction("platformVariantsToTry_first").returns("custom-variant"); PackageManagerImpl impl; std::vector v = impl.getValidVariants(); LOGOS_ASSERT_EQ(static_cast(v.size()), 1); LOGOS_ASSERT_EQ(v[0], std::string("custom-variant")); LOGOS_ASSERT_TRUE(t.cFunctionCalled("platformVariantsToTry")); } LOGOS_TEST(getValidVariants_default_mock_variant) { auto t = LogosTestContext("package_manager"); PackageManagerImpl impl; std::vector v = impl.getValidVariants(); LOGOS_ASSERT_FALSE(v.empty()); LOGOS_ASSERT_EQ(v[0], std::string("mock-variant")); } LOGOS_TEST(no_cross_module_calls_by_default) { auto t = LogosTestContext("package_manager"); PackageManagerImpl impl; LOGOS_ASSERT_EQ(t.moduleCallCount("capability_module", "requestModule"), 0); } LOGOS_TEST(setSignaturePolicy_forwards_to_lib) { auto t = LogosTestContext("package_manager"); PackageManagerImpl impl; impl.setSignaturePolicy("warn"); LOGOS_ASSERT_TRUE(t.cFunctionCalled("setSignaturePolicy")); } LOGOS_TEST(setKeyringDirectory_forwards_to_lib) { auto t = LogosTestContext("package_manager"); PackageManagerImpl impl; impl.setKeyringDirectory("/kr"); LOGOS_ASSERT_TRUE(t.cFunctionCalled("setKeyringDirectory")); } LOGOS_TEST(uninstallPackage_success_core_emits_core_event) { auto t = LogosTestContext("package_manager"); // Scan returns a core package — impl uses this to route the event. InstalledPackage pkg; pkg.name = "foo"; pkg.type = "core"; pkg.installType = InstallType::User; setMockInstalledPackages({pkg}); t.mockCFunction("uninstallPackage_success").returns(true); t.mockCFunction("uninstallPackage_removed").returns("/a/foo.dylib,/a/manifest.json"); std::string lastEvent; std::string lastEventData; PackageManagerImpl impl; ScopedEventSink _sink([&](const std::string& name, const std::string& data) { lastEvent = name; lastEventData = data; }); LogosMap m = impl.uninstallPackage("foo"); LOGOS_ASSERT_TRUE(m["success"].get()); LOGOS_ASSERT_FALSE(m.contains("error")); LOGOS_ASSERT_EQ(m["removedFiles"].size(), static_cast(2)); LOGOS_ASSERT_EQ(lastEvent, std::string("corePluginUninstalled")); LOGOS_ASSERT_EQ(lastEventData, std::string("foo")); LOGOS_ASSERT_TRUE(t.cFunctionCalled("uninstallPackage")); } LOGOS_TEST(uninstallPackage_success_ui_emits_ui_event) { auto t = LogosTestContext("package_manager"); InstalledPackage pkg; pkg.name = "widget"; pkg.type = "ui"; pkg.installType = InstallType::User; setMockInstalledPackages({pkg}); t.mockCFunction("uninstallPackage_success").returns(true); std::string lastEvent; PackageManagerImpl impl; ScopedEventSink _sink([&](const std::string& name, const std::string&) { lastEvent = name; }); LogosMap m = impl.uninstallPackage("widget"); LOGOS_ASSERT_TRUE(m["success"].get()); LOGOS_ASSERT_EQ(lastEvent, std::string("uiPluginUninstalled")); } LOGOS_TEST(uninstallPackage_failure_sets_error_no_event) { auto t = LogosTestContext("package_manager"); InstalledPackage pkg; pkg.name = "foo"; pkg.type = "core"; pkg.installType = InstallType::Embedded; setMockInstalledPackages({pkg}); t.mockCFunction("uninstallPackage_success").returns(false); t.mockCFunction("uninstallPackage_error").returns("Cannot uninstall embedded package"); std::string lastEvent; PackageManagerImpl impl; ScopedEventSink _sink([&](const std::string& name, const std::string&) { lastEvent = name; }); LogosMap m = impl.uninstallPackage("foo"); LOGOS_ASSERT_FALSE(m["success"].get()); LOGOS_ASSERT_EQ(m["error"].get(), std::string("Cannot uninstall embedded package")); LOGOS_ASSERT_FALSE(m.contains("removedFiles")); LOGOS_ASSERT_TRUE(lastEvent.empty()); } // --------------------------------------------------------------------------- // Dependency-resolution helpers — tree fixtures reused across tests. // --------------------------------------------------------------------------- // Forward tree: // root (installed) // └── a (installed) // └── c (installed) // Used by the resolveDependencies + resolveFlatDependencies tests. static DependencyTreeNode makeForwardTree() { DependencyTreeNode root; root.name = "root"; root.status = DependencyStatus::Installed; DependencyTreeNode a; a.name = "a"; a.status = DependencyStatus::Installed; DependencyTreeNode c; c.name = "c"; c.status = DependencyStatus::Installed; a.children = {c}; root.children = {a}; return root; } // Reverse tree rooted at "dep": // dep // └── parent_direct // └── parent_transitive static DependentTreeNode makeReverseTree() { DependentTreeNode root; root.name = "dep"; DependentTreeNode directChild; directChild.name = "parent_direct"; directChild.version = "1.2.3"; DependentTreeNode transitiveChild; transitiveChild.name = "parent_transitive"; transitiveChild.version = "4.5.6"; directChild.children = {transitiveChild}; root.children = {directChild}; return root; } LOGOS_TEST(resolveDependencies_recursive_returns_full_tree) { auto t = LogosTestContext("package_manager"); setMockDependencyTree(makeForwardTree()); PackageManagerImpl impl; LogosMap out = impl.resolveDependencies("root", true); LOGOS_ASSERT_EQ(out["name"].get(), std::string("root")); LOGOS_ASSERT_EQ(out["status"].get(), std::string("installed")); LOGOS_ASSERT_EQ(out["children"].size(), static_cast(1)); // Recursive walks the full tree — grandchild survives. LOGOS_ASSERT_EQ(out["children"][0]["name"].get(), std::string("a")); LOGOS_ASSERT_EQ(out["children"][0]["children"].size(), static_cast(1)); LOGOS_ASSERT_EQ(out["children"][0]["children"][0]["name"].get(), std::string("c")); LOGOS_ASSERT_TRUE(t.cFunctionCalled("resolveDependencies")); } LOGOS_TEST(resolveDependencies_non_recursive_clips_to_depth_one) { auto t = LogosTestContext("package_manager"); setMockDependencyTree(makeForwardTree()); PackageManagerImpl impl; LogosMap out = impl.resolveDependencies("root", false); LOGOS_ASSERT_EQ(out["name"].get(), std::string("root")); LOGOS_ASSERT_EQ(out["children"].size(), static_cast(1)); LOGOS_ASSERT_EQ(out["children"][0]["name"].get(), std::string("a")); // Non-recursive clips at depth 1: "a" has an empty children array, the // grandchild "c" does not appear. LOGOS_ASSERT_TRUE(out["children"][0]["children"].is_array()); LOGOS_ASSERT_TRUE(out["children"][0]["children"].empty()); } LOGOS_TEST(resolveDependencies_unknown_returns_empty_object) { auto t = LogosTestContext("package_manager"); // No registered tree — mock returns nullopt; impl serialises to {}. PackageManagerImpl impl; LogosMap out = impl.resolveDependencies("ghost", true); LOGOS_ASSERT_TRUE(out.is_object()); LOGOS_ASSERT_TRUE(out.empty()); } LOGOS_TEST(resolveDependents_recursive_returns_full_tree) { auto t = LogosTestContext("package_manager"); setMockDependentTree(makeReverseTree()); PackageManagerImpl impl; LogosMap out = impl.resolveDependents("dep", true); LOGOS_ASSERT_EQ(out["name"].get(), std::string("dep")); LOGOS_ASSERT_EQ(out["children"].size(), static_cast(1)); LOGOS_ASSERT_EQ(out["children"][0]["name"].get(), std::string("parent_direct")); LOGOS_ASSERT_EQ(out["children"][0]["version"].get(), std::string("1.2.3")); // Full tree — grandchild survives. LOGOS_ASSERT_EQ(out["children"][0]["children"].size(), static_cast(1)); LOGOS_ASSERT_EQ(out["children"][0]["children"][0]["name"].get(), std::string("parent_transitive")); // No `direct` field in the new wire format. LOGOS_ASSERT_FALSE(out["children"][0].contains("direct")); LOGOS_ASSERT_TRUE(t.cFunctionCalled("resolveDependents")); } LOGOS_TEST(resolveDependents_non_recursive_clips_to_depth_one) { auto t = LogosTestContext("package_manager"); setMockDependentTree(makeReverseTree()); PackageManagerImpl impl; LogosMap out = impl.resolveDependents("dep", false); LOGOS_ASSERT_EQ(out["name"].get(), std::string("dep")); LOGOS_ASSERT_EQ(out["children"].size(), static_cast(1)); LOGOS_ASSERT_EQ(out["children"][0]["name"].get(), std::string("parent_direct")); // Clipped: grandchild does not appear. LOGOS_ASSERT_TRUE(out["children"][0]["children"].is_array()); LOGOS_ASSERT_TRUE(out["children"][0]["children"].empty()); } LOGOS_TEST(resolveDependents_unknown_returns_empty_object) { auto t = LogosTestContext("package_manager"); PackageManagerImpl impl; LogosMap out = impl.resolveDependents("ghost", true); LOGOS_ASSERT_TRUE(out.is_object()); LOGOS_ASSERT_TRUE(out.empty()); } LOGOS_TEST(resolveFlatDependencies_recursive_flattens_tree) { auto t = LogosTestContext("package_manager"); setMockDependencyTree(makeForwardTree()); PackageManagerImpl impl; LogosList list = impl.resolveFlatDependencies("root", true); // Descendants: a + c. Root itself is NOT included. LOGOS_ASSERT_EQ(list.size(), static_cast(2)); std::set names; for (const auto& item : list) names.insert(item["name"].get()); LOGOS_ASSERT_TRUE(names.count("a") != 0); LOGOS_ASSERT_TRUE(names.count("c") != 0); // No `children` field in the flat wire format. LOGOS_ASSERT_FALSE(list[0].contains("children")); } LOGOS_TEST(resolveFlatDependencies_non_recursive_emits_children_only) { auto t = LogosTestContext("package_manager"); setMockDependencyTree(makeForwardTree()); PackageManagerImpl impl; LogosList list = impl.resolveFlatDependencies("root", false); // Only direct children of root — "a". Grandchild "c" does not appear. LOGOS_ASSERT_EQ(list.size(), static_cast(1)); LOGOS_ASSERT_EQ(list[0]["name"].get(), std::string("a")); LOGOS_ASSERT_FALSE(list[0].contains("children")); } LOGOS_TEST(resolveFlatDependencies_unknown_returns_empty_list) { auto t = LogosTestContext("package_manager"); PackageManagerImpl impl; LogosList list = impl.resolveFlatDependencies("ghost", true); LOGOS_ASSERT_TRUE(list.is_array()); LOGOS_ASSERT_TRUE(list.empty()); } LOGOS_TEST(resolveFlatDependents_recursive_flattens_tree) { auto t = LogosTestContext("package_manager"); setMockDependentTree(makeReverseTree()); PackageManagerImpl impl; LogosList list = impl.resolveFlatDependents("dep", true); LOGOS_ASSERT_EQ(list.size(), static_cast(2)); std::set names; for (const auto& item : list) names.insert(item["name"].get()); LOGOS_ASSERT_TRUE(names.count("parent_direct") != 0); LOGOS_ASSERT_TRUE(names.count("parent_transitive") != 0); LOGOS_ASSERT_FALSE(list[0].contains("direct")); LOGOS_ASSERT_FALSE(list[0].contains("children")); } LOGOS_TEST(resolveFlatDependents_non_recursive_emits_children_only) { auto t = LogosTestContext("package_manager"); setMockDependentTree(makeReverseTree()); PackageManagerImpl impl; LogosList list = impl.resolveFlatDependents("dep", false); LOGOS_ASSERT_EQ(list.size(), static_cast(1)); LOGOS_ASSERT_EQ(list[0]["name"].get(), std::string("parent_direct")); LOGOS_ASSERT_FALSE(list[0].contains("children")); } LOGOS_TEST(resolveFlatDependents_unknown_returns_empty_list) { auto t = LogosTestContext("package_manager"); PackageManagerImpl impl; LogosList list = impl.resolveFlatDependents("ghost", true); LOGOS_ASSERT_TRUE(list.is_array()); LOGOS_ASSERT_TRUE(list.empty()); } LOGOS_TEST(verifyPackage_maps_signature_result) { auto t = LogosTestContext("package_manager"); t.mockCFunction("verifyPackageSignature_is_signed").returns(true); t.mockCFunction("verifyPackageSignature_signature_valid").returns(true); t.mockCFunction("verifyPackageSignature_package_valid").returns(true); t.mockCFunction("verifyPackageSignature_signer_did").returns("did:jwk:test"); PackageManagerImpl impl; LogosMap m = impl.verifyPackage("/any.lgx"); LOGOS_ASSERT_TRUE(m["isSigned"].get()); LOGOS_ASSERT_TRUE(m["signatureValid"].get()); LOGOS_ASSERT_TRUE(m["packageValid"].get()); LOGOS_ASSERT_EQ(m["signerDid"].get(), std::string("did:jwk:test")); LOGOS_ASSERT_TRUE(t.cFunctionCalled("verifyPackageSignature")); } // =========================================================================== // Gated uninstall / upgrade flow // =========================================================================== // // Tests the two-phase listener-ack protocol: // // requestXxx -> beforeXxx event -> ackPendingAction (within ackTimeout) // -> confirmXxx (destructive) | cancelXxx (aborts) // // The ack-timeout worker emits on a background thread, so every test uses // the thread-safe EventCapture from the test framework (). // Synchronous paths (request / confirm / cancel) also go through the same // capture to keep assertions uniform. Timeout-path tests shrink the timeout // via setAckTimeoutMsForTest so they fire in milliseconds rather than the // 3-second production default. namespace { // EventCapture / ScopedEventSink come from the test framework // (); the PackageManagerImpl event-method bodies they observe // are defined in package_manager_events_test.cpp (linked into both targets). // Convenience: registers `pkgName` as an installed user-package so // isEmbedded / installedDependentsNames return sensible values. `type` is // "core" or "ui" — controls which *Uninstalled event doUninstall emits. static void primeInstalledUserPackage(const std::string& pkgName, const std::string& type = "core") { InstalledPackage pkg; pkg.name = pkgName; pkg.type = type; pkg.installType = InstallType::User; setMockInstalledPackages({pkg}); } } // namespace // --------------------------------------------------------------------------- // requestUninstall / requestUpgrade: validation, happy path, pending lock-out // --------------------------------------------------------------------------- LOGOS_TEST(requestUninstall_rejects_empty_name) { auto t = LogosTestContext("package_manager"); EventCapture events; PackageManagerImpl impl; LogosMap r = impl.requestUninstall(""); LOGOS_ASSERT_FALSE(r["success"].get()); LOGOS_ASSERT_EQ(r["error"].get(), std::string("Package name cannot be empty")); LOGOS_ASSERT_EQ(events.size(), static_cast(0)); } LOGOS_TEST(requestUninstall_rejects_embedded_package) { auto t = LogosTestContext("package_manager"); InstalledPackage pkg; pkg.name = "core_embed"; pkg.type = "core"; pkg.installType = InstallType::Embedded; setMockInstalledPackages({pkg}); EventCapture events; PackageManagerImpl impl; LogosMap r = impl.requestUninstall("core_embed"); LOGOS_ASSERT_FALSE(r["success"].get()); LOGOS_ASSERT_EQ(r["error"].get(), std::string("Cannot uninstall embedded module 'core_embed'")); LOGOS_ASSERT_FALSE(events.has("beforeUninstall")); } LOGOS_TEST(requestUninstall_happy_emits_beforeUninstall_with_dependents) { auto t = LogosTestContext("package_manager"); primeInstalledUserPackage("foo"); // A listener-style reverse tree rooted at "foo" with one direct dependent. DependentTreeNode root; root.name = "foo"; DependentTreeNode parent; parent.name = "parent_pkg"; root.children = {parent}; setMockDependentTree(root); EventCapture events; PackageManagerImpl impl; LogosMap r = impl.requestUninstall("foo"); LOGOS_ASSERT_TRUE(r["success"].get()); LOGOS_ASSERT_FALSE(r.contains("error")); auto matches = events.all("beforeUninstall"); LOGOS_ASSERT_EQ(matches.size(), static_cast(1)); LogosMap payload = LogosMap::parse(matches[0].data); LOGOS_ASSERT_EQ(payload["name"].get(), std::string("foo")); LOGOS_ASSERT_EQ(payload["installedDependents"].size(), static_cast(1)); LOGOS_ASSERT_EQ(payload["installedDependents"][0].get(), std::string("parent_pkg")); // Clean up the pending action so the dtor doesn't emit a timeout event. impl.resetPendingAction(); } LOGOS_TEST(requestUninstall_rejects_when_already_pending) { auto t = LogosTestContext("package_manager"); primeInstalledUserPackage("foo"); EventCapture events; PackageManagerImpl impl; LogosMap first = impl.requestUninstall("foo"); LOGOS_ASSERT_TRUE(first["success"].get()); LogosMap second = impl.requestUninstall("foo"); LOGOS_ASSERT_FALSE(second["success"].get()); // Error message mentions the in-progress op ("uninstall") and its name. std::string err = second["error"].get(); LOGOS_ASSERT_TRUE(err.find("uninstall") != std::string::npos); LOGOS_ASSERT_TRUE(err.find("foo") != std::string::npos); impl.resetPendingAction(); } LOGOS_TEST(requestUpgrade_rejects_empty_name) { auto t = LogosTestContext("package_manager"); EventCapture events; PackageManagerImpl impl; LogosMap r = impl.requestUpgrade("", "v1.0.0", 0, ""); LOGOS_ASSERT_FALSE(r["success"].get()); LOGOS_ASSERT_EQ(r["error"].get(), std::string("Package name cannot be empty")); LOGOS_ASSERT_FALSE(events.has("beforeUpgrade")); } LOGOS_TEST(requestUpgrade_rejects_embedded_package) { auto t = LogosTestContext("package_manager"); InstalledPackage pkg; pkg.name = "core_embed"; pkg.type = "core"; pkg.installType = InstallType::Embedded; setMockInstalledPackages({pkg}); EventCapture events; PackageManagerImpl impl; LogosMap r = impl.requestUpgrade("core_embed", "v2.0.0", 0, ""); LOGOS_ASSERT_FALSE(r["success"].get()); LOGOS_ASSERT_EQ(r["error"].get(), std::string("Cannot upgrade embedded module 'core_embed'")); LOGOS_ASSERT_FALSE(events.has("beforeUpgrade")); } LOGOS_TEST(requestUpgrade_happy_emits_beforeUpgrade_with_tag_and_mode) { auto t = LogosTestContext("package_manager"); primeInstalledUserPackage("foo"); EventCapture events; PackageManagerImpl impl; LogosMap r = impl.requestUpgrade("foo", "v2.0.0", 7, ""); LOGOS_ASSERT_TRUE(r["success"].get()); auto matches = events.all("beforeUpgrade"); LOGOS_ASSERT_EQ(matches.size(), static_cast(1)); LogosMap payload = LogosMap::parse(matches[0].data); LOGOS_ASSERT_EQ(payload["name"].get(), std::string("foo")); LOGOS_ASSERT_EQ(payload["releaseTag"].get(), std::string("v2.0.0")); LOGOS_ASSERT_EQ(payload["mode"].get(), static_cast(7)); impl.resetPendingAction(); } LOGOS_TEST(requestUpgrade_rejects_when_already_pending) { auto t = LogosTestContext("package_manager"); primeInstalledUserPackage("foo"); EventCapture events; PackageManagerImpl impl; LOGOS_ASSERT_TRUE(impl.requestUpgrade("foo", "v1.0.0", 0, "")["success"].get()); // A second gated op (of either kind) must fail while another is pending. LogosMap blocked = impl.requestUninstall("foo"); LOGOS_ASSERT_FALSE(blocked["success"].get()); std::string err = blocked["error"].get(); LOGOS_ASSERT_TRUE(err.find("upgrade") != std::string::npos); impl.resetPendingAction(); } // --------------------------------------------------------------------------- // ackPendingAction: success / missing / mismatch / idempotent // --------------------------------------------------------------------------- LOGOS_TEST(ackPendingAction_success_on_pending_uninstall) { auto t = LogosTestContext("package_manager"); primeInstalledUserPackage("foo"); EventCapture events; PackageManagerImpl impl; LOGOS_ASSERT_TRUE(impl.requestUninstall("foo")["success"].get()); LogosMap ack = impl.ackPendingAction("foo"); LOGOS_ASSERT_TRUE(ack["success"].get()); LOGOS_ASSERT_FALSE(ack.contains("error")); impl.resetPendingAction(); } LOGOS_TEST(ackPendingAction_no_pending_returns_error) { auto t = LogosTestContext("package_manager"); PackageManagerImpl impl; LogosMap ack = impl.ackPendingAction("anything"); LOGOS_ASSERT_FALSE(ack["success"].get()); LOGOS_ASSERT_TRUE(ack["error"].get().find("No matching pending") != std::string::npos); } LOGOS_TEST(ackPendingAction_name_mismatch_rejected) { auto t = LogosTestContext("package_manager"); primeInstalledUserPackage("foo"); EventCapture events; PackageManagerImpl impl; LOGOS_ASSERT_TRUE(impl.requestUninstall("foo")["success"].get()); LogosMap ack = impl.ackPendingAction("not_foo"); LOGOS_ASSERT_FALSE(ack["success"].get()); LOGOS_ASSERT_TRUE(ack["error"].get().find("not_foo") != std::string::npos); impl.resetPendingAction(); } LOGOS_TEST(ackPendingAction_idempotent_on_repeat) { auto t = LogosTestContext("package_manager"); primeInstalledUserPackage("foo"); EventCapture events; PackageManagerImpl impl; LOGOS_ASSERT_TRUE(impl.requestUninstall("foo")["success"].get()); LOGOS_ASSERT_TRUE(impl.ackPendingAction("foo")["success"].get()); // Second ack should still succeed (same pending, already acked). LOGOS_ASSERT_TRUE(impl.ackPendingAction("foo")["success"].get()); impl.resetPendingAction(); } // --------------------------------------------------------------------------- // confirmUninstall: happy / failed-uninstall / missing / mismatch // --------------------------------------------------------------------------- LOGOS_TEST(confirmUninstall_happy_performs_uninstall_and_emits_core_event) { auto t = LogosTestContext("package_manager"); primeInstalledUserPackage("foo", "core"); t.mockCFunction("uninstallPackage_success").returns(true); t.mockCFunction("uninstallPackage_removed").returns("/m/foo.dylib"); EventCapture events; PackageManagerImpl impl; LOGOS_ASSERT_TRUE(impl.requestUninstall("foo")["success"].get()); LOGOS_ASSERT_TRUE(impl.ackPendingAction("foo")["success"].get()); LogosMap r = impl.confirmUninstall("foo"); LOGOS_ASSERT_TRUE(r["success"].get()); LOGOS_ASSERT_EQ(r["removedFiles"].size(), static_cast(1)); LOGOS_ASSERT_TRUE(events.has("corePluginUninstalled")); LOGOS_ASSERT_TRUE(t.cFunctionCalled("uninstallPackage")); // Pending cleared — a subsequent confirm should fail with "no matching". LogosMap again = impl.confirmUninstall("foo"); LOGOS_ASSERT_FALSE(again["success"].get()); } LOGOS_TEST(confirmUninstall_surfaces_uninstall_failure) { auto t = LogosTestContext("package_manager"); primeInstalledUserPackage("foo", "core"); t.mockCFunction("uninstallPackage_success").returns(false); t.mockCFunction("uninstallPackage_error").returns("delete failed"); EventCapture events; PackageManagerImpl impl; LOGOS_ASSERT_TRUE(impl.requestUninstall("foo")["success"].get()); LOGOS_ASSERT_TRUE(impl.ackPendingAction("foo")["success"].get()); LogosMap r = impl.confirmUninstall("foo"); LOGOS_ASSERT_FALSE(r["success"].get()); LOGOS_ASSERT_EQ(r["error"].get(), std::string("delete failed")); LOGOS_ASSERT_FALSE(events.has("corePluginUninstalled")); LOGOS_ASSERT_FALSE(events.has("uiPluginUninstalled")); } LOGOS_TEST(confirmUninstall_no_pending_returns_error) { auto t = LogosTestContext("package_manager"); PackageManagerImpl impl; LogosMap r = impl.confirmUninstall("foo"); LOGOS_ASSERT_FALSE(r["success"].get()); LOGOS_ASSERT_TRUE(r["error"].get().find("No matching pending uninstall") != std::string::npos); } LOGOS_TEST(confirmUninstall_name_mismatch_returns_error) { auto t = LogosTestContext("package_manager"); primeInstalledUserPackage("foo", "core"); EventCapture events; PackageManagerImpl impl; LOGOS_ASSERT_TRUE(impl.requestUninstall("foo")["success"].get()); LOGOS_ASSERT_TRUE(impl.ackPendingAction("foo")["success"].get()); LogosMap r = impl.confirmUninstall("bar"); LOGOS_ASSERT_FALSE(r["success"].get()); // Original pending still valid. LOGOS_ASSERT_TRUE(impl.cancelUninstall("foo")["success"].get()); } // --------------------------------------------------------------------------- // confirmUpgrade: happy / tag-mismatch / failed-uninstall suppresses event // --------------------------------------------------------------------------- LOGOS_TEST(confirmUpgrade_happy_emits_upgradeUninstallDone) { auto t = LogosTestContext("package_manager"); primeInstalledUserPackage("foo", "core"); t.mockCFunction("uninstallPackage_success").returns(true); EventCapture events; PackageManagerImpl impl; LOGOS_ASSERT_TRUE(impl.requestUpgrade("foo", "v2.0.0", 3, "")["success"].get()); LOGOS_ASSERT_TRUE(impl.ackPendingAction("foo")["success"].get()); LogosMap r = impl.confirmUpgrade("foo", "v2.0.0"); LOGOS_ASSERT_TRUE(r["success"].get()); auto done = events.all("upgradeUninstallDone"); LOGOS_ASSERT_EQ(done.size(), static_cast(1)); LogosMap payload = LogosMap::parse(done[0].data); LOGOS_ASSERT_EQ(payload["name"].get(), std::string("foo")); LOGOS_ASSERT_EQ(payload["releaseTag"].get(), std::string("v2.0.0")); LOGOS_ASSERT_EQ(payload["mode"].get(), static_cast(3)); } LOGOS_TEST(confirmUpgrade_suppresses_event_on_uninstall_failure) { auto t = LogosTestContext("package_manager"); primeInstalledUserPackage("foo", "core"); // Uninstall step inside confirmUpgrade fails — upgradeUninstallDone must NOT fire. t.mockCFunction("uninstallPackage_success").returns(false); t.mockCFunction("uninstallPackage_error").returns("cannot remove"); EventCapture events; PackageManagerImpl impl; LOGOS_ASSERT_TRUE(impl.requestUpgrade("foo", "v2.0.0", 0, "")["success"].get()); LOGOS_ASSERT_TRUE(impl.ackPendingAction("foo")["success"].get()); LogosMap r = impl.confirmUpgrade("foo", "v2.0.0"); LOGOS_ASSERT_FALSE(r["success"].get()); LOGOS_ASSERT_FALSE(events.has("upgradeUninstallDone")); } LOGOS_TEST(confirmUpgrade_tag_mismatch_returns_error) { auto t = LogosTestContext("package_manager"); primeInstalledUserPackage("foo", "core"); EventCapture events; PackageManagerImpl impl; LOGOS_ASSERT_TRUE(impl.requestUpgrade("foo", "v2.0.0", 0, "")["success"].get()); LOGOS_ASSERT_TRUE(impl.ackPendingAction("foo")["success"].get()); LogosMap r = impl.confirmUpgrade("foo", "v3.0.0"); LOGOS_ASSERT_FALSE(r["success"].get()); LOGOS_ASSERT_TRUE(r["error"].get().find("No matching pending upgrade") != std::string::npos); // Original upgrade still pending — the mismatched tag must not have cleared state. LOGOS_ASSERT_TRUE(impl.cancelUpgrade("foo", "v2.0.0")["success"].get()); } LOGOS_TEST(confirmUpgrade_no_pending_returns_error) { auto t = LogosTestContext("package_manager"); PackageManagerImpl impl; LogosMap r = impl.confirmUpgrade("foo", "v1.0.0"); LOGOS_ASSERT_FALSE(r["success"].get()); LOGOS_ASSERT_TRUE(r["error"].get().find("No matching pending upgrade") != std::string::npos); } LOGOS_TEST(confirmUpgrade_requires_ack_before_confirm) { auto t = LogosTestContext("package_manager"); primeInstalledUserPackage("foo", "core"); EventCapture events; PackageManagerImpl impl; LOGOS_ASSERT_TRUE(impl.requestUpgrade("foo", "v2.0.0", 0, "")["success"].get()); LogosMap r = impl.confirmUpgrade("foo", "v2.0.0"); LOGOS_ASSERT_FALSE(r["success"].get()); LOGOS_ASSERT_TRUE(r["error"].get().find("has not been acknowledged") != std::string::npos); LOGOS_ASSERT_FALSE(events.has("upgradeUninstallDone")); // Pending action should remain active until acknowledged/cancelled/reset. // Ack first because cancelUpgrade (like confirmUpgrade) now requires a // prior ack for protocol uniformity — see cancelUpgrade_requires_ack_before_cancel. LOGOS_ASSERT_TRUE(impl.ackPendingAction("foo")["success"].get()); LOGOS_ASSERT_TRUE(impl.cancelUpgrade("foo", "v2.0.0")["success"].get()); } // --------------------------------------------------------------------------- // requestInstall / confirmInstall / cancelInstall: the fresh-install gate. // Mirrors the upgrade gate but with no in-module uninstall step — confirm // emits installApproved so the initiator runs its own download+install. // --------------------------------------------------------------------------- LOGOS_TEST(requestInstall_rejects_empty_name) { auto t = LogosTestContext("package_manager"); EventCapture events; PackageManagerImpl impl; LogosMap r = impl.requestInstall("", "v1.0.0", "https://repo", ""); LOGOS_ASSERT_FALSE(r["success"].get()); LOGOS_ASSERT_EQ(r["error"].get(), std::string("Package name cannot be empty")); LOGOS_ASSERT_FALSE(events.has("beforeInstall")); } LOGOS_TEST(requestInstall_happy_emits_beforeInstall_with_repo_and_depChanges) { auto t = LogosTestContext("package_manager"); EventCapture events; PackageManagerImpl impl; // A fresh install need not be already installed — the gate is pure // confirmation. depChanges is opaque JSON the host dialog will render. const std::string depChanges = "[{\"name\":\"dep1\",\"action\":\"install\",\"toVersion\":\"1.2.0\"}]"; LogosMap r = impl.requestInstall("newpkg", "v1.0.0", "https://repo/x", depChanges); LOGOS_ASSERT_TRUE(r["success"].get()); auto matches = events.all("beforeInstall"); LOGOS_ASSERT_EQ(matches.size(), static_cast(1)); LogosMap payload = LogosMap::parse(matches[0].data); LOGOS_ASSERT_EQ(payload["name"].get(), std::string("newpkg")); LOGOS_ASSERT_EQ(payload["releaseTag"].get(), std::string("v1.0.0")); LOGOS_ASSERT_EQ(payload["repositoryUrl"].get(), std::string("https://repo/x")); // depChanges is embedded as a parsed JSON array, not a re-stringified blob. LOGOS_ASSERT_TRUE(payload["depChanges"].is_array()); LOGOS_ASSERT_EQ(payload["depChanges"].size(), static_cast(1)); LOGOS_ASSERT_EQ(payload["depChanges"][0]["name"].get(), std::string("dep1")); impl.resetPendingAction(); } LOGOS_TEST(requestInstall_empty_depChanges_yields_empty_array) { auto t = LogosTestContext("package_manager"); EventCapture events; PackageManagerImpl impl; LOGOS_ASSERT_TRUE(impl.requestInstall("newpkg", "v1.0.0", "", "")["success"].get()); LogosMap payload = LogosMap::parse(events.all("beforeInstall")[0].data); LOGOS_ASSERT_TRUE(payload["depChanges"].is_array()); LOGOS_ASSERT_EQ(payload["depChanges"].size(), static_cast(0)); impl.resetPendingAction(); } LOGOS_TEST(requestInstall_rejects_when_already_pending) { auto t = LogosTestContext("package_manager"); EventCapture events; PackageManagerImpl impl; LOGOS_ASSERT_TRUE(impl.requestInstall("newpkg", "v1.0.0", "", "")["success"].get()); LogosMap blocked = impl.requestInstall("other", "v1.0.0", "", ""); LOGOS_ASSERT_FALSE(blocked["success"].get()); LOGOS_ASSERT_TRUE(blocked["error"].get().find("install") != std::string::npos); impl.resetPendingAction(); } LOGOS_TEST(confirmInstall_happy_emits_installApproved) { auto t = LogosTestContext("package_manager"); EventCapture events; PackageManagerImpl impl; LOGOS_ASSERT_TRUE(impl.requestInstall("newpkg", "v1.0.0", "https://repo/x", "")["success"].get()); LOGOS_ASSERT_TRUE(impl.ackPendingAction("newpkg")["success"].get()); LogosMap r = impl.confirmInstall("newpkg"); LOGOS_ASSERT_TRUE(r["success"].get()); auto approved = events.all("installApproved"); LOGOS_ASSERT_EQ(approved.size(), static_cast(1)); LogosMap payload = LogosMap::parse(approved[0].data); LOGOS_ASSERT_EQ(payload["name"].get(), std::string("newpkg")); LOGOS_ASSERT_EQ(payload["releaseTag"].get(), std::string("v1.0.0")); LOGOS_ASSERT_EQ(payload["repositoryUrl"].get(), std::string("https://repo/x")); // Gate cleared — a fresh request must now succeed. LOGOS_ASSERT_TRUE(impl.requestInstall("again", "v1.0.0", "", "")["success"].get()); impl.resetPendingAction(); } LOGOS_TEST(confirmInstall_requires_ack_before_confirm) { auto t = LogosTestContext("package_manager"); EventCapture events; PackageManagerImpl impl; LOGOS_ASSERT_TRUE(impl.requestInstall("newpkg", "v1.0.0", "", "")["success"].get()); LogosMap r = impl.confirmInstall("newpkg"); LOGOS_ASSERT_FALSE(r["success"].get()); LOGOS_ASSERT_TRUE(r["error"].get().find("has not been acknowledged") != std::string::npos); LOGOS_ASSERT_FALSE(events.has("installApproved")); impl.resetPendingAction(); } LOGOS_TEST(cancelInstall_emits_installCancelled_with_user_reason) { auto t = LogosTestContext("package_manager"); EventCapture events; PackageManagerImpl impl; LOGOS_ASSERT_TRUE(impl.requestInstall("newpkg", "v1.0.0", "https://repo/x", "")["success"].get()); LOGOS_ASSERT_TRUE(impl.ackPendingAction("newpkg")["success"].get()); LogosMap r = impl.cancelInstall("newpkg"); LOGOS_ASSERT_TRUE(r["success"].get()); auto cancelled = events.all("installCancelled"); LOGOS_ASSERT_EQ(cancelled.size(), static_cast(1)); LogosMap payload = LogosMap::parse(cancelled[0].data); LOGOS_ASSERT_EQ(payload["name"].get(), std::string("newpkg")); LOGOS_ASSERT_EQ(payload["reason"].get(), std::string("user cancelled")); LOGOS_ASSERT_FALSE(events.has("installApproved")); } LOGOS_TEST(requestUpgrade_carries_depChanges_in_beforeUpgrade) { auto t = LogosTestContext("package_manager"); primeInstalledUserPackage("foo"); EventCapture events; PackageManagerImpl impl; const std::string depChanges = "[{\"name\":\"dep1\",\"action\":\"upgrade\",\"fromVersion\":\"1.0.0\",\"toVersion\":\"1.2.0\"}]"; LOGOS_ASSERT_TRUE(impl.requestUpgrade("foo", "v2.0.0", 0, depChanges)["success"].get()); LogosMap payload = LogosMap::parse(events.all("beforeUpgrade")[0].data); LOGOS_ASSERT_TRUE(payload["depChanges"].is_array()); LOGOS_ASSERT_EQ(payload["depChanges"].size(), static_cast(1)); LOGOS_ASSERT_EQ(payload["depChanges"][0]["action"].get(), std::string("upgrade")); impl.resetPendingAction(); } // --------------------------------------------------------------------------- // cancelUninstall / cancelUpgrade: happy / missing / mismatch // --------------------------------------------------------------------------- LOGOS_TEST(cancelUninstall_emits_uninstallCancelled_with_user_reason) { auto t = LogosTestContext("package_manager"); primeInstalledUserPackage("foo"); EventCapture events; PackageManagerImpl impl; LOGOS_ASSERT_TRUE(impl.requestUninstall("foo")["success"].get()); LOGOS_ASSERT_TRUE(impl.ackPendingAction("foo")["success"].get()); LogosMap r = impl.cancelUninstall("foo"); LOGOS_ASSERT_TRUE(r["success"].get()); auto cancelled = events.all("uninstallCancelled"); LOGOS_ASSERT_EQ(cancelled.size(), static_cast(1)); LogosMap payload = LogosMap::parse(cancelled[0].data); LOGOS_ASSERT_EQ(payload["name"].get(), std::string("foo")); LOGOS_ASSERT_EQ(payload["reason"].get(), std::string("user cancelled")); // State cleared — a second cancel is a no-match error. LogosMap again = impl.cancelUninstall("foo"); LOGOS_ASSERT_FALSE(again["success"].get()); } LOGOS_TEST(cancelUninstall_no_pending_returns_error) { auto t = LogosTestContext("package_manager"); EventCapture events; PackageManagerImpl impl; LogosMap r = impl.cancelUninstall("foo"); LOGOS_ASSERT_FALSE(r["success"].get()); LOGOS_ASSERT_FALSE(events.has("uninstallCancelled")); } LOGOS_TEST(cancelUpgrade_emits_upgradeCancelled_with_tag_and_reason) { auto t = LogosTestContext("package_manager"); primeInstalledUserPackage("foo"); EventCapture events; PackageManagerImpl impl; LOGOS_ASSERT_TRUE(impl.requestUpgrade("foo", "v2.0.0", 0, "")["success"].get()); LOGOS_ASSERT_TRUE(impl.ackPendingAction("foo")["success"].get()); LogosMap r = impl.cancelUpgrade("foo", "v2.0.0"); LOGOS_ASSERT_TRUE(r["success"].get()); auto cancelled = events.all("upgradeCancelled"); LOGOS_ASSERT_EQ(cancelled.size(), static_cast(1)); LogosMap payload = LogosMap::parse(cancelled[0].data); LOGOS_ASSERT_EQ(payload["name"].get(), std::string("foo")); LOGOS_ASSERT_EQ(payload["releaseTag"].get(), std::string("v2.0.0")); LOGOS_ASSERT_EQ(payload["reason"].get(), std::string("user cancelled")); } LOGOS_TEST(cancelUpgrade_tag_mismatch_returns_error) { auto t = LogosTestContext("package_manager"); primeInstalledUserPackage("foo"); EventCapture events; PackageManagerImpl impl; LOGOS_ASSERT_TRUE(impl.requestUpgrade("foo", "v2.0.0", 0, "")["success"].get()); LOGOS_ASSERT_TRUE(impl.ackPendingAction("foo")["success"].get()); LogosMap r = impl.cancelUpgrade("foo", "v9.9.9"); LOGOS_ASSERT_FALSE(r["success"].get()); LOGOS_ASSERT_FALSE(events.has("upgradeCancelled")); // Original pending survives the mismatched cancel. LOGOS_ASSERT_TRUE(impl.cancelUpgrade("foo", "v2.0.0")["success"].get()); } LOGOS_TEST(cancelUninstall_requires_ack_before_cancel) { // Symmetric with confirmUninstall_requires_ack_before_confirm: cancel // also demands a prior ack so the gated protocol's two-phase contract // is uniform across all four confirm/cancel slots. An un-acked pending // state belongs to the ack-reception timer — cancelling it directly // would suppress the "no listener acknowledged" timeout event that // initiators rely on for uniform cancellation handling. auto t = LogosTestContext("package_manager"); primeInstalledUserPackage("foo"); EventCapture events; PackageManagerImpl impl; LOGOS_ASSERT_TRUE(impl.requestUninstall("foo")["success"].get()); LogosMap r = impl.cancelUninstall("foo"); LOGOS_ASSERT_FALSE(r["success"].get()); LOGOS_ASSERT_TRUE(r["error"].get().find("has not been acknowledged") != std::string::npos); // No uninstallCancelled event fires — the un-acked request is still // owned by the timer, not by a confirming listener. LOGOS_ASSERT_FALSE(events.has("uninstallCancelled")); // Pending survives — an ack+cancel still succeeds. LOGOS_ASSERT_TRUE(impl.ackPendingAction("foo")["success"].get()); LOGOS_ASSERT_TRUE(impl.cancelUninstall("foo")["success"].get()); } LOGOS_TEST(cancelUpgrade_requires_ack_before_cancel) { auto t = LogosTestContext("package_manager"); primeInstalledUserPackage("foo"); EventCapture events; PackageManagerImpl impl; LOGOS_ASSERT_TRUE(impl.requestUpgrade("foo", "v2.0.0", 0, "")["success"].get()); LogosMap r = impl.cancelUpgrade("foo", "v2.0.0"); LOGOS_ASSERT_FALSE(r["success"].get()); LOGOS_ASSERT_TRUE(r["error"].get().find("has not been acknowledged") != std::string::npos); LOGOS_ASSERT_FALSE(events.has("upgradeCancelled")); LOGOS_ASSERT_TRUE(impl.ackPendingAction("foo")["success"].get()); LOGOS_ASSERT_TRUE(impl.cancelUpgrade("foo", "v2.0.0")["success"].get()); } // --------------------------------------------------------------------------- // Ack-timeout worker: fires cancellation on no-ack; ack / confirm / cancel / // reset all cancel the timer. // --------------------------------------------------------------------------- LOGOS_TEST(ackTimeout_fires_uninstallCancelled_with_timeout_reason) { auto t = LogosTestContext("package_manager"); primeInstalledUserPackage("foo"); EventCapture events; PackageManagerImpl impl; // Hook must be set before requestUninstall — otherwise the worker is // already running with the 3s default. impl.setAckTimeoutMsForTest(30); LOGOS_ASSERT_TRUE(impl.requestUninstall("foo")["success"].get()); // No ack within 30 ms — worker should fire uninstallCancelled. auto e = events.waitFor("uninstallCancelled", 1000); LOGOS_ASSERT_EQ(e.name, std::string("uninstallCancelled")); LogosMap payload = LogosMap::parse(e.data); LOGOS_ASSERT_EQ(payload["name"].get(), std::string("foo")); std::string reason = payload["reason"].get(); LOGOS_ASSERT_TRUE(reason.find("no listener acknowledged") != std::string::npos); LOGOS_ASSERT_TRUE(reason.find("30ms") != std::string::npos); } LOGOS_TEST(ackTimeout_fires_upgradeCancelled_with_timeout_reason) { auto t = LogosTestContext("package_manager"); primeInstalledUserPackage("foo"); EventCapture events; PackageManagerImpl impl; impl.setAckTimeoutMsForTest(30); LOGOS_ASSERT_TRUE(impl.requestUpgrade("foo", "v2.0.0", 0, "")["success"].get()); auto e = events.waitFor("upgradeCancelled", 1000); LOGOS_ASSERT_EQ(e.name, std::string("upgradeCancelled")); LogosMap payload = LogosMap::parse(e.data); LOGOS_ASSERT_EQ(payload["name"].get(), std::string("foo")); LOGOS_ASSERT_EQ(payload["releaseTag"].get(), std::string("v2.0.0")); LOGOS_ASSERT_TRUE(payload["reason"].get().find("no listener acknowledged") != std::string::npos); } LOGOS_TEST(ack_cancels_timer_no_timeout_event) { auto t = LogosTestContext("package_manager"); primeInstalledUserPackage("foo"); EventCapture events; PackageManagerImpl impl; impl.setAckTimeoutMsForTest(30); LOGOS_ASSERT_TRUE(impl.requestUninstall("foo")["success"].get()); LOGOS_ASSERT_TRUE(impl.ackPendingAction("foo")["success"].get()); // Sleep past the timeout window — no cancellation should be emitted. std::this_thread::sleep_for(std::chrono::milliseconds(200)); LOGOS_ASSERT_FALSE(events.has("uninstallCancelled")); impl.resetPendingAction(); } LOGOS_TEST(confirmUninstall_cancels_timer_no_timeout_event) { auto t = LogosTestContext("package_manager"); primeInstalledUserPackage("foo", "core"); t.mockCFunction("uninstallPackage_success").returns(true); EventCapture events; PackageManagerImpl impl; impl.setAckTimeoutMsForTest(30); LOGOS_ASSERT_TRUE(impl.requestUninstall("foo")["success"].get()); LOGOS_ASSERT_TRUE(impl.ackPendingAction("foo")["success"].get()); LOGOS_ASSERT_TRUE(impl.confirmUninstall("foo")["success"].get()); std::this_thread::sleep_for(std::chrono::milliseconds(200)); LOGOS_ASSERT_FALSE(events.has("uninstallCancelled")); // The real uninstall event did still fire. LOGOS_ASSERT_TRUE(events.has("corePluginUninstalled")); } LOGOS_TEST(cancelUninstall_cancels_timer_only_user_reason_emitted) { auto t = LogosTestContext("package_manager"); primeInstalledUserPackage("foo"); EventCapture events; PackageManagerImpl impl; impl.setAckTimeoutMsForTest(30); LOGOS_ASSERT_TRUE(impl.requestUninstall("foo")["success"].get()); LOGOS_ASSERT_TRUE(impl.ackPendingAction("foo")["success"].get()); LOGOS_ASSERT_TRUE(impl.cancelUninstall("foo")["success"].get()); // Wait well past the short timeout — exactly one uninstallCancelled // (from cancelUninstall) with reason "user cancelled", no duplicate // from the worker. std::this_thread::sleep_for(std::chrono::milliseconds(200)); auto cancelled = events.all("uninstallCancelled"); LOGOS_ASSERT_EQ(cancelled.size(), static_cast(1)); LogosMap payload = LogosMap::parse(cancelled[0].data); LOGOS_ASSERT_EQ(payload["reason"].get(), std::string("user cancelled")); } LOGOS_TEST(resetPendingAction_cancels_timer_no_timeout_event) { auto t = LogosTestContext("package_manager"); primeInstalledUserPackage("foo"); EventCapture events; PackageManagerImpl impl; impl.setAckTimeoutMsForTest(30); LOGOS_ASSERT_TRUE(impl.requestUninstall("foo")["success"].get()); LOGOS_ASSERT_TRUE(impl.resetPendingAction()["success"].get()); std::this_thread::sleep_for(std::chrono::milliseconds(200)); LOGOS_ASSERT_FALSE(events.has("uninstallCancelled")); LOGOS_ASSERT_FALSE(events.has("upgradeCancelled")); } // --------------------------------------------------------------------------- // resetPendingAction: clears pending state so the next request goes through. // --------------------------------------------------------------------------- LOGOS_TEST(resetPendingAction_allows_new_request) { auto t = LogosTestContext("package_manager"); primeInstalledUserPackage("foo"); EventCapture events; PackageManagerImpl impl; LOGOS_ASSERT_TRUE(impl.requestUninstall("foo")["success"].get()); // Without reset the second request fails: LOGOS_ASSERT_FALSE(impl.requestUninstall("foo")["success"].get()); LOGOS_ASSERT_TRUE(impl.resetPendingAction()["success"].get()); // Now it goes through again. LOGOS_ASSERT_TRUE(impl.requestUninstall("foo")["success"].get()); impl.resetPendingAction(); } // --------------------------------------------------------------------------- // requestMultiUninstall / confirmMultiUninstall / cancelMultiUninstall // --------------------------------------------------------------------------- // // Multi-package gated uninstall: same protocol as the single-package version // (one pending slot, one ack, one confirm/cancel) but operates on a batch. // Tests below mirror the single-uninstall structure (validation → happy → // confirm → cancel → cross-op) plus the batch-specific bits: dependents // dedup + batch-member exclusion, the multiUninstallCancelled payload shape. namespace { // Prime N user-installed packages — installedDependentsNames lookups in // requestMultiUninstall need each one visible to isEmbedded. static void primeInstalledUserPackages(const std::vector& names, const std::string& type = "core") { std::vector pkgs; pkgs.reserve(names.size()); for (const auto& n : names) { InstalledPackage p; p.name = n; p.type = type; p.installType = InstallType::User; pkgs.push_back(p); } setMockInstalledPackages(pkgs); } } // namespace LOGOS_TEST(requestMultiUninstall_rejects_empty_list) { auto t = LogosTestContext("package_manager"); EventCapture events; PackageManagerImpl impl; LogosMap r = impl.requestMultiUninstall({}); LOGOS_ASSERT_FALSE(r["success"].get()); LOGOS_ASSERT_EQ(r["error"].get(), std::string("Package list cannot be empty")); LOGOS_ASSERT_FALSE(events.has("beforeMultiUninstall")); } LOGOS_TEST(requestMultiUninstall_rejects_empty_name_in_list) { auto t = LogosTestContext("package_manager"); primeInstalledUserPackages({"foo"}); EventCapture events; PackageManagerImpl impl; LogosMap r = impl.requestMultiUninstall({"foo", ""}); LOGOS_ASSERT_FALSE(r["success"].get()); LOGOS_ASSERT_EQ(r["error"].get(), std::string("Package names cannot be empty")); LOGOS_ASSERT_FALSE(events.has("beforeMultiUninstall")); } LOGOS_TEST(requestMultiUninstall_rejects_when_any_embedded) { auto t = LogosTestContext("package_manager"); InstalledPackage user; user.name = "foo"; user.type = "core"; user.installType = InstallType::User; InstalledPackage embedded; embedded.name = "core_embed"; embedded.type = "core"; embedded.installType = InstallType::Embedded; setMockInstalledPackages({user, embedded}); EventCapture events; PackageManagerImpl impl; LogosMap r = impl.requestMultiUninstall({"foo", "core_embed"}); LOGOS_ASSERT_FALSE(r["success"].get()); // Error message lists the offending embedded package(s). std::string err = r["error"].get(); LOGOS_ASSERT_TRUE(err.find("embedded") != std::string::npos); LOGOS_ASSERT_TRUE(err.find("core_embed") != std::string::npos); LOGOS_ASSERT_FALSE(events.has("beforeMultiUninstall")); } LOGOS_TEST(requestMultiUninstall_rejects_when_already_pending) { auto t = LogosTestContext("package_manager"); primeInstalledUserPackages({"foo", "bar"}); EventCapture events; PackageManagerImpl impl; LogosMap first = impl.requestMultiUninstall({"foo", "bar"}); LOGOS_ASSERT_TRUE(first["success"].get()); // A subsequent multi-uninstall while one is pending must be rejected, // and the error message must mention the in-progress op. LogosMap second = impl.requestMultiUninstall({"foo", "bar"}); LOGOS_ASSERT_FALSE(second["success"].get()); std::string err = second["error"].get(); LOGOS_ASSERT_TRUE(err.find("multi-uninstall") != std::string::npos); impl.resetPendingAction(); } LOGOS_TEST(requestMultiUninstall_emits_beforeMultiUninstall_with_names_and_dependents) { auto t = LogosTestContext("package_manager"); primeInstalledUserPackages({"foo", "bar"}); // The same dependent tree is returned for every resolveDependents call // (mock semantics). Root="foo" with child "parent_pkg" — verifies the // payload carries `installedDependents`. DependentTreeNode root; root.name = "foo"; DependentTreeNode parent; parent.name = "parent_pkg"; root.children = {parent}; setMockDependentTree(root); EventCapture events; PackageManagerImpl impl; LogosMap r = impl.requestMultiUninstall({"foo", "bar"}); LOGOS_ASSERT_TRUE(r["success"].get()); LOGOS_ASSERT_FALSE(r.contains("error")); auto matches = events.all("beforeMultiUninstall"); LOGOS_ASSERT_EQ(matches.size(), static_cast(1)); LogosMap payload = LogosMap::parse(matches[0].data); // Payload carries the full `names` array (not a single `name` string). LOGOS_ASSERT_TRUE(payload.contains("names")); LOGOS_ASSERT_FALSE(payload.contains("name")); LOGOS_ASSERT_EQ(payload["names"].size(), static_cast(2)); LOGOS_ASSERT_EQ(payload["names"][0].get(), std::string("foo")); LOGOS_ASSERT_EQ(payload["names"][1].get(), std::string("bar")); // installedDependents has at least parent_pkg (mock returns same tree // for both packages, dedup keeps it just once). LOGOS_ASSERT_TRUE(payload["installedDependents"].is_array()); bool hasParent = false; for (size_t i = 0; i < payload["installedDependents"].size(); ++i) { if (payload["installedDependents"][i].get() == "parent_pkg") { hasParent = true; break; } } LOGOS_ASSERT_TRUE(hasParent); impl.resetPendingAction(); } LOGOS_TEST(requestMultiUninstall_dependents_excludes_batch_members) { auto t = LogosTestContext("package_manager"); primeInstalledUserPackages({"foo", "bar"}); // Tree where one of the dependents IS a member of the batch (`bar`). // The non-trivial logic under test: bar should NOT appear in // installedDependents because it's already being uninstalled. DependentTreeNode root; root.name = "foo"; DependentTreeNode barNode; barNode.name = "bar"; DependentTreeNode parent; parent.name = "parent_pkg"; root.children = {barNode, parent}; setMockDependentTree(root); EventCapture events; PackageManagerImpl impl; LOGOS_ASSERT_TRUE(impl.requestMultiUninstall({"foo", "bar"})["success"].get()); auto matches = events.all("beforeMultiUninstall"); LogosMap payload = LogosMap::parse(matches[0].data); // Walk installedDependents: bar must be filtered out, parent_pkg must remain. bool sawBar = false; bool sawParent = false; for (size_t i = 0; i < payload["installedDependents"].size(); ++i) { const std::string n = payload["installedDependents"][i].get(); if (n == "bar") sawBar = true; if (n == "parent_pkg") sawParent = true; } LOGOS_ASSERT_FALSE(sawBar); LOGOS_ASSERT_TRUE(sawParent); impl.resetPendingAction(); } LOGOS_TEST(ackPendingAction_works_using_first_batch_name) { auto t = LogosTestContext("package_manager"); primeInstalledUserPackages({"foo", "bar"}); EventCapture events; PackageManagerImpl impl; LOGOS_ASSERT_TRUE(impl.requestMultiUninstall({"foo", "bar"})["success"].get()); // Per the impl: m_pendingAction.name is set to names[0], so single-name // ackPendingAction continues to work unchanged for the multi flow. LogosMap ack = impl.ackPendingAction("foo"); LOGOS_ASSERT_TRUE(ack["success"].get()); impl.resetPendingAction(); } LOGOS_TEST(confirmMultiUninstall_all_succeed_emits_per_package_events) { auto t = LogosTestContext("package_manager"); primeInstalledUserPackages({"foo", "bar"}, "core"); t.mockCFunction("uninstallPackage_success").returns(true); t.mockCFunction("uninstallPackage_removed").returns("/m/x.dylib"); EventCapture events; PackageManagerImpl impl; LOGOS_ASSERT_TRUE(impl.requestMultiUninstall({"foo", "bar"})["success"].get()); LOGOS_ASSERT_TRUE(impl.ackPendingAction("foo")["success"].get()); LogosMap r = impl.confirmMultiUninstall({"foo", "bar"}); LOGOS_ASSERT_TRUE(r["success"].get()); // Per-package results array carries one entry per submitted name. LOGOS_ASSERT_EQ(r["results"].size(), static_cast(2)); LOGOS_ASSERT_EQ(r["results"][0]["name"].get(), std::string("foo")); LOGOS_ASSERT_TRUE(r["results"][0]["success"].get()); LOGOS_ASSERT_EQ(r["results"][1]["name"].get(), std::string("bar")); LOGOS_ASSERT_TRUE(r["results"][1]["success"].get()); // Per-package corePluginUninstalled events fired (one per uninstall, two total). LOGOS_ASSERT_EQ(events.all("corePluginUninstalled").size(), static_cast(2)); // A second confirm fails — pending was cleared. LogosMap again = impl.confirmMultiUninstall({"foo", "bar"}); LOGOS_ASSERT_FALSE(again["success"].get()); } LOGOS_TEST(confirmMultiUninstall_all_fail_returns_top_level_success_false) { auto t = LogosTestContext("package_manager"); primeInstalledUserPackages({"foo", "bar"}, "core"); t.mockCFunction("uninstallPackage_success").returns(false); t.mockCFunction("uninstallPackage_error").returns("delete failed"); EventCapture events; PackageManagerImpl impl; LOGOS_ASSERT_TRUE(impl.requestMultiUninstall({"foo", "bar"})["success"].get()); LOGOS_ASSERT_TRUE(impl.ackPendingAction("foo")["success"].get()); LogosMap r = impl.confirmMultiUninstall({"foo", "bar"}); LOGOS_ASSERT_FALSE(r["success"].get()); LOGOS_ASSERT_EQ(r["results"].size(), static_cast(2)); // Both results carry success=false and the per-package error. for (size_t i = 0; i < r["results"].size(); ++i) { LOGOS_ASSERT_FALSE(r["results"][i]["success"].get()); LOGOS_ASSERT_EQ(r["results"][i]["error"].get(), std::string("delete failed")); } // No per-package corePluginUninstalled events when uninstalls fail. LOGOS_ASSERT_FALSE(events.has("corePluginUninstalled")); } LOGOS_TEST(confirmMultiUninstall_no_pending_returns_error) { auto t = LogosTestContext("package_manager"); PackageManagerImpl impl; LogosMap r = impl.confirmMultiUninstall({"foo", "bar"}); LOGOS_ASSERT_FALSE(r["success"].get()); LOGOS_ASSERT_TRUE(r["error"].get() .find("No matching pending multi-uninstall") != std::string::npos); } LOGOS_TEST(confirmMultiUninstall_name_mismatch_returns_error) { auto t = LogosTestContext("package_manager"); primeInstalledUserPackages({"foo", "bar"}, "core"); EventCapture events; PackageManagerImpl impl; LOGOS_ASSERT_TRUE(impl.requestMultiUninstall({"foo", "bar"})["success"].get()); LOGOS_ASSERT_TRUE(impl.ackPendingAction("foo")["success"].get()); // Confirm with a different list — same first name but a different second name. LogosMap r = impl.confirmMultiUninstall({"foo", "baz"}); LOGOS_ASSERT_FALSE(r["success"].get()); // Pending state must still be valid — a correct cancel should succeed. LOGOS_ASSERT_TRUE(impl.cancelMultiUninstall({"foo", "bar"})["success"].get()); } LOGOS_TEST(confirmMultiUninstall_without_ack_returns_error) { auto t = LogosTestContext("package_manager"); primeInstalledUserPackages({"foo", "bar"}); EventCapture events; PackageManagerImpl impl; LOGOS_ASSERT_TRUE(impl.requestMultiUninstall({"foo", "bar"})["success"].get()); // Skip ackPendingAction — confirm should fail. LogosMap r = impl.confirmMultiUninstall({"foo", "bar"}); LOGOS_ASSERT_FALSE(r["success"].get()); LOGOS_ASSERT_TRUE(r["error"].get() .find("not been acknowledged") != std::string::npos); impl.resetPendingAction(); } LOGOS_TEST(cancelMultiUninstall_emits_multiUninstallCancelled_with_names_array) { auto t = LogosTestContext("package_manager"); primeInstalledUserPackages({"foo", "bar"}); EventCapture events; PackageManagerImpl impl; LOGOS_ASSERT_TRUE(impl.requestMultiUninstall({"foo", "bar"})["success"].get()); LOGOS_ASSERT_TRUE(impl.ackPendingAction("foo")["success"].get()); LogosMap r = impl.cancelMultiUninstall({"foo", "bar"}); LOGOS_ASSERT_TRUE(r["success"].get()); auto matches = events.all("multiUninstallCancelled"); LOGOS_ASSERT_EQ(matches.size(), static_cast(1)); LogosMap payload = LogosMap::parse(matches[0].data); // Distinct from uninstallCancelled / upgradeCancelled: payload uses // `names` (array) rather than `name` (string). LOGOS_ASSERT_TRUE(payload.contains("names")); LOGOS_ASSERT_FALSE(payload.contains("name")); LOGOS_ASSERT_EQ(payload["names"].size(), static_cast(2)); LOGOS_ASSERT_EQ(payload["reason"].get(), std::string("user cancelled")); } LOGOS_TEST(cancelMultiUninstall_no_pending_returns_error) { auto t = LogosTestContext("package_manager"); PackageManagerImpl impl; LogosMap r = impl.cancelMultiUninstall({"foo", "bar"}); LOGOS_ASSERT_FALSE(r["success"].get()); LOGOS_ASSERT_TRUE(r["error"].get() .find("No matching pending multi-uninstall") != std::string::npos); } LOGOS_TEST(cancelMultiUninstall_name_mismatch_returns_error) { auto t = LogosTestContext("package_manager"); primeInstalledUserPackages({"foo", "bar"}); EventCapture events; PackageManagerImpl impl; LOGOS_ASSERT_TRUE(impl.requestMultiUninstall({"foo", "bar"})["success"].get()); LOGOS_ASSERT_TRUE(impl.ackPendingAction("foo")["success"].get()); LogosMap r = impl.cancelMultiUninstall({"foo", "baz"}); LOGOS_ASSERT_FALSE(r["success"].get()); // Original pending still valid. LOGOS_ASSERT_TRUE(impl.cancelMultiUninstall({"foo", "bar"})["success"].get()); } LOGOS_TEST(requestMultiUninstall_blocks_subsequent_requestUninstall) { auto t = LogosTestContext("package_manager"); primeInstalledUserPackages({"foo", "bar"}); EventCapture events; PackageManagerImpl impl; LOGOS_ASSERT_TRUE(impl.requestMultiUninstall({"foo", "bar"})["success"].get()); // A single-package request while a multi is pending must fail. LogosMap r = impl.requestUninstall("foo"); LOGOS_ASSERT_FALSE(r["success"].get()); std::string err = r["error"].get(); LOGOS_ASSERT_TRUE(err.find("multi-uninstall") != std::string::npos); impl.resetPendingAction(); } LOGOS_TEST(requestMultiUninstall_dedupes_duplicate_input_names) { auto t = LogosTestContext("package_manager"); primeInstalledUserPackages({"foo", "bar"}); EventCapture events; PackageManagerImpl impl; // Caller passes duplicates; impl must dedupe so doUninstall isn't called // twice for the same name in confirmMultiUninstall. LogosMap r = impl.requestMultiUninstall({"foo", "foo", "bar", "foo"}); LOGOS_ASSERT_TRUE(r["success"].get()); auto matches = events.all("beforeMultiUninstall"); LogosMap payload = LogosMap::parse(matches[0].data); // First-occurrence order preserved: foo (first), bar (third). The // duplicate "foo"s drop out. LOGOS_ASSERT_EQ(payload["names"].size(), static_cast(2)); LOGOS_ASSERT_EQ(payload["names"][0].get(), std::string("foo")); LOGOS_ASSERT_EQ(payload["names"][1].get(), std::string("bar")); impl.resetPendingAction(); } LOGOS_TEST(confirmMultiUninstall_accepts_caller_with_duplicates) { auto t = LogosTestContext("package_manager"); primeInstalledUserPackages({"foo", "bar"}, "core"); t.mockCFunction("uninstallPackage_success").returns(true); t.mockCFunction("uninstallPackage_removed").returns("/m/x.dylib"); EventCapture events; PackageManagerImpl impl; // Stored pending state holds the deduped list — confirm with the original // duplicated form must still match (both sides dedupe at the boundary) // and must call doUninstall exactly once per unique name. LOGOS_ASSERT_TRUE(impl.requestMultiUninstall({"foo", "foo", "bar"})["success"].get()); LOGOS_ASSERT_TRUE(impl.ackPendingAction("foo")["success"].get()); LogosMap r = impl.confirmMultiUninstall({"foo", "foo", "bar"}); LOGOS_ASSERT_TRUE(r["success"].get()); LOGOS_ASSERT_EQ(r["results"].size(), static_cast(2)); // not 3 LOGOS_ASSERT_EQ(events.all("corePluginUninstalled").size(), static_cast(2)); // not 3 } LOGOS_TEST(ackPendingAction_accepts_any_name_in_multi_uninstall_batch) { auto t = LogosTestContext("package_manager"); primeInstalledUserPackages({"foo", "bar", "baz"}); EventCapture events; PackageManagerImpl impl; LOGOS_ASSERT_TRUE(impl.requestMultiUninstall({"foo", "bar", "baz"})["success"].get()); // Listener acks with the THIRD batch member — must succeed. Coupling the // wire protocol to names[0] specifically would force callers to know an // internal storage detail. LogosMap ack = impl.ackPendingAction("baz"); LOGOS_ASSERT_TRUE(ack["success"].get()); impl.resetPendingAction(); } LOGOS_TEST(ackPendingAction_rejects_name_not_in_multi_uninstall_batch) { auto t = LogosTestContext("package_manager"); primeInstalledUserPackages({"foo", "bar"}); EventCapture events; PackageManagerImpl impl; LOGOS_ASSERT_TRUE(impl.requestMultiUninstall({"foo", "bar"})["success"].get()); // Names not in the batch must still be rejected — the relaxation is // "any batch member", not "any name at all". LogosMap ack = impl.ackPendingAction("not_in_batch"); LOGOS_ASSERT_FALSE(ack["success"].get()); impl.resetPendingAction(); } // --------------------------------------------------------------------------- // Dependency CONSTRAINTS across the module ABI: the constraint crosses, and an // unconstrained package crosses byte-identically to before. // --------------------------------------------------------------------------- LOGOS_TEST(getInstalledPackages_carries_dependency_constraints) { auto t = LogosTestContext("package_manager"); InstalledPackage pkg; pkg.name = "app"; pkg.version = "1.0.0"; pkg.dependencies = {"plain", "lib"}; PackageDependency constrained; constrained.name = "lib"; constrained.version = "^2.0.0"; constrained.signer = "did:jwk:eyJrdHkiOiJPS1AifQ"; pkg.dependencyConstraints = {constrained}; setMockInstalledPackages({pkg}); PackageManagerImpl impl; LogosList list = impl.getInstalledPackages(); LOGOS_ASSERT_EQ(list.size(), static_cast(1)); // The edge set is unchanged: plain name STRINGS, both entries, in // declared order — consumers index this array as text. LOGOS_ASSERT_EQ(list[0]["dependencies"].size(), static_cast(2)); LOGOS_ASSERT_EQ(list[0]["dependencies"][0].get(), std::string("plain")); LOGOS_ASSERT_EQ(list[0]["dependencies"][1].get(), std::string("lib")); LOGOS_ASSERT_TRUE(list[0].contains("dependencyConstraints")); LOGOS_ASSERT_EQ(list[0]["dependencyConstraints"].size(), static_cast(1)); LogosMap c = list[0]["dependencyConstraints"][0]; LOGOS_ASSERT_EQ(c["name"].get(), std::string("lib")); LOGOS_ASSERT_EQ(c["version"].get(), std::string("^2.0.0")); LOGOS_ASSERT_EQ(c["signer"].get(), std::string("did:jwk:eyJrdHkiOiJPS1AifQ")); } LOGOS_TEST(getInstalledPackages_omits_constraints_for_bare_names) { // The key must be ABSENT, not an empty array, so a reader that does not // know it sees the payload it has always seen. auto t = LogosTestContext("package_manager"); InstalledPackage pkg; pkg.name = "app"; pkg.dependencies = {"lib"}; setMockInstalledPackages({pkg}); PackageManagerImpl impl; LogosList list = impl.getInstalledPackages(); LOGOS_ASSERT_EQ(list.size(), static_cast(1)); LOGOS_ASSERT_EQ(list[0]["dependencies"].size(), static_cast(1)); LOGOS_ASSERT_FALSE(list[0].contains("dependencyConstraints")); } LOGOS_TEST(getInstalledPackages_carries_a_range_only_constraint) { // `signer` is optional independently of `version`; a range-only entry must // not grow an empty signer key. auto t = LogosTestContext("package_manager"); InstalledPackage pkg; pkg.name = "app"; pkg.dependencies = {"lib"}; PackageDependency constrained; constrained.name = "lib"; constrained.version = "^2.0.0"; pkg.dependencyConstraints = {constrained}; setMockInstalledPackages({pkg}); PackageManagerImpl impl; LogosList list = impl.getInstalledPackages(); LogosMap c = list[0]["dependencyConstraints"][0]; LOGOS_ASSERT_EQ(c["version"].get(), std::string("^2.0.0")); LOGOS_ASSERT_FALSE(c.contains("signer")); } // Child installed at a version its parent's range rejects: lib is 1.0.0, the // edge asked for ^2.0.0. static DependencyTreeNode makeVersionMismatchTree() { DependencyTreeNode root; root.name = "app"; root.status = DependencyStatus::Installed; DependencyTreeNode lib; lib.name = "lib"; lib.status = DependencyStatus::VersionMismatch; lib.version = "1.0.0"; lib.installType = InstallType::User; lib.requiredVersion = "^2.0.0"; lib.requiredSigner = "did:jwk:eyJrdHkiOiJPS1AifQ"; root.children = {lib}; return root; } LOGOS_TEST(resolveDependencies_surfaces_version_mismatch) { auto t = LogosTestContext("package_manager"); setMockDependencyTree(makeVersionMismatchTree()); PackageManagerImpl impl; LogosMap out = impl.resolveDependencies("app", true); LogosMap dep = out["children"][0]; LOGOS_ASSERT_EQ(dep["status"].get(), std::string("version_mismatch")); // Installed, so it keeps the fields an installed node has; blanking them // would hide the version that makes the mismatch actionable. LOGOS_ASSERT_EQ(dep["version"].get(), std::string("1.0.0")); LOGOS_ASSERT_EQ(dep["installType"].get(), std::string("user")); LOGOS_ASSERT_EQ(dep["requiredVersion"].get(), std::string("^2.0.0")); LOGOS_ASSERT_EQ(dep["requiredSigner"].get(), std::string("did:jwk:eyJrdHkiOiJPS1AifQ")); } LOGOS_TEST(resolveFlatDependencies_surfaces_version_mismatch) { // A status that only existed on the tree would never reach a list-shaped // consumer. auto t = LogosTestContext("package_manager"); setMockDependencyTree(makeVersionMismatchTree()); PackageManagerImpl impl; LogosList flat = impl.resolveFlatDependencies("app", true); LOGOS_ASSERT_EQ(flat.size(), static_cast(1)); LOGOS_ASSERT_EQ(flat[0]["name"].get(), std::string("lib")); LOGOS_ASSERT_EQ(flat[0]["status"].get(), std::string("version_mismatch")); LOGOS_ASSERT_EQ(flat[0]["requiredVersion"].get(), std::string("^2.0.0")); } LOGOS_TEST(resolveDependencies_omits_constraint_keys_when_unconstrained) { // Pinned as an EXACT KEY SET, not as a few absences: a new key is additive // only if it is truly conditional, and `signerDid` is a property of the // PACKAGE, not the edge, so it does not go absent just because this edge is // unconstrained. Naming only requiredVersion/requiredSigner would not have // caught it appearing here. These five are what this API emitted before. auto t = LogosTestContext("package_manager"); setMockDependencyTree(makeForwardTree()); PackageManagerImpl impl; LogosMap out = impl.resolveDependencies("root", true); LogosMap dep = out["children"][0]; LOGOS_ASSERT_EQ(dep["status"].get(), std::string("installed")); LOGOS_ASSERT_EQ(dep.size(), static_cast(5)); LOGOS_ASSERT_TRUE(dep.contains("name")); LOGOS_ASSERT_TRUE(dep.contains("status")); LOGOS_ASSERT_TRUE(dep.contains("version")); LOGOS_ASSERT_TRUE(dep.contains("installType")); LOGOS_ASSERT_TRUE(dep.contains("children")); // Named individually too, so a failure says WHICH key appeared. LOGOS_ASSERT_FALSE(dep.contains("requiredVersion")); LOGOS_ASSERT_FALSE(dep.contains("requiredSigner")); LOGOS_ASSERT_FALSE(dep.contains("signerDid")); } LOGOS_TEST(resolveDependencies_absent_dependency_keeps_its_declared_range) { // Absence outranks mismatch — the library decides that, but a // not_installed node still reports the range, so a caller can say WHICH // version to go and install. auto t = LogosTestContext("package_manager"); DependencyTreeNode root; root.name = "app"; root.status = DependencyStatus::Installed; DependencyTreeNode absent; absent.name = "lib"; absent.status = DependencyStatus::NotInstalled; absent.requiredVersion = "^2.0.0"; root.children = {absent}; setMockDependencyTree(root); PackageManagerImpl impl; LogosMap out = impl.resolveDependencies("app", true); LogosMap dep = out["children"][0]; LOGOS_ASSERT_EQ(dep["status"].get(), std::string("not_installed")); LOGOS_ASSERT_EQ(dep["version"].get(), std::string("")); LOGOS_ASSERT_EQ(dep["requiredVersion"].get(), std::string("^2.0.0")); } // --------------------------------------------------------------------------- // The signer identity, across the ABI // // Two different facts, and downstream needs both: `requiredSigner` is the pin // the dependant declared, `signerDid` is what the installed package's own // signature says of itself. The verdict is NOT the two compared — it comes // from verifying under the pin's key, so a signer_mismatch row legitimately // carries a signerDid that differs from requiredSigner. // --------------------------------------------------------------------------- LOGOS_TEST(getInstalledPackages_carries_the_signer_did) { auto t = LogosTestContext("package_manager"); InstalledPackage pkg; pkg.name = "app"; pkg.version = "1.0.0"; pkg.signerDid = "did:jwk:eyJrdHkiOiJPS1AifQ"; setMockInstalledPackages({pkg}); PackageManagerImpl impl; LogosList list = impl.getInstalledPackages(); LOGOS_ASSERT_EQ(list.size(), static_cast(1)); LOGOS_ASSERT_EQ(list[0]["signerDid"].get(), std::string("did:jwk:eyJrdHkiOiJPS1AifQ")); } LOGOS_TEST(getInstalledPackages_omits_the_signer_did_when_unsigned) { // ABSENT, not an empty string: a reader must be able to tell "no // signature" from signed, and an empty string reads as neither. auto t = LogosTestContext("package_manager"); InstalledPackage pkg; pkg.name = "app"; setMockInstalledPackages({pkg}); PackageManagerImpl impl; LogosList list = impl.getInstalledPackages(); LOGOS_ASSERT_FALSE(list[0].contains("signerDid")); } // A dependency installed under the right name, signed by the WRONG KEY. static DependencyTreeNode makeSignerMismatchTree() { DependencyTreeNode root; root.name = "app"; root.status = DependencyStatus::Installed; DependencyTreeNode lib; lib.name = "lib"; lib.status = DependencyStatus::SignerMismatch; lib.version = "1.0.0"; lib.installType = InstallType::User; lib.requiredSigner = "did:jwk:PINNED"; lib.signerDid = "did:jwk:SOMEBODY_ELSE"; root.children = {lib}; return root; } LOGOS_TEST(resolveDependencies_surfaces_signer_mismatch_with_both_dids) { auto t = LogosTestContext("package_manager"); setMockDependencyTree(makeSignerMismatchTree()); PackageManagerImpl impl; LogosMap out = impl.resolveDependencies("app", true); LogosMap dep = out["children"][0]; LOGOS_ASSERT_EQ(dep["status"].get(), std::string("signer_mismatch")); LOGOS_ASSERT_EQ(dep["requiredSigner"].get(), std::string("did:jwk:PINNED")); LOGOS_ASSERT_EQ(dep["signerDid"].get(), std::string("did:jwk:SOMEBODY_ELSE")); // A signer-mismatched package IS on disk, so it keeps these; the // `Installed || VersionMismatch` chain this replaced blanked them. LOGOS_ASSERT_EQ(dep["version"].get(), std::string("1.0.0")); LOGOS_ASSERT_EQ(dep["installType"].get(), std::string("user")); } LOGOS_TEST(resolveFlatDependencies_surfaces_signer_mismatch) { // The flat projection is the ONLY one basecamp's load gate reads; a status // that reached the tree and not this list would block nothing. auto t = LogosTestContext("package_manager"); setMockDependencyTree(makeSignerMismatchTree()); PackageManagerImpl impl; LogosList flat = impl.resolveFlatDependencies("app", true); LOGOS_ASSERT_EQ(flat.size(), static_cast(1)); LOGOS_ASSERT_EQ(flat[0]["status"].get(), std::string("signer_mismatch")); LOGOS_ASSERT_EQ(flat[0]["requiredSigner"].get(), std::string("did:jwk:PINNED")); LOGOS_ASSERT_EQ(flat[0]["signerDid"].get(), std::string("did:jwk:SOMEBODY_ELSE")); LOGOS_ASSERT_EQ(flat[0]["version"].get(), std::string("1.0.0")); } LOGOS_TEST(resolveDependencies_surfaces_signer_unknown_without_a_signer_did) { // Absence of evidence crosses as its own status with no signerDid key, so // the far side names no signer it does not have. auto t = LogosTestContext("package_manager"); DependencyTreeNode root; root.name = "app"; root.status = DependencyStatus::Installed; DependencyTreeNode lib; lib.name = "lib"; lib.status = DependencyStatus::SignerUnknown; lib.version = "1.0.0"; lib.installType = InstallType::Embedded; lib.requiredSigner = "did:jwk:PINNED"; root.children = {lib}; setMockDependencyTree(root); PackageManagerImpl impl; LogosMap dep = impl.resolveDependencies("app", true)["children"][0]; LOGOS_ASSERT_EQ(dep["status"].get(), std::string("signer_unknown")); LOGOS_ASSERT_EQ(dep["requiredSigner"].get(), std::string("did:jwk:PINNED")); LOGOS_ASSERT_FALSE(dep.contains("signerDid")); // Still on disk. An embedded package can NEVER carry a signature: only // installPluginFile copies a manifest.sig into an install tree. LOGOS_ASSERT_EQ(dep["version"].get(), std::string("1.0.0")); LOGOS_ASSERT_EQ(dep["installType"].get(), std::string("embedded")); } LOGOS_TEST(resolveDependencies_omits_signer_did_for_an_absent_dependency) { // NotInstalled blanks version/installType, and an absent package has no // signature to report. auto t = LogosTestContext("package_manager"); DependencyTreeNode root; root.name = "app"; root.status = DependencyStatus::Installed; DependencyTreeNode absent; absent.name = "lib"; absent.status = DependencyStatus::NotInstalled; absent.requiredSigner = "did:jwk:PINNED"; root.children = {absent}; setMockDependencyTree(root); PackageManagerImpl impl; LogosMap dep = impl.resolveDependencies("app", true)["children"][0]; LOGOS_ASSERT_EQ(dep["status"].get(), std::string("not_installed")); LOGOS_ASSERT_EQ(dep["version"].get(), std::string("")); LOGOS_ASSERT_FALSE(dep.contains("signerDid")); // The pin still rides along, so a caller can name the publisher to get it // from — as requiredVersion does on an absent row. LOGOS_ASSERT_EQ(dep["requiredSigner"].get(), std::string("did:jwk:PINNED")); }