Skip to content

Commit

Permalink
Make room to verify email addresses
Browse files Browse the repository at this point in the history
Refs #1307
  • Loading branch information
thewilkybarkid committed Oct 12, 2023
1 parent 2eb9f07 commit c6cab07
Show file tree
Hide file tree
Showing 8 changed files with 124 additions and 50 deletions.
29 changes: 25 additions & 4 deletions src/contact-email-address.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,40 @@
import * as RTE from 'fp-ts/ReaderTaskEither'
import type * as TE from 'fp-ts/TaskEither'
import { flow } from 'fp-ts/function'
import * as C from 'io-ts/Codec'
import type { Orcid } from 'orcid-id-ts'
import { match } from 'ts-pattern'
import type { EmailAddress } from './types/email-address'
import { type EmailAddress, EmailAddressC } from './types/email-address'

export type ContactEmailAddress = VerifiedContactEmailAddress | UnverifiedContactEmailAddress

export interface VerifiedContactEmailAddress {
readonly type: 'verified'
readonly value: EmailAddress
}

export interface UnverifiedContactEmailAddress {
readonly type: 'unverified'
readonly value: EmailAddress
}

export interface GetContactEmailAddressEnv {
getContactEmailAddress: (orcid: Orcid) => TE.TaskEither<'not-found' | 'unavailable', EmailAddress>
getContactEmailAddress: (orcid: Orcid) => TE.TaskEither<'not-found' | 'unavailable', ContactEmailAddress>
}

export interface EditContactEmailAddressEnv extends GetContactEmailAddressEnv {
deleteContactEmailAddress: (orcid: Orcid) => TE.TaskEither<'unavailable', void>
saveContactEmailAddress: (orcid: Orcid, ContactEmailAddress: EmailAddress) => TE.TaskEither<'unavailable', void>
saveContactEmailAddress: (
orcid: Orcid,
ContactEmailAddress: ContactEmailAddress,
) => TE.TaskEither<'unavailable', void>
}

export const ContactEmailAddressC = C.struct({
type: C.literal('verified', 'unverified'),
value: EmailAddressC,
}) satisfies C.Codec<unknown, unknown, ContactEmailAddress>

