promotion.e2e-spec.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406
  1. import { CurrencyCode, DeletionResult, ErrorCode, LanguageCode } from '@vendure/common/lib/generated-types';
  2. import { pick } from '@vendure/common/lib/pick';
  3. import { type PromotionAction, PromotionCondition, PromotionOrderAction } from '@vendure/core';
  4. import {
  5. createErrorResultGuard,
  6. createTestEnvironment,
  7. E2E_DEFAULT_CHANNEL_TOKEN,
  8. type ErrorResultGuard,
  9. } from '@vendure/testing';
  10. import path from 'node:path';
  11. import { afterAll, beforeAll, describe, expect, it } from 'vitest';
  12. import type { channelFragment, promotionFragment } from './graphql/fragments-admin';
  13. import type { FragmentOf, ResultOf } from './graphql/graphql-admin';
  14. import { initialData } from '../../../e2e-common/e2e-initial-data';
  15. import { TEST_SETUP_TIMEOUT_MS, testConfig } from '../../../e2e-common/test-config';
  16. import {
  17. assignPromotionsToChannelDocument,
  18. createChannelDocument,
  19. createPromotionDocument,
  20. deletePromotionDocument,
  21. getAdjustmentOperationsDocument,
  22. getPromotionDocument,
  23. getPromotionListDocument,
  24. removePromotionsFromChannelDocument,
  25. updatePromotionDocument,
  26. } from './graphql/shared-definitions';
  27. import { assertThrowsWithMessage } from './utils/assert-throws-with-message';
  28. type PromotionFragment = FragmentOf<typeof promotionFragment>;
  29. type PromotionListItem = ResultOf<typeof getPromotionListDocument>['promotions']['items'][number];
  30. /* eslint-disable @typescript-eslint/no-non-null-assertion */
  31. describe('Promotion resolver', () => {
  32. const promoCondition = generateTestCondition('promo_condition');
  33. const promoCondition2 = generateTestCondition('promo_condition2');
  34. const promoAction = generateTestAction('promo_action');
  35. const { server, adminClient } = createTestEnvironment({
  36. ...testConfig(),
  37. promotionOptions: {
  38. promotionConditions: [promoCondition, promoCondition2],
  39. promotionActions: [promoAction],
  40. },
  41. });
  42. const snapshotProps: Array<keyof PromotionFragment> = [
  43. 'name',
  44. 'actions',
  45. 'conditions',
  46. 'enabled',
  47. 'couponCode',
  48. 'startsAt',
  49. 'endsAt',
  50. ];
  51. let promotion: PromotionFragment;
  52. const promotionGuard: ErrorResultGuard<PromotionFragment> = createErrorResultGuard(
  53. input => !!input.couponCode,
  54. );
  55. const promotionQueryGuard: ErrorResultGuard<
  56. NonNullable<ResultOf<typeof getPromotionDocument>['promotion']>
  57. > = createErrorResultGuard(input => !!input.id);
  58. const channelGuard: ErrorResultGuard<FragmentOf<typeof channelFragment>> = createErrorResultGuard(
  59. input => !!input.token,
  60. );
  61. beforeAll(async () => {
  62. await server.init({
  63. initialData,
  64. productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-minimal.csv'),
  65. customerCount: 1,
  66. });
  67. await adminClient.asSuperAdmin();
  68. }, TEST_SETUP_TIMEOUT_MS);
  69. afterAll(async () => {
  70. await server.destroy();
  71. });
  72. it('createPromotion', async () => {
  73. const { createPromotion } = await adminClient.query(createPromotionDocument, {
  74. input: {
  75. enabled: true,
  76. couponCode: 'TEST123',
  77. startsAt: new Date('2019-10-30T00:00:00.000Z').toISOString(),
  78. endsAt: new Date('2019-12-01T00:00:00.000Z').toISOString(),
  79. translations: [
  80. {
  81. languageCode: LanguageCode.en,
  82. name: 'test promotion',
  83. description: 'a test promotion',
  84. },
  85. ],
  86. conditions: [
  87. {
  88. code: promoCondition.code,
  89. arguments: [{ name: 'arg', value: '500' }],
  90. },
  91. ],
  92. actions: [
  93. {
  94. code: promoAction.code,
  95. arguments: [
  96. {
  97. name: 'facetValueIds',
  98. value: '["T_1"]',
  99. },
  100. ],
  101. },
  102. ],
  103. },
  104. });
  105. promotionGuard.assertSuccess(createPromotion);
  106. promotion = createPromotion;
  107. expect(pick(promotion, snapshotProps)).toMatchSnapshot();
  108. });
  109. it('createPromotion with no description', async () => {
  110. const { createPromotion } = await adminClient.query(createPromotionDocument, {
  111. input: {
  112. enabled: true,
  113. couponCode: 'TEST567',
  114. translations: [
  115. {
  116. languageCode: LanguageCode.en,
  117. name: 'test promotion no description',
  118. customFields: {},
  119. },
  120. ],
  121. conditions: [],
  122. actions: [
  123. {
  124. code: promoAction.code,
  125. arguments: [
  126. {
  127. name: 'facetValueIds',
  128. value: '["T_1"]',
  129. },
  130. ],
  131. },
  132. ],
  133. },
  134. });
  135. promotionGuard.assertSuccess(createPromotion);
  136. expect(createPromotion.name).toBe('test promotion no description');
  137. expect(createPromotion.description).toBe('');
  138. expect(createPromotion.translations[0].description).toBe('');
  139. });
  140. it('createPromotion return error result with empty conditions and no couponCode', async () => {
  141. const { createPromotion } = await adminClient.query(createPromotionDocument, {
  142. input: {
  143. enabled: true,
  144. translations: [
  145. {
  146. languageCode: LanguageCode.en,
  147. name: 'bad promotion',
  148. },
  149. ],
  150. conditions: [],
  151. actions: [
  152. {
  153. code: promoAction.code,
  154. arguments: [
  155. {
  156. name: 'facetValueIds',
  157. value: '["T_1"]',
  158. },
  159. ],
  160. },
  161. ],
  162. },
  163. });
  164. promotionGuard.assertErrorResult(createPromotion);
  165. expect(createPromotion.message).toBe(
  166. 'A Promotion must have either at least one condition or a coupon code set',
  167. );
  168. expect(createPromotion.errorCode).toBe(ErrorCode.MISSING_CONDITIONS_ERROR);
  169. });
  170. it('updatePromotion', async () => {
  171. const { updatePromotion } = await adminClient.query(updatePromotionDocument, {
  172. input: {
  173. id: promotion.id,
  174. couponCode: 'TEST1235',
  175. startsAt: new Date('2019-05-30T22:00:00.000Z').toISOString(),
  176. endsAt: new Date('2019-06-01T22:00:00.000Z').toISOString(),
  177. conditions: [
  178. {
  179. code: promoCondition.code,
  180. arguments: [{ name: 'arg', value: '90' }],
  181. },
  182. {
  183. code: promoCondition2.code,
  184. arguments: [{ name: 'arg', value: '10' }],
  185. },
  186. ],
  187. },
  188. });
  189. promotionGuard.assertSuccess(updatePromotion);
  190. expect(pick(updatePromotion, snapshotProps)).toMatchSnapshot();
  191. });
  192. it('updatePromotion return error result with empty conditions and no couponCode', async () => {
  193. const { updatePromotion } = await adminClient.query(updatePromotionDocument, {
  194. input: {
  195. id: promotion.id,
  196. couponCode: '',
  197. conditions: [],
  198. },
  199. });
  200. promotionGuard.assertErrorResult(updatePromotion);
  201. expect(updatePromotion.message).toBe(
  202. 'A Promotion must have either at least one condition or a coupon code set',
  203. );
  204. expect(updatePromotion.errorCode).toBe(ErrorCode.MISSING_CONDITIONS_ERROR);
  205. });
  206. it('promotion', async () => {
  207. const result = await adminClient.query(getPromotionDocument, {
  208. id: promotion.id,
  209. });
  210. promotionQueryGuard.assertSuccess(result.promotion);
  211. expect(result.promotion.name).toBe(promotion.name);
  212. });
  213. it('promotions', async () => {
  214. const result = await adminClient.query(getPromotionListDocument, {});
  215. expect(result.promotions.totalItems).toBe(2);
  216. expect(result.promotions.items[0].name).toBe('test promotion');
  217. });
  218. it('adjustmentOperations', async () => {
  219. const result = await adminClient.query(getAdjustmentOperationsDocument);
  220. expect(result.promotionActions).toMatchSnapshot();
  221. expect(result.promotionConditions).toMatchSnapshot();
  222. });
  223. describe('channels', () => {
  224. const SECOND_CHANNEL_TOKEN = 'SECOND_CHANNEL_TOKEN';
  225. let secondChannel: FragmentOf<typeof channelFragment>;
  226. beforeAll(async () => {
  227. const { createChannel } = await adminClient.query(createChannelDocument, {
  228. input: {
  229. code: 'second-channel',
  230. token: SECOND_CHANNEL_TOKEN,
  231. defaultLanguageCode: LanguageCode.en,
  232. pricesIncludeTax: true,
  233. currencyCode: CurrencyCode.EUR,
  234. defaultTaxZoneId: 'T_1',
  235. defaultShippingZoneId: 'T_2',
  236. },
  237. });
  238. channelGuard.assertSuccess(createChannel);
  239. secondChannel = createChannel;
  240. });
  241. it('does not list Promotions not in active channel', async () => {
  242. adminClient.setChannelToken(SECOND_CHANNEL_TOKEN);
  243. const { promotions } = await adminClient.query(getPromotionListDocument);
  244. expect(promotions.totalItems).toBe(0);
  245. expect(promotions.items).toEqual([]);
  246. });
  247. it('does not return Promotion not in active channel', async () => {
  248. adminClient.setChannelToken(SECOND_CHANNEL_TOKEN);
  249. const { promotion: result } = await adminClient.query(getPromotionDocument, {
  250. id: promotion.id,
  251. });
  252. expect(result).toBeNull();
  253. });
  254. it('assignPromotionsToChannel', async () => {
  255. adminClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN);
  256. const { assignPromotionsToChannel } = await adminClient.query(assignPromotionsToChannelDocument, {
  257. input: {
  258. channelId: secondChannel.id,
  259. promotionIds: [promotion.id],
  260. },
  261. });
  262. expect(assignPromotionsToChannel).toEqual([{ id: promotion.id, name: promotion.name }]);
  263. adminClient.setChannelToken(SECOND_CHANNEL_TOKEN);
  264. const { promotion: result } = await adminClient.query(getPromotionDocument, {
  265. id: promotion.id,
  266. });
  267. expect(result?.id).toBe(promotion.id);
  268. const { promotions } = await adminClient.query(getPromotionListDocument);
  269. expect(promotions.totalItems).toBe(1);
  270. expect(promotions.items.map(pick(['id']))).toEqual([{ id: promotion.id }]);
  271. });
  272. it('removePromotionsFromChannel', async () => {
  273. adminClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN);
  274. const { removePromotionsFromChannel } = await adminClient.query(
  275. removePromotionsFromChannelDocument,
  276. {
  277. input: {
  278. channelId: secondChannel.id,
  279. promotionIds: [promotion.id],
  280. },
  281. },
  282. );
  283. expect(removePromotionsFromChannel).toEqual([{ id: promotion.id, name: promotion.name }]);
  284. adminClient.setChannelToken(SECOND_CHANNEL_TOKEN);
  285. const { promotion: result } = await adminClient.query(getPromotionDocument, {
  286. id: promotion.id,
  287. });
  288. expect(result).toBeNull();
  289. const { promotions } = await adminClient.query(getPromotionListDocument);
  290. expect(promotions.totalItems).toBe(0);
  291. });
  292. });
  293. describe('deletion', () => {
  294. let allPromotions: PromotionListItem[];
  295. let promotionToDelete: PromotionListItem;
  296. beforeAll(async () => {
  297. adminClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN);
  298. const result = await adminClient.query(getPromotionListDocument);
  299. allPromotions = result.promotions.items;
  300. });
  301. it('deletes a promotion', async () => {
  302. promotionToDelete = allPromotions[0];
  303. const result = await adminClient.query(deletePromotionDocument, {
  304. id: promotionToDelete.id,
  305. });
  306. expect(result.deletePromotion).toEqual({
  307. result: DeletionResult.DELETED,
  308. });
  309. });
  310. it('cannot get a deleted promotion', async () => {
  311. const result = await adminClient.query(getPromotionDocument, {
  312. id: promotionToDelete.id,
  313. });
  314. expect(result.promotion).toBe(null);
  315. });
  316. it('deleted promotion omitted from list', async () => {
  317. const result = await adminClient.query(getPromotionListDocument);
  318. expect(result.promotions.items.length).toBe(allPromotions.length - 1);
  319. expect(result.promotions.items.map(c => c.id).includes(promotionToDelete.id)).toBe(false);
  320. });
  321. it(
  322. 'updatePromotion throws for deleted promotion',
  323. assertThrowsWithMessage(
  324. () =>
  325. adminClient.query(updatePromotionDocument, {
  326. input: {
  327. id: promotionToDelete.id,
  328. enabled: false,
  329. },
  330. }),
  331. 'No Promotion with the id "1" could be found',
  332. ),
  333. );
  334. });
  335. });
  336. function generateTestCondition(code: string): PromotionCondition {
  337. return new PromotionCondition({
  338. code,
  339. description: [{ languageCode: LanguageCode.en, value: `description for ${code}` }],
  340. args: { arg: { type: 'int' } },
  341. check: () => true,
  342. });
  343. }
  344. function generateTestAction(code: string): PromotionAction<any> {
  345. return new PromotionOrderAction({
  346. code,
  347. description: [{ languageCode: LanguageCode.en, value: `description for ${code}` }],
  348. args: { facetValueIds: { type: 'ID', list: true } },
  349. execute: () => {
  350. return 42;
  351. },
  352. });
  353. }