|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; |
| 4 | +import { SeedLoaderService } from './seed-loader.js'; |
| 5 | +import type { IDataEngine, IMetadataService } from '@objectstack/spec/contracts'; |
| 6 | + |
| 7 | +/** |
| 8 | + * `Seed.env` is ENFORCED, not merely authorable (framework#4704). |
| 9 | + * |
| 10 | + * The key was authorable, defaulted and type-checked for releases while being |
| 11 | + * completely inert: `filterByEnv` gated on the LOADER CONFIG's env, and none of |
| 12 | + * the six call sites that build a `SeedLoaderRequest` (app boot, per-org |
| 13 | + * replay, hot reload, package apply, draft publish, marketplace install) ever |
| 14 | + * passed one — so `config.env` was always `undefined`, the filter |
| 15 | + * short-circuited, and `dataset.env` was never read. A dataset marked |
| 16 | + * `env: ['dev']` seeded into production exactly as if it were marked |
| 17 | + * `['prod']` — and the rows most likely to carry that marking are demo users, |
| 18 | + * fake customers and seeded credentials. |
| 19 | + * |
| 20 | + * The loader now resolves the environment itself, from NODE_ENV — the repo's |
| 21 | + * one established environment source (`os start` defaults it to `production`, |
| 22 | + * `os dev` sets `development`, vitest sets `test`). |
| 23 | + * |
| 24 | + * These tests pin the three outcomes SEPARATELY, because a fix that only |
| 25 | + * proved the first would be indistinguishable from one that dropped every |
| 26 | + * dataset, or from one that dropped none: |
| 27 | + * 1. scoped dataset + matching environment → seeded |
| 28 | + * 2. scoped dataset + non-matching environment → NOT seeded |
| 29 | + * 3. unscoped dataset (schema default) → seeded everywhere |
| 30 | + * 4. environment indeterminate → seeded, but LOUDLY |
| 31 | + */ |
| 32 | + |
| 33 | +function createLogger() { |
| 34 | + return { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }; |
| 35 | +} |
| 36 | + |
| 37 | +function createEngine() { |
| 38 | + const store: Record<string, any[]> = {}; |
| 39 | + let idCounter = 0; |
| 40 | + |
| 41 | + const engine = { |
| 42 | + find: vi.fn(async (objectName: string, query?: any) => { |
| 43 | + let records = store[objectName] || []; |
| 44 | + if (query?.where) { |
| 45 | + records = records.filter((r) => |
| 46 | + Object.entries(query.where).every(([k, v]) => r[k] === v), |
| 47 | + ); |
| 48 | + } |
| 49 | + if (typeof query?.limit === 'number') records = records.slice(0, query.limit); |
| 50 | + return records; |
| 51 | + }), |
| 52 | + findOne: vi.fn(async () => null), |
| 53 | + insert: vi.fn(async (objectName: string, data: any) => { |
| 54 | + if (!store[objectName]) store[objectName] = []; |
| 55 | + if (Array.isArray(data)) { |
| 56 | + const records = data.map((d) => ({ id: `gen-${++idCounter}`, ...d })); |
| 57 | + store[objectName].push(...records); |
| 58 | + return records; |
| 59 | + } |
| 60 | + const record = { id: `gen-${++idCounter}`, ...data }; |
| 61 | + store[objectName].push(record); |
| 62 | + return record; |
| 63 | + }), |
| 64 | + update: vi.fn(async (_o: string, data: any) => data), |
| 65 | + delete: vi.fn(async () => ({ deleted: 1 })), |
| 66 | + count: vi.fn(async (objectName: string) => (store[objectName] || []).length), |
| 67 | + aggregate: vi.fn(async () => []), |
| 68 | + } as unknown as IDataEngine; |
| 69 | + |
| 70 | + return { engine, store }; |
| 71 | +} |
| 72 | + |
| 73 | +function createMetadata(): IMetadataService { |
| 74 | + const objects: Record<string, any> = { |
| 75 | + account: { name: 'account', fields: { name: { type: 'text' } } }, |
| 76 | + demo_user: { name: 'demo_user', fields: { name: { type: 'text' } } }, |
| 77 | + }; |
| 78 | + return { |
| 79 | + getObject: vi.fn(async (name: string) => objects[name]), |
| 80 | + listObjects: vi.fn(async () => Object.values(objects)), |
| 81 | + register: vi.fn(async () => {}), |
| 82 | + get: vi.fn(async () => undefined), |
| 83 | + list: vi.fn(async () => []), |
| 84 | + unregister: vi.fn(async () => {}), |
| 85 | + exists: vi.fn(async () => false), |
| 86 | + listNames: vi.fn(async () => []), |
| 87 | + } as unknown as IMetadataService; |
| 88 | +} |
| 89 | + |
| 90 | +const BASE_CONFIG = { |
| 91 | + dryRun: false, |
| 92 | + haltOnError: false, |
| 93 | + multiPass: true, |
| 94 | + defaultMode: 'upsert', |
| 95 | + batchSize: 1000, |
| 96 | + transaction: false, |
| 97 | +} as any; |
| 98 | + |
| 99 | +/** A production-safe dataset (schema default: every environment). */ |
| 100 | +const ACCOUNT_DATASET = { |
| 101 | + object: 'account', |
| 102 | + externalId: 'name', |
| 103 | + mode: 'upsert', |
| 104 | + env: ['prod', 'dev', 'test'], |
| 105 | + records: [{ name: 'Acme Corporation' }], |
| 106 | +}; |
| 107 | + |
| 108 | +/** The dangerous kind: demo rows the author scoped to development only. */ |
| 109 | +const DEMO_DATASET = { |
| 110 | + object: 'demo_user', |
| 111 | + externalId: 'name', |
| 112 | + mode: 'upsert', |
| 113 | + env: ['dev'], |
| 114 | + records: [{ name: 'Demo Person' }], |
| 115 | +}; |
| 116 | + |
| 117 | +async function seedUnder(nodeEnv: string | undefined, seeds: any[], configOverrides: any = {}) { |
| 118 | + if (nodeEnv === undefined) delete process.env.NODE_ENV; |
| 119 | + else process.env.NODE_ENV = nodeEnv; |
| 120 | + |
| 121 | + const { engine, store } = createEngine(); |
| 122 | + const logger = createLogger(); |
| 123 | + const result = await new SeedLoaderService(engine, createMetadata(), logger).load({ |
| 124 | + seeds, |
| 125 | + config: { ...BASE_CONFIG, ...configOverrides }, |
| 126 | + } as any); |
| 127 | + |
| 128 | + return { result, store, logger }; |
| 129 | +} |
| 130 | + |
| 131 | +const seededObjects = (store: Record<string, any[]>) => Object.keys(store).sort(); |
| 132 | + |
| 133 | +describe('Seed.env is enforced against the runtime environment (#4704)', () => { |
| 134 | + let savedNodeEnv: string | undefined; |
| 135 | + |
| 136 | + beforeEach(() => { |
| 137 | + savedNodeEnv = process.env.NODE_ENV; |
| 138 | + }); |
| 139 | + |
| 140 | + afterEach(() => { |
| 141 | + if (savedNodeEnv === undefined) delete process.env.NODE_ENV; |
| 142 | + else process.env.NODE_ENV = savedNodeEnv; |
| 143 | + }); |
| 144 | + |
| 145 | + // ── 1. The regression itself ──────────────────────────────────────────── |
| 146 | + |
| 147 | + it('does NOT seed an env:[dev] dataset under NODE_ENV=production', async () => { |
| 148 | + const { result, store } = await seedUnder('production', [ACCOUNT_DATASET, DEMO_DATASET]); |
| 149 | + |
| 150 | + expect(seededObjects(store)).toEqual(['account']); |
| 151 | + expect(store.demo_user).toBeUndefined(); |
| 152 | + expect(result.summary.objectsProcessed).toBe(1); |
| 153 | + expect(result.summary.totalInserted).toBe(1); |
| 154 | + }); |
| 155 | + |
| 156 | + it('DOES seed the same env:[dev] dataset under NODE_ENV=development', async () => { |
| 157 | + const { result, store } = await seedUnder('development', [ACCOUNT_DATASET, DEMO_DATASET]); |
| 158 | + |
| 159 | + expect(seededObjects(store)).toEqual(['account', 'demo_user']); |
| 160 | + expect(store.demo_user).toHaveLength(1); |
| 161 | + expect(result.summary.objectsProcessed).toBe(2); |
| 162 | + expect(result.summary.totalInserted).toBe(2); |
| 163 | + }); |
| 164 | + |
| 165 | + // The two above must DIFFER — a fix that dropped everything, or nothing, |
| 166 | + // would satisfy one of them alone. |
| 167 | + it('produces a different result in production than in development', async () => { |
| 168 | + const prod = await seedUnder('production', [ACCOUNT_DATASET, DEMO_DATASET]); |
| 169 | + const dev = await seedUnder('development', [ACCOUNT_DATASET, DEMO_DATASET]); |
| 170 | + |
| 171 | + expect(seededObjects(prod.store)).not.toEqual(seededObjects(dev.store)); |
| 172 | + expect(prod.result.summary.totalInserted).toBeLessThan(dev.result.summary.totalInserted); |
| 173 | + }); |
| 174 | + |
| 175 | + // ── 2. Unscoped datasets are untouched (no behaviour change) ──────────── |
| 176 | + |
| 177 | + it('seeds a dataset carrying the schema default in EVERY environment', async () => { |
| 178 | + for (const nodeEnv of ['production', 'development', 'test']) { |
| 179 | + const { store } = await seedUnder(nodeEnv, [ACCOUNT_DATASET]); |
| 180 | + expect(store.account, `account should seed under NODE_ENV=${nodeEnv}`).toHaveLength(1); |
| 181 | + } |
| 182 | + }); |
| 183 | + |
| 184 | + it('treats a dataset with no env key at all as unrestricted (schema default)', async () => { |
| 185 | + const noEnv = { object: 'account', externalId: 'name', mode: 'upsert', records: [{ name: 'Acme Corporation' }] }; |
| 186 | + const { store } = await seedUnder('production', [noEnv]); |
| 187 | + |
| 188 | + expect(store.account).toHaveLength(1); |
| 189 | + }); |
| 190 | + |
| 191 | + it('honours a multi-environment scope that includes the running environment', async () => { |
| 192 | + const prodAndTest = { ...DEMO_DATASET, env: ['prod', 'test'] }; |
| 193 | + const underProd = await seedUnder('production', [prodAndTest]); |
| 194 | + const underDev = await seedUnder('development', [prodAndTest]); |
| 195 | + |
| 196 | + expect(underProd.store.demo_user).toHaveLength(1); |
| 197 | + expect(underDev.store.demo_user).toBeUndefined(); |
| 198 | + }); |
| 199 | + |
| 200 | + // ── 3. Indeterminate environment: permissive, but loud ────────────────── |
| 201 | + |
| 202 | + it('seeds everything BUT warns loudly when NODE_ENV is unset', async () => { |
| 203 | + const { store, logger } = await seedUnder(undefined, [ACCOUNT_DATASET, DEMO_DATASET]); |
| 204 | + |
| 205 | + // Permissive: pre-fix behaviour preserved, no deployment silently loses rows. |
| 206 | + expect(seededObjects(store)).toEqual(['account', 'demo_user']); |
| 207 | + |
| 208 | + // Loud: names the datasets AND the remedy. |
| 209 | + const warning = logger.warn.mock.calls.map((c) => String(c[0])).find((m) => m.includes('Cannot determine the runtime environment')); |
| 210 | + expect(warning).toBeDefined(); |
| 211 | + expect(warning).toContain('demo_user'); |
| 212 | + expect(warning).toContain('NODE_ENV'); |
| 213 | + expect(warning).toContain('config.env'); |
| 214 | + // The unscoped dataset is not the operator's problem — don't name it. |
| 215 | + expect(warning).not.toContain('account (env:'); |
| 216 | + }); |
| 217 | + |
| 218 | + it('warns the same way for a NODE_ENV that names no seed environment', async () => { |
| 219 | + const { store, logger } = await seedUnder('staging', [DEMO_DATASET]); |
| 220 | + |
| 221 | + expect(store.demo_user).toHaveLength(1); |
| 222 | + expect( |
| 223 | + logger.warn.mock.calls.some((c) => String(c[0]).includes('Cannot determine the runtime environment')), |
| 224 | + ).toBe(true); |
| 225 | + }); |
| 226 | + |
| 227 | + it('stays SILENT when the environment is indeterminate but nothing is scoped', async () => { |
| 228 | + const { store, logger } = await seedUnder(undefined, [ACCOUNT_DATASET]); |
| 229 | + |
| 230 | + expect(store.account).toHaveLength(1); |
| 231 | + expect( |
| 232 | + logger.warn.mock.calls.some((c) => String(c[0]).includes('Cannot determine the runtime environment')), |
| 233 | + ).toBe(false); |
| 234 | + }); |
| 235 | + |
| 236 | + // The three indeterminate/determinate outcomes must be mutually distinguishable. |
| 237 | + it('distinguishes filtered, permissive-and-warned, and clean loads', async () => { |
| 238 | + const filtered = await seedUnder('production', [ACCOUNT_DATASET, DEMO_DATASET]); |
| 239 | + const permissive = await seedUnder(undefined, [ACCOUNT_DATASET, DEMO_DATASET]); |
| 240 | + const clean = await seedUnder('production', [ACCOUNT_DATASET]); |
| 241 | + |
| 242 | + const warned = (l: any) => |
| 243 | + l.warn.mock.calls.some((c: any[]) => String(c[0]).includes('Cannot determine the runtime environment')); |
| 244 | + |
| 245 | + expect([seededObjects(filtered.store), warned(filtered.logger)]).toEqual([['account'], false]); |
| 246 | + expect([seededObjects(permissive.store), warned(permissive.logger)]).toEqual([['account', 'demo_user'], true]); |
| 247 | + expect([seededObjects(clean.store), warned(clean.logger)]).toEqual([['account'], false]); |
| 248 | + }); |
| 249 | + |
| 250 | + // ── 4. Explicit config.env still wins ─────────────────────────────────── |
| 251 | + |
| 252 | + it('lets an explicit config.env override NODE_ENV', async () => { |
| 253 | + // Host says "seed as production" while running under a dev NODE_ENV. |
| 254 | + const { store } = await seedUnder('development', [ACCOUNT_DATASET, DEMO_DATASET], { env: 'prod' }); |
| 255 | + |
| 256 | + expect(seededObjects(store)).toEqual(['account']); |
| 257 | + }); |
| 258 | + |
| 259 | + it('accepts the seed-enum spellings of NODE_ENV', async () => { |
| 260 | + const { store } = await seedUnder('prod', [ACCOUNT_DATASET, DEMO_DATASET]); |
| 261 | + expect(seededObjects(store)).toEqual(['account']); |
| 262 | + |
| 263 | + const dev = await seedUnder('DEV', [ACCOUNT_DATASET, DEMO_DATASET]); |
| 264 | + expect(seededObjects(dev.store)).toEqual(['account', 'demo_user']); |
| 265 | + }); |
| 266 | + |
| 267 | + // ── 5. Skipping is reported, never mysterious ─────────────────────────── |
| 268 | + |
| 269 | + it('names every skipped dataset so missing demo rows are one log line to explain', async () => { |
| 270 | + const { logger } = await seedUnder('production', [ACCOUNT_DATASET, DEMO_DATASET]); |
| 271 | + |
| 272 | + const info = logger.info.mock.calls.map((c) => String(c[0])).find((m) => m.includes('skipped')); |
| 273 | + expect(info).toBeDefined(); |
| 274 | + expect(info).toContain('demo_user'); |
| 275 | + expect(info).toContain("'prod'"); |
| 276 | + }); |
| 277 | +}); |
0 commit comments