export const getContactEmailAddress = (orcid: Orcid) =>
RTE.asksReaderTaskEither(
RTE.fromTaskEitherK(({ getContactEmailAddress }: GetContactEmailAddressEnv) => getContactEmailAddress(orcid)),
Expand All @@ -38,7 +59,7 @@ export const deleteContactEmailAddress = (orcid: Orcid) =>

export const saveContactEmailAddress = (
orcid: Orcid,
emailAddress: EmailAddress,
emailAddress: ContactEmailAddress,
): RTE.ReaderTaskEither<EditContactEmailAddressEnv, 'unavailable', void> =>
RTE.asksReaderTaskEither(
RTE.fromTaskEitherK(({ saveContactEmailAddress }) => saveContactEmailAddress(orcid, emailAddress)),
Expand Down
14 changes: 12 additions & 2 deletions src/keyv.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type { Encoder } from 'io-ts/Encoder'
import type Keyv from 'keyv'
import type { Orcid } from 'orcid-id-ts'
import { type CareerStage, CareerStageC } from './career-stage'
import { ContactEmailAddressC, type UnverifiedContactEmailAddress } from './contact-email-address'
import { IsOpenForRequestsC } from './is-open-for-requests'
import { LanguagesC } from './languages'
import { LocationC } from './location'
Expand Down Expand Up @@ -195,11 +196,20 @@ export const deleteContactEmailAddress = flow(
)

export const getContactEmailAddress = flow(
getKey(OrcidE, EmailAddressC),
getKey(
OrcidE,
D.union(
ContactEmailAddressC,
pipe(
EmailAddressC,
D.map(value => ({ type: 'unverified', value }) satisfies UnverifiedContactEmailAddress),
),
),
),
RTE.local((env: ContactEmailAddressStoreEnv) => env.contactEmailAddressStore),
)

export const saveContactEmailAddress = flow(
setKey(OrcidE, EmailAddressC),
setKey(OrcidE, ContactEmailAddressC),
RTE.local((env: ContactEmailAddressStoreEnv) => env.contactEmailAddressStore),
)
6 changes: 4 additions & 2 deletions src/my-details-page/change-contact-email-address.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,9 @@ const showChangeContactEmailAddressForm = (user: User) =>
pipe(
RM.fromReaderTaskEither(getContactEmailAddress(user.orcid)),
RM.orElseW(() => RM.of(undefined)),
RM.chainReaderKW(contactEmailAddress => createFormPage(user, { emailAddress: E.right(contactEmailAddress) })),
RM.chainReaderKW(contactEmailAddress =>
createFormPage(user, { emailAddress: E.right(contactEmailAddress?.value) }),
),
RM.ichainFirst(() => RM.status(Status.OK)),
RM.ichainMiddlewareK(sendHtml),
)
Expand Down Expand Up @@ -108,7 +110,7 @@ const handleChangeContactEmailAddressForm = (user: User) =>
match(emailAddress)
.with(P.string, emailAddress =>
pipe(
RM.fromReaderTaskEither(saveContactEmailAddress(user.orcid, emailAddress)),
RM.fromReaderTaskEither(saveContactEmailAddress(user.orcid, { type: 'unverified', value: emailAddress })),
RM.ichainMiddlewareK(() => seeOther(format(myDetailsMatch.formatter, {}))),
RM.orElseW(() => serviceUnavailable),
),
Expand Down
7 changes: 3 additions & 4 deletions src/my-details-page/my-details.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import * as RM from 'hyper-ts/ReaderMiddleware'
import * as D from 'io-ts/Decoder'
import { P, match } from 'ts-pattern'
import { type CareerStage, maybeGetCareerStage } from '../career-stage'
import { maybeGetContactEmailAddress } from '../contact-email-address'
import { type ContactEmailAddress, maybeGetContactEmailAddress } from '../contact-email-address'
import { canChangeContactEmailAddress, canConnectSlack } from '../feature-flags'
import { deleteFlashMessage, getFlashMessage } from '../flash-message'
import { html, plainText, sendHtml } from '../html'
Expand Down Expand Up @@ -39,7 +39,6 @@ import {
profileMatch,
} from '../routes'
import { type SlackUser, maybeGetSlackUser } from '../slack-user'
import type { EmailAddress } from '../types/email-address'
import { type GetUserEnv, type User, getUser } from '../user'

export type Env = EnvFor<typeof myDetails>
Expand Down Expand Up @@ -104,7 +103,7 @@ function createPage({
canConnectSlack: boolean
slackUser: O.Option<SlackUser>
canChangeContactEmailAddress: boolean
contactEmailAddress: O.Option<EmailAddress>
contactEmailAddress: O.Option<ContactEmailAddress>
openForRequests: O.Option<IsOpenForRequests>
careerStage: O.Option<CareerStage>
researchInterests: O.Option<ResearchInterests>
Expand Down Expand Up @@ -226,7 +225,7 @@ function createPage({
contactEmailAddress => html`
<div>
<dt>Email address</dt>
<dd>${contactEmailAddress}</dd>
<dd>${contactEmailAddress.value}</dd>
<dd>
<a href="${format(changeContactEmailAddressMatch.formatter, {})}"
>Change <span class="visually-hidden">email address</span></a
Expand Down
20 changes: 20 additions & 0 deletions test/fc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ import {
import { type Orcid, isOrcid } from 'orcid-id-ts'
import { type Uuid, isUuid } from 'uuid-ts'
import type { CareerStage } from '../src/career-stage'
import type {
ContactEmailAddress,
UnverifiedContactEmailAddress,
VerifiedContactEmailAddress,
} from '../src/contact-email-address'
import type { CrossrefPreprintId } from '../src/crossref'
import type { DatacitePreprintId } from '../src/datacite'
import { type Html, sanitizeHtml, html as toHtml } from '../src/html'
Expand Down Expand Up @@ -168,6 +173,21 @@ export const uuid = (): fc.Arbitrary<Uuid> => fc.uuid().filter(isUuid)

export const emailAddress = (): fc.Arbitrary<EmailAddress> => fc.emailAddress() as fc.Arbitrary<EmailAddress>

export const contactEmailAddress = (): fc.Arbitrary<ContactEmailAddress> =>
fc.oneof(unverifiedContactEmailAddress(), verifiedContactEmailAddress())

export const unverifiedContactEmailAddress = (): fc.Arbitrary<UnverifiedContactEmailAddress> =>
fc.record({
type: fc.constant('unverified'),
value: emailAddress(),
})

export const verifiedContactEmailAddress = (): fc.Arbitrary<VerifiedContactEmailAddress> =>
fc.record({
type: fc.constant('verified'),
value: emailAddress(),
})

export const error = (): fc.Arbitrary<Error> => fc.string().map(error => new Error(error))

export const cookieName = (): fc.Arbitrary<string> => fc.lorem({ maxCount: 1 })
Expand Down
72 changes: 47 additions & 25 deletions test/keyv.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { describe, expect } from '@jest/globals'
import * as E from 'fp-ts/Either'
import Keyv from 'keyv'
import { get } from 'spectacles-ts'
import { ContactEmailAddressC } from '../src/contact-email-address'
import * as _ from '../src/keyv'
import { SlackUserIdC } from '../src/slack-user-id'
import { EmailAddressC } from '../src/types/email-address'
Expand Down Expand Up @@ -800,15 +801,18 @@ describe('saveLanguages', () => {
})

describe('deleteContactEmailAddress', () => {
test.prop([fc.orcid(), fc.emailAddress()])('when the key contains an email address', async (orcid, emailAddress) => {
const store = new Keyv()
await store.set(orcid, EmailAddressC.encode(emailAddress))
test.prop([fc.orcid(), fc.contactEmailAddress()])(
'when the key contains an email address',
async (orcid, emailAddress) => {
const store = new Keyv()
await store.set(orcid, ContactEmailAddressC.encode(emailAddress))

const actual = await _.deleteContactEmailAddress(orcid)({ contactEmailAddressStore: store })()
const actual = await _.deleteContactEmailAddress(orcid)({ contactEmailAddressStore: store })()

expect(actual).toStrictEqual(E.right(undefined))
expect(await store.has(orcid)).toBeFalsy()
})
expect(actual).toStrictEqual(E.right(undefined))
expect(await store.has(orcid)).toBeFalsy()
},
)

test.prop([fc.orcid(), fc.anything()])(
'when the key contains something other than an email address',
Expand Down Expand Up @@ -843,14 +847,29 @@ describe('deleteContactEmailAddress', () => {
})

describe('getContactEmailAddress', () => {
test.prop([fc.orcid(), fc.emailAddress()])('when the key contains an email address', async (orcid, emailAddress) => {
const store = new Keyv()
await store.set(orcid, EmailAddressC.encode(emailAddress))
test.prop([fc.orcid(), fc.contactEmailAddress()])(
'when the key contains an email address',
async (orcid, emailAddress) => {
const store = new Keyv()
await store.set(orcid, ContactEmailAddressC.encode(emailAddress))

const actual = await _.getContactEmailAddress(orcid)({ contactEmailAddressStore: store })()
const actual = await _.getContactEmailAddress(orcid)({ contactEmailAddressStore: store })()

expect(actual).toStrictEqual(E.right(emailAddress))
})
expect(actual).toStrictEqual(E.right(emailAddress))
},
)

test.prop([fc.orcid(), fc.emailAddress()])(
'when the key contains a plain email address',
async (orcid, emailAddress) => {
const store = new Keyv()
await store.set(orcid, EmailAddressC.encode(emailAddress))

const actual = await _.getContactEmailAddress(orcid)({ contactEmailAddressStore: store })()

expect(actual).toStrictEqual(E.right({ type: 'unverified', value: emailAddress }))
},
)

test.prop([fc.orcid(), fc.anything()])(
'when the key contains something other than an email address',
Expand Down Expand Up @@ -883,17 +902,20 @@ describe('getContactEmailAddress', () => {
})

describe('saveContactEmailAddress', () => {
test.prop([fc.orcid(), fc.emailAddress()])('when the key contains an email address', async (orcid, emailAddress) => {
const store = new Keyv()
await store.set(orcid, EmailAddressC.encode(emailAddress))
test.prop([fc.orcid(), fc.contactEmailAddress()])(
'when the key contains an email address',
async (orcid, emailAddress) => {
const store = new Keyv()
await store.set(orcid, ContactEmailAddressC.encode(emailAddress))

const actual = await _.saveContactEmailAddress(orcid, emailAddress)({ contactEmailAddressStore: store })()
const actual = await _.saveContactEmailAddress(orcid, emailAddress)({ contactEmailAddressStore: store })()

expect(actual).toStrictEqual(E.right(undefined))
expect(await store.get(orcid)).toStrictEqual(EmailAddressC.encode(emailAddress))
})
expect(actual).toStrictEqual(E.right(undefined))
expect(await store.get(orcid)).toStrictEqual(ContactEmailAddressC.encode(emailAddress))
},
)

test.prop([fc.orcid(), fc.anything(), fc.emailAddress()])(
test.prop([fc.orcid(), fc.anything(), fc.contactEmailAddress()])(
'when the key already contains something other than an email address',
async (orcid, value, emailAddress) => {
const store = new Keyv()
Expand All @@ -902,20 +924,20 @@ describe('saveContactEmailAddress', () => {
const actual = await _.saveContactEmailAddress(orcid, emailAddress)({ contactEmailAddressStore: store })()

expect(actual).toStrictEqual(E.right(undefined))
expect(await store.get(orcid)).toStrictEqual(EmailAddressC.encode(emailAddress))
expect(await store.get(orcid)).toStrictEqual(ContactEmailAddressC.encode(emailAddress))
},
)

test.prop([fc.orcid(), fc.emailAddress()])('when the key is not set', async (orcid, emailAddress) => {
test.prop([fc.orcid(), fc.contactEmailAddress()])('when the key is not set', async (orcid, emailAddress) => {
const store = new Keyv()

const actual = await _.saveContactEmailAddress(orcid, emailAddress)({ contactEmailAddressStore: store })()

expect(actual).toStrictEqual(E.right(undefined))
expect(await store.get(orcid)).toStrictEqual(EmailAddressC.encode(emailAddress))
expect(await store.get(orcid)).toStrictEqual(ContactEmailAddressC.encode(emailAddress))
})

test.prop([fc.orcid(), fc.emailAddress(), fc.anything()])(
test.prop([fc.orcid(), fc.contactEmailAddress(), fc.anything()])(
'when the key cannot be accessed',
async (orcid, emailAddress, error) => {
const store = new Keyv()
Expand Down
12 changes: 6 additions & 6 deletions test/my-details-page/change-contact-email-address.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ describe('changeContactEmailAddress', () => {
fc.origin(),
fc.connection({ method: fc.requestMethod().filter(method => method !== 'POST') }),
fc.user(),
fc.either(fc.constantFrom('not-found' as const, 'unavailable' as const), fc.emailAddress()),
fc.either(fc.constantFrom('not-found' as const, 'unavailable' as const), fc.contactEmailAddress()),
])('when there is a logged in user', async (oauth, publicUrl, connection, user, emailAddress) => {
const canChangeContactEmailAddress = jest.fn<CanChangeContactEmailAddressEnv['canChangeContactEmailAddress']>(
_ => true,
Expand Down Expand Up @@ -61,7 +61,7 @@ describe('changeContactEmailAddress', () => {
),
),
fc.user(),
fc.emailAddress(),
fc.contactEmailAddress(),
])(
'there is an email address already',
async (oauth, publicUrl, [emailAddress, connection], user, existingEmailAddress) => {
Expand All @@ -87,7 +87,7 @@ describe('changeContactEmailAddress', () => {
{ type: 'endResponse' },
]),
)
expect(saveContactEmailAddress).toHaveBeenCalledWith(user.orcid, emailAddress)
expect(saveContactEmailAddress).toHaveBeenCalledWith(user.orcid, { type: 'unverified', value: emailAddress })
},
)

Expand Down Expand Up @@ -127,7 +127,7 @@ describe('changeContactEmailAddress', () => {
{ type: 'endResponse' },
]),
)
expect(saveContactEmailAddress).toHaveBeenCalledWith(user.orcid, emailAddress)
expect(saveContactEmailAddress).toHaveBeenCalledWith(user.orcid, { type: 'unverified', value: emailAddress })
})
})

Expand All @@ -139,7 +139,7 @@ describe('changeContactEmailAddress', () => {
method: fc.constant('POST'),
}),
fc.user(),
fc.either(fc.constant('not-found' as const), fc.emailAddress()),
fc.either(fc.constant('not-found' as const), fc.contactEmailAddress()),
])('it is not an email address', async (oauth, publicUrl, connection, user, emailAddress) => {
const actual = await runMiddleware(
_.changeContactEmailAddress({
Expand Down Expand Up @@ -171,7 +171,7 @@ describe('changeContactEmailAddress', () => {
method: fc.constant('POST'),
}),
fc.user(),
fc.either(fc.constant('not-found' as const), fc.emailAddress()),
fc.either(fc.constant('not-found' as const), fc.contactEmailAddress()),
])(
'when the form has been submitted but the email address cannot be saved',
async (oauth, publicUrl, connection, user, emailAddress) => {
Expand Down
Loading

0 comments on commit c6cab07

Please sign in to comment.