|
| 1 | +import { getMetadataArgsStorage } from 'typeorm'; |
| 2 | +import { afterEach, describe, expect, it } from 'vitest'; |
| 3 | + |
| 4 | +import { runPluginConfigurations } from './bootstrap'; |
| 5 | +import { CustomFieldConfig } from './config/custom-field/custom-field-types'; |
| 6 | +import { RuntimeVendureConfig } from './config/vendure-config'; |
| 7 | +// Importing the core entities registers their `customFields` embedded columns in the |
| 8 | +// TypeORM metadata, which is how getEntityNamesWithCustomFields() detects the entities |
| 9 | +// that support custom fields. Imported for its side effect only. |
| 10 | +import './entity/entities'; |
| 11 | +import { registerCustomEntityFields } from './entity/register-custom-entity-fields'; |
| 12 | +import { VendurePlugin } from './plugin/vendure-plugin'; |
| 13 | + |
| 14 | +/** |
| 15 | + * Registers a `translations` relation (and a matching `customFields` embedded on the |
| 16 | + * translation target) directly in the TypeORM metadata, so we can exercise the different |
| 17 | + * shapes TypeORM allows for a relation target — a constructor closure, a bare string name, |
| 18 | + * or a closure returning a string — without declaring throwaway `@Entity` classes that would |
| 19 | + * pollute the global metadata for every other test in the process. Returns a cleanup fn. |
| 20 | + */ |
| 21 | +function registerTranslationRelation(baseName: string, type: unknown): () => void { |
| 22 | + const storage = getMetadataArgsStorage(); |
| 23 | + const base = { name: baseName }; |
| 24 | + const translationTarget = { name: `${baseName}Translation` }; |
| 25 | + storage.relations.push({ |
| 26 | + target: base, |
| 27 | + propertyName: 'translations', |
| 28 | + relationType: 'one-to-many', |
| 29 | + type, |
| 30 | + isLazy: false, |
| 31 | + options: {}, |
| 32 | + } as any); |
| 33 | + storage.embeddeds.push({ |
| 34 | + target: translationTarget, |
| 35 | + propertyName: 'customFields', |
| 36 | + prefix: undefined, |
| 37 | + type: () => Object, |
| 38 | + } as any); |
| 39 | + return () => { |
| 40 | + storage.relations.pop(); |
| 41 | + storage.embeddeds.pop(); |
| 42 | + }; |
| 43 | +} |
| 44 | + |
| 45 | +function makeConfig(partial: { |
| 46 | + plugins?: RuntimeVendureConfig['plugins']; |
| 47 | + customFields?: Record<string, CustomFieldConfig[]>; |
| 48 | +}): RuntimeVendureConfig { |
| 49 | + return { plugins: [], customFields: {}, ...partial } as unknown as RuntimeVendureConfig; |
| 50 | +} |
| 51 | + |
| 52 | +describe('runPluginConfigurations()', () => { |
| 53 | + // OSS-408: entities that support custom fields get an empty array pre-initialised so a |
| 54 | + // plugin's `configuration` callback can extend them without a defensive guard. |
| 55 | + it('auto-initialises customFields for entities that support them', async () => { |
| 56 | + const config = makeConfig({}); |
| 57 | + await runPluginConfigurations(config); |
| 58 | + expect(config.customFields.Product).toEqual([]); |
| 59 | + expect(config.customFields.Customer).toEqual([]); |
| 60 | + }); |
| 61 | + |
| 62 | + // OSS-408: translation entities also declare a `customFields` embedded (for localized |
| 63 | + // values), but must NOT be auto-initialised — a `config.customFields.<Entity>Translation` |
| 64 | + // entry makes the GraphQL schema builder emit a duplicate `customFields` field on the |
| 65 | + // `*TranslationInput` types ("... can only be defined once"). |
| 66 | + it('does not auto-initialise translation entities', async () => { |
| 67 | + const config = makeConfig({}); |
| 68 | + await runPluginConfigurations(config); |
| 69 | + expect(config.customFields.ProductTranslation).toBeUndefined(); |
| 70 | + expect(config.customFields.CollectionTranslation).toBeUndefined(); |
| 71 | + }); |
| 72 | + |
| 73 | + it('does not overwrite an existing customFields entry', async () => { |
| 74 | + const existing: CustomFieldConfig[] = [{ name: 'foo', type: 'string' }]; |
| 75 | + const config = makeConfig({ customFields: { Product: existing } }); |
| 76 | + await runPluginConfigurations(config); |
| 77 | + expect(config.customFields.Product).toBe(existing); |
| 78 | + }); |
| 79 | + |
| 80 | + // OSS-408: a `translations` relation target need not be a constructor closure. TypeORM also |
| 81 | + // accepts a bare string name and a closure returning one (both used to break circular imports). |
| 82 | + // Building the exclusion set must handle all three, or one such relation anywhere in the |
| 83 | + // process throws `relation.type is not a function` and kills every bootstrap (Michael's review). |
| 84 | + describe('translation relation target shapes', () => { |
| 85 | + let cleanup: (() => void) | undefined; |
| 86 | + afterEach(() => { |
| 87 | + cleanup?.(); |
| 88 | + cleanup = undefined; |
| 89 | + }); |
| 90 | + |
| 91 | + it('does not throw on a string relation target', async () => { |
| 92 | + cleanup = registerTranslationRelation('Oss408StringTarget', 'Oss408StringTargetTranslation'); |
| 93 | + const config = makeConfig({}); |
| 94 | + await expect(runPluginConfigurations(config)).resolves.toBeDefined(); |
| 95 | + // and the translation entity is still excluded from auto-init |
| 96 | + expect(config.customFields.Oss408StringTargetTranslation).toBeUndefined(); |
| 97 | + }); |
| 98 | + |
| 99 | + it('does not throw on a closure returning a string target, and still excludes it', async () => { |
| 100 | + cleanup = registerTranslationRelation( |
| 101 | + 'Oss408ClosureString', |
| 102 | + () => 'Oss408ClosureStringTranslation', |
| 103 | + ); |
| 104 | + const config = makeConfig({}); |
| 105 | + await expect(runPluginConfigurations(config)).resolves.toBeDefined(); |
| 106 | + expect(config.customFields.Oss408ClosureStringTranslation).toBeUndefined(); |
| 107 | + }); |
| 108 | + |
| 109 | + it('still excludes a constructor-closure translation target', async () => { |
| 110 | + cleanup = registerTranslationRelation('Oss408Closure', () => ({ |
| 111 | + name: 'Oss408ClosureTranslation', |
| 112 | + })); |
| 113 | + const config = makeConfig({}); |
| 114 | + await runPluginConfigurations(config); |
| 115 | + expect(config.customFields.Oss408ClosureTranslation).toBeUndefined(); |
| 116 | + }); |
| 117 | + }); |
| 118 | + |
| 119 | + it('lets a plugin extend a supported entity without a guard', async () => { |
| 120 | + @VendurePlugin({ |
| 121 | + configuration: cfg => { |
| 122 | + // No `if (!cfg.customFields.Product) cfg.customFields.Product = []` guard needed. |
| 123 | + cfg.customFields.Product.push({ name: 'fromPlugin', type: 'string' }); |
| 124 | + return cfg; |
| 125 | + }, |
| 126 | + }) |
| 127 | + class TestPlugin {} |
| 128 | + |
| 129 | + const config = makeConfig({ plugins: [TestPlugin] }); |
| 130 | + await runPluginConfigurations(config); |
| 131 | + expect(config.customFields.Product).toContainEqual({ name: 'fromPlugin', type: 'string' }); |
| 132 | + }); |
| 133 | +}); |
| 134 | + |
| 135 | +describe('registerCustomEntityFields()', () => { |
| 136 | + // OSS-408 / Michael's review: the translatable branch resolved the translation entity via |
| 137 | + // `(translationsMetadata.type as Function)()`, which threw `type is not a function` for a |
| 138 | + // bare-string relation target — the same crash class fixed in getEntityNamesWithCustomFields(). |
| 139 | + // It now reuses getRelationTargetName(), so a translatable entity with a string translations |
| 140 | + // target and real custom fields registers without aborting bootstrap. |
| 141 | + it('does not throw when a translatable entity has a bare-string translations relation target', () => { |
| 142 | + const storage = getMetadataArgsStorage(); |
| 143 | + class Oss408RegBase {} |
| 144 | + class Oss408RegBaseTranslation {} |
| 145 | + // Base entity declares a customFields embedded… |
| 146 | + storage.embeddeds.push({ |
| 147 | + target: Oss408RegBase, |
| 148 | + propertyName: 'customFields', |
| 149 | + prefix: undefined, |
| 150 | + type: () => Oss408RegBase, |
| 151 | + } as any); |
| 152 | + // …a `translations` relation whose target is a BARE STRING (the crash case)… |
| 153 | + storage.relations.push({ |
| 154 | + target: Oss408RegBase, |
| 155 | + propertyName: 'translations', |
| 156 | + relationType: 'one-to-many', |
| 157 | + type: 'Oss408RegBaseTranslation', |
| 158 | + isLazy: false, |
| 159 | + options: {}, |
| 160 | + } as any); |
| 161 | + // …and the translation entity also declares a customFields embedded. |
| 162 | + storage.embeddeds.push({ |
| 163 | + target: Oss408RegBaseTranslation, |
| 164 | + propertyName: 'customFields', |
| 165 | + prefix: undefined, |
| 166 | + type: () => Oss408RegBaseTranslation, |
| 167 | + } as any); |
| 168 | + |
| 169 | + const config = { |
| 170 | + customFields: { Oss408RegBase: [{ name: 'foo', type: 'string' }] }, |
| 171 | + dbConnectionOptions: { type: 'sqljs' }, |
| 172 | + } as unknown as RuntimeVendureConfig; |
| 173 | + |
| 174 | + try { |
| 175 | + expect(() => registerCustomEntityFields(config)).not.toThrow(); |
| 176 | + } finally { |
| 177 | + storage.embeddeds.pop(); |
| 178 | + storage.relations.pop(); |
| 179 | + storage.embeddeds.pop(); |
| 180 | + } |
| 181 | + }); |
| 182 | +}); |
0 commit comments