From fd871daa0f467de0bf481fff8e39d47c65a87f59 Mon Sep 17 00:00:00 2001 From: Lars Waage <46653859+larwaa@users.noreply.github.com> Date: Fri, 15 Mar 2024 18:45:49 +0100 Subject: [PATCH] :recycle: refactor: use union response for success and error --- codegen.config.ts | 4 + schema.graphql | 14 +- src/domain/errors.ts | 18 ++- .../__tests__/unit/organizations.test.ts | 44 +++--- .../resolvers/AddMemberErrorResponse.ts | 19 +++ .../resolvers/AddMemberResponse.ts | 4 - .../resolvers/AddMemberSuccessResponse.ts | 2 + .../resolvers/Mutation/addMember.ts | 22 ++- .../resolvers/Mutation/addMember.unit.test.ts | 126 +++++++++++++++--- src/graphql/organizations/schema.graphql | 14 +- src/graphql/organizations/schema.mappers.ts | 13 ++ src/lib/result.ts | 2 +- 12 files changed, 233 insertions(+), 49 deletions(-) create mode 100644 src/graphql/organizations/resolvers/AddMemberErrorResponse.ts delete mode 100644 src/graphql/organizations/resolvers/AddMemberResponse.ts create mode 100644 src/graphql/organizations/resolvers/AddMemberSuccessResponse.ts diff --git a/codegen.config.ts b/codegen.config.ts index 6ea34405..28d0cd56 100644 --- a/codegen.config.ts +++ b/codegen.config.ts @@ -50,6 +50,8 @@ const config: CodegenConfig = { documentMode: "string", enumsAsTypes: true, useTypeImports: true, + // We must explicitly call include this as we don't do so automatically + skipTypename: true, }, presetConfig: { useTypeImports: true, @@ -78,6 +80,8 @@ const config: CodegenConfig = { config: { enumsAsTypes: true, useTypeImports: true, + // We must explicitly call include this as we don't do so automatically + skipTypename: true, }, presetConfig: { /* Fragment masking is only useful for actual clients, and it's not relevant for testing */ diff --git a/schema.graphql b/schema.graphql index 13fd9ab3..35cb9c28 100644 --- a/schema.graphql +++ b/schema.graphql @@ -5,6 +5,16 @@ Please do not edit this file directly. To regenerate this file, run `npm run generate:gql` """ +enum AddMemberErrorCode { + ALREADY_MEMBER + USER_NOT_FOUND +} + +type AddMemberErrorResponse { + code: AddMemberErrorCode! + message: String! +} + input AddMemberInput { """The email of the user ot add to the organization""" email: String @@ -19,7 +29,9 @@ input AddMemberInput { userId: ID } -type AddMemberResponse { +union AddMemberResponse = AddMemberErrorResponse | AddMemberSuccessResponse + +type AddMemberSuccessResponse { member: Member! } diff --git a/src/domain/errors.ts b/src/domain/errors.ts index 44969711..9daa4a53 100644 --- a/src/domain/errors.ts +++ b/src/domain/errors.ts @@ -198,17 +198,27 @@ const DomainErrorType = { type ErrorType = (typeof DomainErrorType)[keyof typeof DomainErrorType]; -class DomainError extends Error { +class DomainError< + TErrorType extends ErrorType, + TName extends string, +> extends Error { public code: ErrorCode; public type: TErrorType; + public name: TName; constructor( message: string, - options: { code: ErrorCode; type: TErrorType; cause?: unknown }, + options: { + code: ErrorCode; + type: TErrorType; + cause?: unknown; + name: TName; + }, ) { super(message, { cause: options.cause }); this.code = options.code; this.type = options.type; + this.name = options.name; Error.captureStackTrace(this); } @@ -223,7 +233,8 @@ class DomainError extends Error { } class InvalidArgumentErrorV2 extends DomainError< - typeof DomainErrorType.InvalidArgumentError + typeof DomainErrorType.InvalidArgumentError, + "InvalidArgumentError" > { public reason: Record | undefined; @@ -238,6 +249,7 @@ class InvalidArgumentErrorV2 extends DomainError< super(message, { code: errorCodes.ERR_BAD_USER_INPUT, type: DomainErrorType.InvalidArgumentError, + name: "InvalidArgumentError", ...rest, }); this.reason = reason; diff --git a/src/graphql/organizations/__tests__/unit/organizations.test.ts b/src/graphql/organizations/__tests__/unit/organizations.test.ts index 010354a3..d9a8bcb3 100644 --- a/src/graphql/organizations/__tests__/unit/organizations.test.ts +++ b/src/graphql/organizations/__tests__/unit/organizations.test.ts @@ -184,15 +184,17 @@ describe("OrganizationResolvers", () => { mutation: graphql(` mutation addMember2 { addMember(data: { userId: "user", organizationId: "org" }) { - member { - id - organization { - id - members { - id - } - } - } + ... on AddMemberSuccessResponse { + member { + id + organization { + id + members { + id + } + } + } + } } } `), @@ -245,18 +247,20 @@ describe("OrganizationResolvers", () => { { mutation: graphql(` mutation addMember2 { - addMember(data: { userId: "user", organizationId: "org" }) { - member { - id - organization { - id - members { - id - } + addMember(data: { userId: "user", organizationId: "org" }) { + ... on AddMemberSuccessResponse { + member { + id + organization { + id + members { + id + } + } + } } - } - } - } + } + } `), }, { diff --git a/src/graphql/organizations/resolvers/AddMemberErrorResponse.ts b/src/graphql/organizations/resolvers/AddMemberErrorResponse.ts new file mode 100644 index 00000000..7c92386d --- /dev/null +++ b/src/graphql/organizations/resolvers/AddMemberErrorResponse.ts @@ -0,0 +1,19 @@ +import type { AddMemberErrorResponseResolvers } from "./../../types.generated.js"; +export const AddMemberErrorResponse: AddMemberErrorResponseResolvers = { + code: ({ error }) => { + switch (error.name) { + case "InvalidArgumentError": + return "ALREADY_MEMBER"; + case "NotFoundError": + return "USER_NOT_FOUND"; + } + }, + message: ({ error }) => { + switch (error.name) { + case "InvalidArgumentError": + return "User is already a member of the organization"; + case "NotFoundError": + return "User not found"; + } + }, +}; diff --git a/src/graphql/organizations/resolvers/AddMemberResponse.ts b/src/graphql/organizations/resolvers/AddMemberResponse.ts deleted file mode 100644 index 99eca5a0..00000000 --- a/src/graphql/organizations/resolvers/AddMemberResponse.ts +++ /dev/null @@ -1,4 +0,0 @@ -import type { AddMemberResponseResolvers } from "./../../types.generated.js"; -export const AddMemberResponse: AddMemberResponseResolvers = { - /* Implement AddMemberResponse resolver logic here */ -}; diff --git a/src/graphql/organizations/resolvers/AddMemberSuccessResponse.ts b/src/graphql/organizations/resolvers/AddMemberSuccessResponse.ts new file mode 100644 index 00000000..6fcf837e --- /dev/null +++ b/src/graphql/organizations/resolvers/AddMemberSuccessResponse.ts @@ -0,0 +1,2 @@ +import type { AddMemberSuccessResponseResolvers } from "./../../types.generated.js"; +export const AddMemberSuccessResponse: AddMemberSuccessResponseResolvers = {}; diff --git a/src/graphql/organizations/resolvers/Mutation/addMember.ts b/src/graphql/organizations/resolvers/Mutation/addMember.ts index 214fef30..384615d4 100644 --- a/src/graphql/organizations/resolvers/Mutation/addMember.ts +++ b/src/graphql/organizations/resolvers/Mutation/addMember.ts @@ -31,7 +31,23 @@ export const addMember: NonNullable = async ( }); } - if (!addMemberResult.ok) throw addMemberResult.error; - const { member } = addMemberResult.data; - return { member }; + if (addMemberResult.ok) { + return { + __typename: "AddMemberSuccessResponse", + member: addMemberResult.data.member, + }; + } + + switch (addMemberResult.error.name) { + case "InvalidArgumentError": + case "NotFoundError": { + return { + __typename: "AddMemberErrorResponse", + error: addMemberResult.error, + }; + } + default: { + throw addMemberResult.error; + } + } }; diff --git a/src/graphql/organizations/resolvers/Mutation/addMember.unit.test.ts b/src/graphql/organizations/resolvers/Mutation/addMember.unit.test.ts index aacf9de8..862eca34 100644 --- a/src/graphql/organizations/resolvers/Mutation/addMember.unit.test.ts +++ b/src/graphql/organizations/resolvers/Mutation/addMember.unit.test.ts @@ -1,6 +1,12 @@ +import assert from "node:assert"; import { ApolloServerErrorCode } from "@apollo/server/errors"; import { faker } from "@faker-js/faker"; -import { NotFoundError, errorCodes } from "~/domain/errors.js"; +import { + InvalidArgumentErrorV2, + NotFoundError, + PermissionDeniedError, + errorCodes, +} from "~/domain/errors.js"; import { createMockApolloServer } from "~/graphql/test-clients/mock-apollo-server.js"; import { graphql } from "~/graphql/test-clients/unit/gql.js"; import { Result } from "~/lib/result.js"; @@ -14,9 +20,11 @@ describe("Organization mutations", () => { mutation: graphql(` mutation AddMember($data: AddMemberInput!) { addMember(data: $data) { - member { - id - } + ... on AddMemberSuccessResponse { + member { + id + } + } } } `), @@ -42,9 +50,11 @@ describe("Organization mutations", () => { mutation: graphql(` mutation AddMember($data: AddMemberInput!) { addMember(data: $data) { - member { - id - } + ... on AddMemberSuccessResponse { + member { + id + } + } } } `), @@ -73,9 +83,11 @@ describe("Organization mutations", () => { mutation: graphql(` mutation AddMember($data: AddMemberInput!) { addMember(data: $data) { - member { - id - } + ... on AddMemberSuccessResponse { + member { + id + } + } } } `), @@ -96,11 +108,11 @@ describe("Organization mutations", () => { ); }); - it("throws if adding a member fails", async () => { + it("throws if adding a member fails with permission denied", async () => { const { client, organizationService } = createMockApolloServer(); organizationService.members.addMember.mockResolvedValue( - Result.error(new NotFoundError("")), + Result.error(new PermissionDeniedError("")), ); const userId = faker.string.uuid(); @@ -108,9 +120,11 @@ describe("Organization mutations", () => { mutation: graphql(` mutation AddMember($data: AddMemberInput!) { addMember(data: $data) { - member { - id - } + ... on AddMemberSuccessResponse { + member { + id + } + } } } `), @@ -124,7 +138,87 @@ describe("Organization mutations", () => { }); expect(errors).toHaveLength(1); - expect(errors?.[0]?.extensions?.code).toBe(errorCodes.ERR_NOT_FOUND); + expect(errors?.[0]?.extensions?.code).toBe( + errorCodes.ERR_PERMISSION_DENIED, + ); + }); + + it("returns error response if adding fails with NotFoundError", async () => { + const { client, organizationService } = createMockApolloServer(); + + organizationService.members.addMember.mockResolvedValue( + Result.error(new NotFoundError("")), + ); + + const userId = faker.string.uuid(); + const { errors, data } = await client.mutate({ + mutation: graphql(` + mutation AddMemberErrorResponse($data: AddMemberInput!) { + addMember(data: $data) { + __typename + ... on AddMemberSuccessResponse { + member { + id + } + } + ... on AddMemberErrorResponse { + code + message + } + } + } + `), + variables: { + data: { + organizationId: faker.string.uuid(), + role: "MEMBER", + userId, + }, + }, + }); + + expect(errors).toBeUndefined(); + assert(data?.addMember.__typename === "AddMemberErrorResponse"); + expect(data.addMember.code).toBe("USER_NOT_FOUND"); + }); + + it("returns error response if adding fails with InvalidArgumentError", async () => { + const { client, organizationService } = createMockApolloServer(); + + organizationService.members.addMember.mockResolvedValue( + Result.error(new InvalidArgumentErrorV2("")), + ); + + const userId = faker.string.uuid(); + const { errors, data } = await client.mutate({ + mutation: graphql(` + mutation AddMemberErrorResponse($data: AddMemberInput!) { + addMember(data: $data) { + __typename + ... on AddMemberSuccessResponse { + member { + id + } + } + ... on AddMemberErrorResponse { + code + message + } + } + } + `), + variables: { + data: { + organizationId: faker.string.uuid(), + role: "MEMBER", + userId, + }, + }, + }); + + expect(errors).toBeUndefined(); + assert(data?.addMember.__typename === "AddMemberErrorResponse"); + expect(data.addMember.code).toBe("ALREADY_MEMBER"); }); }); }); diff --git a/src/graphql/organizations/schema.graphql b/src/graphql/organizations/schema.graphql index 2e507a21..da507c13 100644 --- a/src/graphql/organizations/schema.graphql +++ b/src/graphql/organizations/schema.graphql @@ -152,10 +152,22 @@ input AddMemberInput { role: Role } -type AddMemberResponse { +enum AddMemberErrorCode { + ALREADY_MEMBER + USER_NOT_FOUND +} + +type AddMemberErrorResponse { + code: AddMemberErrorCode! + message: String! +} + +type AddMemberSuccessResponse { member: Member! } +union AddMemberResponse = AddMemberErrorResponse | AddMemberSuccessResponse + input RemoveMemberInput { id: ID! } diff --git a/src/graphql/organizations/schema.mappers.ts b/src/graphql/organizations/schema.mappers.ts index 0685d52e..0d43db5c 100644 --- a/src/graphql/organizations/schema.mappers.ts +++ b/src/graphql/organizations/schema.mappers.ts @@ -1,4 +1,17 @@ +import type { InvalidArgumentErrorV2, NotFoundError } from "~/domain/errors.js"; +import type { OrganizationMember } from "~/domain/organizations.js"; + export type { Organization as OrganizationMapper, OrganizationMember as MemberMapper, } from "~/domain/organizations.js"; + +type AddMemberErrorResponseMapper = { + error: InvalidArgumentErrorV2 | NotFoundError; +}; + +type AddMemberSuccessResponseMapper = { + member: OrganizationMember; +}; + +export type { AddMemberErrorResponseMapper, AddMemberSuccessResponseMapper }; diff --git a/src/lib/result.ts b/src/lib/result.ts index 36a72f76..fe8e99ad 100644 --- a/src/lib/result.ts +++ b/src/lib/result.ts @@ -17,7 +17,7 @@ type ResultAsync< TError extends Error, > = Promise>; -export type { TResult, ResultAsync }; +export type { TResult, ResultAsync, ErrorResult, SuccessResult }; export { Result }; const Result = {