Add tests for participation without account

Signed-off-by: Thomas Citharel <tcit@tcit.fr>
This commit is contained in:
Thomas Citharel 2020-12-07 14:48:48 +01:00
parent c0a367a014
commit d35ccff5a1
No known key found for this signature in database
GPG Key ID: A061B9DDE0CA0773
9 changed files with 562 additions and 22 deletions

View File

@ -81,33 +81,72 @@
{{ $t("Request for participation confirmation sent") }}
</h1>
<p class="content">
{{ $t("Check your inbox (and your junk mail folder).") }}
<span>{{
$t("Check your inbox (and your junk mail folder).")
}}</span>
<span
class="details"
v-if="event.joinOptions === EventJoinOptions.RESTRICTED"
>
{{
$t(
"Your participation will be validated once you click the confirmation link into the email, and after the organizer manually validates your participation."
)
}} </span
><span class="details" v-else>{{
$t(
"Your participation will be validated once you click the confirmation link into the email."
)
}}</span>
</p>
<b-message type="is-warning" v-if="error">{{ error }}</b-message>
<p class="content">
<i18n path="You may now close this window, or {return_to_event}.">
<router-link
slot="return_to_event"
:to="{ name: RouteName.EVENT, params: { uuid: event.uuid } }"
>{{ $t("return to the event's page") }}</router-link
>
</i18n>
</p>
<p class="content">{{ $t("You may now close this window.") }}</p>
</div>
</div>
</div>
<b-message type="is-danger" v-else-if="!$apollo.loading"
>{{
$t(
"Unable to load event for participation. The error details are provided below:"
)
}}
<details>
<pre>{{ error }}</pre>
</details>
</b-message>
</section>
</template>
<script lang="ts">
import { Component, Prop, Vue } from "vue-property-decorator";
import { EventModel, IEvent } from "@/types/event.model";
import { FETCH_EVENT, JOIN_EVENT } from "@/graphql/event";
import { FETCH_EVENT_BASIC, JOIN_EVENT } from "@/graphql/event";
import { IConfig } from "@/types/config.model";
import { CONFIG } from "@/graphql/config";
import { addLocalUnconfirmedAnonymousParticipation } from "@/services/AnonymousParticipationStorage";
import { EventJoinOptions, ParticipantRole } from "@/types/enums";
import RouteName from "@/router/name";
import { IParticipant } from "../../types/participant.model";
@Component({
apollo: {
event: {
query: FETCH_EVENT,
query: FETCH_EVENT_BASIC,
variables() {
return {
uuid: this.uuid,
};
},
error(e) {
this.error = e;
},
skip() {
return !this.uuid;
},
@ -141,6 +180,8 @@ export default class ParticipationWithoutAccount extends Vue {
EventJoinOptions = EventJoinOptions;
RouteName = RouteName;
async joinEvent(): Promise<void> {
this.error = false;
this.sendingForm = true;
@ -163,7 +204,7 @@ export default class ParticipationWithoutAccount extends Vue {
}
const cachedData = store.readQuery<{ event: IEvent }>({
query: FETCH_EVENT,
query: FETCH_EVENT_BASIC,
variables: { uuid: this.event.uuid },
});
if (cachedData == null) {
@ -186,16 +227,15 @@ export default class ParticipationWithoutAccount extends Vue {
event.participantStats.going += 1;
event.participantStats.participant += 1;
}
console.log("just before writequery");
store.writeQuery({
query: FETCH_EVENT,
query: FETCH_EVENT_BASIC,
variables: { uuid: this.event.uuid },
data: { event },
});
},
});
console.log("finished with store", data);
this.formSent = true;
if (
data &&
data.joinEvent.metadata.cancellationToken &&
@ -205,13 +245,28 @@ export default class ParticipationWithoutAccount extends Vue {
this.event,
data.joinEvent.metadata.cancellationToken
);
console.log("done with crypto stuff");
}
} catch (e) {
this.error = e.message;
if (
["TextEncoder is not defined", "crypto.subtle is undefined"].includes(
e.message
)
) {
this.error = this.$t(
"Unable to save your participation in this browser."
) as string;
} else if (e.graphQLErrors && e.graphQLErrors.length > 0) {
this.error = e.graphQLErrors[0].message;
} else if (e.networkError) {
this.error = e.networkError.message;
}
}
this.sendingForm = false;
this.formSent = true;
}
}
</script>
<style lang="scss" scoped>
section.container.section {
background: $white;
}
</style>

View File

@ -176,6 +176,22 @@ export const FETCH_EVENT = gql`
}
`;
export const FETCH_EVENT_BASIC = gql`
query($uuid: UUID!) {
event(uuid: $uuid) {
id
uuid
joinOptions
participantStats {
going
notApproved
notConfirmed
participant
}
}
}
`;
export const FETCH_EVENTS = gql`
query {
events {

View File

@ -24,7 +24,6 @@
"Anonymous participant": "Anonymous participant",
"Anonymous participants will be asked to confirm their participation through e-mail.": "Anonymous participants will be asked to confirm their participation through e-mail.",
"Anonymous participations": "Anonymous participations",
"Approve": "Approve",
"Are you really sure you want to delete your whole account? You'll lose everything. Identities, settings, events created, messages and participations will be gone forever.": "Are you really sure you want to delete your whole account? You'll lose everything. Identities, settings, events created, messages and participations will be gone forever.",
"Are you sure you want to <b>delete</b> this comment? This action cannot be undone.": "Are you sure you want to <b>delete</b> this comment? This action cannot be undone.",
"Are you sure you want to <b>delete</b> this event? This action cannot be undone. You may want to engage the discussion with the event creator or edit its event instead.": "Are you sure you want to <b>delete</b> this event? This action cannot be undone. You may want to engage the discussion with the event creator or edit its event instead.",
@ -70,7 +69,6 @@
"Create my profile": "Create my profile",
"Create token": "Create token",
"Create": "Create",
"Creator": "Creator",
"Current identity has been changed to {identityName} in order to manage this event.": "Current identity has been changed to {identityName} in order to manage this event.",
"Current page": "Current page",
"Custom URL": "Custom URL",
@ -126,7 +124,6 @@
"Event": "Event",
"Events": "Events",
"Ex: mobilizon.fr": "Ex: mobilizon.fr",
"Exclude": "Exclude",
"Explore": "Explore",
"Failed to save admin settings": "Failed to save admin settings",
"Featured events": "Featured events",
@ -145,7 +142,6 @@
"General": "General",
"Getting location": "Getting location",
"Go": "Go",
"Going as {name}": "Going as {name}",
"Group name": "Group name",
"Group {displayName} created": "Group {displayName} created",
"Groups": "Groups",
@ -280,7 +276,6 @@
"Registration is allowed, anyone can register.": "Registration is allowed, anyone can register.",
"Registration is closed.": "Registration is closed.",
"Registration is currently closed.": "Registration is currently closed.",
"Reject": "Reject",
"Rejected": "Rejected",
"Reopen": "Reopen",
"Reply": "Reply",
@ -749,7 +744,7 @@
"Go to the event page": "Go to the event page",
"Request for participation confirmation sent": "Request for participation confirmation sent",
"Check your inbox (and your junk mail folder).": "Check your inbox (and your junk mail folder).",
"You may now close this window.": "You may now close this window.",
"You may now close this window, or {return_to_event}.": "You may now close this window, or {return_to_event}.",
"Create an account": "Create an account",
"You are not an administrator for this group.": "You are not an administrator for this group.",
"Why create an account?": "Why create an account?",
@ -810,5 +805,10 @@
"Your participation status is saved only on this device and will be deleted one month after the event's passed.": "Your participation status is saved only on this device and will be deleted one month after the event's passed.",
"You may clear all participation information for this device with the buttons below.": "You may clear all participation information for this device with the buttons below.",
"Clear participation data for this event": "Clear participation data for this event",
"Clear participation data for all events": "Clear participation data for all events"
"Clear participation data for all events": "Clear participation data for all events",
"Your participation will be validated once you click the confirmation link into the email, and after the organizer manually validates your participation.": "Your participation will be validated once you click the confirmation link into the email, and after the organizer manually validates your participation.",
"Your participation will be validated once you click the confirmation link into the email.": "Your participation will be validated once you click the confirmation link into the email.",
"Unable to load event for participation. The error details are provided below:": "Unable to load event for participation. The error details are provided below:",
"Unable to save your participation in this browser.": "Unable to save your participation in this browser.",
"return to the event's page": "return to the event's page"
}

View File

@ -794,7 +794,6 @@
"You have one event today.": "Vous n'avez pas d'évenement aujourd'hui | Vous avez un évènement aujourd'hui. | Vous avez {count} évènements aujourd'hui",
"You have one event tomorrow.": "Vous n'avez pas d'évènement demain | Vous avez un évènement demain. | Vous avez {count} évènements demain",
"You may also ask to {resend_confirmation_email}.": "Vous pouvez aussi demander à {resend_confirmation_email}.",
"You may now close this window.": "Vous pouvez maintenant fermer cette fenêtre.",
"You need to create the group before you create an event.": "Vous devez créer le groupe avant de créer l'événement.",
"You need to login.": "Vous devez vous connecter.",
"You will be able to add an avatar and set other options in your account settings.": "Vous pourrez ajouter un avatar et définir d'autres options dans les paramètres de votre compte.",
@ -898,5 +897,11 @@
"Your participation status is saved only on this device and will be deleted one month after the event's passed.": "Le statut de votre participation est enregistré uniquement sur cet appareil et sera supprimé un mois après la fin de l'événement.",
"You may clear all participation information for this device with the buttons below.": "Vous pouvez effacer toutes les informations de participation pour cet appareil avec les boutons ci-dessous.",
"Clear participation data for this event": "Effacer mes données de participation pour cet événement",
"Clear participation data for all events": "Effacer mes données de participation pour tous les événements"
"Clear participation data for all events": "Effacer mes données de participation pour tous les événements",
"Your participation will be validated once you click the confirmation link into the email, and after the organizer manually validates your participation.": "Votre participation sera validée une fois que vous aurez cliqué sur le lien de confirmation contenu dans le courriel, et après que l'organisateur·ice valide votre participation.",
"Your participation will be validated once you click the confirmation link into the email.": "Votre participation sera validée une fois que vous aurez cliqué sur le lien de confirmation contenu dans le courriel.",
"Unable to load event for participation. The error details are provided below:": "Impossible de charger l'événement pour la participation. Les détails de l'erreur sont disponibles ci-dessous :",
"Unable to save your participation in this browser.": "Échec de la sauvegarde de votre participation dans ce navigateur.",
"return to the event's page": "retourner sur la page de l'événement",
"You may now close this window, or {return_to_event}.": "Vous pouvez maintenant fermer cette fenêtre, ou bien {return_to_event}."
}

View File

@ -102,7 +102,9 @@ describe("CommentTree", () => {
await wrapper.vm.$nextTick(); // because of the <transition>
expect(wrapper.exists()).toBe(true);
expect(requestHandlers.eventCommentThreadsQueryHandler).toHaveBeenCalled();
expect(
requestHandlers.eventCommentThreadsQueryHandler
).toHaveBeenCalledWith({ eventUUID: eventData.uuid });
expect(wrapper.vm.$apollo.queries.comments).toBeTruthy();
expect(wrapper.find(".loading").exists()).toBe(false);
expect(wrapper.findAll(".comment-list .root-comment").length).toBe(2);

View File

@ -0,0 +1,278 @@
import { config, createLocalVue, mount, Wrapper } from "@vue/test-utils";
import ParticipationWithoutAccount from "@/components/Participation/ParticipationWithoutAccount.vue";
import Buefy from "buefy";
import VueRouter from "vue-router";
import { routes } from "@/router";
import {
CommentModeration,
EventJoinOptions,
ParticipantRole,
} from "@/types/enums";
import {
createMockClient,
MockApolloClient,
RequestHandler,
} from "mock-apollo-client";
import buildCurrentUserResolver from "@/apollo/user";
import { InMemoryCache } from "apollo-cache-inmemory";
import { CONFIG } from "@/graphql/config";
import VueApollo from "vue-apollo";
import { FETCH_EVENT_BASIC, JOIN_EVENT } from "@/graphql/event";
import { IEvent } from "@/types/event.model";
import { i18n } from "@/utils/i18n";
import { configMock } from "../../mocks/config";
import {
fetchEventBasicMock,
joinEventMock,
joinEventResponseMock,
} from "../../mocks/event";
const localVue = createLocalVue();
localVue.use(Buefy);
localVue.use(VueRouter);
const router = new VueRouter({ routes, mode: "history" });
config.mocks.$t = (key: string): string => key;
const eventData = {
id: "1",
uuid: "f37910ea-fd5a-4756-9679-00971f3f4106",
options: {
commentModeration: CommentModeration.ALLOW_ALL,
},
joinOptions: EventJoinOptions.FREE,
beginsOn: new Date("2089-12-04T09:21:25Z"),
endsOn: new Date("2089-12-04T11:21:25Z"),
participantStats: {
notApproved: 0,
notConfirmed: 0,
rejected: 0,
participant: 0,
creator: 1,
moderator: 0,
administrator: 0,
going: 1,
},
};
describe("ParticipationWithoutAccount", () => {
let wrapper: Wrapper<Vue>;
let mockClient: MockApolloClient;
let apolloProvider;
let requestHandlers: Record<string, RequestHandler>;
const generateWrapper = (
handlers: Record<string, unknown> = {},
customProps: Record<string, unknown> = {},
baseData: Record<string, unknown> = {}
) => {
const cache = new InMemoryCache({ addTypename: false });
mockClient = createMockClient({
cache,
resolvers: buildCurrentUserResolver(cache),
});
requestHandlers = {
configQueryHandler: jest.fn().mockResolvedValue(configMock),
fetchEventQueryHandler: jest.fn().mockResolvedValue(fetchEventBasicMock),
joinEventMutationHandler: jest
.fn()
.mockResolvedValue(joinEventResponseMock),
...handlers,
};
mockClient.setRequestHandler(CONFIG, requestHandlers.configQueryHandler);
mockClient.setRequestHandler(
FETCH_EVENT_BASIC,
requestHandlers.fetchEventQueryHandler
);
mockClient.setRequestHandler(
JOIN_EVENT,
requestHandlers.joinEventMutationHandler
);
apolloProvider = new VueApollo({
defaultClient: mockClient,
});
wrapper = mount(ParticipationWithoutAccount, {
localVue,
router,
i18n,
apolloProvider,
propsData: {
uuid: eventData.uuid,
...customProps,
},
data() {
return {
...baseData,
};
},
});
};
it("renders the participation without account view with minimal data", async () => {
generateWrapper();
await wrapper.vm.$nextTick();
await wrapper.vm.$nextTick();
expect(wrapper.exists()).toBe(true);
expect(requestHandlers.configQueryHandler).toHaveBeenCalled();
expect(wrapper.vm.$apollo.queries.config).toBeTruthy();
expect(requestHandlers.fetchEventQueryHandler).toHaveBeenCalledWith({
uuid: eventData.uuid,
});
expect(wrapper.vm.$apollo.queries.event).toBeTruthy();
expect(wrapper.find(".hero-body .container").isVisible()).toBeTruthy();
expect(wrapper.find("article.message.is-info").text()).toBe(
"Your email will only be used to confirm that you're a real person and send you eventual updates for this event. It will NOT be transmitted to other instances or to the event organizer."
);
wrapper.find('input[type="email"]').setValue("some@email.tld");
wrapper.find("textarea").setValue("a message long enough");
wrapper.find("form").trigger("submit");
await wrapper.vm.$nextTick();
expect(requestHandlers.joinEventMutationHandler).toHaveBeenCalledWith({
...joinEventMock,
});
const cachedData = mockClient.cache.readQuery<{ event: IEvent }>({
query: FETCH_EVENT_BASIC,
variables: {
uuid: eventData.uuid,
},
});
if (cachedData) {
const { event } = cachedData;
expect(event.participantStats.going).toBe(
eventData.participantStats.going + 1
);
expect(event.participantStats.participant).toBe(
eventData.participantStats.participant + 1
);
}
// lots of things to await
await wrapper.vm.$nextTick();
await wrapper.vm.$nextTick();
await wrapper.vm.$nextTick();
await wrapper.vm.$nextTick();
expect(wrapper.find("form").exists()).toBeFalsy();
expect(wrapper.find("h1.title").text()).toBe(
"Request for participation confirmation sent"
);
// TextEncoder is not in js-dom
expect(
wrapper.find("article.message.is-warning .media-content").text()
).toBe("Unable to save your participation in this browser.");
expect(wrapper.find("span.details").text()).toBe(
"Your participation will be validated once you click the confirmation link into the email."
);
expect(wrapper.html()).toMatchSnapshot();
});
it("renders the warning if the event participation is restricted", async () => {
generateWrapper({
fetchEventQueryHandler: jest.fn().mockResolvedValue({
data: {
event: {
...fetchEventBasicMock.data.event,
joinOptions: EventJoinOptions.RESTRICTED,
},
},
}),
joinEventMutationHandler: jest.fn().mockResolvedValue({
data: {
joinEvent: {
...joinEventResponseMock.data.joinEvent,
role: ParticipantRole.NOT_CONFIRMED,
},
},
}),
});
await wrapper.vm.$nextTick();
await wrapper.vm.$nextTick();
expect(wrapper.vm.$data.event.joinOptions).toBe(
EventJoinOptions.RESTRICTED
);
expect(wrapper.find(".hero-body .container").text()).toContain(
"The event organizer manually approves participations. Since you've chosen to participate without an account, please explain why you want to participate to this event."
);
expect(wrapper.find(".hero-body .container").text()).not.toContain(
"If you want, you may send a message to the event organizer here."
);
wrapper.find('input[type="email"]').setValue("some@email.tld");
wrapper.find("textarea").setValue("a message long enough");
wrapper.find("form").trigger("submit");
await wrapper.vm.$nextTick();
expect(requestHandlers.joinEventMutationHandler).toHaveBeenCalledWith({
...joinEventMock,
});
const cachedData = mockClient.cache.readQuery<{ event: IEvent }>({
query: FETCH_EVENT_BASIC,
variables: {
uuid: eventData.uuid,
},
});
if (cachedData) {
const { event } = cachedData;
expect(event.participantStats.notConfirmed).toBe(
eventData.participantStats.notConfirmed + 1
);
}
// lots of things to await
await wrapper.vm.$nextTick();
await wrapper.vm.$nextTick();
await wrapper.vm.$nextTick();
await wrapper.vm.$nextTick();
expect(wrapper.find("form").exists()).toBeFalsy();
expect(wrapper.find("h1.title").text()).toBe(
"Request for participation confirmation sent"
);
expect(wrapper.find("span.details").text()).toBe(
"Your participation will be validated once you click the confirmation link into the email, and after the organizer manually validates your participation."
);
expect(wrapper.html()).toMatchSnapshot();
});
it("handles being already a participant", async () => {
generateWrapper({
joinEventMutationHandler: jest
.fn()
.mockRejectedValue(
new Error("You are already a participant of this event")
),
});
await wrapper.vm.$nextTick();
await wrapper.vm.$nextTick();
wrapper.find('input[type="email"]').setValue("some@email.tld");
wrapper.find("textarea").setValue("a message long enough");
wrapper.find("form").trigger("submit");
await wrapper.vm.$nextTick();
expect(requestHandlers.joinEventMutationHandler).toHaveBeenCalledWith({
...joinEventMock,
});
await wrapper.vm.$nextTick();
await wrapper.vm.$nextTick();
expect(wrapper.find("form").exists()).toBeTruthy();
expect(
wrapper.find("article.message.is-danger .media-content").text()
).toContain("You are already a participant of this event");
expect(wrapper.html()).toMatchSnapshot();
});
});

View File

@ -0,0 +1,126 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ParticipationWithoutAccount handles being already a participant 1`] = `
<section class="container section hero is-fullheight">
<div class="hero-body">
<div class="container">
<form>
<p>
This Mobilizon instance and this event organizer allows anonymous participations, but requires validation through email confirmation.
</p>
<transition-stub name="fade">
<article class="message is-info">
<!---->
<section class="message-body">
<div class="media">
<!---->
<div class="media-content">
Your email will only be used to confirm that you're a real person and send you eventual updates for this event. It will NOT be transmitted to other instances or to the event organizer.
</div>
</div>
</section>
</article>
</transition-stub>
<transition-stub name="fade">
<article class="message is-danger">
<!---->
<section class="message-body">
<div class="media">
<!---->
<div class="media-content">You are already a participant of this event</div>
</div>
</section>
</article>
</transition-stub>
<div class="field"><label class="label">Email address</label>
<div class="control is-clearfix"><input type="email" autocomplete="on" placeholder="Your email" required="required" class="input">
<!---->
<!---->
<!---->
</div>
<!---->
</div>
<p>
If you want, you may send a message to the event organizer here.
</p>
<div class="field"><label class="label">Message</label>
<div class="control is-clearfix"><textarea minlength="10" class="textarea"></textarea>
<!---->
<!---->
<!---->
</div>
<!---->
</div>
<div class="field">
<!----><label class="b-checkbox checkbox"><input type="checkbox" true-value="true" value="false"><span class="check"></span><span class="control-label"><b>Remember my participation in this browser</b> <p>
Will allow to display and manage your participation status on the event page when using this device. Uncheck if you're using a public device.
</p></span></label>
<!---->
</div> <button type="submit" class="button is-primary">
<!----><span>Send email</span>
<!---->
</button>
<div class="has-text-centered"><a type="button" class="button is-text">
<!----><span>Back to previous page</span>
<!---->
</a></div>
</form>
</div>
</div>
</section>
`;
exports[`ParticipationWithoutAccount renders the participation without account view with minimal data 1`] = `
<section class="container section hero is-fullheight">
<div class="hero-body">
<div class="container">
<div>
<h1 class="title">
Request for participation confirmation sent
</h1>
<p class="content"><span>Check your inbox (and your junk mail folder).</span> <span class="details">Your participation will be validated once you click the confirmation link into the email.</span></p>
<transition-stub name="fade">
<article class="message is-warning">
<!---->
<section class="message-body">
<div class="media">
<!---->
<div class="media-content">Unable to save your participation in this browser.</div>
</div>
</section>
</article>
</transition-stub>
<p class="content"><span>You may now close this window, or <a href="/events/f37910ea-fd5a-4756-9679-00971f3f4106" class="">return to the event's page</a>.</span></p>
</div>
</div>
</div>
</section>
`;
exports[`ParticipationWithoutAccount renders the warning if the event participation is restricted 1`] = `
<section class="container section hero is-fullheight">
<div class="hero-body">
<div class="container">
<div>
<h1 class="title">
Request for participation confirmation sent
</h1>
<p class="content"><span>Check your inbox (and your junk mail folder).</span> <span class="details">
Your participation will be validated once you click the confirmation link into the email, and after the organizer manually validates your participation. </span></p>
<transition-stub name="fade">
<article class="message is-warning">
<!---->
<section class="message-body">
<div class="media">
<!---->
<div class="media-content">Unable to save your participation in this browser.</div>
</div>
</section>
</article>
</transition-stub>
<p class="content"><span>You may now close this window, or <a href="/events/f37910ea-fd5a-4756-9679-00971f3f4106" class="">return to the event's page</a>.</span></p>
</div>
</div>
</div>
</section>
`;

