transaction-test-plugin.ts 12 KB

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