transaction-test-plugin.ts 12 KB

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