View File

@ -1,3 +1,59 @@
import { EventJoinOptions, ParticipantRole } from "@/types/enums";
type DataMock = {
data: Record<string, unknown>;
};
export const fetchEventBasicMock = {
data: {
event: {
__typename: "Event",
id: "1",
uuid: "f37910ea-fd5a-4756-9679-00971f3f4106",
joinOptions: EventJoinOptions.FREE,
participantStats: {
notApproved: 0,
notConfirmed: 0,
rejected: 0,
participant: 0,
creator: 1,
moderator: 0,
administrator: 0,
going: 1,
},
},
},
};
export const joinEventResponseMock = {
data: {
joinEvent: {
id: "5",
role: ParticipantRole.NOT_APPROVED,
insertedAt: "2020-12-07T09:33:41Z",
metadata: {
cancellationToken: "some token",
message: "a message long enough",
},
event: {
id: "1",
uuid: "f37910ea-fd5a-4756-9679-00971f3f4106",
},
actor: {
id: "1",
},
},
},
};
export const joinEventMock = {
eventId: "1",
actorId: "1",
email: "some@email.tld",
message: "a message long enough",
locale: "en_US",
};
export const eventCommentThreadsMock = {
data: {
event: {
@ -64,7 +120,7 @@ export const newCommentForEventMock = {
inReplyToCommentId: null,
};
export const newCommentForEventResponse = {
export const newCommentForEventResponse: DataMock = {
data: {
createComment: {
id: "79",

View File

@ -100,6 +100,8 @@ defmodule Mobilizon.Service.Workers.Notification do
user
|> Notification.pending_participation_notification(event, total)
|> Mailer.deliver_later()
:ok
else
err ->
require Logger