|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * A package uninstalled on a cloud-managed environment is not drawn as |
| 5 | + * installed (objectstack#28521). |
| 6 | + * |
| 7 | + * The control plane's uninstall is a soft disable: it keeps the installation |
| 8 | + * row (the ADR-0025 consent record and the sample-data state) and its env |
| 9 | + * listing, `GET /cloud-connection/installed`, still lists that row, with |
| 10 | + * `status: 'disabled'` and `enabled: false`. The console read every listed row |
| 11 | + * as installed, so the org card said "Installed" with no way back, and Setup → |
| 12 | + * Packages (`readMarketplaceInstalls`) drew it as an install. The install door, |
| 13 | + * `POST /cloud-connection/install`, re-enables the same row. |
| 14 | + * |
| 15 | + * `listInstalledPackages` and `installPackage` are the REAL client here, so the |
| 16 | + * rows are the ones that crossed the wire and the reinstall is the request the |
| 17 | + * door received. Only the catalog, the org listing and the local ledger are |
| 18 | + * stubbed. `t` echoes the KEY: the claim is WHICH control the card draws. |
| 19 | + */ |
| 20 | + |
| 21 | +import '@testing-library/jest-dom/vitest'; |
| 22 | +import { afterEach, describe, expect, it, vi } from 'vitest'; |
| 23 | +import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; |
| 24 | + |
| 25 | +vi.mock('react-router-dom', () => ({ |
| 26 | + useNavigate: () => vi.fn(), |
| 27 | + useParams: () => ({}), |
| 28 | +})); |
| 29 | + |
| 30 | +vi.mock('@object-ui/auth', async (importOriginal) => ({ |
| 31 | + ...(await importOriginal<Record<string, unknown>>()), |
| 32 | + useWorkspaceAdminStatus: () => ({ isAdmin: true, isResolved: true }), |
| 33 | + useAuth: () => ({ activeOrganization: { id: 'org_1' } }), |
| 34 | +})); |
| 35 | + |
| 36 | +vi.mock('@object-ui/i18n', async (importOriginal) => ({ |
| 37 | + ...(await importOriginal<Record<string, unknown>>()), |
| 38 | + useObjectTranslation: () => ({ |
| 39 | + t: (key: string) => `«${key}»`, |
| 40 | + language: 'en', |
| 41 | + }), |
| 42 | +})); |
| 43 | + |
| 44 | +const PKG = 'com.acme.crm'; |
| 45 | +const ORG_ROW = { |
| 46 | + id: 'pkg_1', |
| 47 | + manifest_id: PKG, |
| 48 | + display_name: 'Acme CRM', |
| 49 | + description: 'A CRM.', |
| 50 | + category: null, |
| 51 | + icon_url: null, |
| 52 | + visibility: 'org', |
| 53 | + latest_version: '1.0.0', |
| 54 | +}; |
| 55 | + |
| 56 | +vi.mock('../marketplaceApi', async (importOriginal) => ({ |
| 57 | + ...(await importOriginal<Record<string, unknown>>()), |
| 58 | + listMarketplacePackages: async () => ({ items: [] }), |
| 59 | + listLocalInstalls: async () => [], |
| 60 | + listOrgPackages: async () => ({ connected: true, items: [ORG_ROW] }), |
| 61 | +})); |
| 62 | + |
| 63 | +// The org install's post-install wait is objectui#12097's subject, not this |
| 64 | +// suite's: it settles at once, so no poll outlives a case. |
| 65 | +vi.mock('../waitForServedApp', async (importOriginal) => ({ |
| 66 | + ...(await importOriginal<Record<string, unknown>>()), |
| 67 | + waitForServedApp: async () => ({ served: false }), |
| 68 | +})); |
| 69 | + |
| 70 | +import { initRuntimeConfig, resetRuntimeConfigForTesting } from '../../../runtime-config'; |
| 71 | +import { MarketplacePage } from '../MarketplacePage'; |
| 72 | +import { listInstalledPackages, readMarketplaceInstalls } from '../marketplaceApi'; |
| 73 | + |
| 74 | +const INSTALLED_PATH = '/api/v1/cloud-connection/installed'; |
| 75 | +const INSTALL_PATH = '/api/v1/cloud-connection/install'; |
| 76 | + |
| 77 | +/** An installation row as the control plane's env package listing sends it. */ |
| 78 | +const row = (state: 'enabled' | 'disabled', manifestId = PKG) => ({ |
| 79 | + package_id: manifestId, |
| 80 | + package_version_id: 'ver_1', |
| 81 | + version: '1.0.0', |
| 82 | + installed_at: '2026-10-31T00:00:00.000Z', |
| 83 | + installed_by: 'usr_1', |
| 84 | + with_sample_data: false, |
| 85 | + status: state === 'enabled' ? 'installed' : 'disabled', |
| 86 | + enabled: state === 'enabled', |
| 87 | +}); |
| 88 | + |
| 89 | +const answer = (status: number, body: unknown) => ({ |
| 90 | + ok: status >= 200 && status < 300, |
| 91 | + status, |
| 92 | + statusText: '', |
| 93 | + headers: new Headers({ 'content-type': 'application/json' }), |
| 94 | + json: async () => body, |
| 95 | + text: async () => JSON.stringify(body), |
| 96 | +}); |
| 97 | + |
| 98 | +/** The environment: its listed rows, and every install request the door received. */ |
| 99 | +const env = { rows: [] as unknown[], installs: [] as unknown[] }; |
| 100 | + |
| 101 | +async function boot(rows: unknown[]) { |
| 102 | + resetRuntimeConfigForTesting(); |
| 103 | + env.rows = rows; |
| 104 | + env.installs = []; |
| 105 | + vi.stubGlobal('fetch', vi.fn(async (input: unknown, init?: { method?: string; body?: string }) => { |
| 106 | + const path = new URL(String(input), 'http://console.test').pathname; |
| 107 | + const method = (init?.method ?? 'GET').toUpperCase(); |
| 108 | + if (path === INSTALLED_PATH && method === 'GET') { |
| 109 | + return answer(200, { success: true, data: { packages: env.rows, total: env.rows.length, connected: true } }); |
| 110 | + } |
| 111 | + if (path === INSTALL_PATH && method === 'POST') { |
| 112 | + const body = JSON.parse(init?.body ?? '{}'); |
| 113 | + env.installs.push(body); |
| 114 | + // The door re-enables the same row. |
| 115 | + env.rows = env.rows.map((r: any) => (r.package_id === PKG ? row('enabled') : r)); |
| 116 | + return answer(200, { |
| 117 | + success: true, |
| 118 | + data: { outcome: 'reinstalled', installation: { id: 'inst_1', environment_id: 'env_1', package_id: 'pkg_1', version: '1.0.0' } }, |
| 119 | + }); |
| 120 | + } |
| 121 | + if (path === '/api/v1/runtime/config') { |
| 122 | + // A cloud-managed environment: no install-local door, a control plane. |
| 123 | + return answer(200, { |
| 124 | + cloudUrl: 'https://cloud.example', singleEnvironment: false, defaultEnvironmentId: 'env_1', |
| 125 | + features: { installLocal: false, marketplace: true, aiStudio: true, autoPublishAiBuilds: true }, |
| 126 | + branding: { productName: 'ObjectOS', productShortName: 'ObjectOS' }, |
| 127 | + }); |
| 128 | + } |
| 129 | + return answer(404, { success: false, error: { code: 'NOT_FOUND', message: `no route ${method} ${path}` } }); |
| 130 | + })); |
| 131 | + await initRuntimeConfig(); |
| 132 | +} |
| 133 | + |
| 134 | +afterEach(() => { |
| 135 | + cleanup(); |
| 136 | + vi.unstubAllGlobals(); |
| 137 | + resetRuntimeConfigForTesting(); |
| 138 | +}); |
| 139 | + |
| 140 | +describe('the control plane lists an uninstalled package; the console does not call it installed', () => { |
| 141 | + it('the client marks the disabled row, and only that one', async () => { |
| 142 | + await boot([row('disabled'), row('enabled', 'com.acme.psa')]); |
| 143 | + const listed = await listInstalledPackages(); |
| 144 | + expect(listed.connected).toBe(true); |
| 145 | + expect(listed.items.map((i) => [i.manifestId, i.disabled])).toEqual([ |
| 146 | + [PKG, true], |
| 147 | + ['com.acme.psa', undefined], |
| 148 | + ]); |
| 149 | + // Absent, never `false`, on an installed row. |
| 150 | + expect('disabled' in listed.items[1]).toBe(false); |
| 151 | + }); |
| 152 | + |
| 153 | + it('either field alone marks the row disabled', async () => { |
| 154 | + await boot([ |
| 155 | + { ...row('enabled'), status: 'disabled' }, |
| 156 | + { ...row('enabled', 'com.acme.psa'), enabled: false }, |
| 157 | + ]); |
| 158 | + const listed = await listInstalledPackages(); |
| 159 | + expect(listed.items.map((i) => i.disabled)).toEqual([true, true]); |
| 160 | + }); |
| 161 | + |
| 162 | + it('Setup → Packages reads only the installed rows', async () => { |
| 163 | + await boot([row('disabled'), row('enabled', 'com.acme.psa')]); |
| 164 | + const installs = await readMarketplaceInstalls(); |
| 165 | + expect(installs.source).toBe('cloud'); |
| 166 | + expect(installs.items.map((i) => i.manifestId)).toEqual(['com.acme.psa']); |
| 167 | + }); |
| 168 | + |
| 169 | + it('the org card offers Reinstall, which the install door answers by re-enabling the row', async () => { |
| 170 | + await boot([row('disabled')]); |
| 171 | + render(<MarketplacePage />); |
| 172 | + const card = await screen.findByTestId(`org-card-${PKG}`); |
| 173 | + |
| 174 | + const reinstall = await within(card).findByRole('button', { name: '«marketplace.action.reinstall»' }); |
| 175 | + expect(within(card).queryByText('«marketplace.org.installedBadge»')).not.toBeInTheDocument(); |
| 176 | + |
| 177 | + fireEvent.click(reinstall); |
| 178 | + await waitFor(() => expect(env.installs).toEqual([{ package_id: 'pkg_1', seed_sample_data: true }])); |
| 179 | + // The page reads the listing again: the re-enabled row is installed. |
| 180 | + expect(await within(card).findByText('«marketplace.org.installedBadge»')).toBeInTheDocument(); |
| 181 | + expect(within(card).queryByRole('button')).not.toBeInTheDocument(); |
| 182 | + }); |
| 183 | + |
| 184 | + it('CONTROL: an installed row still reads Installed, with no action', async () => { |
| 185 | + await boot([row('enabled')]); |
| 186 | + render(<MarketplacePage />); |
| 187 | + const card = await screen.findByTestId(`org-card-${PKG}`); |
| 188 | + expect(await within(card).findByText('«marketplace.org.installedBadge»')).toBeInTheDocument(); |
| 189 | + expect(within(card).queryByRole('button')).not.toBeInTheDocument(); |
| 190 | + }); |
| 191 | + |
| 192 | + it('CONTROL: a package with no row still offers Install', async () => { |
| 193 | + await boot([]); |
| 194 | + render(<MarketplacePage />); |
| 195 | + const card = await screen.findByTestId(`org-card-${PKG}`); |
| 196 | + expect(await within(card).findByRole('button', { name: '«marketplace.org.install»' })).toBeInTheDocument(); |
| 197 | + }); |
| 198 | +}); |
0 commit comments