feat(translation,fulfillment,customer,product,region,tax,core-flows,medusa,types): Implement dynamic translation settings management (#14536)

* Add is_active field to translation_settings model

* Types

* Workflows

* Api layer

* Tests

* Add changeset

* Add comment

* Hook to create or deactivate translatable entities on startup

* Cleanup old code

* Configure translatable option for core entities

* Validation step and snake case correction

* Cleanup

* Tests

* Comment in PR

* Update changeset

* Mock DmlEntity.getTranslatableEntities

* Move validation to module service layer

* Remove validation from remaining workflow

* Return object directly

* Type improvements

* Remove .only from tests

* Apply snakeCase

* Fix tests

* Fix tests

* Remove unnecessary map and use set instead

* Fix tests

* Comments

* Include translatable product properties

* Avoid race condition in translations tests

* Update test
This commit is contained in:
Nicolas Gorga
2026-01-14 07:09:49 -03:00
committed by GitHub
parent 42235825ee
commit d60ea7268a
50 changed files with 1397 additions and 199 deletions

View File

@@ -44,6 +44,10 @@ medusaIntegrationTestRunner({
beforeEach(async () => {
await createAdminUser(dbConnection, adminHeaders, appContainer)
const translationModule = appContainer.resolve(Modules.TRANSLATION)
await translationModule.__hooks?.onApplicationStart?.().catch(() => {})
const publishableKey = await generatePublishableKey(appContainer)
storeHeaders = generateStoreHeaders({ publishableKey })

View File

@@ -41,6 +41,8 @@ medusaIntegrationTestRunner({
beforeEach(async () => {
appContainer = getContainer()
const translationModule = appContainer.resolve(Modules.TRANSLATION)
await translationModule.__hooks?.onApplicationStart?.().catch(() => {})
await createAdminUser(dbConnection, adminHeaders, appContainer)
const taxStructure = await setupTaxStructure(

View File

@@ -33,6 +33,9 @@ medusaIntegrationTestRunner({
)
await createAdminUser(dbConnection, adminHeaders, appContainer)
const translationModule = appContainer.resolve(Modules.TRANSLATION)
await translationModule.__hooks?.onApplicationStart?.().catch(() => {})
salesChannel = (
await api.post(
"/admin/sales-channels",

View File

@@ -47,6 +47,10 @@ medusaIntegrationTestRunner({
appContainer.resolve(Modules.TAX)
)
await createAdminUser(dbConnection, adminHeaders, appContainer)
const translationModule = appContainer.resolve(Modules.TRANSLATION)
await translationModule.__hooks?.onApplicationStart?.().catch(() => {})
const publishableKey = await generatePublishableKey(appContainer)
storeHeaders = generateStoreHeaders({ publishableKey })

View File

@@ -46,6 +46,9 @@ medusaIntegrationTestRunner({
appContainer.resolve(Modules.TAX)
)
await createAdminUser(dbConnection, adminHeaders, appContainer)
const translationModule = appContainer.resolve(Modules.TRANSLATION)
await translationModule.__hooks?.onApplicationStart?.().catch(() => {})
const publishableKey = await generatePublishableKey(appContainer)
storeHeaders = generateStoreHeaders({ publishableKey })

View File

@@ -47,6 +47,10 @@ medusaIntegrationTestRunner({
appContainer.resolve(Modules.TAX)
)
await createAdminUser(dbConnection, adminHeaders, appContainer)
const translationModule = appContainer.resolve(Modules.TRANSLATION)
await translationModule.__hooks?.onApplicationStart?.().catch(() => {})
const publishableKey = await generatePublishableKey(appContainer)
storeHeaders = generateStoreHeaders({ publishableKey })

View File

@@ -24,6 +24,9 @@ medusaIntegrationTestRunner({
const container = getContainer()
await createAdminUser(dbConnection, adminHeaders, container)
const translationModule = container.resolve(Modules.TRANSLATION)
await translationModule.__hooks?.onApplicationStart?.().catch(() => {})
// Set up supported locales in the store
const storeModule = container.resolve(Modules.STORE)
const [defaultStore] = await storeModule.listStores(

View File

@@ -41,6 +41,9 @@ medusaIntegrationTestRunner({
await createAdminUser(dbConnection, adminHeaders, appContainer)
const translationModule = appContainer.resolve(Modules.TRANSLATION)
await translationModule.__hooks?.onApplicationStart?.().catch(() => {})
// Set up store locales
const storeModule = appContainer.resolve(Modules.STORE)
const [defaultStore] = await storeModule.listStores(

View File

@@ -1,4 +1,5 @@
import { medusaIntegrationTestRunner } from "@medusajs/test-utils"
import { Modules } from "@medusajs/utils"
import {
adminHeaders,
createAdminUser,
@@ -13,6 +14,10 @@ medusaIntegrationTestRunner({
describe("Admin Locale API", () => {
beforeEach(async () => {
await createAdminUser(dbConnection, adminHeaders, getContainer())
const appContainer = getContainer()
const translationModule = appContainer.resolve(Modules.TRANSLATION)
await translationModule.__hooks?.onApplicationStart?.().catch(() => {})
})
afterAll(async () => {

View File

@@ -0,0 +1,528 @@
import { medusaIntegrationTestRunner } from "@medusajs/test-utils"
import { DmlEntity, Modules } from "@medusajs/utils"
import {
adminHeaders,
createAdminUser,
} from "../../../../helpers/create-admin-user"
jest.setTimeout(100000)
process.env.MEDUSA_FF_TRANSLATION = "true"
medusaIntegrationTestRunner({
testSuite: ({ dbConnection, getContainer, api }) => {
describe("Admin Translation Settings Batch API", () => {
let mockGetTranslatableEntities: jest.SpyInstance
beforeEach(async () => {
mockGetTranslatableEntities = jest.spyOn(
DmlEntity,
"getTranslatableEntities"
)
mockGetTranslatableEntities.mockReturnValue([
{ entity: "ProductVariant", fields: ["title", "material"] },
{ entity: "ProductCategory", fields: ["name", "description"] },
{ entity: "ProductCollection", fields: ["title"] },
])
await createAdminUser(dbConnection, adminHeaders, getContainer())
const appContainer = getContainer()
const translationModuleService = appContainer.resolve(
Modules.TRANSLATION
)
await translationModuleService.__hooks
?.onApplicationStart?.()
.catch(() => {})
// Delete all translation settings to be able to test the create operation
const settings =
await translationModuleService.listTranslationSettings()
await translationModuleService.deleteTranslationSettings(
settings.map((s) => s.id)
)
})
afterAll(async () => {
delete process.env.MEDUSA_FF_TRANSLATION
mockGetTranslatableEntities.mockRestore()
})
describe("POST /admin/translations/settings/batch", () => {
describe("create", () => {
it("should create a single translation setting", async () => {
const response = await api.post(
"/admin/translations/settings/batch",
{
create: [
{
entity_type: "product_variant",
fields: ["title", "material"],
is_active: true,
},
],
},
adminHeaders
)
expect(response.status).toEqual(200)
expect(response.data.created).toHaveLength(1)
expect(response.data.created[0]).toEqual(
expect.objectContaining({
id: expect.any(String),
entity_type: "product_variant",
fields: ["title", "material"],
is_active: true,
created_at: expect.any(String),
updated_at: expect.any(String),
})
)
})
it("should create multiple translation settings", async () => {
const response = await api.post(
"/admin/translations/settings/batch",
{
create: [
{
entity_type: "product_variant",
fields: ["title", "material"],
is_active: true,
},
{
entity_type: "product_category",
fields: ["name", "description"],
is_active: true,
},
{
entity_type: "product_collection",
fields: ["title"],
is_active: false,
},
],
},
adminHeaders
)
expect(response.status).toEqual(200)
expect(response.data.created).toHaveLength(3)
expect(response.data.created).toEqual(
expect.arrayContaining([
expect.objectContaining({
entity_type: "product_variant",
fields: ["title", "material"],
is_active: true,
}),
expect.objectContaining({
entity_type: "product_category",
fields: ["name", "description"],
is_active: true,
}),
expect.objectContaining({
entity_type: "product_collection",
fields: ["title"],
is_active: false,
}),
])
)
})
})
describe("update", () => {
it("should update an existing translation setting", async () => {
const createResponse = await api.post(
"/admin/translations/settings/batch",
{
create: [
{
entity_type: "product_variant",
fields: ["title"],
is_active: true,
},
],
},
adminHeaders
)
const settingId = createResponse.data.created[0].id
const updateResponse = await api.post(
"/admin/translations/settings/batch",
{
update: [
{
id: settingId,
entity_type: "product_variant",
fields: ["title", "material"],
},
],
},
adminHeaders
)
expect(updateResponse.status).toEqual(200)
expect(updateResponse.data.updated).toHaveLength(1)
expect(updateResponse.data.updated[0]).toEqual(
expect.objectContaining({
id: settingId,
entity_type: "product_variant",
fields: ["title", "material"],
is_active: true,
})
)
})
it("should update multiple translation settings", async () => {
const createResponse = await api.post(
"/admin/translations/settings/batch",
{
create: [
{
entity_type: "product_variant",
fields: ["title"],
is_active: true,
},
{
entity_type: "product_category",
fields: ["name"],
is_active: true,
},
],
},
adminHeaders
)
const [settingId1, settingId2] = createResponse.data.created.map(
(s) => s.id
)
const updateResponse = await api.post(
"/admin/translations/settings/batch",
{
update: [
{
id: settingId1,
entity_type: "product_variant",
fields: ["title", "material"],
},
{
id: settingId2,
entity_type: "product_category",
is_active: false,
},
],
},
adminHeaders
)
expect(updateResponse.status).toEqual(200)
expect(updateResponse.data.updated).toHaveLength(2)
expect(updateResponse.data.updated).toEqual(
expect.arrayContaining([
expect.objectContaining({
entity_type: "product_variant",
fields: ["title", "material"],
}),
expect.objectContaining({
entity_type: "product_category",
is_active: false,
}),
])
)
})
})
describe("delete", () => {
it("should delete a translation setting", async () => {
const createResponse = await api.post(
"/admin/translations/settings/batch",
{
create: [
{
entity_type: "product_variant",
fields: ["title"],
is_active: true,
},
],
},
adminHeaders
)
const settingId = createResponse.data.created[0].id
const deleteResponse = await api.post(
"/admin/translations/settings/batch",
{
delete: [settingId],
},
adminHeaders
)
expect(deleteResponse.status).toEqual(200)
expect(deleteResponse.data.deleted).toEqual({
ids: [settingId],
object: "translation_settings",
deleted: true,
})
})
it("should delete multiple translation settings", async () => {
const createResponse = await api.post(
"/admin/translations/settings/batch",
{
create: [
{
entity_type: "product_variant",
fields: ["title"],
is_active: true,
},
{
entity_type: "product_category",
fields: ["name"],
is_active: true,
},
{
entity_type: "product_collection",
fields: ["title"],
is_active: true,
},
],
},
adminHeaders
)
const ids = createResponse.data.created.map((s) => s.id)
const deleteResponse = await api.post(
"/admin/translations/settings/batch",
{
delete: ids,
},
adminHeaders
)
expect(deleteResponse.status).toEqual(200)
expect(deleteResponse.data.deleted).toEqual({
ids: expect.arrayContaining(ids),
object: "translation_settings",
deleted: true,
})
expect(deleteResponse.data.deleted.ids).toHaveLength(3)
})
it("should handle deleting with empty array", async () => {
const response = await api.post(
"/admin/translations/settings/batch",
{
delete: [],
},
adminHeaders
)
expect(response.status).toEqual(200)
expect(response.data.deleted).toEqual({
ids: [],
object: "translation_settings",
deleted: true,
})
})
})
describe("combined operations", () => {
it("should handle create, update, and delete in a single batch", async () => {
const setupResponse = await api.post(
"/admin/translations/settings/batch",
{
create: [
{
entity_type: "product_variant",
fields: ["title"],
is_active: true,
},
{
entity_type: "product_category",
fields: ["name"],
is_active: true,
},
],
},
adminHeaders
)
const [settingId1, settingId2] = setupResponse.data.created.map(
(s) => s.id
)
const batchResponse = await api.post(
"/admin/translations/settings/batch",
{
create: [
{
entity_type: "product_collection",
fields: ["title"],
is_active: true,
},
],
update: [
{
id: settingId1,
entity_type: "product_variant",
fields: ["title", "material"],
is_active: false,
},
],
delete: [settingId2],
},
adminHeaders
)
expect(batchResponse.status).toEqual(200)
expect(batchResponse.data.created).toHaveLength(1)
expect(batchResponse.data.updated).toHaveLength(1)
expect(batchResponse.data.deleted.ids).toContain(settingId2)
expect(batchResponse.data.created[0]).toEqual(
expect.objectContaining({
entity_type: "product_collection",
fields: ["title"],
is_active: true,
})
)
expect(batchResponse.data.updated[0]).toEqual(
expect.objectContaining({
id: settingId1,
fields: ["title", "material"],
is_active: false,
})
)
})
it("should handle empty batch request", async () => {
const response = await api.post(
"/admin/translations/settings/batch",
{
create: [],
update: [],
delete: [],
},
adminHeaders
)
expect(response.status).toEqual(200)
expect(response.data.created).toEqual([])
expect(response.data.updated).toEqual([])
expect(response.data.deleted.ids).toEqual([])
})
})
describe("validation", () => {
it("should reject non-translatable entity types", async () => {
const error = await api
.post(
"/admin/translations/settings/batch",
{
create: [
{
entity_type: "NonExistentEntity",
fields: ["title"],
is_active: true,
},
],
},
adminHeaders
)
.catch((e) => e)
expect(error.response.status).toEqual(400)
expect(error.response.data.message).toContain(
"NonExistentEntity is not a translatable entity"
)
})
it("should reject invalid fields for translatable entities", async () => {
const error = await api
.post(
"/admin/translations/settings/batch",
{
create: [
{
entity_type: "product_variant",
fields: ["title", "invalid_field", "another_invalid"],
is_active: true,
},
],
},
adminHeaders
)
.catch((e) => e)
expect(error.response.status).toEqual(400)
expect(error.response.data.message).toContain("product_variant")
expect(error.response.data.message).toContain("invalid_field")
expect(error.response.data.message).toContain("another_invalid")
})
it("should reject multiple invalid settings in a single batch", async () => {
const error = await api
.post(
"/admin/translations/settings/batch",
{
create: [
{
entity_type: "NonExistentEntity",
fields: ["title"],
is_active: true,
},
{
entity_type: "product_variant",
fields: ["title", "invalid_field"],
is_active: true,
},
],
},
adminHeaders
)
.catch((e) => e)
expect(error.response.status).toEqual(400)
expect(error.response.data.message).toContain(
"NonExistentEntity is not a translatable entity"
)
expect(error.response.data.message).toContain("product_variant")
expect(error.response.data.message).toContain("invalid_field")
})
it("should accept valid fields for translatable entities", async () => {
const response = await api.post(
"/admin/translations/settings/batch",
{
create: [
{
entity_type: "product_variant",
fields: ["title", "material"],
is_active: true,
},
{
entity_type: "product_category",
fields: ["name", "description"],
is_active: true,
},
{
entity_type: "product_collection",
fields: ["title"],
is_active: true,
},
],
},
adminHeaders
)
expect(response.status).toEqual(200)
expect(response.data.created).toHaveLength(3)
})
})
})
})
},
})

View File

@@ -22,6 +22,9 @@ medusaIntegrationTestRunner({
beforeEach(async () => {
await createAdminUser(dbConnection, adminHeaders, getContainer())
const translationModule = appContainer.resolve(Modules.TRANSLATION)
await translationModule.__hooks?.onApplicationStart?.().catch(() => {})
const storeModule = appContainer.resolve(Modules.STORE)
const [defaultStore] = await storeModule.listStores(
{},