transaction-test-plugin.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367
  1. /* eslint-disable @typescript-eslint/restrict-template-expressions */
  2. import { Injectable, OnApplicationBootstrap } from '@nestjs/common';
  3. import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
  4. import {
  5. Administrator,
  6. Ctx,
  7. EventBus,
  8. InternalServerError,
  9. NativeAuthenticationMethod,
  10. PluginCommonModule,
  11. RequestContext,
  12. Transaction,
  13. TransactionalConnection,
  14. User,
  15. VendureEvent,
  16. VendurePlugin,
  17. } from '@vendure/core';
  18. import gql from 'graphql-tag';
  19. import { ReplaySubject, Subscription } from 'rxjs';
  20. import { vi } from 'vitest';
  21. export class TestEvent extends VendureEvent {
  22. constructor(
  23. public ctx: RequestContext,
  24. public administrator: Administrator,
  25. ) {
  26. super();
  27. }
  28. }
  29. export const TRIGGER_NO_OPERATION = 'trigger-no-operation';
  30. export const TRIGGER_ATTEMPTED_UPDATE_EMAIL = 'trigger-attempted-update-email';
  31. export const TRIGGER_ATTEMPTED_READ_EMAIL = 'trigger-attempted-read-email';
  32. @Injectable()
  33. class TestUserService {
  34. constructor(private connection: TransactionalConnection) {}
  35. async createUser(ctx: RequestContext, identifier: string) {
  36. const authMethod = await this.connection.getRepository(ctx, NativeAuthenticationMethod).save(
  37. new NativeAuthenticationMethod({
  38. identifier,
  39. passwordHash: 'abc',
  40. }),
  41. );
  42. await this.connection.getRepository(ctx, User).insert(
  43. new User({
  44. authenticationMethods: [authMethod],
  45. identifier,
  46. roles: [],
  47. verified: true,
  48. }),
  49. );
  50. return this.connection.getRepository(ctx, User).findOne({
  51. where: { identifier },
  52. });
  53. }
  54. }
  55. @Injectable()
  56. class TestAdminService {
  57. constructor(
  58. private connection: TransactionalConnection,
  59. private userService: TestUserService,
  60. ) {}
  61. async createAdministrator(ctx: RequestContext, emailAddress: string, fail: boolean) {
  62. const user = await this.userService.createUser(ctx, emailAddress);
  63. if (fail) {
  64. throw new InternalServerError('Failed!');
  65. }
  66. const admin = await this.connection.getRepository(ctx, Administrator).save(
  67. new Administrator({
  68. emailAddress,
  69. user,
  70. firstName: 'jim',
  71. lastName: 'jiminy',
  72. }),
  73. );
  74. return admin;
  75. }
  76. }
  77. @Resolver()
  78. class TestResolver {
  79. constructor(
  80. private testAdminService: TestAdminService,
  81. private connection: TransactionalConnection,
  82. private eventBus: EventBus,
  83. ) {}
  84. @Mutation()
  85. @Transaction()
  86. async createTestAdministrator(@Ctx() ctx: RequestContext, @Args() args: any) {
  87. const admin = await this.testAdminService.createAdministrator(ctx, args.emailAddress, args.fail);
  88. await this.eventBus.publish(new TestEvent(ctx, admin));
  89. return admin;
  90. }
  91. @Mutation()
  92. @Transaction('manual')
  93. async createTestAdministrator2(@Ctx() ctx: RequestContext, @Args() args: any) {
  94. await this.connection.startTransaction(ctx);
  95. return this.testAdminService.createAdministrator(ctx, args.emailAddress, args.fail);
  96. }
  97. @Mutation()
  98. @Transaction('manual')
  99. async createTestAdministrator3(@Ctx() ctx: RequestContext, @Args() args: any) {
  100. // no transaction started
  101. return this.testAdminService.createAdministrator(ctx, args.emailAddress, args.fail);
  102. }
  103. @Mutation()
  104. @Transaction()
  105. async createTestAdministrator4(@Ctx() ctx: RequestContext, @Args() args: any) {
  106. const admin = await this.testAdminService.createAdministrator(ctx, args.emailAddress, args.fail);
  107. await this.eventBus.publish(new TestEvent(ctx, admin));
  108. await new Promise(resolve => setTimeout(resolve, 50));
  109. return admin;
  110. }
  111. @Mutation()
  112. async createTestAdministrator5(@Ctx() ctx: RequestContext, @Args() args: any) {
  113. if (args.noContext === true) {
  114. return this.connection.withTransaction(async _ctx => {
  115. const admin = await this.testAdminService.createAdministrator(
  116. _ctx,
  117. args.emailAddress,
  118. args.fail,
  119. );
  120. return admin;
  121. });
  122. } else {
  123. return this.connection.withTransaction(ctx, async _ctx => {
  124. const admin = await this.testAdminService.createAdministrator(
  125. _ctx,
  126. args.emailAddress,
  127. args.fail,
  128. );
  129. return admin;
  130. });
  131. }
  132. }
  133. @Mutation()
  134. @Transaction()
  135. async createNTestAdministrators(@Ctx() ctx: RequestContext, @Args() args: any) {
  136. let error: any;
  137. const promises: Array<Promise<any>> = [];
  138. for (let i = 0; i < args.n; i++) {
  139. promises.push(
  140. new Promise(resolve => setTimeout(resolve, i * 10))
  141. .then(() =>
  142. this.testAdminService.createAdministrator(
  143. ctx,
  144. `${args.emailAddress}${i}`,
  145. i < args.n * args.failFactor,
  146. ),
  147. )
  148. .then(async admin => {
  149. await this.eventBus.publish(new TestEvent(ctx, admin));
  150. return admin;
  151. }),
  152. );
  153. }
  154. const result = await Promise.all(promises).catch((e: any) => {
  155. error = e;
  156. });
  157. await this.allSettled(promises);
  158. if (error) {
  159. throw error;
  160. }
  161. return result;
  162. }
  163. @Mutation()
  164. async createNTestAdministrators2(@Ctx() ctx: RequestContext, @Args() args: any) {
  165. let error: any;
  166. const promises: Array<Promise<any>> = [];
  167. const result = await this.connection
  168. .withTransaction(ctx, _ctx => {
  169. for (let i = 0; i < args.n; i++) {
  170. promises.push(
  171. new Promise(resolve => setTimeout(resolve, i * 10)).then(() =>
  172. this.testAdminService.createAdministrator(
  173. _ctx,
  174. `${args.emailAddress}${i}`,
  175. i < args.n * args.failFactor,
  176. ),
  177. ),
  178. );
  179. }
  180. return Promise.all(promises);
  181. })
  182. .catch((e: any) => {
  183. error = e;
  184. });
  185. await this.allSettled(promises);
  186. if (error) {
  187. throw error;
  188. }
  189. return result;
  190. }
  191. @Mutation()
  192. @Transaction()
  193. async createNTestAdministrators3(@Ctx() ctx: RequestContext, @Args() args: any) {
  194. const result: any[] = [];
  195. const admin = await this.testAdminService.createAdministrator(
  196. ctx,
  197. `${args.emailAddress}${args.n}`,
  198. args.failFactor >= 1,
  199. );
  200. result.push(admin);
  201. if (args.n > 0) {
  202. try {
  203. const admins = await this.connection.withTransaction(ctx, _ctx =>
  204. this.createNTestAdministrators3(_ctx, {
  205. ...args,
  206. n: args.n - 1,
  207. failFactor: (args.n * args.failFactor) / (args.n - 1),
  208. }),
  209. );
  210. result.push(...admins);
  211. } catch (e) {
  212. /* */
  213. }
  214. }
  215. return result;
  216. }
  217. @Query()
  218. async verify() {
  219. const admins = await this.connection.getRepository(Administrator).find();
  220. const users = await this.connection.getRepository(User).find();
  221. return {
  222. admins,
  223. users,
  224. };
  225. }
  226. // Promise.allSettled polyfill
  227. // Same as Promise.all but waits until all promises will be fulfilled or rejected.
  228. private allSettled<T>(
  229. promises: Array<Promise<T>>,
  230. ): Promise<Array<{ status: 'fulfilled'; value: T } | { status: 'rejected'; reason: any }>> {
  231. return Promise.all(
  232. promises.map((promise, i) =>
  233. promise
  234. .then(value => ({
  235. status: 'fulfilled' as const,
  236. value,
  237. }))
  238. .catch(reason => ({
  239. status: 'rejected' as const,
  240. reason,
  241. })),
  242. ),
  243. );
  244. }
  245. }
  246. @VendurePlugin({
  247. imports: [PluginCommonModule],
  248. providers: [TestAdminService, TestUserService],
  249. adminApiExtensions: {
  250. schema: gql`
  251. extend type Mutation {
  252. createTestAdministrator(emailAddress: String!, fail: Boolean!): Administrator
  253. createTestAdministrator2(emailAddress: String!, fail: Boolean!): Administrator
  254. createTestAdministrator3(emailAddress: String!, fail: Boolean!): Administrator
  255. createTestAdministrator4(emailAddress: String!, fail: Boolean!): Administrator
  256. createTestAdministrator5(
  257. emailAddress: String!
  258. fail: Boolean!
  259. noContext: Boolean!
  260. ): Administrator
  261. createNTestAdministrators(emailAddress: String!, failFactor: Float!, n: Int!): JSON
  262. createNTestAdministrators2(emailAddress: String!, failFactor: Float!, n: Int!): JSON
  263. createNTestAdministrators3(emailAddress: String!, failFactor: Float!, n: Int!): JSON
  264. }
  265. type VerifyResult {
  266. admins: [Administrator!]!
  267. users: [User!]!
  268. }
  269. extend type Query {
  270. verify: VerifyResult!
  271. }
  272. `,
  273. resolvers: [TestResolver],
  274. },
  275. })
  276. export class TransactionTestPlugin implements OnApplicationBootstrap {
  277. private subscription: Subscription;
  278. static callHandler = vi.fn();
  279. static errorHandler = vi.fn();
  280. static eventHandlerComplete$ = new ReplaySubject(1);
  281. constructor(
  282. private eventBus: EventBus,
  283. private connection: TransactionalConnection,
  284. ) {}
  285. static reset() {
  286. this.eventHandlerComplete$ = new ReplaySubject(1);
  287. this.callHandler.mockClear();
  288. this.errorHandler.mockClear();
  289. }
  290. onApplicationBootstrap(): any {
  291. // This part is used to test how RequestContext with transactions behave
  292. // when used in an Event subscription
  293. this.subscription = this.eventBus.ofType(TestEvent).subscribe(async event => {
  294. const { ctx, administrator } = event;
  295. if (administrator.emailAddress?.includes(TRIGGER_NO_OPERATION)) {
  296. TransactionTestPlugin.callHandler();
  297. TransactionTestPlugin.eventHandlerComplete$.complete();
  298. }
  299. if (administrator.emailAddress?.includes(TRIGGER_ATTEMPTED_UPDATE_EMAIL)) {
  300. TransactionTestPlugin.callHandler();
  301. const adminRepository = this.connection.getRepository(ctx, Administrator);
  302. await new Promise(resolve => setTimeout(resolve, 50));
  303. administrator.lastName = 'modified';
  304. try {
  305. await adminRepository.save(administrator);
  306. } catch (e: any) {
  307. TransactionTestPlugin.errorHandler(e);
  308. } finally {
  309. TransactionTestPlugin.eventHandlerComplete$.complete();
  310. }
  311. }
  312. if (administrator.emailAddress?.includes(TRIGGER_ATTEMPTED_READ_EMAIL)) {
  313. TransactionTestPlugin.callHandler();
  314. // note the ctx is not passed here, so we are not inside the ongoing transaction
  315. const adminRepository = this.connection.getRepository(Administrator);
  316. try {
  317. await adminRepository.findOneOrFail({ where: { id: administrator.id } });
  318. } catch (e: any) {
  319. TransactionTestPlugin.errorHandler(e);
  320. } finally {
  321. TransactionTestPlugin.eventHandlerComplete$.complete();
  322. }
  323. }
  324. });
  325. }
  326. }