merge: sync non-integrated upload permission foundation

This commit is contained in:
WuYongJie 2026-06-05 17:50:57 +08:00
commit b2837c6426
13 changed files with 328 additions and 34 deletions

View File

@ -256,6 +256,7 @@ function auditSteps(outputDir, options = {}) {
id: 'foundation-preflight',
label: '结构变更前置预检',
outputFile: path.join(outputDir, 'foundation-preflight.json'),
usesShadowDatabase: true,
args: [
'scripts/preflight-database-foundation-change.js',
'--output',
@ -266,6 +267,7 @@ function auditSteps(outputDir, options = {}) {
id: 'foundation-migrate-plan',
label: '治理迁移执行计划',
outputFile: path.join(outputDir, 'foundation-migrate-plan.json'),
usesShadowDatabase: true,
args: [
'scripts/run-database-foundation-migrations.js',
'--output',
@ -432,16 +434,25 @@ function auditSteps(outputDir, options = {}) {
return steps
}
function environmentForAuditStep(step, env = process.env) {
const stepEnv = { ...env }
if (step?.usesShadowDatabase === true) return stepEnv
delete stepEnv.SHADOW_DATABASE_URL
return stepEnv
}
function readJsonFile(filePath) {
if (!fs.existsSync(filePath)) return null
return JSON.parse(fs.readFileSync(filePath, 'utf8'))
}
function runAuditStep(step, env = process.env) {
const stepEnv = environmentForAuditStep(step, env)
const result = spawnSync(process.execPath, step.args, {
cwd: ROOT_DIR,
encoding: 'utf8',
env: { ...process.env, ...env },
env: { ...process.env, ...stepEnv },
windowsHide: true,
})
const outputExists = fs.existsSync(step.outputFile)
@ -1766,6 +1777,7 @@ module.exports = {
countByField,
createDockerShadowDatabase,
defaultOutputDir,
environmentForAuditStep,
parseArgs,
redactStepPreview,
runAudit,

View File

@ -1239,13 +1239,19 @@ describe("NonIntegratedDetailClient", () => {
expect(mockedUploadDetachedNonIntegratedFile).toHaveBeenCalledWith(
undefined,
expect.objectContaining({ name: "remote-preview.png" }),
undefined,
{
category: undefined,
onProgress: undefined,
},
);
const [, uploadedFile, uploadProgressHandler] =
const [, uploadedFile, uploadOptions] =
mockedUploadDetachedNonIntegratedFile.mock.calls[0] ?? [];
expect(uploadedFile).toBeInstanceOf(File);
expect(uploadedFile.name).toBe("remote-preview.png");
expect(uploadProgressHandler).toBeUndefined();
expect(uploadOptions).toEqual({
category: undefined,
onProgress: undefined,
});
expect(mockedSaveSharedNonIntegratedProduct.mock.calls[0]?.[0]).toEqual(
expect.objectContaining({
previewImage: "http://test.local/remote-preview.png",
@ -1256,6 +1262,47 @@ describe("NonIntegratedDetailClient", () => {
);
});
it("passes the active workspace category when uploading SKU images on an existing product", async () => {
(globalThis as NonIntegratedRemoteAssetStorageTestGlobal)
.__NON_INTEGRATED_REMOTE_ASSET_STORAGE_TEST_OVERRIDE__ = true;
const actor = userEvent.setup();
const productId = "DNZ0001-sku-image-upload";
const { container } = render(
<NonIntegratedDetailClient
mode="edit"
productId={productId}
initialProduct={createProductRecord({
id: productId,
spu: "DNZ0001",
previewImage: "",
skuItems: [createSkuItem({ id: "sku-image-row-1" })],
})}
/>,
);
const skuImageInput = container.querySelector<HTMLInputElement>(
"#sku-image-input-sku-image-row-1",
);
expect(skuImageInput).not.toBeNull();
await actor.upload(
skuImageInput!,
new File(["sku-image"], "DNZ000101.png", { type: "image/png" }),
);
await waitFor(() =>
expect(mockedUploadDetachedNonIntegratedFile).toHaveBeenCalledTimes(1),
);
expect(mockedUploadDetachedNonIntegratedFile).toHaveBeenCalledWith(
productId,
expect.objectContaining({ name: "DNZ000101.png" }),
expect.objectContaining({
category: "基础信息",
onProgress: expect.any(Function),
}),
);
});
it("uses the opening reference image as the shared product main image", async () => {
const actor = userEvent.setup();
saveNonIntegratedProduct(

View File

@ -9510,6 +9510,7 @@ async function uploadFileToServer(
file: File,
options?: {
productId?: string;
category?: string | null;
},
onProgress?: NonIntegratedUploadProgressHandler,
) {
@ -9524,7 +9525,10 @@ async function uploadFileToServer(
const payload = await uploadDetachedNonIntegratedFile(
options?.productId,
uploadFile,
{
category: options?.category,
onProgress,
},
);
return {
@ -13659,6 +13663,7 @@ export default function NonIntegratedDetailClient({
const detachedUploadOptions = sharedUploadProductId
? {
productId: sharedUploadProductId,
category: activePermissionWorkspaceCategory,
}
: undefined;
const uploadProgressPercent = uploadProgress

View File

@ -1,8 +1,12 @@
import { uploadDetachedNonIntegratedFile } from './nonIntegratedApi'
type MutableGlobal = typeof globalThis & { fetch?: typeof fetch }
type MutableGlobal = typeof globalThis & {
fetch?: typeof fetch
XMLHttpRequest?: typeof XMLHttpRequest
}
const originalFetch = (globalThis as MutableGlobal).fetch
const originalXMLHttpRequest = (globalThis as MutableGlobal).XMLHttpRequest
function setFetch(fetchMock: typeof fetch) {
Object.defineProperty(globalThis, 'fetch', {
@ -19,6 +23,24 @@ function restoreFetch() {
}
}
function useFetchUploadBranch() {
Object.defineProperty(globalThis, 'XMLHttpRequest', {
configurable: true,
value: undefined,
})
}
function restoreXMLHttpRequest() {
if (originalXMLHttpRequest) {
Object.defineProperty(globalThis, 'XMLHttpRequest', {
configurable: true,
value: originalXMLHttpRequest,
})
} else {
delete (globalThis as MutableGlobal).XMLHttpRequest
}
}
function jsonResponse(data: unknown, status = 200) {
return {
ok: status >= 200 && status < 300,
@ -27,6 +49,7 @@ function jsonResponse(data: unknown, status = 200) {
get: () => null,
},
json: async () => data,
text: async () => JSON.stringify(data),
} as Response
}
@ -41,6 +64,7 @@ function emptyResponse(
get: (name: string) => headers[name] ?? headers[name.toLowerCase()] ?? null,
},
json: async () => null,
text: async () => '',
} as Response
}
@ -48,6 +72,7 @@ describe('nonIntegratedApi uploads', () => {
afterEach(() => {
jest.restoreAllMocks()
restoreFetch()
restoreXMLHttpRequest()
})
it('uploads detached files without a product id through the temporary upload endpoint', async () => {
@ -63,6 +88,7 @@ describe('nonIntegratedApi uploads', () => {
}),
)
setFetch(fetchMock as typeof fetch)
useFetchUploadBranch()
const payload = await uploadDetachedNonIntegratedFile(
undefined,
@ -101,14 +127,21 @@ describe('nonIntegratedApi uploads', () => {
}),
)
setFetch(fetchMock as typeof fetch)
useFetchUploadBranch()
const file = new File(['tiny'], 'tiny.png', { type: 'image/png' })
await uploadDetachedNonIntegratedFile('product-1', file)
await uploadDetachedNonIntegratedFile('product-1', file, {
category: '基础信息',
})
expect(fetchMock).toHaveBeenCalledTimes(1)
expect(fetchMock.mock.calls[0][0]).toBe(
'/api/intake/non-integrated/product-1/upload',
)
expect(fetchMock.mock.calls[0][1]?.body).toBeInstanceOf(FormData)
expect((fetchMock.mock.calls[0][1]?.body as FormData).get('category')).toBe(
'基础信息',
)
})
it('uses chunk upload before production request body limits can reject images', async () => {
@ -137,16 +170,25 @@ describe('nonIntegratedApi uploads', () => {
),
)
setFetch(fetchMock as typeof fetch)
useFetchUploadBranch()
const file = new File([new Uint8Array(600 * 1024)], 'large.png', {
type: 'image/png',
})
await uploadDetachedNonIntegratedFile('product-1', file)
await uploadDetachedNonIntegratedFile('product-1', file, {
category: '产品实拍图',
})
expect(fetchMock).toHaveBeenCalledTimes(2)
expect(fetchMock.mock.calls.map((call) => call[0])).toEqual([
'/api/intake/non-integrated/product-1/upload/chunk',
'/api/intake/non-integrated/product-1/upload/chunk',
])
expect((fetchMock.mock.calls[0][1]?.body as FormData).get('category')).toBe(
'产品实拍图',
)
expect((fetchMock.mock.calls[1][1]?.body as FormData).get('category')).toBe(
'产品实拍图',
)
})
})

View File

@ -24,6 +24,11 @@ export type NonIntegratedUploadProgressHandler = (
progress: NonIntegratedUploadProgress,
) => void
export type NonIntegratedDetachedUploadOptions = {
category?: string | null
onProgress?: NonIntegratedUploadProgressHandler
}
export type NonIntegratedProductPageInfo = {
page: number
pageSize: number
@ -385,15 +390,28 @@ export async function uploadSharedNonIntegratedFile(
export async function uploadDetachedNonIntegratedFile(
productId: string | undefined,
file: File,
onProgress?: NonIntegratedUploadProgressHandler,
optionsOrProgress?: NonIntegratedDetachedUploadOptions | NonIntegratedUploadProgressHandler,
) {
const options =
typeof optionsOrProgress === 'function'
? { onProgress: optionsOrProgress }
: (optionsOrProgress ?? {})
const category =
typeof options.category === 'string' && options.category.trim()
? options.category.trim()
: undefined
const onProgress = options.onProgress
if (productId && file.size > NON_INTEGRATED_UPLOAD_CHUNK_BYTES) {
return uploadDetachedNonIntegratedFileInChunks(productId, file, onProgress)
return uploadDetachedNonIntegratedFileInChunks(productId, file, category, onProgress)
}
const formData = new FormData()
formData.append('file', file)
formData.append('attach', 'false')
if (category) {
formData.append('category', category)
}
reportFileUploadProgress(onProgress, file.size, 0)
@ -432,12 +450,14 @@ async function uploadSharedNonIntegratedFileInChunks(
async function uploadDetachedNonIntegratedFileInChunks(
productId: string,
file: File,
category?: string,
onProgress?: NonIntegratedUploadProgressHandler,
) {
return uploadNonIntegratedFileInChunks<UploadDetachedNonIntegratedFileResponse>({
productId,
file,
attach: false,
category,
onProgress,
})
}

View File

@ -1,6 +1,7 @@
import {
buildAssetSkuSnapshotFromProfile,
buildProductSkuAssetProfileData,
resolveAssetSkuSnapshotSyncPolicy,
type AssetSkuSnapshot,
} from '../asset-sku-snapshot'
@ -154,4 +155,84 @@ describe('asset-sku-snapshot', () => {
}),
)
})
it('资产同步策略会尊重人工锁定图片并保留可审计规则', () => {
const snapshot: AssetSkuSnapshot = {
sourceAssetVariantId: 'variant-primary',
sourceAssetFamilyId: 'family-1',
sourceAssetSeriesId: 'series-1',
model: 'DBZ-SPJK001',
modelName: '桌面板',
specCode: 'DBZ-SPJ00001',
specName: '1200 柚木色',
barcode: 'DBZ-SPJ00001N05V01',
size: { length: 1200 },
color: '柚木色',
material: '颗粒板',
imageUrl: '/new-thumb.webp',
sourceStatus: 'ACTIVE',
sourceAssetVariantIds: ['variant-primary'],
sourceVariants: [],
syncedAt: '2026-05-31T10:00:00.000Z',
lockedFields: [' imageUrl ', 'barcode'],
lockedReasons: {
imageUrl: '保留人工图',
barcode: '人工条码',
},
}
expect(
resolveAssetSkuSnapshotSyncPolicy({
reason: 'THUMBNAIL_REGENERATED',
isPrimarySource: true,
next: snapshot,
}),
).toEqual({
syncsTopLevelFields: true,
syncsAssetProfile: true,
refreshesImages: false,
marksDeletedSource: false,
marksMissingSource: false,
preservesLockedFields: ['imageUrl', 'barcode'],
})
})
it('资产同步策略会把删除来源表达成正式 SKU 副本状态', () => {
const snapshot: AssetSkuSnapshot = {
sourceAssetVariantId: 'variant-deleted',
sourceAssetFamilyId: 'family-1',
sourceAssetSeriesId: 'series-1',
model: 'DBZ-SPJK001',
modelName: '桌面板',
specCode: 'DBZ-SPJ00001',
specName: '1200 柚木色',
barcode: 'DBZ-SPJ00001N05V01',
size: { length: 1200 },
color: '柚木色',
material: '颗粒板',
imageUrl: '/old-thumb.webp',
sourceStatus: 'DELETED',
sourceAssetVariantIds: ['variant-deleted'],
sourceVariants: [],
syncedAt: '2026-05-31T10:00:00.000Z',
lockedFields: [],
lockedReasons: {},
}
expect(
resolveAssetSkuSnapshotSyncPolicy({
reason: 'VARIANT_DELETED',
isPrimarySource: true,
next: snapshot,
}),
).toEqual(
expect.objectContaining({
syncsTopLevelFields: true,
syncsAssetProfile: true,
refreshesImages: true,
marksDeletedSource: true,
marksMissingSource: false,
}),
)
})
})

View File

@ -927,7 +927,7 @@ describe('intakeNonIntegratedProductService', () => {
)
})
it('分配正式 SPU 时允许 SKU 主数据在后续节点补齐', async () => {
it('分配正式 SPU 时阻断未补齐的 SKU 主数据,避免生成新的地基阻塞', async () => {
const payload = createPayload({
id: 'draft-incomplete-before-formal-spu',
spu: '',
@ -966,21 +966,17 @@ describe('intakeNonIntegratedProductService', () => {
}) as never,
)
await intakeNonIntegratedProductService.save(payload, baseUser, {
const promise = intakeNonIntegratedProductService.save(payload, baseUser, {
formalSpuAssignmentProductCategory: '餐桌',
})
const createArg = mockedRepository.createInTransaction.mock.calls[0]?.[1] as {
skuItems?: { createMany?: { data?: Array<Record<string, unknown>> } }
}
expect(createArg.skuItems?.createMany?.data?.[0]).toEqual(
expect.objectContaining({
skuCode: 'CZ001101',
barcode: 'CZ001101N01V01',
model: '',
specName: '',
}),
)
await expect(promise).rejects.toMatchObject({
code: ErrorCode.VALIDATION_REQUIRED_FIELD,
})
await expect(promise).rejects.toThrow('正式 SPU 分配存在未补齐的 SKU 主数据')
expect(mockedRepository.createInTransaction).not.toHaveBeenCalled()
expect(mockedRepository.updateInTransaction).not.toHaveBeenCalled()
})
it('分配正式 SPU 前仍必须补齐产品主数据', async () => {

View File

@ -259,6 +259,10 @@ describe('syncJstSkuStockProducts', () => {
data: expect.objectContaining({
payload: expect.objectContaining({
source: 'jst_sku',
productCategory: '桌类',
openingReference: expect.objectContaining({
productCategory: '桌类',
}),
skuItems: expect.arrayContaining([
expect.objectContaining({
barcode: 'BAR001',
@ -272,6 +276,7 @@ describe('syncJstSkuStockProducts', () => {
create: expect.objectContaining({
productName: '测试存量商品 1200*600*750',
productImageUrl: 'https://cdn.example.com/stock-a.jpg',
productCategory: '桌类',
productDevelopmentKind: 'product',
materialType: '颗粒板',
sourceSpu: 'SPU001',
@ -343,6 +348,22 @@ describe('syncJstSkuStockProducts', () => {
)
})
it('apply 遇到聚水潭来源缺分类时跳过写库,避免重新生成缺 productCategory 的主数据', async () => {
createConnectionMock(
createSourceRow({
category: '',
}),
)
const result = await syncJstSkuStockProducts({ apply: true, batchSize: 1, limit: 1 })
expect(result.created).toBe(0)
expect(result.skipped).toBe(1)
expect(result.errors[0]).toContain('聚水潭存量商品同步存在未补齐的产品主数据')
expect(mockedPrisma.intakeNonIntegratedProduct.create).not.toHaveBeenCalled()
expect(mockedPrisma.intakeNonIntegratedProduct.update).not.toHaveBeenCalled()
})
it('apply 更新存量商品时 upsert 产品 profile 并刷新结构化 SKU 明细', async () => {
createConnectionMock(createSourceRow({ name: '更新后的存量商品 1200*600*750' }))
mockedPrisma.intakeNonIntegratedProduct.findUnique.mockResolvedValue({
@ -363,12 +384,14 @@ describe('syncJstSkuStockProducts', () => {
upsert: {
create: expect.objectContaining({
productName: '更新后的存量商品 1200*600*750',
productCategory: '桌类',
materialType: '颗粒板',
sourceSpu: 'SPU001',
sourceSkuId: 'BAR001',
}),
update: expect.objectContaining({
productName: '更新后的存量商品 1200*600*750',
productCategory: '桌类',
materialType: '颗粒板',
sourceSpu: 'SPU001',
sourceSkuId: 'BAR001',

View File

@ -43,6 +43,21 @@ export interface AssetSkuSnapshot extends AssetSkuSnapshotVariant {
lockedReasons: Record<string, string>
}
export type AssetSkuSnapshotSyncReason =
| 'VARIANT_UPDATED'
| 'THUMBNAIL_REGENERATED'
| 'VARIANT_DELETED'
| 'MANUAL_RETRY'
export type AssetSkuSnapshotSyncPolicy = {
syncsTopLevelFields: boolean
syncsAssetProfile: boolean
refreshesImages: boolean
marksDeletedSource: boolean
marksMissingSource: boolean
preservesLockedFields: AssetSkuSnapshotLockableField[]
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
@ -348,6 +363,26 @@ export function applyLockedSnapshotFields(input: {
return normalizeSnapshot(merged)
}
export function resolveAssetSkuSnapshotSyncPolicy(input: {
reason: AssetSkuSnapshotSyncReason
isPrimarySource: boolean
next: AssetSkuSnapshot
}): AssetSkuSnapshotSyncPolicy {
const normalizedNext = normalizeSnapshot(input.next)
const lockedFields = normalizeLockedFields(normalizedNext.lockedFields)
const imageLocked = lockedFields.includes('imageUrl') || lockedFields.includes('images')
return {
syncsTopLevelFields: input.isPrimarySource,
syncsAssetProfile: true,
refreshesImages: input.isPrimarySource && !imageLocked && Boolean(normalizedNext.imageUrl),
marksDeletedSource:
input.reason === 'VARIANT_DELETED' || normalizedNext.sourceStatus === 'DELETED',
marksMissingSource: normalizedNext.sourceStatus === 'MISSING',
preservesLockedFields: lockedFields,
}
}
function parseSnapshotSyncedAt(value: string): Date {
const parsed = new Date(value)
return Number.isNaN(parsed.getTime()) ? new Date() : parsed

View File

@ -12,6 +12,7 @@ import {
buildMissingAssetSkuSnapshotVariant,
buildAssetSkuSnapshotVariant,
buildProductSkuAssetProfileData,
resolveAssetSkuSnapshotSyncPolicy,
type AssetSkuSnapshot,
} from './asset-sku-snapshot'
@ -32,10 +33,6 @@ function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function isImageLocked(snapshot: AssetSkuSnapshot | null): boolean {
return Boolean(snapshot?.lockedFields.includes('imageUrl') || snapshot?.lockedFields.includes('images'))
}
function upsertSourceVariant(
snapshot: AssetSkuSnapshot,
updatedVariant: ReturnType<typeof buildAssetSkuSnapshotVariant>,
@ -289,8 +286,13 @@ class AssetSkuSyncService {
...existingAttributes,
assetSkuSnapshot: nextSnapshot,
}
const syncPolicy = resolveAssetSkuSnapshotSyncPolicy({
reason: event.reason as AssetSkuSyncReason,
isPrimarySource: shouldUpdateTopLevel,
next: nextSnapshot,
})
const nextImages =
shouldUpdateTopLevel && !isImageLocked(nextSnapshot) && sourceVariant.imageUrl
syncPolicy.refreshesImages && sourceVariant.imageUrl
? [sourceVariant.imageUrl, ...sku.images.filter((item) => item !== sourceVariant.imageUrl)]
: sku.images

View File

@ -40,7 +40,7 @@ import {
preSkuRepository,
} from '../repositories/draft-group.repository'
import {
assertNonIntegratedProductProfileReadyForFormalUse,
assertNonIntegratedProductMasterDataReadyForFormalUse,
resolveNonIntegratedSkuQuality,
} from './non-integrated-sku-quality'
import {
@ -4172,7 +4172,7 @@ class IntakeNonIntegratedProductService {
if (!normalizedProduct) {
throw new AppError(ErrorCode.VALIDATION_FAILED, '非集成产品数据不完整,无法保存')
}
assertNonIntegratedProductProfileReadyForFormalUse(normalizedProduct, {
assertNonIntegratedProductMasterDataReadyForFormalUse(normalizedProduct, {
usageLabel: '正式 SPU 分配',
})

View File

@ -13,6 +13,7 @@ import {
buildStructuredProductProfileInput,
buildStructuredSkuItemInputs,
} from './intake-non-integrated-product.service'
import { assertNonIntegratedProductMasterDataReadyForFormalUse } from './non-integrated-sku-quality'
type JstSkuSourceField =
| 'sku_id'
@ -379,7 +380,7 @@ function buildSourceProductPayload(
temporaryCode: sourceSpu,
brand: BASIC_BRAND,
adaptableBrands: [BASIC_BRAND],
productCategory: '',
productCategory: sourceCategory,
salesCategory: '',
designer: '',
planningOwner: '',
@ -403,7 +404,7 @@ function buildSourceProductPayload(
year: '',
demandFlowNo: '',
processTemplate: STOCK_PRODUCT_LABEL,
productCategory: '',
productCategory: sourceCategory,
salesCategory: '',
productPlanning: '',
demandDepartment: '',
@ -517,10 +518,17 @@ function buildSourceProductPayload(
} as unknown as Partial<NonIntegratedProductRecord> & Record<string, unknown>
const normalizedProduct = normalizeNonIntegratedProductRecord(rawPayload)
return normalizedProduct
const product = normalizedProduct
? ({ ...rawPayload, ...normalizedProduct } as NonIntegratedProductRecord &
Record<string, unknown>)
: null
if (!product) return null
assertNonIntegratedProductMasterDataReadyForFormalUse(product, {
usageLabel: '聚水潭存量商品同步',
})
return product
}
function mergeExistingProductPayload(
@ -581,6 +589,7 @@ function mergeExistingProductPayload(
temporaryCode: sourceOpeningReference.temporaryCode,
brand: sourceOpeningReference.brand,
adaptableBrands: sourceOpeningReference.adaptableBrands,
productCategory: sourceOpeningReference.productCategory,
currentStage: sourceOpeningReference.currentStage,
size: sourceOpeningReference.size,
material: sourceOpeningReference.material,
@ -613,6 +622,7 @@ function mergeExistingProductPayload(
color: sourcePayload.color,
material: sourcePayload.material,
materialType: sourcePayload.materialType,
productCategory: sourcePayload.productCategory,
openingReference: nextOpeningReference,
previewImage: sourcePayload.previewImage || existingProduct.previewImage,
previewImageName: sourcePayload.previewImage

View File

@ -26,6 +26,7 @@ const {
countByField,
countQualityDecisionPlaceholderFields,
defaultOutputDir,
environmentForAuditStep,
parseArgs,
timestampForDirectory,
writeJsonReport,
@ -205,6 +206,26 @@ describe('run-master-data-foundation-audit', () => {
)
})
it('only passes shadow database URLs to steps that need migration diff evidence', () => {
const outputDir = path.resolve(__dirname, '../../.master-data-audits/example')
const steps = auditSteps(outputDir)
const readinessStep = steps.find((step) => step.id === 'readiness')
const preflightStep = steps.find((step) => step.id === 'foundation-preflight')
const migratePlanStep = steps.find((step) => step.id === 'foundation-migrate-plan')
const env = {
DATABASE_URL: 'postgresql://app:secret@localhost:5432/furniture_3d',
SHADOW_DATABASE_URL: 'postgresql://shadow:secret@localhost:65436/furniture_3d_shadow',
}
expect(preflightStep).toEqual(expect.objectContaining({ usesShadowDatabase: true }))
expect(migratePlanStep).toEqual(expect.objectContaining({ usesShadowDatabase: true }))
expect(environmentForAuditStep(readinessStep, env)).toEqual({
DATABASE_URL: env.DATABASE_URL,
})
expect(environmentForAuditStep(preflightStep, env)).toEqual(env)
expect(environmentForAuditStep(migratePlanStep, env)).toEqual(env)
})
it('adds the sensitive cleanup template step only when explicitly requested', () => {
const outputDir = path.resolve(__dirname, '../../.master-data-audits/example')
const defaultSteps = auditSteps(outputDir)