Skip to content

Commit

Permalink
♻️ refactor: use union response for success and error
Browse files Browse the repository at this point in the history
  • Loading branch information
larwaa committed Mar 15, 2024
1 parent ee91959 commit fd871da
Show file tree
Hide file tree
Showing 12 changed files with 233 additions and 49 deletions.
4 changes: 4 additions & 0 deletions codegen.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 */
Expand Down
14 changes: 13 additions & 1 deletion schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -19,7 +29,9 @@ input AddMemberInput {
userId: ID
}

type AddMemberResponse {
union AddMemberResponse = AddMemberErrorResponse | AddMemberSuccessResponse

type AddMemberSuccessResponse {
member: Member!
}

Expand Down
18 changes: 15 additions & 3 deletions src/domain/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,17 +198,27 @@ const DomainErrorType = {

type ErrorType = (typeof DomainErrorType)[keyof typeof DomainErrorType];

class DomainError<TErrorType extends ErrorType> 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);
}

Expand All @@ -223,7 +233,8 @@ class DomainError<TErrorType extends ErrorType> extends Error {
}

class InvalidArgumentErrorV2 extends DomainError<
typeof DomainErrorType.InvalidArgumentError
typeof DomainErrorType.InvalidArgumentError,
"InvalidArgumentError"
> {
public reason: Record<string, string[] | undefined> | undefined;

Expand All @@ -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;
Expand Down
44 changes: 24 additions & 20 deletions src/graphql/organizations/__tests__/unit/organizations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
}
}
}
}
`),
Expand Down Expand Up @@ -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
}
}
}
}
}
}
}
}
}
`),
},
{
Expand Down
19 changes: 19 additions & 0 deletions src/graphql/organizations/resolvers/AddMemberErrorResponse.ts
Original file line number Diff line number Diff line change
@@ -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";
}
},
};
4 changes: 0 additions & 4 deletions src/graphql/organizations/resolvers/AddMemberResponse.ts

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
import type { AddMemberSuccessResponseResolvers } from "./../../types.generated.js";
export const AddMemberSuccessResponse: AddMemberSuccessResponseResolvers = {};
22 changes: 19 additions & 3 deletions src/graphql/organizations/resolvers/Mutation/addMember.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,23 @@ export const addMember: NonNullable<MutationResolvers["addMember"]> = 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;
}
}
};
126 changes: 110 additions & 16 deletions src/graphql/organizations/resolvers/Mutation/addMember.unit.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -14,9 +20,11 @@ describe("Organization mutations", () => {
mutation: graphql(`
mutation AddMember($data: AddMemberInput!) {
addMember(data: $data) {
member {
id
}
... on AddMemberSuccessResponse {
member {
id
}
}
}
}
`),
Expand All @@ -42,9 +50,11 @@ describe("Organization mutations", () => {
mutation: graphql(`
mutation AddMember($data: AddMemberInput!) {
addMember(data: $data) {
member {
id
}
... on AddMemberSuccessResponse {
member {
id
}
}
}
}
`),
Expand Down Expand Up @@ -73,9 +83,11 @@ describe("Organization mutations", () => {
mutation: graphql(`
mutation AddMember($data: AddMemberInput!) {
addMember(data: $data) {
member {
id
}
... on AddMemberSuccessResponse {
member {
id
}
}
}
}
`),
Expand All @@ -96,21 +108,23 @@ 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();
const { errors } = await client.mutate({
mutation: graphql(`
mutation AddMember($data: AddMemberInput!) {
addMember(data: $data) {
member {
id
}
... on AddMemberSuccessResponse {
member {
id
}
}
}
}
`),
Expand All @@ -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");
});
});
});
Loading

0 comments on commit fd871da

Please sign in to comment.