diff --git a/src/classes/Settings.js b/src/classes/Settings.js index 33de19ae3..3aa511afb 100644 --- a/src/classes/Settings.js +++ b/src/classes/Settings.js @@ -14,7 +14,7 @@ const { // Constants const FOOTER_PATH = "footer.yml" const NAVIGATION_PATH = "navigation.yml" -const HOMEPAGE_INDEX_PATH = "index.md" // Empty string +const { HOMEPAGE_NAME } = require("@root/constants") const retrieveSettingsFiles = async ( accessToken, @@ -42,7 +42,7 @@ const retrieveSettingsFiles = async ( // Retrieve homepage only if flag is set to true if (shouldRetrieveHomepage) { - fileRetrievalObj.homepage = HomepageFile.read(HOMEPAGE_INDEX_PATH) + fileRetrievalObj.homepage = HomepageFile.read(HOMEPAGE_NAME) } const fileContentsArr = await Bluebird.map( @@ -233,7 +233,7 @@ class Settings { const homepageContent = ["---\n", homepageFrontMatter, "---"].join("") const newHomepageContent = Base64.encode(homepageContent) - await HomepageFile.update(HOMEPAGE_INDEX_PATH, newHomepageContent, sha) + await HomepageFile.update(HOMEPAGE_NAME, newHomepageContent, sha) } } diff --git a/src/constants/index.ts b/src/constants/index.ts index 23fdb6981..81dcdc22e 100644 --- a/src/constants/index.ts +++ b/src/constants/index.ts @@ -1 +1,2 @@ export * from "./constants" +export * from "./pages" diff --git a/src/constants/pages.ts b/src/constants/pages.ts new file mode 100644 index 000000000..35c3bda81 --- /dev/null +++ b/src/constants/pages.ts @@ -0,0 +1,3 @@ +export const HOMEPAGE_FILENAME = "index.md" + +export const CONTACT_US_FILENAME = "contact-us.md" diff --git a/src/database/models/Deployment.ts b/src/database/models/Deployment.ts index e6e2bb3bb..dba519896 100644 --- a/src/database/models/Deployment.ts +++ b/src/database/models/Deployment.ts @@ -11,6 +11,7 @@ import { } from "sequelize-typescript" import { Site } from "@database/models/Site" +import { ProdPermalink, StagingPermalink } from "@root/types/pages" @Table({ tableName: "deployments", paranoid: true }) export class Deployment extends Model { @@ -26,13 +27,13 @@ export class Deployment extends Model { allowNull: false, type: DataType.TEXT, }) - productionUrl!: string + productionUrl!: ProdPermalink @Column({ allowNull: false, type: DataType.TEXT, }) - stagingUrl!: string + stagingUrl!: StagingPermalink @CreatedAt createdAt!: Date diff --git a/src/errors/DatabaseError.ts b/src/errors/DatabaseError.ts new file mode 100644 index 000000000..a1e38ecb0 --- /dev/null +++ b/src/errors/DatabaseError.ts @@ -0,0 +1,7 @@ +import { BaseIsomerError } from "./BaseError" + +export default class DatabaseError extends BaseIsomerError { + constructor(message = "Unable to retrieve data from database") { + super(500, message) + } +} diff --git a/src/errors/EmptyStringError.ts b/src/errors/EmptyStringError.ts new file mode 100644 index 000000000..14a010ab6 --- /dev/null +++ b/src/errors/EmptyStringError.ts @@ -0,0 +1,9 @@ +import { BaseIsomerError } from "./BaseError" + +export default class EmptyStringError extends BaseIsomerError { + constructor( + message = "An empty string was provided for a method that requires a non-empty string" + ) { + super(500, message) + } +} diff --git a/src/errors/MissingResourceRoomError.ts b/src/errors/MissingResourceRoomError.ts new file mode 100644 index 000000000..a31988437 --- /dev/null +++ b/src/errors/MissingResourceRoomError.ts @@ -0,0 +1,7 @@ +import { NotFoundError } from "./NotFoundError" + +export default class MissingResourceRoomError extends NotFoundError { + constructor(message = "No resource room exists for the site") { + super(message) + } +} diff --git a/src/errors/MissingSiteError.ts b/src/errors/MissingSiteError.ts new file mode 100644 index 000000000..417c1210d --- /dev/null +++ b/src/errors/MissingSiteError.ts @@ -0,0 +1,7 @@ +import { NotFoundError } from "./NotFoundError" + +export default class MissingSiteError extends NotFoundError { + constructor(message = "The site could not be found in Isomer") { + super(message) + } +} diff --git a/src/errors/MissingUserEmailError.ts b/src/errors/MissingUserEmailError.ts new file mode 100644 index 000000000..c948098ff --- /dev/null +++ b/src/errors/MissingUserEmailError.ts @@ -0,0 +1,7 @@ +import { NotFoundError } from "./NotFoundError" + +export default class MissingUserEmailError extends NotFoundError { + constructor(message = "No email exists for the specified user!") { + super(message) + } +} diff --git a/src/errors/MissingUserError.ts b/src/errors/MissingUserError.ts new file mode 100644 index 000000000..3f07fa676 --- /dev/null +++ b/src/errors/MissingUserError.ts @@ -0,0 +1,7 @@ +import { NotFoundError } from "./NotFoundError" + +export default class MissingUserError extends NotFoundError { + constructor(message = "The user could not be found in Isomer") { + super(message) + } +} diff --git a/src/errors/RequestNotFoundError.ts b/src/errors/RequestNotFoundError.ts index 6dcd7513b..f8acc2ab1 100644 --- a/src/errors/RequestNotFoundError.ts +++ b/src/errors/RequestNotFoundError.ts @@ -2,9 +2,6 @@ import { NotFoundError } from "./NotFoundError" export default class RequestNotFoundError extends NotFoundError { constructor(message = "The specified review request could not be found!") { - super() - Error.captureStackTrace(this, this.constructor) - this.name = this.constructor.name - this.message = message + super(message) } } diff --git a/src/fixtures/github.ts b/src/fixtures/github.ts index 0adefcfb6..710558e36 100644 --- a/src/fixtures/github.ts +++ b/src/fixtures/github.ts @@ -137,3 +137,13 @@ export const MOCK_GITHUB_RAWCOMMENT_TWO: RawComment = { body: JSON.stringify(MOCK_GITHUB_COMMENT_OBJECT_TWO), created_at: MOCK_GITHUB_COMMIT_DATE_THREE, } + +export const MOCK_PAGE_PERMALINK = "/department/english" + +export const MOCK_GITHUB_FRONTMATTER = Buffer.from( + `--- +permalink: ${MOCK_PAGE_PERMALINK} +--- +`, + "binary" +).toString("base64") diff --git a/src/fixtures/repoInfo.ts b/src/fixtures/repoInfo.ts index 49b7474dc..5233f045c 100644 --- a/src/fixtures/repoInfo.ts +++ b/src/fixtures/repoInfo.ts @@ -1,14 +1,26 @@ +import { ConfigYmlData } from "@root/types/configYml" import { GitHubRepositoryData } from "@root/types/repoInfo" +import { Brand } from "@root/types/util" -export const MOCK_STAGING_URL_GITHUB = "https://repo-staging.netlify.app" -export const MOCK_STAGING_URL_CONFIGYML = - "https://repo-staging-configyml.netlify.app" -export const MOCK_STAGING_URL_DB = "https://repo-staging-db.netlify.app" +export const MOCK_STAGING_URL_GITHUB: NonNullable< + ConfigYmlData["staging"] +> = Brand.fromString("https://repo-staging.netlify.app") +export const MOCK_STAGING_URL_CONFIGYML: NonNullable< + ConfigYmlData["staging"] +> = Brand.fromString("https://repo-staging-configyml.netlify.app") +export const MOCK_STAGING_URL_DB: NonNullable< + ConfigYmlData["staging"] +> = Brand.fromString("https://repo-staging-db.netlify.app") -export const MOCK_PRODUCTION_URL_GITHUB = "https://repo-prod.netlify.app" -export const MOCK_PRODUCTION_URL_CONFIGYML = - "https://repo-prod-configyml.netlify.app" -export const MOCK_PRODUCTION_URL_DB = "https://repo-prod-db.netlify.app" +export const MOCK_PRODUCTION_URL_GITHUB: NonNullable< + ConfigYmlData["prod"] +> = Brand.fromString("https://repo-prod.netlify.app") +export const MOCK_PRODUCTION_URL_CONFIGYML: NonNullable< + ConfigYmlData["prod"] +> = Brand.fromString("https://repo-prod-configyml.netlify.app") +export const MOCK_PRODUCTION_URL_DB: NonNullable< + ConfigYmlData["prod"] +> = Brand.fromString("https://repo-prod-db.netlify.app") export const repoInfo: GitHubRepositoryData = { name: "repo", diff --git a/src/fixtures/sites.ts b/src/fixtures/sites.ts index 0aa44f3af..374152401 100644 --- a/src/fixtures/sites.ts +++ b/src/fixtures/sites.ts @@ -75,7 +75,7 @@ export const MOCK_REPO_DBENTRY_TWO: Attributes = { updatedAt: MOCK_SITE_DATE_TWO, } -export const MOCK_DEPLOYMENT_DBENTRY_ONE: Attributes = { +export const MOCK_DEPLOYMENT_DBENTRY_ONE = { id: 1, siteId: MOCK_SITE_ID_ONE, productionUrl: MOCK_DEPLOYMENT_PROD_URL_ONE, diff --git a/src/integration/NotificationOnEditHandler.spec.ts b/src/integration/NotificationOnEditHandler.spec.ts index 24d208925..e252c1f1b 100644 --- a/src/integration/NotificationOnEditHandler.spec.ts +++ b/src/integration/NotificationOnEditHandler.spec.ts @@ -23,6 +23,17 @@ import { mockIsomerUserId, mockSiteName, } from "@fixtures/sessionData" +import { BaseDirectoryService } from "@root/services/directoryServices/BaseDirectoryService" +import { ResourceRoomDirectoryService } from "@root/services/directoryServices/ResourceRoomDirectoryService" +import { CollectionPageService } from "@root/services/fileServices/MdPageServices/CollectionPageService" +import { ContactUsPageService } from "@root/services/fileServices/MdPageServices/ContactUsPageService" +import { HomepagePageService } from "@root/services/fileServices/MdPageServices/HomepagePageService" +import { PageService } from "@root/services/fileServices/MdPageServices/PageService" +import { ResourcePageService } from "@root/services/fileServices/MdPageServices/ResourcePageService" +import { SubcollectionPageService } from "@root/services/fileServices/MdPageServices/SubcollectionPageService" +import { UnlinkedPageService } from "@root/services/fileServices/MdPageServices/UnlinkedPageService" +import { CollectionYmlService } from "@root/services/fileServices/YmlFileServices/CollectionYmlService" +import { FooterYmlService } from "@root/services/fileServices/YmlFileServices/FooterYmlService" import { GitHubService } from "@services/db/GitHubService" import * as ReviewApi from "@services/db/review" import { ConfigYmlService } from "@services/fileServices/YmlFileServices/ConfigYmlService" @@ -41,13 +52,62 @@ const mockGithubService = { getComments: jest.fn(), } const usersService = getUsersService(sequelize) +const footerYmlService = new FooterYmlService({ + gitHubService: mockGithubService, +}) +const collectionYmlService = new CollectionYmlService({ + gitHubService: mockGithubService, +}) +const baseDirectoryService = new BaseDirectoryService({ + gitHubService: mockGithubService, +}) + +const contactUsService = new ContactUsPageService({ + gitHubService: mockGithubService, + footerYmlService, +}) +const collectionPageService = new CollectionPageService({ + gitHubService: mockGithubService, + collectionYmlService, +}) +const subCollectionPageService = new SubcollectionPageService({ + gitHubService: mockGithubService, + collectionYmlService, +}) +const homepageService = new HomepagePageService({ + gitHubService: mockGithubService, +}) +const resourcePageService = new ResourcePageService({ + gitHubService: mockGithubService, +}) +const unlinkedPageService = new UnlinkedPageService({ + gitHubService: mockGithubService, +}) +const configYmlService = new ConfigYmlService({ + gitHubService: mockGithubService, +}) +const resourceRoomDirectoryService = new ResourceRoomDirectoryService({ + baseDirectoryService, + configYmlService, + gitHubService: mockGithubService, +}) +const pageService = new PageService({ + collectionPageService, + contactUsService, + subCollectionPageService, + homepageService, + resourcePageService, + unlinkedPageService, + resourceRoomDirectoryService, +}) const reviewRequestService = new ReviewRequestService( (mockGithubService as unknown) as typeof ReviewApi, User, ReviewRequest, Reviewer, ReviewMeta, - ReviewRequestView + ReviewRequestView, + pageService ) const sitesService = new SitesService({ siteRepository: Site, diff --git a/src/integration/Notifications.spec.ts b/src/integration/Notifications.spec.ts index 79b822be5..d1754d43a 100644 --- a/src/integration/Notifications.spec.ts +++ b/src/integration/Notifications.spec.ts @@ -13,9 +13,8 @@ import { User, Whitelist, } from "@database/models" -import { generateRouter, generateRouterForUserWithSite } from "@fixtures/app" +import { generateRouterForUserWithSite } from "@fixtures/app" import UserSessionData from "@root/classes/UserSessionData" -import UserWithSiteSessionData from "@root/classes/UserWithSiteSessionData" import { formatNotification, highPriorityOldReadNotification, @@ -35,7 +34,18 @@ import { NotificationsRouter as _NotificationsRouter } from "@root/routes/v2/aut import { SitesRouter as _SitesRouter } from "@root/routes/v2/authenticated/sites" import { genericGitHubAxiosInstance } from "@root/services/api/AxiosInstance" import { GitHubService } from "@root/services/db/GitHubService" +import { BaseDirectoryService } from "@root/services/directoryServices/BaseDirectoryService" +import { ResourceRoomDirectoryService } from "@root/services/directoryServices/ResourceRoomDirectoryService" +import { CollectionPageService } from "@root/services/fileServices/MdPageServices/CollectionPageService" +import { ContactUsPageService } from "@root/services/fileServices/MdPageServices/ContactUsPageService" +import { HomepagePageService } from "@root/services/fileServices/MdPageServices/HomepagePageService" +import { PageService } from "@root/services/fileServices/MdPageServices/PageService" +import { ResourcePageService } from "@root/services/fileServices/MdPageServices/ResourcePageService" +import { SubcollectionPageService } from "@root/services/fileServices/MdPageServices/SubcollectionPageService" +import { UnlinkedPageService } from "@root/services/fileServices/MdPageServices/UnlinkedPageService" +import { CollectionYmlService } from "@root/services/fileServices/YmlFileServices/CollectionYmlService" import { ConfigYmlService } from "@root/services/fileServices/YmlFileServices/ConfigYmlService" +import { FooterYmlService } from "@root/services/fileServices/YmlFileServices/FooterYmlService" import CollaboratorsService from "@root/services/identity/CollaboratorsService" import SitesService from "@root/services/identity/SitesService" import ReviewRequestService from "@root/services/review/ReviewRequestService" @@ -58,13 +68,47 @@ const gitHubService = new GitHubService({ const identityAuthService = getIdentityAuthService(gitHubService) const usersService = getUsersService(sequelize) const configYmlService = new ConfigYmlService({ gitHubService }) +const footerYmlService = new FooterYmlService({ gitHubService }) +const collectionYmlService = new CollectionYmlService({ gitHubService }) +const baseDirectoryService = new BaseDirectoryService({ gitHubService }) + +const contactUsService = new ContactUsPageService({ + gitHubService, + footerYmlService, +}) +const collectionPageService = new CollectionPageService({ + gitHubService, + collectionYmlService, +}) +const subCollectionPageService = new SubcollectionPageService({ + gitHubService, + collectionYmlService, +}) +const homepageService = new HomepagePageService({ gitHubService }) +const resourcePageService = new ResourcePageService({ gitHubService }) +const unlinkedPageService = new UnlinkedPageService({ gitHubService }) +const resourceRoomDirectoryService = new ResourceRoomDirectoryService({ + baseDirectoryService, + configYmlService, + gitHubService, +}) +const pageService = new PageService({ + collectionPageService, + contactUsService, + subCollectionPageService, + homepageService, + resourcePageService, + unlinkedPageService, + resourceRoomDirectoryService, +}) const reviewRequestService = new ReviewRequestService( (gitHubService as unknown) as typeof ReviewApi, User, ReviewRequest, Reviewer, ReviewMeta, - ReviewRequestView + ReviewRequestView, + pageService ) const sitesService = new SitesService({ siteRepository: Site, diff --git a/src/integration/Reviews.spec.ts b/src/integration/Reviews.spec.ts index cfbb48e10..f883d6c61 100644 --- a/src/integration/Reviews.spec.ts +++ b/src/integration/Reviews.spec.ts @@ -6,6 +6,7 @@ import { ReviewsRouter as _ReviewsRouter } from "@routes/v2/authenticated/review import { SitesRouter as _SitesRouter } from "@routes/v2/authenticated/sites" import { + Deployment, IsomerAdmin, Notification, Repo, @@ -35,6 +36,8 @@ import { MOCK_GITHUB_PULL_REQUEST_NUMBER, MOCK_GITHUB_RAWCOMMENT_ONE, MOCK_GITHUB_RAWCOMMENT_TWO, + MOCK_GITHUB_FRONTMATTER, + MOCK_PAGE_PERMALINK, } from "@fixtures/github" import { MOCK_GITHUB_DATE_ONE } from "@fixtures/identity" import { @@ -57,6 +60,7 @@ import { MOCK_REPO_NAME_ONE, MOCK_REPO_NAME_TWO, MOCK_SITE_ID_TWO, + MOCK_DEPLOYMENT_DBENTRY_ONE, } from "@fixtures/sites" import { MOCK_USER_DBENTRY_ONE, @@ -69,6 +73,17 @@ import { MOCK_USER_ID_TWO, } from "@fixtures/users" import { ReviewRequestStatus } from "@root/constants" +import { BaseDirectoryService } from "@root/services/directoryServices/BaseDirectoryService" +import { ResourceRoomDirectoryService } from "@root/services/directoryServices/ResourceRoomDirectoryService" +import { CollectionPageService } from "@root/services/fileServices/MdPageServices/CollectionPageService" +import { ContactUsPageService } from "@root/services/fileServices/MdPageServices/ContactUsPageService" +import { HomepagePageService } from "@root/services/fileServices/MdPageServices/HomepagePageService" +import { PageService } from "@root/services/fileServices/MdPageServices/PageService" +import { ResourcePageService } from "@root/services/fileServices/MdPageServices/ResourcePageService" +import { SubcollectionPageService } from "@root/services/fileServices/MdPageServices/SubcollectionPageService" +import { UnlinkedPageService } from "@root/services/fileServices/MdPageServices/UnlinkedPageService" +import { CollectionYmlService } from "@root/services/fileServices/YmlFileServices/CollectionYmlService" +import { FooterYmlService } from "@root/services/fileServices/YmlFileServices/FooterYmlService" import { ReviewRequestDto } from "@root/types/dto/review" import { GitHubService } from "@services/db/GitHubService" import * as ReviewApi from "@services/db/review" @@ -84,13 +99,47 @@ const gitHubService = new GitHubService({ axiosInstance: mockAxios.create() }) const configYmlService = new ConfigYmlService({ gitHubService }) const usersService = getUsersService(sequelize) const isomerAdminsService = new IsomerAdminsService({ repository: IsomerAdmin }) +const footerYmlService = new FooterYmlService({ gitHubService }) +const collectionYmlService = new CollectionYmlService({ gitHubService }) +const baseDirectoryService = new BaseDirectoryService({ gitHubService }) + +const contactUsService = new ContactUsPageService({ + gitHubService, + footerYmlService, +}) +const collectionPageService = new CollectionPageService({ + gitHubService, + collectionYmlService, +}) +const subCollectionPageService = new SubcollectionPageService({ + gitHubService, + collectionYmlService, +}) +const homepageService = new HomepagePageService({ gitHubService }) +const resourcePageService = new ResourcePageService({ gitHubService }) +const unlinkedPageService = new UnlinkedPageService({ gitHubService }) +const resourceRoomDirectoryService = new ResourceRoomDirectoryService({ + baseDirectoryService, + configYmlService, + gitHubService, +}) +const pageService = new PageService({ + collectionPageService, + contactUsService, + subCollectionPageService, + homepageService, + resourcePageService, + unlinkedPageService, + resourceRoomDirectoryService, +}) const reviewRequestService = new ReviewRequestService( (gitHubService as unknown) as typeof ReviewApi, User, ReviewRequest, Reviewer, ReviewMeta, - ReviewRequestView + ReviewRequestView, + pageService ) const sitesService = new SitesService({ siteRepository: Site, @@ -145,11 +194,13 @@ describe("Review Requests Integration Tests", () => { await SiteMember.sync({ force: true }) await Notification.sync({ force: true }) await ReviewMeta.sync({ force: true }) + await Deployment.sync({ force: true }) await User.create(MOCK_USER_DBENTRY_ONE) await User.create(MOCK_USER_DBENTRY_TWO) await User.create(MOCK_USER_DBENTRY_THREE) await Site.create(MOCK_SITE_DBENTRY_ONE) + await Deployment.create(MOCK_DEPLOYMENT_DBENTRY_ONE) await Repo.create(MOCK_REPO_DBENTRY_ONE) await SiteMember.create(MOCK_SITEMEMBER_DBENTRY_ONE) await SiteMember.create(MOCK_SITEMEMBER_DBENTRY_TWO) @@ -191,6 +242,9 @@ describe("Review Requests Integration Tests", () => { MOCK_USER_SESSION_DATA_ONE, MOCK_REPO_NAME_ONE ) + mockAxios.get.mockResolvedValue({ + data: { content: MOCK_GITHUB_FRONTMATTER }, + }) mockGenericAxios.get.mockResolvedValueOnce({ data: { files: [ @@ -210,7 +264,7 @@ describe("Review Requests Integration Tests", () => { type: ["page"], name: MOCK_GITHUB_FILENAME_ALPHA_ONE, path: [], - url: "www.google.com", + url: `${MOCK_DEPLOYMENT_DBENTRY_ONE.stagingUrl}${MOCK_PAGE_PERMALINK}`, lastEditedBy: MOCK_USER_EMAIL_TWO, // TODO: This should be MOCK_USER_EMAIL_ONE lastEditedTime: new Date(MOCK_GITHUB_COMMIT_DATE_THREE).getTime(), }, @@ -218,7 +272,7 @@ describe("Review Requests Integration Tests", () => { type: ["page"], name: MOCK_GITHUB_FILENAME_ALPHA_TWO, path: MOCK_GITHUB_FILEPATH_ALPHA_TWO.split("/").filter((x) => x), - url: "www.google.com", + url: `${MOCK_DEPLOYMENT_DBENTRY_ONE.stagingUrl}${MOCK_PAGE_PERMALINK}`, lastEditedBy: MOCK_USER_EMAIL_TWO, lastEditedTime: new Date(MOCK_GITHUB_COMMIT_DATE_THREE).getTime(), }, @@ -695,7 +749,7 @@ describe("Review Requests Integration Tests", () => { type: ["page"], name: MOCK_GITHUB_FILENAME_ALPHA_ONE, path: [], - url: "www.google.com", + url: `${MOCK_DEPLOYMENT_DBENTRY_ONE.stagingUrl}${MOCK_PAGE_PERMALINK}`, lastEditedBy: MOCK_USER_EMAIL_TWO, // TODO: This should be MOCK_USER_EMAIL_ONE lastEditedTime: new Date(MOCK_GITHUB_COMMIT_DATE_THREE).getTime(), }, @@ -703,7 +757,7 @@ describe("Review Requests Integration Tests", () => { type: ["page"], name: MOCK_GITHUB_FILENAME_ALPHA_TWO, path: MOCK_GITHUB_FILEPATH_ALPHA_TWO.split("/").filter((x) => x), - url: "www.google.com", + url: `${MOCK_DEPLOYMENT_DBENTRY_ONE.stagingUrl}${MOCK_PAGE_PERMALINK}`, lastEditedBy: MOCK_USER_EMAIL_TWO, lastEditedTime: new Date(MOCK_GITHUB_COMMIT_DATE_THREE).getTime(), }, diff --git a/src/integration/Sites.spec.ts b/src/integration/Sites.spec.ts index 865881bdf..c5b66d746 100644 --- a/src/integration/Sites.spec.ts +++ b/src/integration/Sites.spec.ts @@ -19,8 +19,20 @@ import UserSessionData from "@root/classes/UserSessionData" import { mockEmail, mockIsomerUserId } from "@root/fixtures/sessionData" import { getAuthorizationMiddleware } from "@root/middleware" import { SitesRouter as _SitesRouter } from "@root/routes/v2/authenticated/sites" +import { isomerRepoAxiosInstance } from "@root/services/api/AxiosInstance" import { GitHubService } from "@root/services/db/GitHubService" +import { BaseDirectoryService } from "@root/services/directoryServices/BaseDirectoryService" +import { ResourceRoomDirectoryService } from "@root/services/directoryServices/ResourceRoomDirectoryService" +import { CollectionPageService } from "@root/services/fileServices/MdPageServices/CollectionPageService" +import { ContactUsPageService } from "@root/services/fileServices/MdPageServices/ContactUsPageService" +import { HomepagePageService } from "@root/services/fileServices/MdPageServices/HomepagePageService" +import { PageService } from "@root/services/fileServices/MdPageServices/PageService" +import { ResourcePageService } from "@root/services/fileServices/MdPageServices/ResourcePageService" +import { SubcollectionPageService } from "@root/services/fileServices/MdPageServices/SubcollectionPageService" +import { UnlinkedPageService } from "@root/services/fileServices/MdPageServices/UnlinkedPageService" +import { CollectionYmlService } from "@root/services/fileServices/YmlFileServices/CollectionYmlService" import { ConfigYmlService } from "@root/services/fileServices/YmlFileServices/ConfigYmlService" +import { FooterYmlService } from "@root/services/fileServices/YmlFileServices/FooterYmlService" import IsomerAdminsService from "@root/services/identity/IsomerAdminsService" import SitesService from "@root/services/identity/SitesService" import ReviewRequestService from "@root/services/review/ReviewRequestService" @@ -35,18 +47,53 @@ const mockUpdatedAt = "now" const mockPermissions = { push: true } const mockPrivate = true -const gitHubService = new GitHubService({ axiosInstance: mockAxios.create() }) +const gitHubService = new GitHubService({ + axiosInstance: isomerRepoAxiosInstance, +}) const configYmlService = new ConfigYmlService({ gitHubService }) const usersService = getUsersService(sequelize) const isomerAdminsService = new IsomerAdminsService({ repository: IsomerAdmin }) const identityAuthService = getIdentityAuthService(gitHubService) +const unlinkedPageService = new UnlinkedPageService({ gitHubService }) +const collectionYmlService = new CollectionYmlService({ gitHubService }) +const homepageService = new HomepagePageService({ gitHubService }) +const footerYmlService = new FooterYmlService({ gitHubService }) +const collectionPageService = new CollectionPageService({ + gitHubService, + collectionYmlService, +}) +const subCollectionPageService = new SubcollectionPageService({ + gitHubService, + collectionYmlService, +}) +const contactUsService = new ContactUsPageService({ + gitHubService, + footerYmlService, +}) +const baseDirectoryService = new BaseDirectoryService({ gitHubService }) +const resourcePageService = new ResourcePageService({ gitHubService }) +const resourceRoomDirectoryService = new ResourceRoomDirectoryService({ + baseDirectoryService, + configYmlService, + gitHubService, +}) +const pageService = new PageService({ + contactUsService, + collectionPageService, + subCollectionPageService, + homepageService, + resourcePageService, + unlinkedPageService, + resourceRoomDirectoryService, +}) const reviewRequestService = new ReviewRequestService( gitHubService, User, ReviewRequest, Reviewer, ReviewMeta, - ReviewRequestView + ReviewRequestView, + pageService ) const sitesService = new SitesService({ siteRepository: Site, @@ -116,7 +163,6 @@ describe("Sites Router", () => { await Site.sync({ force: true }) await Repo.sync({ force: true }) await SiteMember.sync({ force: true }) - // Set up User and Site table entries await User.create({ id: mockIsomerUserId, diff --git a/src/integration/Users.spec.ts b/src/integration/Users.spec.ts index 00efa1698..a54575ac6 100644 --- a/src/integration/Users.spec.ts +++ b/src/integration/Users.spec.ts @@ -21,7 +21,6 @@ const mockValidEmail = "open@up.gov.sg" const mockInvalidEmail = "stay@home.sg" const mockUnwhitelistedEmail = "blacklisted@sad.sg" const mockWhitelistedDomain = ".gov.sg" -const mockGithubId = "i m a git" const mockValidNumber = "92341234" const mockInvalidNumber = "00000000" const maxNumOfOtpAttempts = config.get("auth.maxNumOtpAttempts") diff --git a/src/middleware/notificationOnEditHandler.ts b/src/middleware/notificationOnEditHandler.ts index 1eb1b9ef2..747ddd20c 100644 --- a/src/middleware/notificationOnEditHandler.ts +++ b/src/middleware/notificationOnEditHandler.ts @@ -8,6 +8,7 @@ import SitesService from "@root/services/identity/SitesService" import ReviewRequestService from "@root/services/review/ReviewRequestService" import { RequestHandler } from "@root/types" +// eslint-disable-next-line import/prefer-default-export export class NotificationOnEditHandler { private readonly reviewRequestService: ReviewRequestService @@ -53,10 +54,10 @@ export class NotificationOnEditHandler { const { siteName, isomerUserId: userId, email } = userWithSiteSessionData const site = await this.sitesService.getBySiteName(siteName) const users = await this.collaboratorsService.list(siteName, userId) - if (!site) throw new Error("Site should always exist") + if (site.isErr()) throw new Error("Site should always exist") const reviewRequests = await this.reviewRequestService.listReviewRequest( userWithSiteSessionData, - site + site.value ) if (reviewRequests.length === 0) return // For now, we only have 1 active review request diff --git a/src/routes/v1/authenticatedSites/homepage.js b/src/routes/v1/authenticatedSites/homepage.js index 792fd1561..54410032b 100644 --- a/src/routes/v1/authenticatedSites/homepage.js +++ b/src/routes/v1/authenticatedSites/homepage.js @@ -12,7 +12,7 @@ const { const { File, HomepageType } = require("@classes/File") // Constants -const HOMEPAGE_INDEX_PATH = "index.md" // Empty string +const { HOMEPAGE_NAME } = require("@root/constants") // Read homepage index file async function readHomepage(req, res) { @@ -24,9 +24,7 @@ async function readHomepage(req, res) { const IsomerFile = new File(accessToken, siteName) const homepageType = new HomepageType() IsomerFile.setFileType(homepageType) - const { sha, content: encodedContent } = await IsomerFile.read( - HOMEPAGE_INDEX_PATH - ) + const { sha, content: encodedContent } = await IsomerFile.read(HOMEPAGE_NAME) const content = Base64.decode(encodedContent) // TO-DO: @@ -50,7 +48,7 @@ async function updateHomepage(req, res) { const homepageType = new HomepageType() IsomerFile.setFileType(homepageType) const { newSha } = await IsomerFile.update( - HOMEPAGE_INDEX_PATH, + HOMEPAGE_NAME, Base64.encode(content), sha ) diff --git a/src/routes/v2/authenticated/__tests__/review.spec.ts b/src/routes/v2/authenticated/__tests__/review.spec.ts index f6a807a5b..81a76fb20 100644 --- a/src/routes/v2/authenticated/__tests__/review.spec.ts +++ b/src/routes/v2/authenticated/__tests__/review.spec.ts @@ -1,4 +1,5 @@ import express from "express" +import { err, errAsync, ok, okAsync } from "neverthrow" import request from "supertest" import RequestNotFoundError from "@errors/RequestNotFoundError" @@ -11,6 +12,7 @@ import { generateRouterForDefaultUserWithSite } from "@fixtures/app" import { mockUserId } from "@fixtures/identity" import { MOCK_USER_EMAIL_ONE, MOCK_USER_EMAIL_TWO } from "@fixtures/users" import { CollaboratorRoles, ReviewRequestStatus } from "@root/constants" +import MissingSiteError from "@root/errors/MissingSiteError" import { GitHubService } from "@root/services/db/GitHubService" import CollaboratorsService from "@services/identity/CollaboratorsService" import NotificationsService from "@services/identity/NotificationsService" @@ -47,7 +49,8 @@ describe("Review Requests Router", () => { } const mockSitesService = { - getBySiteName: jest.fn(), + getBySiteName: jest.fn().mockReturnValue(ok("site")), + getStagingUrl: jest.fn().mockReturnValue(okAsync("")), } const mockCollaboratorsService = { @@ -148,7 +151,8 @@ describe("Review Requests Router", () => { mockReviewRequestService.compareDiff.mockResolvedValueOnce( mockFilesChanged ) - mockSitesService.getBySiteName.mockResolvedValueOnce(true) + mockSitesService.getBySiteName.mockResolvedValueOnce(ok(true)) + // Act const response = await request(app).get("/mockSite/review/compare") @@ -162,7 +166,7 @@ describe("Review Requests Router", () => { it("should return 404 if user is not a site member", async () => { // Arrange mockIdentityUsersService.getSiteMember.mockResolvedValueOnce(null) - mockSitesService.getBySiteName.mockResolvedValueOnce(true) + mockSitesService.getBySiteName.mockResolvedValueOnce(ok(true)) // Act const response = await request(app).get("/mockSite/review/compare") @@ -179,7 +183,6 @@ describe("Review Requests Router", () => { // Arrange const mockPullRequestNumber = 1 const mockReviewer = "reviewer@test.gov.sg" - mockSitesService.getBySiteName.mockResolvedValueOnce("site") mockCollaboratorsService.getRole.mockResolvedValueOnce("role") mockIdentityUsersService.findByEmail.mockResolvedValueOnce("user") mockCollaboratorsService.list.mockResolvedValueOnce([ @@ -222,7 +225,9 @@ describe("Review Requests Router", () => { it("should return 404 if the site does not exist", async () => { // Arrange - mockSitesService.getBySiteName.mockResolvedValueOnce(null) + mockSitesService.getBySiteName.mockReturnValueOnce( + err(new MissingSiteError("site")) + ) // Act const response = await request(app) @@ -243,7 +248,7 @@ describe("Review Requests Router", () => { it("should return 404 if user is not a site collaborator", async () => { // Arrange - mockSitesService.getBySiteName.mockResolvedValueOnce("site") + mockCollaboratorsService.getRole.mockResolvedValueOnce(null) // Act @@ -265,7 +270,7 @@ describe("Review Requests Router", () => { it("should return 400 if no reviewers are provided", async () => { // Arrange - mockSitesService.getBySiteName.mockResolvedValueOnce("site") + mockCollaboratorsService.getRole.mockResolvedValueOnce("role") mockIdentityUsersService.findByEmail.mockResolvedValueOnce("user") @@ -293,7 +298,7 @@ describe("Review Requests Router", () => { it("should return 400 if provided reviewer is not an admin", async () => { // Arrange const mockReviewer = "reviewer@test.gov.sg" - mockSitesService.getBySiteName.mockResolvedValueOnce("site") + mockCollaboratorsService.getRole.mockResolvedValueOnce("role") mockIdentityUsersService.findByEmail.mockResolvedValueOnce("user") mockCollaboratorsService.list.mockResolvedValueOnce([]) @@ -329,7 +334,7 @@ describe("Review Requests Router", () => { it("should return 200 with the list of reviews", async () => { // Arrange const mockReviews = ["review1", "review2"] - mockSitesService.getBySiteName.mockResolvedValueOnce("site") + mockCollaboratorsService.getRole.mockResolvedValueOnce("role") mockReviewRequestService.listReviewRequest.mockResolvedValueOnce( mockReviews @@ -350,9 +355,11 @@ describe("Review Requests Router", () => { it("should return 404 if the site does not exist", async () => { // Arrange - mockSitesService.getBySiteName.mockResolvedValueOnce(null) mockGithubService.getRepoInfo.mockRejectedValueOnce(false) mockIdentityUsersService.getSiteMember.mockResolvedValueOnce({}) + mockSitesService.getBySiteName.mockReturnValueOnce( + err(new MissingSiteError("site")) + ) // Act const response = await request(app).get("/mockSite/review/summary") @@ -366,7 +373,7 @@ describe("Review Requests Router", () => { it("should return 404 if user is not a site collaborator", async () => { // Arrange - mockSitesService.getBySiteName.mockResolvedValueOnce("site") + mockCollaboratorsService.getRole.mockResolvedValueOnce(null) // Act @@ -383,7 +390,7 @@ describe("Review Requests Router", () => { describe("markAllReviewRequestsAsViewed", () => { it("should return 200 and mark all review requests as viewed", async () => { // Arrange - mockSitesService.getBySiteName.mockResolvedValueOnce("site") + mockCollaboratorsService.getRole.mockResolvedValueOnce("role") // Act @@ -400,7 +407,9 @@ describe("Review Requests Router", () => { it("should return 404 if the site does not exist", async () => { // Arrange - mockSitesService.getBySiteName.mockResolvedValueOnce(null) + mockSitesService.getBySiteName.mockReturnValueOnce( + err(new MissingSiteError("site")) + ) // Act const response = await request(app).post("/mockSite/review/viewed") @@ -416,7 +425,7 @@ describe("Review Requests Router", () => { it("should return 404 if user is not a site collaborator", async () => { // Arrange - mockSitesService.getBySiteName.mockResolvedValueOnce("site") + mockCollaboratorsService.getRole.mockResolvedValueOnce(null) // Act @@ -435,7 +444,7 @@ describe("Review Requests Router", () => { describe("markReviewRequestAsViewed", () => { it("should return 200 and mark review request as viewed", async () => { // Arrange - mockSitesService.getBySiteName.mockResolvedValueOnce("site") + mockCollaboratorsService.getRole.mockResolvedValueOnce("role") mockReviewRequestService.getReviewRequest.mockResolvedValueOnce({ id: 12345, @@ -456,7 +465,9 @@ describe("Review Requests Router", () => { it("should return 404 if the site does not exist", async () => { // Arrange - mockSitesService.getBySiteName.mockResolvedValueOnce(null) + mockSitesService.getBySiteName.mockReturnValueOnce( + err(new MissingSiteError("site")) + ) // Act const response = await request(app).post(`/mockSite/review/12345/viewed`) @@ -473,7 +484,7 @@ describe("Review Requests Router", () => { it("should return 404 if user is not a site collaborator", async () => { // Arrange - mockSitesService.getBySiteName.mockResolvedValueOnce("site") + mockCollaboratorsService.getRole.mockResolvedValueOnce(null) // Act @@ -491,7 +502,7 @@ describe("Review Requests Router", () => { it("should return 404 if review request does not exist", async () => { // Arrange - mockSitesService.getBySiteName.mockResolvedValueOnce("site") + mockCollaboratorsService.getRole.mockResolvedValueOnce("role") mockReviewRequestService.getReviewRequest.mockResolvedValueOnce( new RequestNotFoundError() @@ -521,10 +532,9 @@ describe("Review Requests Router", () => { it("should return 200 with the full review request", async () => { // Arrange const mockReviewRequest = "review request" - mockSitesService.getBySiteName.mockResolvedValueOnce("site") mockCollaboratorsService.getRole.mockResolvedValueOnce("role") - mockReviewRequestService.getFullReviewRequest.mockResolvedValueOnce( - mockReviewRequest + mockReviewRequestService.getFullReviewRequest.mockReturnValueOnce( + okAsync(mockReviewRequest) ) // Act @@ -542,8 +552,10 @@ describe("Review Requests Router", () => { it("should return 404 if the site does not exist", async () => { // Arrange - mockSitesService.getBySiteName.mockResolvedValueOnce(null) - mockGithubService.getRepoInfo.mockRejectedValueOnce(false) + mockSitesService.getBySiteName.mockReturnValueOnce( + errAsync(new MissingSiteError("site")) + ) + mockGithubService.getRepoInfo.mockRejectedValueOnce(null) // Act const response = await request(app).get(`/mockSite/review/12345`) @@ -559,7 +571,7 @@ describe("Review Requests Router", () => { it("should return 404 if user is not a site collaborator", async () => { // Arrange - mockSitesService.getBySiteName.mockResolvedValueOnce("site") + mockCollaboratorsService.getRole.mockResolvedValueOnce(null) // Act @@ -576,10 +588,10 @@ describe("Review Requests Router", () => { it("should return 404 if review request does not exist", async () => { // Arrange - mockSitesService.getBySiteName.mockResolvedValueOnce("site") + mockCollaboratorsService.getRole.mockResolvedValueOnce("role") - mockReviewRequestService.getFullReviewRequest.mockResolvedValueOnce( - new RequestNotFoundError() + mockReviewRequestService.getFullReviewRequest.mockReturnValueOnce( + errAsync(new RequestNotFoundError()) ) // Act @@ -600,7 +612,7 @@ describe("Review Requests Router", () => { // Arrange const mockReviewRequest = { requestor: { email: MOCK_USER_EMAIL_ONE } } const mockReviewer = "reviewer@test.gov.sg" - mockSitesService.getBySiteName.mockResolvedValueOnce("site") + mockReviewRequestService.getReviewRequest.mockResolvedValueOnce( mockReviewRequest ) @@ -636,7 +648,9 @@ describe("Review Requests Router", () => { it("should return 404 if the site does not exist", async () => { // Arrange - mockSitesService.getBySiteName.mockResolvedValueOnce(null) + mockSitesService.getBySiteName.mockReturnValueOnce( + err(new MissingSiteError("site")) + ) // Act const response = await request(app).post(`/mockSite/review/12345`) @@ -653,7 +667,7 @@ describe("Review Requests Router", () => { it("should return 404 if the review request is not found", async () => { // Arrange - mockSitesService.getBySiteName.mockResolvedValueOnce("site") + mockReviewRequestService.getReviewRequest.mockResolvedValueOnce( new RequestNotFoundError() ) @@ -674,7 +688,7 @@ describe("Review Requests Router", () => { it("should return 403 if user is not the original requestor", async () => { // Arrange const mockReviewRequest = { requestor: { email: "other@test.gov.sg" } } - mockSitesService.getBySiteName.mockResolvedValueOnce("site") + mockReviewRequestService.getReviewRequest.mockResolvedValueOnce( mockReviewRequest ) @@ -695,7 +709,7 @@ describe("Review Requests Router", () => { it("should return 400 if the given reviewers are not admins of the site", async () => { // Arrange const mockReviewRequest = { requestor: { email: MOCK_USER_EMAIL_ONE } } - mockSitesService.getBySiteName.mockResolvedValueOnce("site") + mockReviewRequestService.getReviewRequest.mockResolvedValueOnce( mockReviewRequest ) @@ -722,7 +736,7 @@ describe("Review Requests Router", () => { describe("mergeReviewRequest", () => { it("should return 200 with the review request successfully merged", async () => { // Arrange - mockSitesService.getBySiteName.mockResolvedValueOnce("site") + mockCollaboratorsService.getRole.mockResolvedValueOnce("role") mockReviewRequestService.getReviewRequest.mockResolvedValueOnce( "review request" @@ -752,7 +766,9 @@ describe("Review Requests Router", () => { it("should return 404 if the site does not exist", async () => { // Arrange - mockSitesService.getBySiteName.mockResolvedValueOnce(null) + mockSitesService.getBySiteName.mockReturnValueOnce( + err(new MissingSiteError("site")) + ) // Act const response = await request(app).post(`/mockSite/review/12345/merge`) @@ -770,7 +786,7 @@ describe("Review Requests Router", () => { it("should return 404 if user is not a site member", async () => { // Arrange - mockSitesService.getBySiteName.mockResolvedValueOnce("site") + mockCollaboratorsService.getRole.mockResolvedValueOnce(null) // Act @@ -789,7 +805,7 @@ describe("Review Requests Router", () => { it("should return 404 if the review request does not exist", async () => { // Arrange - mockSitesService.getBySiteName.mockResolvedValueOnce("site") + mockCollaboratorsService.getRole.mockResolvedValueOnce("role") mockReviewRequestService.getReviewRequest.mockResolvedValueOnce( new RequestNotFoundError() @@ -819,7 +835,7 @@ describe("Review Requests Router", () => { requestor: MOCK_USER_EMAIL_TWO, } const mockReviewer = "reviewer@test.gov.sg" - mockSitesService.getBySiteName.mockResolvedValueOnce("site") + mockReviewRequestService.getReviewRequest.mockResolvedValueOnce( mockReviewRequest ) @@ -851,7 +867,9 @@ describe("Review Requests Router", () => { it("should return 404 if the site does not exist", async () => { // Arrange - mockSitesService.getBySiteName.mockResolvedValueOnce(null) + mockSitesService.getBySiteName.mockReturnValueOnce( + err(new MissingSiteError("site")) + ) // Act const response = await request(app).post(`/mockSite/review/12345/approve`) @@ -868,7 +886,7 @@ describe("Review Requests Router", () => { it("should return 404 if the review request does not exist", async () => { // Arrange - mockSitesService.getBySiteName.mockResolvedValueOnce("site") + mockReviewRequestService.getReviewRequest.mockResolvedValueOnce( new RequestNotFoundError() ) @@ -893,7 +911,7 @@ describe("Review Requests Router", () => { reviewStatus: ReviewRequestStatus.Open, requestor: MOCK_USER_EMAIL_TWO, } - mockSitesService.getBySiteName.mockResolvedValueOnce("site") + mockReviewRequestService.getReviewRequest.mockResolvedValueOnce( mockReviewRequest ) @@ -921,7 +939,7 @@ describe("Review Requests Router", () => { it("should return 200 with the comments for a review request", async () => { // Arrange const mockComments = ["comment1", "comment2"] - mockSitesService.getBySiteName.mockResolvedValueOnce("site") + mockCollaboratorsService.getRole.mockResolvedValueOnce("role") mockReviewRequestService.getReviewRequest.mockResolvedValueOnce( "review request" @@ -942,8 +960,10 @@ describe("Review Requests Router", () => { it("should return 404 if the site does not exist", async () => { // Arrange - mockSitesService.getBySiteName.mockResolvedValueOnce(null) mockGithubService.getRepoInfo.mockRejectedValueOnce(false) + mockSitesService.getBySiteName.mockReturnValueOnce( + err(new MissingSiteError("site")) + ) // Act const response = await request(app).get(`/mockSite/review/12345/comments`) @@ -958,7 +978,7 @@ describe("Review Requests Router", () => { it("should return 404 if user is not a valid site member", async () => { // Arrange - mockSitesService.getBySiteName.mockResolvedValueOnce("site") + mockCollaboratorsService.getRole.mockResolvedValueOnce(null) // Act @@ -974,7 +994,7 @@ describe("Review Requests Router", () => { it("should return 404 if the review request does not exist", async () => { // Arrange - mockSitesService.getBySiteName.mockResolvedValueOnce("site") + mockCollaboratorsService.getRole.mockResolvedValueOnce("role") mockReviewRequestService.getReviewRequest.mockResolvedValueOnce( new RequestNotFoundError() @@ -995,7 +1015,7 @@ describe("Review Requests Router", () => { describe("createComment", () => { it("should return 200 with the comment created successfully", async () => { // Arrange - mockSitesService.getBySiteName.mockResolvedValueOnce("site") + mockCollaboratorsService.getRole.mockResolvedValueOnce("role") mockReviewRequestService.getReviewRequest.mockResolvedValueOnce( "review request" @@ -1017,7 +1037,9 @@ describe("Review Requests Router", () => { it("should return 404 if the site does not exist", async () => { // Arrange - mockSitesService.getBySiteName.mockResolvedValueOnce(null) + mockSitesService.getBySiteName.mockReturnValueOnce( + err(new MissingSiteError("site")) + ) // Act const response = await request(app) @@ -1034,7 +1056,7 @@ describe("Review Requests Router", () => { it("should return 404 if user is not a valid site member", async () => { // Arrange - mockSitesService.getBySiteName.mockResolvedValueOnce("site") + mockCollaboratorsService.getRole.mockResolvedValueOnce(null) // Act @@ -1052,7 +1074,7 @@ describe("Review Requests Router", () => { it("should return 404 if the review request does not exist", async () => { // Arrange - mockSitesService.getBySiteName.mockResolvedValueOnce("site") + mockCollaboratorsService.getRole.mockResolvedValueOnce("role") mockReviewRequestService.getReviewRequest.mockResolvedValueOnce( new RequestNotFoundError() @@ -1075,7 +1097,7 @@ describe("Review Requests Router", () => { describe("markReviewRequestCommentsAsViewed", () => { it("should return 200 with the lastViewedAt timestamp updated successfully", async () => { // Arrange - mockSitesService.getBySiteName.mockResolvedValueOnce("site") + mockCollaboratorsService.getRole.mockResolvedValueOnce("role") mockReviewRequestService.getReviewRequest.mockResolvedValueOnce( "review request" @@ -1101,7 +1123,9 @@ describe("Review Requests Router", () => { it("should return 404 if the site does not exist", async () => { // Arrange - mockSitesService.getBySiteName.mockResolvedValueOnce(null) + mockSitesService.getBySiteName.mockReturnValueOnce( + err(new MissingSiteError("site")) + ) // Act const response = await request(app).post( @@ -1120,7 +1144,7 @@ describe("Review Requests Router", () => { it("should return 404 if user is not a valid site member", async () => { // Arrange - mockSitesService.getBySiteName.mockResolvedValueOnce("site") + mockCollaboratorsService.getRole.mockResolvedValueOnce(null) // Act @@ -1140,7 +1164,7 @@ describe("Review Requests Router", () => { it("should return 404 if the review request does not exist", async () => { // Arrange - mockSitesService.getBySiteName.mockResolvedValueOnce("site") + mockCollaboratorsService.getRole.mockResolvedValueOnce("role") mockReviewRequestService.getReviewRequest.mockResolvedValueOnce( new RequestNotFoundError() @@ -1167,7 +1191,7 @@ describe("Review Requests Router", () => { // Arrange const mockReviewRequest = { requestor: { email: MOCK_USER_EMAIL_ONE } } const mockReviewer = "reviewer@test.gov.sg" - mockSitesService.getBySiteName.mockResolvedValueOnce("site") + mockReviewRequestService.getReviewRequest.mockResolvedValueOnce( mockReviewRequest ) @@ -1205,7 +1229,9 @@ describe("Review Requests Router", () => { it("should return 404 if the site does not exist", async () => { // Arrange - mockSitesService.getBySiteName.mockResolvedValueOnce(null) + mockSitesService.getBySiteName.mockReturnValueOnce( + err(new MissingSiteError("site")) + ) // Act const response = await request(app).delete(`/mockSite/review/12345`) @@ -1223,7 +1249,7 @@ describe("Review Requests Router", () => { it("should return 404 if the review request does not exist", async () => { // Arrange - mockSitesService.getBySiteName.mockResolvedValueOnce("site") + mockReviewRequestService.getReviewRequest.mockResolvedValueOnce( new RequestNotFoundError() ) @@ -1245,7 +1271,7 @@ describe("Review Requests Router", () => { it("should return 404 if the user is not the requestor of the review request", async () => { // Arrange const mockReviewRequest = { requestor: { email: "other@test.gov.sg" } } - mockSitesService.getBySiteName.mockResolvedValueOnce("site") + mockReviewRequestService.getReviewRequest.mockResolvedValueOnce( mockReviewRequest ) @@ -1269,7 +1295,7 @@ describe("Review Requests Router", () => { it("should return 200 with the review request approval deleted successfully", async () => { // Arrange const mockReviewRequest = { reviewers: [{ email: MOCK_USER_EMAIL_ONE }] } - mockSitesService.getBySiteName.mockResolvedValueOnce("site") + mockReviewRequestService.getReviewRequest.mockResolvedValueOnce( mockReviewRequest ) @@ -1293,7 +1319,9 @@ describe("Review Requests Router", () => { it("should return 404 if the site does not exist", async () => { // Arrange - mockSitesService.getBySiteName.mockResolvedValueOnce(null) + mockSitesService.getBySiteName.mockReturnValueOnce( + err(new MissingSiteError("site")) + ) // Act const response = await request(app).delete( @@ -1311,7 +1339,7 @@ describe("Review Requests Router", () => { it("should return 404 if the review request does not exist", async () => { // Arrange - mockSitesService.getBySiteName.mockResolvedValueOnce("site") + mockReviewRequestService.getReviewRequest.mockResolvedValueOnce( new RequestNotFoundError() ) @@ -1333,7 +1361,7 @@ describe("Review Requests Router", () => { it("should return 404 if the user is not a reviewer of the review request", async () => { // Arrange const mockReviewRequest = { reviewers: [{ email: "other@test.gov.sg" }] } - mockSitesService.getBySiteName.mockResolvedValueOnce("site") + mockReviewRequestService.getReviewRequest.mockResolvedValueOnce( mockReviewRequest ) diff --git a/src/routes/v2/authenticated/review.ts b/src/routes/v2/authenticated/review.ts index 1ae3f98d3..0a71aedfb 100644 --- a/src/routes/v2/authenticated/review.ts +++ b/src/routes/v2/authenticated/review.ts @@ -1,6 +1,7 @@ import autoBind from "auto-bind" import express from "express" import _ from "lodash" +import { ResultAsync } from "neverthrow" import logger from "@logger/logger" @@ -101,13 +102,13 @@ export class ReviewsRouter { userWithSiteSessionData ) - if (!site && doesUnmigratedSiteExist) + if (site.isErr() && doesUnmigratedSiteExist) return res.status(200).json({ message: "Unmigrated site" }) const siteMembers = await this.collaboratorsService.list(siteName) // NOTE: This is an initial migrated site but // we haven't migrated the users. - if (siteMembers.length === 0 && site) { + if (siteMembers.length === 0 && site.isOk()) { return res.status(200).json({ message: "Unmigrated users" }) } @@ -121,11 +122,17 @@ export class ReviewsRouter { return res.status(404).json({ message: "No site members found" }) } - const files = await this.reviewRequestService.compareDiff( - userWithSiteSessionData - ) - - return res.status(200).json({ items: files }) + return this.sitesService + .getStagingUrl(userWithSiteSessionData) + .andThen((stagingLink) => + ResultAsync.fromSafePromise( + this.reviewRequestService.compareDiff( + userWithSiteSessionData, + stagingLink + ) + ) + ) + .map((items) => res.status(200).json({ items })) } createReviewRequest: RequestHandler< @@ -140,7 +147,7 @@ export class ReviewsRouter { const site = await this.sitesService.getBySiteName(siteName) const { userWithSiteSessionData } = res.locals - if (!site) { + if (site.isErr()) { logger.error({ message: "Invalid site requested", method: "createReviewRequest", @@ -229,7 +236,7 @@ export class ReviewsRouter { // and assert that the user is an admin of said site. // This guarantees that the user exists in our database. admin!, - site, + site.value, title, description ) @@ -271,17 +278,17 @@ export class ReviewsRouter { userWithSiteSessionData ) - if (!site && doesUnmigratedSiteExist) + if (site.isErr() && doesUnmigratedSiteExist) return res.status(200).json({ message: "Unmigrated site" }) const siteMembers = await this.collaboratorsService.list(siteName) // NOTE: This is an initial migrated site but // we haven't migrated the users. - if (siteMembers.length === 0 && site) { + if (siteMembers.length === 0 && site.isOk()) { return res.status(200).json({ message: "Unmigrated users" }) } - if (!site) { + if (site.isErr()) { logger.error({ message: "Invalid site requested", method: "listReviews", @@ -323,7 +330,7 @@ export class ReviewsRouter { // Step 3: Fetch data and return const reviews = await this.reviewRequestService.listReviewRequest( userWithSiteSessionData, - site + site.value ) return res.status(200).json({ @@ -342,7 +349,7 @@ export class ReviewsRouter { const { siteName } = req.params const site = await this.sitesService.getBySiteName(siteName) - if (!site) { + if (site.isErr()) { return res.status(404).send({ message: "Please ensure that the site exists!", }) @@ -368,7 +375,7 @@ export class ReviewsRouter { // Step 3: Update all review requests for the site as viewed await this.reviewRequestService.markAllReviewRequestsAsViewed( userWithSiteSessionData, - site + site.value ) return res.status(200).send() @@ -385,7 +392,7 @@ export class ReviewsRouter { const { siteName, requestId: prNumber } = req.params const site = await this.sitesService.getBySiteName(siteName) - if (!site) { + if (site.isErr()) { return res.status(404).send({ message: "Please ensure that the site exists!", }) @@ -410,7 +417,7 @@ export class ReviewsRouter { // Step 3: Retrieve review request const possibleReviewRequest = await this.reviewRequestService.getReviewRequest( - site, + site.value, prNumber ) @@ -431,7 +438,7 @@ export class ReviewsRouter { // Step 4: Mark review request as viewed await this.reviewRequestService.markReviewRequestAsViewed( userWithSiteSessionData, - site, + site.value, possibleReviewRequest.id ) @@ -453,18 +460,18 @@ export class ReviewsRouter { userWithSiteSessionData ) - if (!site && doesUnmigratedSiteExist) { + if (site.isErr() && doesUnmigratedSiteExist) { return res.status(200).json({ message: "Unmigrated site" }) } const siteMembers = await this.collaboratorsService.list(siteName) // NOTE: This is an initial migrated site but // we haven't migrated the users. - if (siteMembers.length === 0 && site) { + if (siteMembers.length === 0 && site.isOk()) { return res.status(200).json({ message: "Unmigrated users" }) } - if (!site) { + if (site.isErr()) { logger.error({ message: "Invalid site requested", method: "getReviewRequest", @@ -505,13 +512,21 @@ export class ReviewsRouter { }) } - const possibleReviewRequest = await this.reviewRequestService.getFullReviewRequest( - userWithSiteSessionData, - site, - requestId - ) + const possibleReviewRequest = await this.sitesService + .getStagingUrl(userWithSiteSessionData) + .andThen((stagingLink) => + this.reviewRequestService.getFullReviewRequest( + userWithSiteSessionData, + site.value, + requestId, + stagingLink + ) + ) - if (isIsomerError(possibleReviewRequest)) { + // NOTE: This method call (`getFullReviewRequest`) should be + // in the result also but as it returns a promise at the moment, + // we just do a simple check + if (possibleReviewRequest.isErr()) { logger.error({ message: "Invalid review request requested", method: "getReviewRequest", @@ -522,12 +537,12 @@ export class ReviewsRouter { requestId, }, }) - return res.status(possibleReviewRequest.status).send({ - message: possibleReviewRequest.message, + return res.status(possibleReviewRequest.error.status).send({ + message: possibleReviewRequest.error.message, }) } - return res.status(200).json({ reviewRequest: possibleReviewRequest }) + return res.status(200).json({ reviewRequest: possibleReviewRequest.value }) } updateReviewRequest: RequestHandler< @@ -542,7 +557,7 @@ export class ReviewsRouter { const { userWithSiteSessionData } = res.locals const site = await this.sitesService.getBySiteName(siteName) - if (!site) { + if (site.isErr()) { logger.error({ message: "Invalid site requested", method: "updateReviewRequest", @@ -560,7 +575,7 @@ export class ReviewsRouter { // Step 2: Retrieve review request const possibleReviewRequest = await this.reviewRequestService.getReviewRequest( - site, + site.value, requestId ) @@ -641,7 +656,7 @@ export class ReviewsRouter { const { userSessionData } = res.locals const site = await this.sitesService.getBySiteName(siteName) - if (!site) { + if (site.isErr()) { logger.error({ message: "Invalid site requested", method: "mergeReviewRequest", @@ -683,7 +698,7 @@ export class ReviewsRouter { // Step 3: Retrieve review request const possibleReviewRequest = await this.reviewRequestService.getReviewRequest( - site, + site.value, requestId ) @@ -709,7 +724,10 @@ export class ReviewsRouter { // Step 5: Clean up the review request view records // The error is discarded as we are guaranteed to have a review request - await this.reviewRequestService.deleteAllReviewRequestViews(site, requestId) + await this.reviewRequestService.deleteAllReviewRequestViews( + site.value, + requestId + ) return res.status(200).send() } @@ -726,7 +744,7 @@ export class ReviewsRouter { const { userWithSiteSessionData } = res.locals const site = await this.sitesService.getBySiteName(siteName) - if (!site) { + if (site.isErr()) { logger.error({ message: "Invalid site requested", method: "approveReviewRequest", @@ -743,7 +761,7 @@ export class ReviewsRouter { // Step 2: Retrieve review request const possibleReviewRequest = await this.reviewRequestService.getReviewRequest( - site, + site.value, requestId ) @@ -832,18 +850,18 @@ export class ReviewsRouter { userWithSiteSessionData ) - if (!site && doesUnmigratedSiteExist) { + if (site.isErr() && doesUnmigratedSiteExist) { return res.status(200).json({ message: "Unmigrated site" }) } const siteMembers = await this.collaboratorsService.list(siteName) // NOTE: This is an initial migrated site but // we haven't migrated the users. - if (siteMembers.length === 0 && site) { + if (siteMembers.length === 0 && site.isOk()) { return res.status(200).json({ message: "Unmigrated users" }) } - if (!site) { + if (site.isErr()) { logger.error({ message: "Invalid site requested", method: "getComments", @@ -886,7 +904,7 @@ export class ReviewsRouter { // Step 3: Retrieve review request const possibleReviewRequest = await this.reviewRequestService.getReviewRequest( - site, + site.value, requestId ) @@ -907,7 +925,7 @@ export class ReviewsRouter { // Step 4: Retrieve comments const comments = await this.reviewRequestService.getComments( userWithSiteSessionData, - site, + site.value, requestId ) @@ -927,7 +945,7 @@ export class ReviewsRouter { // Step 1: Check that the site exists const site = await this.sitesService.getBySiteName(siteName) - if (!site) { + if (site.isErr()) { logger.error({ message: "Invalid site requested", method: "createComment", @@ -970,7 +988,7 @@ export class ReviewsRouter { // Step 3: Retrieve review request const possibleReviewRequest = await this.reviewRequestService.getReviewRequest( - site, + site.value, requestId ) @@ -1009,7 +1027,7 @@ export class ReviewsRouter { const { siteName, requestId } = req.params const site = await this.sitesService.getBySiteName(siteName) - if (!site) { + if (site.isErr()) { return res.status(404).send({ message: "Please ensure that the site exists!", }) @@ -1033,7 +1051,7 @@ export class ReviewsRouter { // Step 3: Retrieve review request const possibleReviewRequest = await this.reviewRequestService.getReviewRequest( - site, + site.value, requestId ) @@ -1044,7 +1062,7 @@ export class ReviewsRouter { // Step 4: Update user's last viewed timestamp for the review request await this.reviewRequestService.updateReviewRequestLastViewedAt( userWithSiteSessionData, - site, + site.value, possibleReviewRequest ) @@ -1063,7 +1081,7 @@ export class ReviewsRouter { const { userWithSiteSessionData } = res.locals const site = await this.sitesService.getBySiteName(siteName) - if (!site) { + if (site.isErr()) { logger.error({ message: "Invalid site requested", method: "closeReviewRequest", @@ -1080,7 +1098,7 @@ export class ReviewsRouter { // Step 2: Retrieve review request const possibleReviewRequest = await this.reviewRequestService.getReviewRequest( - site, + site.value, requestId ) @@ -1127,7 +1145,10 @@ export class ReviewsRouter { // Step 5: Clean up the review request view records // The error is discarded as we are guaranteed to have a review request - await this.reviewRequestService.deleteAllReviewRequestViews(site, requestId) + await this.reviewRequestService.deleteAllReviewRequestViews( + site.value, + requestId + ) // Step 7: Create notifications const collaborators = await this.collaboratorsService.list(siteName) @@ -1158,7 +1179,7 @@ export class ReviewsRouter { const { userWithSiteSessionData } = res.locals const site = await this.sitesService.getBySiteName(siteName) - if (!site) { + if (site.isErr()) { logger.error({ message: "Invalid site requested", method: "deleteReviewRequestApproval", @@ -1175,7 +1196,7 @@ export class ReviewsRouter { // Step 2: Retrieve review request const possibleReviewRequest = await this.reviewRequestService.getReviewRequest( - site, + site.value, requestId ) @@ -1241,18 +1262,18 @@ export class ReviewsRouter { userWithSiteSessionData ) - if (!site && doesUnmigratedSiteExist) { + if (site.isErr() && doesUnmigratedSiteExist) { return res.status(200).json({ message: "Unmigrated site" }) } const siteMembers = await this.collaboratorsService.list(siteName) // NOTE: This is an initial migrated site but // we haven't migrated the users. - if (siteMembers.length === 0 && site) { + if (siteMembers.length === 0 && site.isOk()) { return res.status(200).json({ message: "Unmigrated users" }) } - if (!site) { + if (site.isErr()) { logger.error({ message: "Invalid site requested", method: "getBlob", @@ -1269,7 +1290,7 @@ export class ReviewsRouter { // Step 2: Retrieve review request const possibleReviewRequest = await this.reviewRequestService.getReviewRequest( - site, + site.value, requestId ) diff --git a/src/server.js b/src/server.js index 140b4aa3c..20e44c7dd 100644 --- a/src/server.js +++ b/src/server.js @@ -32,7 +32,18 @@ import { getAuthenticationMiddleware, getAuthorizationMiddleware, } from "@root/middleware" +import { BaseDirectoryService } from "@root/services/directoryServices/BaseDirectoryService" +import { CollectionPageService } from "@root/services/fileServices/MdPageServices/CollectionPageService" +import { ContactUsPageService } from "@root/services/fileServices/MdPageServices/ContactUsPageService" +import { HomepagePageService } from "@root/services/fileServices/MdPageServices/HomepagePageService" +import { ResourcePageService } from "@root/services/fileServices/MdPageServices/ResourcePageService" +import { SubcollectionPageService } from "@root/services/fileServices/MdPageServices/SubcollectionPageService" +import { UnlinkedPageService } from "@root/services/fileServices/MdPageServices/UnlinkedPageService" +import { CollectionYmlService } from "@root/services/fileServices/YmlFileServices/CollectionYmlService" +import { FooterYmlService } from "@root/services/fileServices/YmlFileServices/FooterYmlService" import { isomerRepoAxiosInstance } from "@services/api/AxiosInstance" +import { ResourceRoomDirectoryService } from "@services/directoryServices/ResourceRoomDirectoryService" +import { ConfigYmlService } from "@services/fileServices/YmlFileServices/ConfigYmlService" import { getIdentityAuthService, getUsersService, @@ -53,6 +64,7 @@ import getAuthenticatedSitesSubrouterV1 from "./routes/v1/authenticatedSites" import getAuthenticatedSubrouter from "./routes/v2/authenticated" import { ReviewsRouter } from "./routes/v2/authenticated/review" import getAuthenticatedSitesSubrouter from "./routes/v2/authenticatedSites" +import { PageService } from "./services/fileServices/MdPageServices/PageService" import CollaboratorsService from "./services/identity/CollaboratorsService" import LaunchClient from "./services/identity/LaunchClient" import LaunchesService from "./services/identity/LaunchesService" @@ -122,9 +134,6 @@ const { FormsgSiteLaunchRouter } = require("@routes/formsgSiteLaunch") const { AuthRouter } = require("@routes/v2/auth") const { GitHubService } = require("@services/db/GitHubService") -const { - ConfigYmlService, -} = require("@services/fileServices/YmlFileServices/ConfigYmlService") const { AuthService } = require("@services/utilServices/AuthService") const authService = new AuthService({ usersService }) @@ -132,13 +141,47 @@ const gitHubService = new GitHubService({ axiosInstance: isomerRepoAxiosInstance, }) const configYmlService = new ConfigYmlService({ gitHubService }) +const footerYmlService = new FooterYmlService({ gitHubService }) +const collectionYmlService = new CollectionYmlService({ gitHubService }) +const baseDirectoryService = new BaseDirectoryService({ gitHubService }) + +const contactUsService = new ContactUsPageService({ + gitHubService, + footerYmlService, +}) +const collectionPageService = new CollectionPageService({ + gitHubService, + collectionYmlService, +}) +const subCollectionPageService = new SubcollectionPageService({ + gitHubService, + collectionYmlService, +}) +const homepageService = new HomepagePageService({ gitHubService }) +const resourcePageService = new ResourcePageService({ gitHubService }) +const unlinkedPageService = new UnlinkedPageService({ gitHubService }) +const resourceRoomDirectoryService = new ResourceRoomDirectoryService({ + baseDirectoryService, + configYmlService, + gitHubService, +}) +const pageService = new PageService({ + collectionPageService, + contactUsService, + subCollectionPageService, + homepageService, + resourcePageService, + unlinkedPageService, + resourceRoomDirectoryService, +}) const reviewRequestService = new ReviewRequestService( gitHubService, User, ReviewRequest, Reviewer, ReviewMeta, - ReviewRequestView + ReviewRequestView, + pageService ) const sitesService = new SitesService({ siteRepository: Site, diff --git a/src/services/fileServices/MdPageServices/ContactUsPageService.js b/src/services/fileServices/MdPageServices/ContactUsPageService.js index 0d9c17c72..c9a3323e5 100644 --- a/src/services/fileServices/MdPageServices/ContactUsPageService.js +++ b/src/services/fileServices/MdPageServices/ContactUsPageService.js @@ -3,7 +3,8 @@ const { convertDataToMarkdown, } = require("@utils/markdown-utils") -const CONTACT_US_FILE_NAME = "contact-us.md" +const { CONTACT_US_FILENAME } = require("@root/constants/pages") + const CONTACT_US_DIRECTORY_NAME = "pages" class ContactUsPageService { @@ -18,7 +19,7 @@ class ContactUsPageService { const { content: rawContent, sha } = await this.gitHubService.read( sessionData, { - fileName: CONTACT_US_FILE_NAME, + fileName: CONTACT_US_FILENAME, directoryName: CONTACT_US_DIRECTORY_NAME, } ) @@ -37,7 +38,7 @@ class ContactUsPageService { const { newSha } = await this.gitHubService.update(sessionData, { fileContent: newContent, sha, - fileName: CONTACT_US_FILE_NAME, + fileName: CONTACT_US_FILENAME, directoryName: CONTACT_US_DIRECTORY_NAME, }) const { diff --git a/src/services/fileServices/MdPageServices/HomepagePageService.js b/src/services/fileServices/MdPageServices/HomepagePageService.js index fe267717a..cf82590df 100644 --- a/src/services/fileServices/MdPageServices/HomepagePageService.js +++ b/src/services/fileServices/MdPageServices/HomepagePageService.js @@ -3,7 +3,7 @@ const { convertDataToMarkdown, } = require("@utils/markdown-utils") -const HOMEPAGE_FILE_NAME = "index.md" +const { HOMEPAGE_NAME } = require("@root/constants") class HomepagePageService { constructor({ gitHubService }) { @@ -14,7 +14,7 @@ class HomepagePageService { const { content: rawContent, sha } = await this.gitHubService.read( sessionData, { - fileName: HOMEPAGE_FILE_NAME, + fileName: HOMEPAGE_NAME, } ) const { frontMatter, pageContent } = retrieveDataFromMarkdown(rawContent) @@ -26,7 +26,7 @@ class HomepagePageService { const { newSha } = await this.gitHubService.update(sessionData, { fileContent: newContent, sha, - fileName: HOMEPAGE_FILE_NAME, + fileName: HOMEPAGE_NAME, }) return { content: { frontMatter, pageBody: content }, diff --git a/src/services/fileServices/MdPageServices/PageService.ts b/src/services/fileServices/MdPageServices/PageService.ts new file mode 100644 index 000000000..0550b07ea --- /dev/null +++ b/src/services/fileServices/MdPageServices/PageService.ts @@ -0,0 +1,399 @@ +import { ok, err, Result, ResultAsync, okAsync, errAsync } from "neverthrow" + +import UserSessionData from "@root/classes/UserSessionData" +import { CONTACT_US_FILENAME, HOMEPAGE_FILENAME } from "@root/constants" +import { BaseIsomerError } from "@root/errors/BaseError" +import EmptyStringError from "@root/errors/EmptyStringError" +import MissingResourceRoomError from "@root/errors/MissingResourceRoomError" +import { NotFoundError } from "@root/errors/NotFoundError" +import { ResourceRoomDirectoryService } from "@root/services/directoryServices/ResourceRoomDirectoryService" +import { + CollectionPageName, + ContactUsPageName, + HomepageName, + PageName, + ResourceCategoryPageName, + ResourceRoomName, + StagingPermalink, + SubcollectionPageName, + UnlinkedPageName, + PathInfo, + Homepage, + PageInfo, + ContactUsPage, + CollectionPage, + SubcollectionPage, + ResourceCategoryPage, + UnlinkedPage, +} from "@root/types/pages" +import { Brand } from "@root/types/util" + +import { CollectionPageService } from "./CollectionPageService" +import { ContactUsPageService } from "./ContactUsPageService" +import { HomepagePageService } from "./HomepagePageService" +import { ResourcePageService } from "./ResourcePageService" +import { SubcollectionPageService } from "./SubcollectionPageService" +import { UnlinkedPageService } from "./UnlinkedPageService" + +// NOTE: This handler retrieves data from github then parses it +// and it should handle the errors appropriately. +// We return `BaseIsomerError` as a stop gap measure but in the future, +// this should return a sum type of the possible errors. +// This is (at a glance), either a failure to read from github +// or the parsing fails (we have a sentinel value of an empty string) +const withErrorHandler = (promise: Promise) => + ResultAsync.fromPromise(promise, () => new BaseIsomerError()).andThen( + (data) => + // NOTE: This is a fail-safe check as `yaml.parse` + // doesn't guarantee existence. + data?.content?.frontMatter?.permalink + ? ok(data) + : err(new BaseIsomerError()) + ) + +const extractStagingPermalink = (stagingLink: string) => ({ + content, +}: PageInfo): StagingPermalink => + Brand.fromString(`${stagingLink}${content.frontMatter.permalink}`) + +interface PageServiceProps { + contactUsService: ContactUsPageService + collectionPageService: CollectionPageService + subCollectionPageService: SubcollectionPageService + homepageService: HomepagePageService + resourcePageService: ResourcePageService + unlinkedPageService: UnlinkedPageService + resourceRoomDirectoryService: ResourceRoomDirectoryService +} + +// eslint-disable-next-line import/prefer-default-export +export class PageService { + private contactUsService: ContactUsPageService + + private collectionPageService: CollectionPageService + + private subCollectionPageService: SubcollectionPageService + + private homepageService: HomepagePageService + + private resourcePageService: ResourcePageService + + private unlinkedPageService: UnlinkedPageService + + private resourceRoomDirectoryService: ResourceRoomDirectoryService + + constructor({ + contactUsService, + collectionPageService, + subCollectionPageService, + homepageService, + resourcePageService, + unlinkedPageService, + resourceRoomDirectoryService, + }: PageServiceProps) { + this.contactUsService = contactUsService + this.collectionPageService = collectionPageService + this.subCollectionPageService = subCollectionPageService + this.homepageService = homepageService + this.resourcePageService = resourcePageService + this.unlinkedPageService = unlinkedPageService + this.resourceRoomDirectoryService = resourceRoomDirectoryService + } + + /** + * This method assumes that only 1 call to `_config.yml` is needed + * to determine a page's name. + * + * In order for this assumption to hold true, we maintain the invariant that + * the method call **must always** act upon some existing fully qualified file path from + * the frontend. + * + * This is done to avoid expensive calls to fetch a page's raw blob data and + * parsing it thereafter which, for big files, might be expensive. + * @param pageName A **valid and fully qualified** file path + * @param sessionData session credentials of the user + */ + parsePageName = ( + pageName: string, + sessionData: UserSessionData + ): ResultAsync => + this.parseHomepage(pageName) + // NOTE: Order is important as `contact-us` and unlinked pages + // are both rooted at `/pages` + .orElse(() => this.parseContactUsPage(pageName)) + .orElse(() => this.parseUnlinkedPages(pageName)) + .asyncAndThen(okAsync) + // NOTE: We read the `_config.yml` to determine if it is a resource page. + // If it is not, we assume that this is a collection page. + // Because this method is invoked on existing file paths from the frontend, + // this assumption will hold true. + .orElse(() => this.parseResourceRoomPage(pageName, sessionData)) + .orElse(() => this.parseCollectionPage(pageName)) + + // NOTE: Collection pages can be nested in either a collection: a/collection + // or within a sub-collection: a/sub/collection + private parseCollectionPage = ( + pageName: string + ): ResultAsync< + CollectionPageName | SubcollectionPageName, + EmptyStringError | NotFoundError + > => + this.extractPathInfo(pageName).asyncAndThen(({ name, path }) => + path + .mapErr(() => new NotFoundError()) + .asyncAndThen((rawPath) => { + if (rawPath.length === 1 && !!rawPath[0]) { + return okAsync({ + name: Brand.fromString(name), + collection: rawPath[0], + kind: "CollectionPage", + }) + } + if (rawPath.length === 2 && !!rawPath[0] && !!rawPath[1]) { + return okAsync({ + name: Brand.fromString(name), + collection: rawPath[0], + subcollection: rawPath[1], + kind: "SubcollectionPage", + }) + } + return errAsync( + new NotFoundError( + `Error when parsing path: ${rawPath}, please ensure that the file exists!` + ) + ) + }) + ) + + private parseHomepage = ( + pageName: string + ): Result => + this.extractPathInfo(pageName).andThen( + ({ name, path }) => { + if (path.isErr() && name === HOMEPAGE_FILENAME) { + return ok({ name: Brand.fromString(name), kind: "Homepage" }) + } + return err(new NotFoundError()) + } + ) + + // NOTE: The contact us page has a fixed structure + // It needs to be rooted at `/pages/contact-us` + private parseContactUsPage = ( + pageName: string + ): Result => + this.extractPathInfo(pageName).andThen( + ({ name, path }) => { + if ( + path.isOk() && + path.value.pop() === "pages" && + name === CONTACT_US_FILENAME + ) { + return ok({ name: Brand.fromString(name), kind: "ContactUsPage" }) + } + return err(new NotFoundError()) + } + ) + + private parseUnlinkedPages = ( + pageName: string + ): Result => + this.extractPathInfo(pageName) + .andThen(({ path, name }) => + path + .map((rawPath) => rawPath.length === 1 && rawPath[0] === "pages") + .andThen((isPages) => + isPages + ? ok({ name: Brand.fromString(name), kind: "UnlinkedPage" }) + : err(new NotFoundError()) + ) + ) + // NOTE: If there's no containing folder, it's not an unlinked page. + .mapErr(() => new NotFoundError()) + + // NOTE: All resource room pages are pre-fixed by the resource room name. + // The page can be nested 1 or 2 levels deep: + // eg: one/level or two/levels/deep + private parseResourceRoomPage = ( + pageName: string, + sessionData: UserSessionData + ): ResultAsync< + ResourceCategoryPageName, + NotFoundError | MissingResourceRoomError + > => + this.extractResourceRoomName(sessionData).andThen((name) => + this.extractResourcePageName(name, pageName, sessionData) + ) + + private extractResourcePageName = ( + resourceRoomName: ResourceRoomName, + pageName: string, + sessionData: UserSessionData + ): ResultAsync => + this.extractPathInfo(pageName) + .asyncAndThen(({ name, path }) => + path.asyncAndThen( + (rawPath) => { + if (rawPath[0] !== resourceRoomName.name) { + return errAsync(new NotFoundError()) + } + + if (rawPath.length !== 3 && rawPath.at(-1) !== "_posts") { + return errAsync(new NotFoundError()) + } + + // NOTE: We need to read the frontmatter and check the layout. + // The `layout` needs to be `post` for us to give a staging url + // as the others are either an ext link or a file. + // Because we only have the filename at this point, it is + // insufficient to use that to determine the resource type. + // This is because the actual underlying resource can be + // named totally differently from the containing github file. + return ResultAsync.fromPromise( + this.resourcePageService.read(sessionData, { + fileName: name, + resourceRoomName: resourceRoomName.name, + resourceCategoryName: rawPath[1], + }), + () => new NotFoundError() + ).andThen( + ({ content }) => { + if (content.frontMatter.layout !== "post") + return errAsync(new NotFoundError()) + return okAsync({ + name: Brand.fromString(name), + resourceRoom: resourceRoomName.name, + resourceCategory: rawPath[1], + kind: "ResourceCategoryPage", + }) + } + ) + } + ) + ) + // NOTE: If we get an empty string as the `pageName`, + // we just treat the file as not being found + .mapErr(() => new NotFoundError()) + + // NOTE: This is a safe wrapper over the js file for `getResourceRoomDirectoryName` + extractResourceRoomName = ( + sessionData: UserSessionData + ): ResultAsync => + ResultAsync.fromPromise( + this.resourceRoomDirectoryService.getResourceRoomDirectoryName( + sessionData + ), + // NOTE: Assumed that errors are of a 4xx nature rather than 5xx + () => new MissingResourceRoomError() + ).andThen( + ({ resourceRoomName }: { resourceRoomName: string }) => + // NOTE: Underlying service can return this as `null` or as an empty string + resourceRoomName + ? ok({ + name: Brand.fromString(resourceRoomName), + kind: "ResourceRoomName", + }) + : err(new MissingResourceRoomError()) + ) + + extractPathInfo = (pageName: string): Result => { + if (!pageName) { + return err(new EmptyStringError()) + } + + const fullPath = pageName.split("/") + // NOTE: Name is guaranteed to exist + // as this method only accepts a string + // and we've validated that the string is not empty + const name = fullPath.pop()! + + if (fullPath.length === 0) { + return ok({ + name, + path: err([]), + }) + } + + return ok({ + name, + path: ok(fullPath), + }) + } + + retrieveStagingPermalink = ( + sessionData: UserSessionData, + stagingLink: StagingPermalink, + pageName: PageName + ): ResultAsync => { + const withPermalink = extractStagingPermalink(stagingLink) + switch (pageName.kind) { + // NOTE: For both collections and subcollections, + // the service method will automatically append an `_` + // in front of the collection name (which is reflected in the raw name here). + case "CollectionPage": { + return withErrorHandler( + this.collectionPageService + .read(sessionData, { + fileName: pageName.name, + collectionName: pageName.collection.slice(1), + }) + .then((collectionPage) => collectionPage as CollectionPage) + ).map(withPermalink) + } + case "SubcollectionPage": { + return withErrorHandler( + this.subCollectionPageService + .read(sessionData, { + fileName: pageName.name, + collectionName: pageName.collection.slice(1), + subcollectionName: pageName.subcollection, + }) + .then((subcollectionPage) => subcollectionPage as SubcollectionPage) + ).map(withPermalink) + } + case "ResourceCategoryPage": { + return withErrorHandler( + this.resourcePageService + .read(sessionData, { + fileName: pageName.name, + resourceCategoryName: pageName.resourceCategory, + resourceRoomName: pageName.resourceRoom, + }) + .then( + (resourceCategoryPage) => + resourceCategoryPage as ResourceCategoryPage + ) + ).map(withPermalink) + } + case "Homepage": { + return withErrorHandler( + this.homepageService + .read(sessionData) + .then((homepage) => homepage as Homepage) + ).map(withPermalink) + } + case "ContactUsPage": { + return withErrorHandler( + this.contactUsService + .read(sessionData) + .then((contactUsPage) => contactUsPage as ContactUsPage) + ).map(withPermalink) + } + case "UnlinkedPage": { + return withErrorHandler( + this.unlinkedPageService + .read(sessionData, { + fileName: pageName.name, + }) + .then((unlinkedPage) => unlinkedPage as UnlinkedPage) + ).map(withPermalink) + } + default: { + const error: never = pageName + throw new Error( + `Expected all cases to be matched for page types. Received ${error}` + ) + } + } + } +} diff --git a/src/services/fileServices/MdPageServices/__tests__/ContactUsPageService.spec.js b/src/services/fileServices/MdPageServices/__tests__/ContactUsPageService.spec.js index 592dbeae0..b1bb97abf 100644 --- a/src/services/fileServices/MdPageServices/__tests__/ContactUsPageService.spec.js +++ b/src/services/fileServices/MdPageServices/__tests__/ContactUsPageService.spec.js @@ -7,6 +7,7 @@ const { footerContent: mockFooterContent, footerSha: mockFooterSha, } = require("@fixtures/footer") +const { CONTACT_US_FILENAME } = require("@root/constants/pages") const { NotFoundError } = require("@root/errors/NotFoundError") describe("ContactUs Page Service", () => { @@ -14,7 +15,6 @@ describe("ContactUs Page Service", () => { const accessToken = "test-token" const reqDetails = { siteName, accessToken } - const CONTACT_US_FILE_NAME = "contact-us.md" const CONTACT_US_DIRECTORY_NAME = "pages" const mockFrontMatter = { @@ -80,7 +80,7 @@ describe("ContactUs Page Service", () => { mockRawContactUsContent ) expect(mockGithubService.read).toHaveBeenCalledWith(reqDetails, { - fileName: CONTACT_US_FILE_NAME, + fileName: CONTACT_US_FILENAME, directoryName: CONTACT_US_DIRECTORY_NAME, }) expect(mockFooterYmlService.read).toHaveBeenCalledWith(reqDetails) @@ -95,7 +95,7 @@ describe("ContactUs Page Service", () => { mockRawContactUsContent ) expect(mockGithubService.read).toHaveBeenCalledWith(reqDetails, { - fileName: CONTACT_US_FILE_NAME, + fileName: CONTACT_US_FILENAME, directoryName: CONTACT_US_DIRECTORY_NAME, }) expect(mockFooterYmlService.read).toHaveBeenCalledWith(reqDetails) @@ -112,7 +112,7 @@ describe("ContactUs Page Service", () => { feedback: updatedFeedback, } const updateReq = { - fileName: CONTACT_US_FILE_NAME, + fileName: CONTACT_US_FILENAME, content: mockContent, frontMatter: mockUpdatedFrontMatter, sha: oldSha, @@ -132,7 +132,7 @@ describe("ContactUs Page Service", () => { mockContent ) expect(mockGithubService.update).toHaveBeenCalledWith(reqDetails, { - fileName: CONTACT_US_FILE_NAME, + fileName: CONTACT_US_FILENAME, directoryName: CONTACT_US_DIRECTORY_NAME, fileContent: mockRawContactUsContent, sha: oldSha, @@ -149,7 +149,7 @@ describe("ContactUs Page Service", () => { it("Propagates the correct error on failed update", async () => { mockGithubService.update.mockRejectedValueOnce(new NotFoundError("")) const updateReq = { - fileName: CONTACT_US_FILE_NAME, + fileName: CONTACT_US_FILENAME, content: mockContent, frontMatter: mockFrontMatter, sha: oldSha, @@ -164,7 +164,7 @@ describe("ContactUs Page Service", () => { mockContent ) expect(mockGithubService.update).toHaveBeenCalledWith(reqDetails, { - fileName: CONTACT_US_FILE_NAME, + fileName: CONTACT_US_FILENAME, directoryName: CONTACT_US_DIRECTORY_NAME, fileContent: mockRawContactUsContent, sha: oldSha, diff --git a/src/services/fileServices/MdPageServices/__tests__/HomepagePageService.spec.js b/src/services/fileServices/MdPageServices/__tests__/HomepagePageService.spec.js index 2e89645e8..e12c6d9c0 100644 --- a/src/services/fileServices/MdPageServices/__tests__/HomepagePageService.spec.js +++ b/src/services/fileServices/MdPageServices/__tests__/HomepagePageService.spec.js @@ -3,14 +3,13 @@ const { homepageSha: mockHomepageSha, rawHomepageContent: mockRawHomepageContent, } = require("@fixtures/homepage") +const { HOMEPAGE_NAME } = require("@root/constants") describe("Homepage Page Service", () => { const siteName = "test-site" const accessToken = "test-token" const reqDetails = { siteName, accessToken } - const HOMEPAGE_FILE_NAME = "index.md" - const mockFrontMatter = mockHomepageContent.frontMatter const mockContent = mockHomepageContent.pageBody @@ -59,7 +58,7 @@ describe("Homepage Page Service", () => { mockRawHomepageContent ) expect(mockGithubService.read).toHaveBeenCalledWith(reqDetails, { - fileName: HOMEPAGE_FILE_NAME, + fileName: HOMEPAGE_NAME, }) }) }) @@ -70,7 +69,7 @@ describe("Homepage Page Service", () => { it("Updating page content works correctly", async () => { await expect( service.update(reqDetails, { - fileName: HOMEPAGE_FILE_NAME, + fileName: HOMEPAGE_NAME, content: mockContent, frontMatter: mockFrontMatter, sha: oldSha, @@ -85,7 +84,7 @@ describe("Homepage Page Service", () => { mockContent ) expect(mockGithubService.update).toHaveBeenCalledWith(reqDetails, { - fileName: HOMEPAGE_FILE_NAME, + fileName: HOMEPAGE_NAME, fileContent: mockRawHomepageContent, sha: oldSha, }) diff --git a/src/services/fileServices/MdPageServices/__tests__/PageService.spec.ts b/src/services/fileServices/MdPageServices/__tests__/PageService.spec.ts new file mode 100644 index 000000000..9d4268523 --- /dev/null +++ b/src/services/fileServices/MdPageServices/__tests__/PageService.spec.ts @@ -0,0 +1,956 @@ +import { err, ok } from "neverthrow" + +import { CONTACT_US_FILENAME, HOMEPAGE_FILENAME } from "@root/constants" +import { BaseIsomerError } from "@root/errors/BaseError" +import EmptyStringError from "@root/errors/EmptyStringError" +import MissingResourceRoomError from "@root/errors/MissingResourceRoomError" +import { NotFoundError } from "@root/errors/NotFoundError" +import { MOCK_STAGING_URL_GITHUB } from "@root/fixtures/repoInfo" +import { MOCK_USER_SESSION_DATA_ONE } from "@root/fixtures/sessionData" +import { + CollectionPageName, + ContactUsPageName, + HomepageName, + ResourceCategoryPageName, + SubcollectionPageName, + UnlinkedPageName, +} from "@root/types/pages" +import { Brand } from "@root/types/util" +import { ResourceRoomDirectoryService } from "@services/directoryServices/ResourceRoomDirectoryService" + +import { CollectionPageService } from "../CollectionPageService" +import { ContactUsPageService } from "../ContactUsPageService" +import { HomepagePageService } from "../HomepagePageService" +import { PageService } from "../PageService" +import { ResourcePageService } from "../ResourcePageService" +import { SubcollectionPageService } from "../SubcollectionPageService" +import { UnlinkedPageService } from "../UnlinkedPageService" + +const mockContactUsService = jest.mocked(({ + read: jest.fn(), +} as unknown) as ContactUsPageService) +const mockCollectionPageService = jest.mocked(({ + read: jest.fn(), +} as unknown) as CollectionPageService) +const mockSubcollectionPageService = jest.mocked(({ + read: jest.fn(), +} as unknown) as SubcollectionPageService) +const mockHomepageService = jest.mocked(({ + read: jest.fn(), +} as unknown) as HomepagePageService) +const mockResourcePageService = jest.mocked(({ + read: jest.fn(), +} as unknown) as ResourcePageService) +const mockUnlinkedPageService = jest.mocked(({ + read: jest.fn(), +} as unknown) as UnlinkedPageService) +const mockResourceRoomDirectoryService = jest.mocked( + ({ + getResourceRoomDirectoryName: jest.fn(), + } as unknown) as ResourceRoomDirectoryService +) +const pageService = new PageService({ + contactUsService: mockContactUsService, + collectionPageService: mockCollectionPageService, + subCollectionPageService: mockSubcollectionPageService, + homepageService: mockHomepageService, + resourcePageService: mockResourcePageService, + unlinkedPageService: mockUnlinkedPageService, + resourceRoomDirectoryService: mockResourceRoomDirectoryService, +}) + +const MOCK_RESOURCE_ROOM_NAME = "resources" +const MOCK_UNLINKED_PAGE_NAME = "some_page" +const MOCK_COLLECTION_NAME = "a_collection" +const MOCK_SUBCOLLECTION_NAME = "submarine" +const MOCK_RESOURCE_CATEGORY_NAME = "meow" +const createMockStagingPermalink = (mockPageName: string) => + `${MOCK_STAGING_URL_GITHUB}/${mockPageName}` +const createMockFrontMatter = (mockPageName: string) => ({ + fileName: MOCK_UNLINKED_PAGE_NAME, + content: { + frontMatter: { + permalink: `/${mockPageName}`, + }, + pageBody: "", + }, + sha: "", +}) + +describe("PageService", () => { + describe("parsePageName", () => { + it("should only accept 'index.md' as the homepage name", async () => { + // Arrange + const expected = ok({ + name: HOMEPAGE_FILENAME, + kind: "Homepage", + }) + + // Act + const actual = await pageService.parsePageName( + "index.md", + MOCK_USER_SESSION_DATA_ONE + ) + + // Assert + expect(actual).toEqual(expected) + }) + + it("should accept 'pages/contact-us.md' as the contact-us page name", async () => { + // Arrange + const expected = ok({ + name: CONTACT_US_FILENAME, + kind: "ContactUsPage", + }) + + // Act + const actual = await pageService.parsePageName( + "pages/contact-us.md", + MOCK_USER_SESSION_DATA_ONE + ) + + // Assert + expect(actual).toEqual(expected) + }) + + it('should parse "contact-us.md" into an error', async () => { + // Arrange + const expected = err(new NotFoundError()) + mockResourceRoomDirectoryService.getResourceRoomDirectoryName.mockResolvedValueOnce( + { resourceRoomName: MOCK_RESOURCE_ROOM_NAME } + ) + + // Act + const actual = await pageService.parsePageName( + CONTACT_US_FILENAME, + MOCK_USER_SESSION_DATA_ONE + ) + + // Assert + expect(actual).toEqual(expected) + }) + + it('should parse "pages/" into an unlinked page name', async () => { + // Arrange + const expected = ok({ + name: MOCK_UNLINKED_PAGE_NAME, + kind: "UnlinkedPage", + }) + + // Act + const actual = await pageService.parsePageName( + `pages/${MOCK_UNLINKED_PAGE_NAME}`, + MOCK_USER_SESSION_DATA_ONE + ) + + // Assert + expect(actual).toEqual(expected) + }) + + it("should parse filepaths like `//_posts/` into a resource category page name if the layout is `post`", async () => { + // Arrange + const expected = ok({ + name: MOCK_UNLINKED_PAGE_NAME, + resourceRoom: MOCK_RESOURCE_ROOM_NAME, + resourceCategory: MOCK_RESOURCE_CATEGORY_NAME, + kind: "ResourceCategoryPage", + }) + mockResourceRoomDirectoryService.getResourceRoomDirectoryName.mockResolvedValueOnce( + { resourceRoomName: MOCK_RESOURCE_ROOM_NAME } + ) + mockResourcePageService.read.mockResolvedValueOnce({ + content: { + frontMatter: { + layout: "post", + }, + pageBody: "", + }, + fileName: MOCK_UNLINKED_PAGE_NAME, + sha: "sha", + }) + + // Act + const actual = await pageService.parsePageName( + `${MOCK_RESOURCE_ROOM_NAME}/${MOCK_RESOURCE_CATEGORY_NAME}/_posts/${MOCK_UNLINKED_PAGE_NAME}`, + MOCK_USER_SESSION_DATA_ONE + ) + + // Assert + expect(actual).toEqual(expected) + expect(mockResourcePageService.read).toBeCalled() + }) + + it("should return `NotFoundError` if the `layout` of the resource item is not `post`", async () => { + // Arrange + const MOCK_RESOURCE_ITEM = `${MOCK_RESOURCE_ROOM_NAME}/${MOCK_RESOURCE_CATEGORY_NAME}/_posts/${MOCK_UNLINKED_PAGE_NAME}` + const expected = err( + new NotFoundError( + `Error when parsing path: ${MOCK_RESOURCE_ITEM.split("/").slice( + 0, + -1 + )}, please ensure that the file exists!` + ) + ) + mockResourceRoomDirectoryService.getResourceRoomDirectoryName.mockResolvedValueOnce( + { resourceRoomName: MOCK_RESOURCE_ROOM_NAME } + ) + mockResourcePageService.read.mockResolvedValueOnce({ + content: { + frontMatter: { + layout: "file", + }, + pageBody: "", + }, + fileName: MOCK_UNLINKED_PAGE_NAME, + sha: "sha", + }) + + // Act + const actual = await pageService.parsePageName( + MOCK_RESOURCE_ITEM, + MOCK_USER_SESSION_DATA_ONE + ) + + // Assert + expect(actual).toEqual(expected) + expect(mockResourcePageService.read).toBeCalled() + }) + + it("should parse 2 level filepaths without 'index.md' into a collection name if there is no resource room name", async () => { + // Arrange + const expected = ok({ + name: MOCK_UNLINKED_PAGE_NAME, + collection: MOCK_COLLECTION_NAME, + kind: "CollectionPage", + }) + mockResourceRoomDirectoryService.getResourceRoomDirectoryName.mockRejectedValueOnce( + null + ) + + // Act + const actual = await pageService.parsePageName( + `${MOCK_COLLECTION_NAME}/${MOCK_UNLINKED_PAGE_NAME}`, + MOCK_USER_SESSION_DATA_ONE + ) + + // Assert + expect(actual).toEqual(expected) + expect(mockResourcePageService.read).toBeCalled() + }) + + it("should parse 3 level filepaths without 'index.md' into a sub-collection name if there is no resource room name", async () => { + // Arrange + const expected = ok({ + name: MOCK_UNLINKED_PAGE_NAME, + collection: MOCK_COLLECTION_NAME, + subcollection: MOCK_SUBCOLLECTION_NAME, + kind: "SubcollectionPage", + }) + mockResourceRoomDirectoryService.getResourceRoomDirectoryName.mockRejectedValueOnce( + null + ) + + // Act + const actual = await pageService.parsePageName( + `${MOCK_COLLECTION_NAME}/${MOCK_SUBCOLLECTION_NAME}/${MOCK_UNLINKED_PAGE_NAME}`, + MOCK_USER_SESSION_DATA_ONE + ) + + // Assert + expect(actual).toEqual(expected) + expect(mockResourcePageService.read).toBeCalled() + }) + + it("should parse two level filepaths including 'index.md' to `NotFoundError`", async () => { + // Arrange + const expected = err( + new NotFoundError( + `Error when parsing path: , please ensure that the file exists!` + ) + ) + mockResourceRoomDirectoryService.getResourceRoomDirectoryName.mockResolvedValueOnce( + { resourceRoomName: MOCK_RESOURCE_ROOM_NAME } + ) + + // Act + const actual = await pageService.parsePageName( + // NOTE: Extra front slash + `/${HOMEPAGE_FILENAME}`, + MOCK_USER_SESSION_DATA_ONE + ) + + // Assert + expect(actual).toEqual(expected) + expect(mockResourcePageService.read).toBeCalled() + }) + + it("should parse empty strings into `EmptyStringError`", async () => { + // Arrange + const expected = err(new EmptyStringError()) + mockResourceRoomDirectoryService.getResourceRoomDirectoryName.mockResolvedValueOnce( + { resourceRoomName: MOCK_RESOURCE_ROOM_NAME } + ) + + // Act + const actual = await pageService.parsePageName( + "", + MOCK_USER_SESSION_DATA_ONE + ) + + // Assert + expect(actual).toEqual(expected) + expect(mockResourcePageService.read).toBeCalled() + }) + + it("should parse `/` into `NotFoundError`", async () => { + // Arrange + const expected = err( + new NotFoundError( + "Error when parsing path: , please ensure that the file exists!" + ) + ) + mockResourceRoomDirectoryService.getResourceRoomDirectoryName.mockResolvedValueOnce( + { resourceRoomName: MOCK_RESOURCE_ROOM_NAME } + ) + + // Act + const actual = await pageService.parsePageName( + "/", + MOCK_USER_SESSION_DATA_ONE + ) + + // Assert + expect(actual).toEqual(expected) + expect(mockResourcePageService.read).toBeCalled() + }) + + it("should parse single level filepaths that are not 'index.md' into `NotFoundError`", async () => { + // Arrange + const expected = err(new NotFoundError()) + mockResourceRoomDirectoryService.getResourceRoomDirectoryName.mockResolvedValueOnce( + { resourceRoomName: MOCK_RESOURCE_ROOM_NAME } + ) + + // Act + const actual = await pageService.parsePageName( + "gibberish", + MOCK_USER_SESSION_DATA_ONE + ) + + // Assert + expect(actual).toEqual(expected) + expect(mockResourcePageService.read).toBeCalled() + }) + }) + + describe("extractResourceRoomName", () => { + it("should call the underlying service and return a result if the promise resolves", async () => { + // Arrange + const expected = ok({ + name: MOCK_RESOURCE_ROOM_NAME, + kind: "ResourceRoomName", + }) + mockResourceRoomDirectoryService.getResourceRoomDirectoryName.mockResolvedValueOnce( + { resourceRoomName: MOCK_RESOURCE_ROOM_NAME } + ) + + // Act + const actual = await pageService.extractResourceRoomName( + MOCK_USER_SESSION_DATA_ONE + ) + + // Assert + expect(actual).toEqual(expected) + expect( + mockResourceRoomDirectoryService.getResourceRoomDirectoryName + ).toBeCalledWith(MOCK_USER_SESSION_DATA_ONE) + }) + + it("should call the underlying service and return a `MissingResourceRoomError` if the promise resolves to `null`", async () => { + // Arrange + const expected = err(new MissingResourceRoomError()) + mockResourceRoomDirectoryService.getResourceRoomDirectoryName.mockResolvedValueOnce( + { resourceRoomName: null } + ) + + // Act + const actual = await pageService.extractResourceRoomName( + MOCK_USER_SESSION_DATA_ONE + ) + + // Assert + expect(actual).toEqual(expected) + expect( + mockResourceRoomDirectoryService.getResourceRoomDirectoryName + ).toBeCalledWith(MOCK_USER_SESSION_DATA_ONE) + }) + + it("should call the underlying service and return a `MissingResourceRoomError` if the promise rejects", async () => { + // Arrange + const expected = err(new MissingResourceRoomError()) + mockResourceRoomDirectoryService.getResourceRoomDirectoryName.mockRejectedValueOnce( + null + ) + + // Act + const actual = await pageService.extractResourceRoomName( + MOCK_USER_SESSION_DATA_ONE + ) + + // Assert + expect(actual).toEqual(expected) + expect( + mockResourceRoomDirectoryService.getResourceRoomDirectoryName + ).toBeCalledWith(MOCK_USER_SESSION_DATA_ONE) + }) + }) + + describe("extractPathInfo", () => { + it("should return a `PathInfo` with a valid path when the string provided is a valid filepath", () => { + // Arrange + const expected = ok({ + name: MOCK_UNLINKED_PAGE_NAME, + path: ok([MOCK_RESOURCE_ROOM_NAME, MOCK_RESOURCE_CATEGORY_NAME]), + }) + + // Act + const actual = pageService.extractPathInfo( + `${MOCK_RESOURCE_ROOM_NAME}/${MOCK_RESOURCE_CATEGORY_NAME}/${MOCK_UNLINKED_PAGE_NAME}` + ) + + // Assert + expect(actual).toStrictEqual(expected) + }) + + it("should return a `PathInfo` with an `err` path when the string provided does not contain `/`", () => { + // Arrange + const expected = ok({ + name: MOCK_UNLINKED_PAGE_NAME, + path: err([]), + }) + + // Act + const actual = pageService.extractPathInfo(`${MOCK_UNLINKED_PAGE_NAME}`) + + // Assert + expect(actual).toStrictEqual(expected) + }) + + it("should return a `PathInfo` with an empty string as the name when the `/` terminates the string", () => { + // Arrange + const expected = ok({ + name: "", + path: ok([MOCK_RESOURCE_ROOM_NAME]), + }) + + // Act + const actual = pageService.extractPathInfo(`${MOCK_RESOURCE_ROOM_NAME}/`) + + // Assert + expect(actual).toStrictEqual(expected) + }) + + it("should return a `EmptyStringError` when an empty string is provided as input", () => { + // Arrange + const expected = err(new EmptyStringError()) + + // Act + const actual = pageService.extractPathInfo("") + + // Assert + expect(actual).toEqual(expected) + }) + }) + describe("retrieveStagingPermalink", () => { + it("should call the underlying service and return the `permalink` of the unlinked page when successful", async () => { + // Arrange + const MOCK_PAGE_NAME: UnlinkedPageName = { + name: Brand.fromString(MOCK_UNLINKED_PAGE_NAME), + kind: "UnlinkedPage", + } + mockUnlinkedPageService.read.mockResolvedValueOnce( + createMockFrontMatter(MOCK_UNLINKED_PAGE_NAME) + ) + const expected = ok(createMockStagingPermalink(MOCK_UNLINKED_PAGE_NAME)) + + // Act + const actual = await pageService.retrieveStagingPermalink( + MOCK_USER_SESSION_DATA_ONE, + MOCK_STAGING_URL_GITHUB, + MOCK_PAGE_NAME + ) + + // Assert + expect(actual).toEqual(expected) + expect(mockUnlinkedPageService.read).toHaveBeenCalledWith( + MOCK_USER_SESSION_DATA_ONE, + { + fileName: MOCK_PAGE_NAME.name, + } + ) + }) + + it("should call the underlying service and return a `BaseIsomerError` when the unlinked page could not be fetched", async () => { + // Arrange + const MOCK_PAGE_NAME: UnlinkedPageName = { + name: Brand.fromString(MOCK_UNLINKED_PAGE_NAME), + kind: "UnlinkedPage", + } + mockUnlinkedPageService.read.mockRejectedValueOnce({}) + const expected = err(new BaseIsomerError()) + + // Act + const actual = await pageService.retrieveStagingPermalink( + MOCK_USER_SESSION_DATA_ONE, + MOCK_STAGING_URL_GITHUB, + MOCK_PAGE_NAME + ) + + // Assert + expect(actual).toEqual(expected) + expect(mockUnlinkedPageService.read).toHaveBeenCalledWith( + MOCK_USER_SESSION_DATA_ONE, + { + fileName: MOCK_PAGE_NAME.name, + } + ) + }) + + it("should call the underlying service and return a `BaseIsomerError` when the frontmatter of the unlinked page has no `permalink`", async () => { + // Arrange + const MOCK_PAGE_NAME: UnlinkedPageName = { + name: Brand.fromString(MOCK_UNLINKED_PAGE_NAME), + kind: "UnlinkedPage", + } + mockUnlinkedPageService.read.mockRejectedValueOnce({}) + const expected = err(new BaseIsomerError()) + + // Act + const actual = await pageService.retrieveStagingPermalink( + MOCK_USER_SESSION_DATA_ONE, + MOCK_STAGING_URL_GITHUB, + MOCK_PAGE_NAME + ) + + // Assert + expect(actual).toEqual(expected) + expect(mockUnlinkedPageService.read).toHaveBeenCalledWith( + MOCK_USER_SESSION_DATA_ONE, + { + fileName: MOCK_PAGE_NAME.name, + } + ) + }) + it("should call the underlying service and return the `permalink` of the contact us page when successful", async () => { + // Arrange + const MOCK_PAGE_NAME: ContactUsPageName = { + name: Brand.fromString(CONTACT_US_FILENAME), + kind: "ContactUsPage", + } + mockContactUsService.read.mockResolvedValueOnce( + createMockFrontMatter(CONTACT_US_FILENAME) + ) + const expected = ok(createMockStagingPermalink(CONTACT_US_FILENAME)) + + // Act + const actual = await pageService.retrieveStagingPermalink( + MOCK_USER_SESSION_DATA_ONE, + MOCK_STAGING_URL_GITHUB, + MOCK_PAGE_NAME + ) + + // Assert + expect(actual).toEqual(expected) + expect(mockContactUsService.read).toHaveBeenCalledWith( + MOCK_USER_SESSION_DATA_ONE + ) + }) + + it("should call the underlying service and return a `BaseIsomerError` when the contact us page could not be fetched", async () => { + // Arrange + const MOCK_PAGE_NAME: ContactUsPageName = { + name: Brand.fromString(CONTACT_US_FILENAME), + kind: "ContactUsPage", + } + mockContactUsService.read.mockRejectedValueOnce({}) + const expected = err(new BaseIsomerError()) + + // Act + const actual = await pageService.retrieveStagingPermalink( + MOCK_USER_SESSION_DATA_ONE, + MOCK_STAGING_URL_GITHUB, + MOCK_PAGE_NAME + ) + + // Assert + expect(actual).toEqual(expected) + expect(mockContactUsService.read).toHaveBeenCalledWith( + MOCK_USER_SESSION_DATA_ONE + ) + }) + + it("should call the underlying service and return a `BaseIsomerError` when the frontmatter of the contact-us page has no `permalink`", async () => { + // Arrange + const MOCK_PAGE_NAME: ContactUsPageName = { + name: Brand.fromString(CONTACT_US_FILENAME), + kind: "ContactUsPage", + } + mockContactUsService.read.mockRejectedValueOnce({}) + const expected = err(new BaseIsomerError()) + + // Act + const actual = await pageService.retrieveStagingPermalink( + MOCK_USER_SESSION_DATA_ONE, + MOCK_STAGING_URL_GITHUB, + MOCK_PAGE_NAME + ) + + // Assert + expect(actual).toEqual(expected) + expect(mockContactUsService.read).toHaveBeenCalledWith( + MOCK_USER_SESSION_DATA_ONE + ) + }) + + it("should call the underlying service and return the `permalink` of the homepage when successful", async () => { + // Arrange + const MOCK_PAGE_NAME: HomepageName = { + name: Brand.fromString(HOMEPAGE_FILENAME), + kind: "Homepage", + } + mockHomepageService.read.mockResolvedValueOnce( + createMockFrontMatter(HOMEPAGE_FILENAME) + ) + const expected = ok(createMockStagingPermalink(HOMEPAGE_FILENAME)) + + // Act + const actual = await pageService.retrieveStagingPermalink( + MOCK_USER_SESSION_DATA_ONE, + MOCK_STAGING_URL_GITHUB, + MOCK_PAGE_NAME + ) + + // Assert + expect(actual).toEqual(expected) + expect(mockHomepageService.read).toHaveBeenCalledWith( + MOCK_USER_SESSION_DATA_ONE + ) + }) + + it("should call the underlying service and return a `BaseIsomerError` when the homepage could not be fetched", async () => { + // Arrange + const MOCK_PAGE_NAME: HomepageName = { + name: Brand.fromString(HOMEPAGE_FILENAME), + kind: "Homepage", + } + mockHomepageService.read.mockRejectedValueOnce({}) + const expected = err(new BaseIsomerError()) + + // Act + const actual = await pageService.retrieveStagingPermalink( + MOCK_USER_SESSION_DATA_ONE, + MOCK_STAGING_URL_GITHUB, + MOCK_PAGE_NAME + ) + + // Assert + expect(actual).toEqual(expected) + expect(mockHomepageService.read).toHaveBeenCalledWith( + MOCK_USER_SESSION_DATA_ONE + ) + }) + + it("should call the underlying service and return a `BaseIsomerError` when the frontmatter of the homepage has no `permalink`", async () => { + // Arrange + const MOCK_PAGE_NAME: HomepageName = { + name: Brand.fromString(HOMEPAGE_FILENAME), + kind: "Homepage", + } + mockHomepageService.read.mockRejectedValueOnce({}) + const expected = err(new BaseIsomerError()) + + // Act + const actual = await pageService.retrieveStagingPermalink( + MOCK_USER_SESSION_DATA_ONE, + MOCK_STAGING_URL_GITHUB, + MOCK_PAGE_NAME + ) + + // Assert + expect(actual).toEqual(expected) + expect(mockHomepageService.read).toHaveBeenCalledWith( + MOCK_USER_SESSION_DATA_ONE + ) + }) + + it("should call the underlying service and return the `permalink` of the resource category page when successful", async () => { + // Arrange + const MOCK_PAGE_NAME: ResourceCategoryPageName = { + name: Brand.fromString(MOCK_UNLINKED_PAGE_NAME), + kind: "ResourceCategoryPage", + resourceCategory: MOCK_RESOURCE_CATEGORY_NAME, + resourceRoom: Brand.fromString(MOCK_RESOURCE_ROOM_NAME), + } + mockResourcePageService.read.mockResolvedValueOnce( + createMockFrontMatter(MOCK_UNLINKED_PAGE_NAME) + ) + const expected = ok(createMockStagingPermalink(MOCK_UNLINKED_PAGE_NAME)) + + // Act + const actual = await pageService.retrieveStagingPermalink( + MOCK_USER_SESSION_DATA_ONE, + MOCK_STAGING_URL_GITHUB, + MOCK_PAGE_NAME + ) + + // Assert + expect(actual).toEqual(expected) + expect(mockResourcePageService.read).toHaveBeenCalledWith( + MOCK_USER_SESSION_DATA_ONE, + { + fileName: MOCK_PAGE_NAME.name, + resourceCategoryName: MOCK_PAGE_NAME.resourceCategory, + resourceRoomName: MOCK_PAGE_NAME.resourceRoom, + } + ) + }) + + it("should call the underlying service and return a `BaseIsomerError` when the resource category page could not be fetched", async () => { + // Arrange + const MOCK_PAGE_NAME: ResourceCategoryPageName = { + name: Brand.fromString(MOCK_UNLINKED_PAGE_NAME), + kind: "ResourceCategoryPage", + resourceCategory: MOCK_RESOURCE_CATEGORY_NAME, + resourceRoom: Brand.fromString(MOCK_RESOURCE_ROOM_NAME), + } + mockResourcePageService.read.mockRejectedValueOnce({}) + const expected = err(new BaseIsomerError()) + + // Act + const actual = await pageService.retrieveStagingPermalink( + MOCK_USER_SESSION_DATA_ONE, + MOCK_STAGING_URL_GITHUB, + MOCK_PAGE_NAME + ) + + // Assert + expect(actual).toEqual(expected) + expect(mockResourcePageService.read).toHaveBeenCalledWith( + MOCK_USER_SESSION_DATA_ONE, + { + fileName: MOCK_PAGE_NAME.name, + resourceCategoryName: MOCK_PAGE_NAME.resourceCategory, + resourceRoomName: MOCK_PAGE_NAME.resourceRoom, + } + ) + }) + + it("should call the underlying service and return a `BaseIsomerError` when the frontmatter of the resource category page has no `permalink`", async () => { + // Arrange + const MOCK_PAGE_NAME: ResourceCategoryPageName = { + name: Brand.fromString(MOCK_UNLINKED_PAGE_NAME), + kind: "ResourceCategoryPage", + resourceCategory: MOCK_RESOURCE_CATEGORY_NAME, + resourceRoom: Brand.fromString(MOCK_RESOURCE_ROOM_NAME), + } + mockResourcePageService.read.mockRejectedValueOnce({}) + const expected = err(new BaseIsomerError()) + + // Act + const actual = await pageService.retrieveStagingPermalink( + MOCK_USER_SESSION_DATA_ONE, + MOCK_STAGING_URL_GITHUB, + MOCK_PAGE_NAME + ) + + // Assert + expect(actual).toEqual(expected) + expect(mockResourcePageService.read).toHaveBeenCalledWith( + MOCK_USER_SESSION_DATA_ONE, + { + fileName: MOCK_PAGE_NAME.name, + resourceCategoryName: MOCK_PAGE_NAME.resourceCategory, + resourceRoomName: MOCK_PAGE_NAME.resourceRoom, + } + ) + }) + + it("should call the underlying service and return the `permalink` of the subcollection page when successful", async () => { + // Arrange + const MOCK_PAGE_NAME: SubcollectionPageName = { + name: Brand.fromString(MOCK_UNLINKED_PAGE_NAME), + kind: "SubcollectionPage", + collection: MOCK_COLLECTION_NAME, + subcollection: Brand.fromString(MOCK_SUBCOLLECTION_NAME), + } + mockSubcollectionPageService.read.mockResolvedValueOnce( + createMockFrontMatter(MOCK_UNLINKED_PAGE_NAME) + ) + const expected = ok(createMockStagingPermalink(MOCK_UNLINKED_PAGE_NAME)) + + // Act + const actual = await pageService.retrieveStagingPermalink( + MOCK_USER_SESSION_DATA_ONE, + MOCK_STAGING_URL_GITHUB, + MOCK_PAGE_NAME + ) + + // Assert + expect(actual).toEqual(expected) + expect(mockSubcollectionPageService.read).toHaveBeenCalledWith( + MOCK_USER_SESSION_DATA_ONE, + { + fileName: MOCK_PAGE_NAME.name, + collectionName: MOCK_PAGE_NAME.collection.slice(1), + subcollectionName: MOCK_PAGE_NAME.subcollection, + } + ) + }) + + it("should call the underlying service and return a `BaseIsomerError` when the subcollection page could not be fetched", async () => { + // Arrange + const MOCK_PAGE_NAME: SubcollectionPageName = { + name: Brand.fromString(MOCK_UNLINKED_PAGE_NAME), + kind: "SubcollectionPage", + collection: MOCK_COLLECTION_NAME, + subcollection: Brand.fromString(MOCK_SUBCOLLECTION_NAME), + } + mockSubcollectionPageService.read.mockRejectedValueOnce({}) + const expected = err(new BaseIsomerError()) + + // Act + const actual = await pageService.retrieveStagingPermalink( + MOCK_USER_SESSION_DATA_ONE, + MOCK_STAGING_URL_GITHUB, + MOCK_PAGE_NAME + ) + + // Assert + expect(actual).toEqual(expected) + expect(mockSubcollectionPageService.read).toHaveBeenCalledWith( + MOCK_USER_SESSION_DATA_ONE, + { + fileName: MOCK_PAGE_NAME.name, + collectionName: MOCK_PAGE_NAME.collection.slice(1), + subcollectionName: MOCK_PAGE_NAME.subcollection, + } + ) + }) + + it("should call the underlying service and return a `BaseIsomerError` when the frontmatter of the subcollection page has no `permalink`", async () => { + // Arrange + const MOCK_PAGE_NAME: SubcollectionPageName = { + name: Brand.fromString(MOCK_UNLINKED_PAGE_NAME), + kind: "SubcollectionPage", + collection: MOCK_COLLECTION_NAME, + subcollection: Brand.fromString(MOCK_SUBCOLLECTION_NAME), + } + mockSubcollectionPageService.read.mockRejectedValueOnce({}) + const expected = err(new BaseIsomerError()) + + // Act + const actual = await pageService.retrieveStagingPermalink( + MOCK_USER_SESSION_DATA_ONE, + MOCK_STAGING_URL_GITHUB, + MOCK_PAGE_NAME + ) + + // Assert + expect(actual).toEqual(expected) + expect(mockSubcollectionPageService.read).toHaveBeenCalledWith( + MOCK_USER_SESSION_DATA_ONE, + { + fileName: MOCK_PAGE_NAME.name, + collectionName: MOCK_PAGE_NAME.collection.slice(1), + subcollectionName: MOCK_PAGE_NAME.subcollection, + } + ) + }) + + it("should call the underlying service and return the `permalink` of the collection page when successful", async () => { + // Arrange + const MOCK_PAGE_NAME: CollectionPageName = { + name: Brand.fromString(MOCK_UNLINKED_PAGE_NAME), + kind: "CollectionPage", + collection: MOCK_COLLECTION_NAME, + } + mockCollectionPageService.read.mockResolvedValueOnce( + createMockFrontMatter(MOCK_UNLINKED_PAGE_NAME) + ) + const expected = ok(createMockStagingPermalink(MOCK_UNLINKED_PAGE_NAME)) + + // Act + const actual = await pageService.retrieveStagingPermalink( + MOCK_USER_SESSION_DATA_ONE, + MOCK_STAGING_URL_GITHUB, + MOCK_PAGE_NAME + ) + + // Assert + expect(actual).toEqual(expected) + expect(mockCollectionPageService.read).toHaveBeenCalledWith( + MOCK_USER_SESSION_DATA_ONE, + { + fileName: MOCK_PAGE_NAME.name, + collectionName: MOCK_PAGE_NAME.collection.slice(1), + } + ) + }) + + it("should call the underlying service and return a `BaseIsomerError` when the collection page could not be fetched", async () => { + // Arrange + const MOCK_PAGE_NAME: CollectionPageName = { + name: Brand.fromString(MOCK_UNLINKED_PAGE_NAME), + kind: "CollectionPage", + collection: MOCK_COLLECTION_NAME, + } + mockCollectionPageService.read.mockRejectedValueOnce({}) + const expected = err(new BaseIsomerError()) + + // Act + const actual = await pageService.retrieveStagingPermalink( + MOCK_USER_SESSION_DATA_ONE, + MOCK_STAGING_URL_GITHUB, + MOCK_PAGE_NAME + ) + + // Assert + expect(actual).toEqual(expected) + expect(mockCollectionPageService.read).toHaveBeenCalledWith( + MOCK_USER_SESSION_DATA_ONE, + { + fileName: MOCK_PAGE_NAME.name, + collectionName: MOCK_PAGE_NAME.collection.slice(1), + } + ) + }) + + it("should call the underlying service and return a `BaseIsomerError` when the frontmatter of the collection page has no `permalink`", async () => { + // Arrange + const MOCK_PAGE_NAME: CollectionPageName = { + name: Brand.fromString(MOCK_UNLINKED_PAGE_NAME), + kind: "CollectionPage", + collection: MOCK_COLLECTION_NAME, + } + mockCollectionPageService.read.mockRejectedValueOnce({}) + const expected = err(new BaseIsomerError()) + + // Act + const actual = await pageService.retrieveStagingPermalink( + MOCK_USER_SESSION_DATA_ONE, + MOCK_STAGING_URL_GITHUB, + MOCK_PAGE_NAME + ) + + // Assert + expect(actual).toEqual(expected) + expect(mockCollectionPageService.read).toHaveBeenCalledWith( + MOCK_USER_SESSION_DATA_ONE, + { + fileName: MOCK_PAGE_NAME.name, + collectionName: MOCK_PAGE_NAME.collection.slice(1), + } + ) + }) + }) +}) diff --git a/src/services/identity/CollaboratorsService.ts b/src/services/identity/CollaboratorsService.ts index c9b8b1efb..3029bde9f 100644 --- a/src/services/identity/CollaboratorsService.ts +++ b/src/services/identity/CollaboratorsService.ts @@ -146,7 +146,7 @@ class CollaboratorsService { // 2. Check if site exists const site = await this.sitesService.getBySiteName(siteName) - if (!site) { + if (site.isErr()) { // Error - site does not exist logger.error(`create collaborators error: site ${siteName} is not valid`) return new NotFoundError(`Site does not exist`) @@ -165,7 +165,7 @@ class CollaboratorsService { // 4. Check if user is already a site member const existingSiteMember = await this.siteMemberRepository.findOne({ where: { - siteId: site.id, + siteId: site.value.id, userId: user.id, }, }) @@ -180,7 +180,7 @@ class CollaboratorsService { // 6. Create the SiteMembers record return this.siteMemberRepository.create({ - siteId: site.id, + siteId: site.value.id, userId: user.id, role: derivedRole, }) diff --git a/src/services/identity/SitesService.ts b/src/services/identity/SitesService.ts index 88a040060..607c59396 100644 --- a/src/services/identity/SitesService.ts +++ b/src/services/identity/SitesService.ts @@ -1,4 +1,5 @@ import _ from "lodash" +import { errAsync, okAsync, ResultAsync } from "neverthrow" import { ModelStatic } from "sequelize" import { Deployment, Repo, Site } from "@database/models" @@ -10,18 +11,24 @@ import { GH_MAX_REPO_COUNT, ISOMER_ADMIN_REPOS, } from "@root/constants" +import DatabaseError from "@root/errors/DatabaseError" +import MissingSiteError from "@root/errors/MissingSiteError" +import MissingUserEmailError from "@root/errors/MissingUserEmailError" +import MissingUserError from "@root/errors/MissingUserError" import { NotFoundError } from "@root/errors/NotFoundError" -import RequestNotFoundError from "@root/errors/RequestNotFoundError" import { UnprocessableError } from "@root/errors/UnprocessableError" import { genericGitHubAxiosInstance } from "@root/services/api/AxiosInstance" import { GitHubCommitData } from "@root/types/commitData" import { ConfigYmlData } from "@root/types/configYml" +import { ProdPermalink, StagingPermalink } from "@root/types/pages" import type { GitHubRepositoryData, RepositoryData, SiteUrls, } from "@root/types/repoInfo" import { SiteInfo } from "@root/types/siteInfo" +import { Brand } from "@root/types/util" +import { safeJsonParse } from "@root/utils/json" import { GitHubService } from "@services/db/GitHubService" import { ConfigYmlService } from "@services/fileServices/YmlFileServices/ConfigYmlService" import IsomerAdminsService from "@services/identity/IsomerAdminsService" @@ -86,90 +93,92 @@ class SitesService { return authorEmail } - async insertUrlsFromConfigYml( + insertUrlsFromConfigYml( siteUrls: SiteUrls, sessionData: UserWithSiteSessionData - ): Promise { + ): ResultAsync { if (siteUrls.staging && siteUrls.prod) { // We call ConfigYmlService only when necessary - return siteUrls + return okAsync(siteUrls) } - const { - content: configYmlData, - }: { content: ConfigYmlData } = await this.configYmlService.read( - sessionData + return ResultAsync.fromPromise( + this.configYmlService.read(sessionData), + () => new NotFoundError() + ).map( + ({ content: configYmlData }: { content: ConfigYmlData }) => ({ + staging: + configYmlData.staging && !siteUrls.staging + ? configYmlData.staging + : siteUrls.staging, + prod: + configYmlData.prod && !siteUrls.prod + ? configYmlData.prod + : siteUrls.prod, + }) ) - - // Only replace the urls if they are not already present - const newSiteUrls: SiteUrls = { - staging: - configYmlData.staging && !siteUrls.staging - ? configYmlData.staging - : siteUrls.staging, - prod: - configYmlData.prod && !siteUrls.prod - ? configYmlData.prod - : siteUrls.prod, - } - - return newSiteUrls } - async insertUrlsFromGitHubDescription( + insertUrlsFromGitHubDescription( siteUrls: SiteUrls, sessionData: UserWithSiteSessionData - ): Promise { + ): ResultAsync { if (siteUrls.staging && siteUrls.prod) { // We call GitHubService only when necessary - return siteUrls + return okAsync(siteUrls) } - const { - description, - }: { description: string } = await this.gitHubService.getRepoInfo( - sessionData - ) + return ResultAsync.fromPromise( + this.gitHubService.getRepoInfo(sessionData), + () => new NotFoundError() + ).map(({ description }: { description: string }) => { + // Retrieve the url from the description + // repo descriptions have varying formats, so we look for the first link + const repoDescTokens = description.replace("/;/g", " ").split(" ") - // Retrieve the url from the description - // repo descriptions have varying formats, so we look for the first link - const repoDescTokens = description.replace("/;/g", " ").split(" ") - - const stagingUrlFromDesc = repoDescTokens.find( - (token) => token.includes("http") && token.includes("staging") - ) - const prodUrlFromDesc = repoDescTokens.find( - (token) => token.includes("http") && token.includes("prod") - ) - - // Only replace the urls if they are not already present - const newSiteUrls: SiteUrls = { - staging: - stagingUrlFromDesc && !siteUrls.staging - ? stagingUrlFromDesc - : siteUrls.staging, - prod: prodUrlFromDesc && !siteUrls.prod ? prodUrlFromDesc : siteUrls.prod, - } + const stagingUrlFromDesc = repoDescTokens.find( + (token) => token.includes("http") && token.includes("staging") + ) + const prodUrlFromDesc = repoDescTokens.find( + (token) => token.includes("http") && token.includes("prod") + ) - return newSiteUrls + // Only replace the urls if they are not already present + return { + staging: + stagingUrlFromDesc && !siteUrls.staging + ? Brand.fromString(stagingUrlFromDesc) + : siteUrls.staging, + prod: + prodUrlFromDesc && !siteUrls.prod + ? Brand.fromString(prodUrlFromDesc) + : siteUrls.prod, + } + }) } - async getBySiteName(siteName: string): Promise { - const site = await this.siteRepository.findOne({ - include: [ - { - model: Repo, - where: { - name: siteName, + getBySiteName(siteName: string): ResultAsync { + return ResultAsync.fromPromise( + this.siteRepository.findOne({ + include: [ + { + model: Repo, + where: { + name: siteName, + }, }, - }, - ], + ], + }), + () => new MissingSiteError() + ).andThen((site) => { + if (!site) { + return errAsync(new MissingSiteError()) + } + return okAsync(site) }) - - return site } - async getSitesForEmailUser(userId: string) { + async getSitesForEmailUser(userId: string): Promise<(string | undefined)[]> { const user = await this.usersService.findSitesByUserId(userId) if (!user) { @@ -179,31 +188,50 @@ class SitesService { return user.site_members.map((site) => site.repo?.name) } - async getCommitAuthorEmail(commit: GitHubCommitData) { + getCommitAuthorEmail( + commit: GitHubCommitData + ): ResultAsync< + string, + UnprocessableError | MissingUserError | MissingUserEmailError + > { const { message } = commit // Commit message created as part of phase 2 identity if (message.startsWith("{") && message.endsWith("}")) { - try { - const { userId }: { userId: string } = JSON.parse(message) - const user = await this.usersService.findById(userId) - - if (user && user.email) { - return user.email - } - } catch (e) { - // Do nothing - } + return safeJsonParse(message) + .map( + (obj) => + // TODO: write a validator for this instead of cast as this is unsafe + obj as { userId: string } + ) + .asyncAndThen(({ userId }) => + ResultAsync.fromPromise( + this.usersService.findById(userId), + () => new MissingUserError() + ) + ) + .andThen((user) => { + if (!user) { + return errAsync(new MissingUserError()) + } + return okAsync(user) + }) + .andThen(({ email }) => { + if (!email) { + return errAsync(new MissingUserEmailError()) + } + return okAsync(email) + }) } // Legacy style of commits, or if the user is not found - return this.extractAuthorEmail(commit) + return okAsync(this.extractAuthorEmail(commit)) } - async getMergeAuthorEmail( + getMergeAuthorEmail( commit: GitHubCommitData, sessionData: UserWithSiteSessionData - ) { + ): ResultAsync { const { author: { name: authorName }, } = commit @@ -211,84 +239,81 @@ class SitesService { if (!authorName.startsWith("isomergithub")) { // Legacy style of commits, or if the user is not found - return this.extractAuthorEmail(commit) + return okAsync(this.extractAuthorEmail(commit)) } // Commit was made by our common identity GitHub user - const site = await this.getBySiteName(siteName) - if (!site) { - return this.extractAuthorEmail(commit) - } - - // Retrieve the latest merged review request for the site - const possibleReviewRequest = await this.reviewRequestService.getLatestMergedReviewRequest( - site - ) - if (possibleReviewRequest instanceof RequestNotFoundError) { - // No review request found, fallback to the commit author email - return this.extractAuthorEmail(commit) - } - - // Return the email address of the requestor who made the review request - const { - requestor: { email: requestorEmail }, - } = possibleReviewRequest - - if (requestorEmail) { - return requestorEmail - } - - // No email address found, fallback to the commit author email - return this.extractAuthorEmail(commit) + return this.getBySiteName(siteName) + .andThen(this.reviewRequestService.getLatestMergedReviewRequest) + .andThen(({ requestor: { email: requestorEmail } }) => { + if (requestorEmail) { + return okAsync(requestorEmail) + } + return okAsync(this.extractAuthorEmail(commit)) + }) + .orElse(() => okAsync(this.extractAuthorEmail(commit))) } - async getUrlsOfSite( + // Tries to get the site urls in the following order: + // 1. From the deployments database table + // 2. From the config.yml file + // 3. From the GitHub repository description + // Otherwise, returns a NotFoundError + getUrlsOfSite( sessionData: UserWithSiteSessionData - ): Promise { - // Tries to get the site urls in the following order: - // 1. From the deployments database table - // 2. From the config.yml file - // 3. From the GitHub repository description - // Otherwise, returns a NotFoundError - const { siteName } = sessionData - - const site = await this.siteRepository.findOne({ - include: [ - { - model: Deployment, - as: "deployment", - }, - { - model: Repo, - where: { - name: siteName, - }, - }, - ], - }) - - // Note: site may be null if the site does not exist - const siteUrls: SiteUrls = { - staging: site?.deployment?.stagingUrl ?? "", - prod: site?.deployment?.productionUrl ?? "", - } - - _.assign( - siteUrls, - await this.insertUrlsFromConfigYml(siteUrls, sessionData) - ) - _.assign( - siteUrls, - await this.insertUrlsFromGitHubDescription(siteUrls, sessionData) - ) - - if (!siteUrls.staging && !siteUrls.prod) { - return new NotFoundError( - `The site ${siteName} does not have a staging or production url` + ): ResultAsync { + return ( + ResultAsync.fromPromise( + this.siteRepository.findOne({ + include: [ + { + model: Deployment, + as: "deployment", + }, + { + model: Repo, + where: { + name: sessionData.siteName, + }, + }, + ], + }), + () => new DatabaseError() ) - } - - return siteUrls + .orElse(() => okAsync(null)) + // NOTE: Even if the site does not exist, we still continue on with the flow. + // This is because only migrated sites will have a db entry + // and legacy sites using github login will not. + // Hence, for such sites, extract their URLs through + // the _config.yml or github description + .andThen((site) => + site?.deployment + ? okAsync(site.deployment) + : okAsync({ stagingUrl: undefined, productionUrl: undefined }) + ) + .andThen(({ stagingUrl, productionUrl }) => { + const siteUrls = { + staging: stagingUrl, + prod: productionUrl, + } + + return this.insertUrlsFromConfigYml(siteUrls, sessionData) + .map((newSiteUrls) => _.assign(siteUrls, newSiteUrls)) + .orElse(() => okAsync(siteUrls)) + }) + .andThen((siteUrls) => + this.insertUrlsFromGitHubDescription( + siteUrls, + sessionData + ).map((newSiteUrls) => _.assign(siteUrls, newSiteUrls)) + ) + .andThen(({ staging, prod }) => { + if (!staging && !prod) { + return errAsync(new MissingSiteError()) + } + return okAsync({ staging, prod }) + }) + ) } async getSites(sessionData: UserSessionData): Promise { @@ -300,7 +325,7 @@ class SitesService { // Simultaneously retrieve all isomerpages repos const paramsArr = _.fill(Array(ISOMERPAGES_REPO_PAGE_COUNT), null).map( - (_, idx) => ({ + (__, idx) => ({ per_page: GH_MAX_REPO_COUNT, sort: "full_name", page: idx + 1, @@ -366,34 +391,20 @@ class SitesService { return updatedAt } - async getStagingUrl( + getStagingUrl( sessionData: UserWithSiteSessionData - ): Promise { - const siteUrls = await this.getUrlsOfSite(sessionData) - if (siteUrls instanceof NotFoundError) { - return new NotFoundError( - `${sessionData.siteName} does not have a staging url` - ) - } - - const { staging } = siteUrls - - return staging + ): ResultAsync { + return this.getUrlsOfSite(sessionData).andThen(({ staging }) => + staging ? okAsync(staging) : errAsync(new NotFoundError()) + ) } - async getSiteUrl( + getSiteUrl( sessionData: UserWithSiteSessionData - ): Promise { - const siteUrls = await this.getUrlsOfSite(sessionData) - if (siteUrls instanceof NotFoundError) { - return new NotFoundError( - `${sessionData.siteName} does not have a site url` - ) - } - - const { prod } = siteUrls - - return prod + ): ResultAsync { + return this.getUrlsOfSite(sessionData).andThen(({ prod }) => + prod ? okAsync(prod) : errAsync(new NotFoundError()) + ) } async create( @@ -412,50 +423,78 @@ class SitesService { }) } - async getSiteInfo( - sessionData: UserWithSiteSessionData - ): Promise { - const siteUrls = await this.getUrlsOfSite(sessionData) - if (siteUrls instanceof NotFoundError) { - return new UnprocessableError("Unable to retrieve site info") - } - const { staging: stagingUrl, prod: prodUrl } = siteUrls - - const stagingCommit = await this.gitHubService.getLatestCommitOfBranch( - sessionData, - "staging" - ) - - const prodCommit = await this.gitHubService.getLatestCommitOfBranch( - sessionData, - "master" - ) - - if ( - !this.isGitHubCommitData(stagingCommit) || - !this.isGitHubCommitData(prodCommit) - ) { - return new UnprocessableError("Unable to retrieve GitHub commit info") - } + private getLatestCommitOfBranch( + sessionData: UserWithSiteSessionData, + branch: "staging" | "master" + ): ResultAsync { + return ResultAsync.fromPromise( + this.gitHubService.getLatestCommitOfBranch(sessionData, branch), + () => new NotFoundError() + ).andThen((possibleCommit: unknown) => { + if (this.isGitHubCommitData(possibleCommit)) { + return okAsync(possibleCommit) + } + return errAsync( + new UnprocessableError("Unable to retrieve GitHub commit info") + ) + }) + } - const { - author: { date: stagingDate }, - } = stagingCommit - const { - author: { date: prodDate }, - } = prodCommit - - const stagingAuthor = await this.getCommitAuthorEmail(stagingCommit) - const prodAuthor = await this.getMergeAuthorEmail(prodCommit, sessionData) - - return { - savedAt: new Date(stagingDate).getTime() || 0, - savedBy: stagingAuthor || "Unknown Author", - publishedAt: new Date(prodDate).getTime() || 0, - publishedBy: prodAuthor || "Unknown Author", - stagingUrl: stagingUrl || "", - siteUrl: prodUrl || "", - } + getSiteInfo( + sessionData: UserWithSiteSessionData + ): ResultAsync { + return this.getLatestCommitOfBranch(sessionData, "staging") + .andThen((staging) => + this.getLatestCommitOfBranch(sessionData, "master").map((prod) => ({ + staging, + prod, + })) + ) + .map(({ staging, prod }) => { + const { + author: { date: stagingDate }, + } = staging + const { + author: { date: prodDate }, + } = prod + return { + staging, + prod, + savedAt: new Date(stagingDate).getTime() || 0, + publishedAt: new Date(prodDate).getTime() || 0, + } + }) + .andThen(({ staging, ...rest }) => + this.getCommitAuthorEmail(staging).map((stagingAuthor) => ({ + savedBy: stagingAuthor || "Unknown Author", + staging, + ...rest, + })) + ) + .andThen(({ prod, ...rest }) => + this.getCommitAuthorEmail(prod).map((prodAuthor) => ({ + publishedBy: prodAuthor || "Unknown Author", + prod, + ...rest, + })) + ) + .andThen((partialSiteInfo) => + this.getUrlsOfSite(sessionData).map((urls) => ({ + stagingUrl: urls.staging || "", + siteUrl: urls.prod || "", + ...partialSiteInfo, + })) + ) + .map((siteInfo) => + _.pick(siteInfo, [ + "savedAt", + "savedBy", + "publishedAt", + "publishedBy", + "siteUrl", + "stagingUrl", + ]) + ) } } diff --git a/src/services/identity/__tests__/CollaboratorsService.spec.ts b/src/services/identity/__tests__/CollaboratorsService.spec.ts index 925aee7ff..14c2830a3 100644 --- a/src/services/identity/__tests__/CollaboratorsService.spec.ts +++ b/src/services/identity/__tests__/CollaboratorsService.spec.ts @@ -1,3 +1,4 @@ +import { errAsync, okAsync } from "neverthrow" import { ModelStatic } from "sequelize" import { ForbiddenError } from "@errors/ForbiddenError" @@ -286,7 +287,9 @@ describe("CollaboratorsService", () => { collaboratorsService.deriveAllowedRoleFromEmail = (jest.fn( () => CollaboratorRoles.Admin ) as unknown) as () => Promise - mockSitesService.getBySiteName.mockResolvedValue({ id: mockSiteId }) + mockSitesService.getBySiteName.mockReturnValue( + okAsync({ id: mockSiteId }) + ) mockUsersService.findByEmail.mockResolvedValue({ id: mockUserId }) mockSiteMemberRepo.findOne.mockResolvedValue(null) mockSiteMemberRepo.create.mockResolvedValue(mockSiteMemberRecord) @@ -369,7 +372,7 @@ describe("CollaboratorsService", () => { collaboratorsService.deriveAllowedRoleFromEmail = (jest.fn( () => CollaboratorRoles.Admin ) as unknown) as () => Promise - mockSitesService.getBySiteName.mockResolvedValue(null) + mockSitesService.getBySiteName.mockResolvedValue(errAsync(null)) mockUsersService.findByEmail.mockResolvedValue({ id: mockUserId }) mockSiteMemberRepo.findOne.mockResolvedValue(null) mockSiteMemberRepo.create.mockResolvedValue(mockSiteMemberRecord) @@ -397,7 +400,9 @@ describe("CollaboratorsService", () => { collaboratorsService.deriveAllowedRoleFromEmail = (jest.fn( () => CollaboratorRoles.Admin ) as unknown) as () => Promise - mockSitesService.getBySiteName.mockResolvedValue({ id: mockSiteId }) + mockSitesService.getBySiteName.mockResolvedValue( + okAsync({ id: mockSiteId }) + ) mockUsersService.findByEmail.mockResolvedValue(null) mockSiteMemberRepo.findOne.mockResolvedValue(null) mockSiteMemberRepo.create.mockResolvedValue(mockSiteMemberRecord) @@ -425,7 +430,9 @@ describe("CollaboratorsService", () => { collaboratorsService.deriveAllowedRoleFromEmail = (jest.fn( () => CollaboratorRoles.Admin ) as unknown) as () => Promise - mockSitesService.getBySiteName.mockResolvedValue({ id: mockSiteId }) + mockSitesService.getBySiteName.mockResolvedValue( + okAsync({ id: mockSiteId }) + ) mockUsersService.findByEmail.mockResolvedValue({ id: mockUserId }) mockSiteMemberRepo.findOne.mockResolvedValue(mockSiteMemberRecord) mockSiteMemberRepo.create.mockResolvedValue(mockSiteMemberRecord) @@ -453,7 +460,9 @@ describe("CollaboratorsService", () => { collaboratorsService.deriveAllowedRoleFromEmail = (jest.fn( () => CollaboratorRoles.Contributor ) as unknown) as () => Promise - mockSitesService.getBySiteName.mockResolvedValue({ id: mockSiteId }) + mockSitesService.getBySiteName.mockResolvedValue( + okAsync({ id: mockSiteId }) + ) mockUsersService.findByEmail.mockResolvedValue({ id: mockUserId }) mockSiteMemberRepo.findOne.mockResolvedValue(null) mockSiteMemberRepo.create.mockResolvedValue(mockSiteMemberRecord) diff --git a/src/services/identity/__tests__/SitesService.spec.ts b/src/services/identity/__tests__/SitesService.spec.ts index 458f27ac2..647bd1898 100644 --- a/src/services/identity/__tests__/SitesService.spec.ts +++ b/src/services/identity/__tests__/SitesService.spec.ts @@ -1,3 +1,4 @@ +import { err, errAsync, Ok, ok, okAsync } from "neverthrow" import { ModelStatic } from "sequelize" import { config } from "@config/config" @@ -36,6 +37,7 @@ import { mockSessionDataEmailUserWithSite, } from "@fixtures/sessionData" import mockAxios from "@mocks/axios" +import MissingSiteError from "@root/errors/MissingSiteError" import { NotFoundError } from "@root/errors/NotFoundError" import RequestNotFoundError from "@root/errors/RequestNotFoundError" import { UnprocessableError } from "@root/errors/UnprocessableError" @@ -125,14 +127,11 @@ describe("SitesService", () => { describe("insertUrlsFromConfigYml", () => { it("should insert URLs if both are not already present", async () => { // Arrange - const expected: SiteUrls = { + const expected = ok({ staging: MOCK_STAGING_URL_CONFIGYML, prod: MOCK_PRODUCTION_URL_CONFIGYML, - } - const initial: SiteUrls = { - staging: "", - prod: "", - } + }) + const configYmlResponse = { content: { staging: MOCK_STAGING_URL_CONFIGYML, @@ -144,7 +143,7 @@ describe("SitesService", () => { // Act const actual = await SitesService.insertUrlsFromConfigYml( - initial, + {}, mockSessionDataEmailUserWithSite ) @@ -155,12 +154,11 @@ describe("SitesService", () => { it("should only insert staging URL if it is not already present", async () => { // Arrange - const expected: SiteUrls = { + const expected = ok({ staging: MOCK_STAGING_URL_CONFIGYML, prod: MOCK_PRODUCTION_URL_DB, - } + }) const initial: SiteUrls = { - staging: "", prod: MOCK_PRODUCTION_URL_DB, } const configYmlResponse = { @@ -185,13 +183,12 @@ describe("SitesService", () => { it("should only insert production URL if it is not already present", async () => { // Arrange - const expected: SiteUrls = { + const expected = ok({ staging: MOCK_STAGING_URL_DB, prod: MOCK_PRODUCTION_URL_CONFIGYML, - } + }) const initial: SiteUrls = { staging: MOCK_STAGING_URL_DB, - prod: "", } const configYmlResponse = { content: { @@ -215,10 +212,10 @@ describe("SitesService", () => { it("should not insert URLs if both are already present", async () => { // Arrange - const expected: SiteUrls = { + const expected = ok({ staging: MOCK_STAGING_URL_DB, prod: MOCK_PRODUCTION_URL_DB, - } + }) const initial: SiteUrls = { staging: MOCK_STAGING_URL_DB, prod: MOCK_PRODUCTION_URL_DB, @@ -237,14 +234,10 @@ describe("SitesService", () => { it("should not insert staging URL if it does not exist in config.yml", async () => { // Arrange - const expected: SiteUrls = { - staging: "", + const expected = ok({ prod: MOCK_PRODUCTION_URL_CONFIGYML, - } - const initial: SiteUrls = { - staging: "", - prod: "", - } + }) + const configYmlResponse = { content: { prod: MOCK_PRODUCTION_URL_CONFIGYML, @@ -255,7 +248,7 @@ describe("SitesService", () => { // Act const actual = await SitesService.insertUrlsFromConfigYml( - initial, + {}, mockSessionDataEmailUserWithSite ) @@ -266,14 +259,9 @@ describe("SitesService", () => { it("should not insert production URL if it does not exist in config.yml", async () => { // Arrange - const expected: SiteUrls = { + const expected = ok({ staging: MOCK_STAGING_URL_CONFIGYML, - prod: "", - } - const initial: SiteUrls = { - staging: "", - prod: "", - } + }) const configYmlResponse = { content: { staging: MOCK_STAGING_URL_CONFIGYML, @@ -284,7 +272,7 @@ describe("SitesService", () => { // Act const actual = await SitesService.insertUrlsFromConfigYml( - initial, + {}, mockSessionDataEmailUserWithSite ) @@ -295,14 +283,8 @@ describe("SitesService", () => { it("should not insert URLs if config.yml does not contain both staging and production URLs", async () => { // Arrange - const expected: SiteUrls = { - staging: "", - prod: "", - } - const initial: SiteUrls = { - staging: "", - prod: "", - } + const expected = ok({}) + const initial: SiteUrls = {} const configYmlResponse = { content: {}, sha: "abc", @@ -324,14 +306,11 @@ describe("SitesService", () => { describe("insertUrlsFromGitHubDescription", () => { it("should insert URLs if both are not already present", async () => { // Arrange - const expected: SiteUrls = { + const expected = ok({ staging: MOCK_STAGING_URL_GITHUB, prod: MOCK_PRODUCTION_URL_GITHUB, - } - const initial: SiteUrls = { - staging: "", - prod: "", - } + }) + const initial: SiteUrls = {} MockGithubService.getRepoInfo.mockResolvedValueOnce(repoInfo) // Act @@ -347,12 +326,11 @@ describe("SitesService", () => { it("should only insert staging URL if it is not already present", async () => { // Arrange - const expected: SiteUrls = { + const expected = ok({ staging: MOCK_STAGING_URL_GITHUB, prod: MOCK_PRODUCTION_URL_DB, - } + }) const initial: SiteUrls = { - staging: "", prod: MOCK_PRODUCTION_URL_DB, } MockGithubService.getRepoInfo.mockResolvedValueOnce(repoInfo) @@ -370,13 +348,12 @@ describe("SitesService", () => { it("should only insert production URL if it is not already present", async () => { // Arrange - const expected: SiteUrls = { + const expected = ok({ staging: MOCK_STAGING_URL_DB, prod: MOCK_PRODUCTION_URL_GITHUB, - } + }) const initial: SiteUrls = { staging: MOCK_STAGING_URL_DB, - prod: "", } MockGithubService.getRepoInfo.mockResolvedValueOnce(repoInfo) @@ -393,10 +370,10 @@ describe("SitesService", () => { it("should not insert URLs if both are already present", async () => { // Arrange - const expected: SiteUrls = { + const expected = ok({ staging: MOCK_STAGING_URL_DB, prod: MOCK_PRODUCTION_URL_DB, - } + }) const initial: SiteUrls = { staging: MOCK_STAGING_URL_DB, prod: MOCK_PRODUCTION_URL_DB, @@ -415,14 +392,10 @@ describe("SitesService", () => { it("should not insert staging URL if it does not exist in the description", async () => { // Arrange - const expected: SiteUrls = { - staging: "", + const expected = ok({ prod: MOCK_PRODUCTION_URL_CONFIGYML, - } - const initial: SiteUrls = { - staging: "", - prod: "", - } + }) + const initial: SiteUrls = {} const repoInfoWithoutStagingUrl = { description: `Production: ${MOCK_PRODUCTION_URL_CONFIGYML}`, } @@ -443,14 +416,10 @@ describe("SitesService", () => { it("should not insert production URL if it does not exist in the description", async () => { // Arrange - const expected: SiteUrls = { + const expected = ok({ staging: MOCK_STAGING_URL_CONFIGYML, - prod: "", - } - const initial: SiteUrls = { - staging: "", - prod: "", - } + }) + const initial: SiteUrls = {} const repoInfoWithoutProductionUrl = { description: `Staging: ${MOCK_STAGING_URL_CONFIGYML}`, } @@ -471,14 +440,8 @@ describe("SitesService", () => { it("should not insert URLs if description is empty", async () => { // Arrange - const expected: SiteUrls = { - staging: "", - prod: "", - } - const initial: SiteUrls = { - staging: "", - prod: "", - } + const expected = ok({}) + const initial: SiteUrls = {} const repoInfoWithoutDescription = { description: "", } @@ -499,14 +462,8 @@ describe("SitesService", () => { it("should not insert URLs if description is some gibberish", async () => { // Arrange - const expected: SiteUrls = { - staging: "", - prod: "", - } - const initial: SiteUrls = { - staging: "", - prod: "", - } + const expected = ok({}) + const initial: SiteUrls = {} const repoInfoWithGibberishDescription = { description: "abcdefghijklmnopqrstuvwxyz-staging and-prod", } @@ -529,14 +486,14 @@ describe("SitesService", () => { describe("getBySiteName", () => { it("should call the findOne method of the db model to get the siteName", async () => { // Arrange - const expected = mockSite + const expected = ok(mockSite) MockRepository.findOne.mockResolvedValueOnce(mockSite) // Act const actual = await SitesService.getBySiteName(mockSiteName) // Assert - expect(actual).toBe(expected) + expect(actual).toEqual(expected) expect(MockRepository.findOne).toBeCalledWith({ include: [ { @@ -581,7 +538,7 @@ describe("SitesService", () => { describe("getCommitAuthorEmail", () => { it("should return the email of the commit author who is an email login user", async () => { // Arrange - const expected = mockEmail + const expected = ok(mockEmail) const commit: GitHubCommitData = { author: { name: MOCK_GITHUB_NAME_ONE, @@ -596,14 +553,14 @@ describe("SitesService", () => { const actual = await SitesService.getCommitAuthorEmail(commit) // Assert - expect(actual).toBe(expected) + expect(actual).toEqual(expected) expect(MockUsersService.findById).toHaveBeenCalledWith(mockIsomerUserId) expect(SpySitesService.extractAuthorEmail).not.toHaveBeenCalled() }) it("should return the email of the commit author who is a GitHub login user", async () => { // Arrange - const expected = MOCK_GITHUB_EMAIL_ADDRESS_ONE + const expected = ok(MOCK_GITHUB_EMAIL_ADDRESS_ONE) const commit: GitHubCommitData = { author: { name: MOCK_GITHUB_NAME_ONE, @@ -617,7 +574,7 @@ describe("SitesService", () => { const actual = await SitesService.getCommitAuthorEmail(commit) // Assert - expect(actual).toBe(expected) + expect(actual).toEqual(expected) expect(MockUsersService.findById).not.toHaveBeenCalled() expect(SpySitesService.extractAuthorEmail).toHaveBeenCalled() }) @@ -626,7 +583,7 @@ describe("SitesService", () => { describe("getMergeAuthorEmail", () => { it("should return the email of the merge commit author if it was not performed using the common access token", async () => { // Arrange - const expected = MOCK_GITHUB_EMAIL_ADDRESS_ONE + const expected = ok(MOCK_GITHUB_EMAIL_ADDRESS_ONE) const commit: GitHubCommitData = { author: { name: MOCK_GITHUB_NAME_ONE, @@ -652,7 +609,7 @@ describe("SitesService", () => { it("should return the email of the merge commit author if the site cannot be found", async () => { // Arrange - const expected = MOCK_GITHUB_EMAIL_ADDRESS_ONE + const expected = ok(MOCK_GITHUB_EMAIL_ADDRESS_ONE) const commit: GitHubCommitData = { author: { name: MOCK_COMMON_ACCESS_TOKEN_GITHUB_NAME, @@ -679,7 +636,7 @@ describe("SitesService", () => { it("should return the email of the merge commit author if there are no merged review requests", async () => { // Arrange - const expected = MOCK_GITHUB_EMAIL_ADDRESS_ONE + const expected = ok(MOCK_GITHUB_EMAIL_ADDRESS_ONE) const commit: GitHubCommitData = { author: { name: MOCK_COMMON_ACCESS_TOKEN_GITHUB_NAME, @@ -690,7 +647,7 @@ describe("SitesService", () => { } MockRepository.findOne.mockResolvedValueOnce(mockSite) MockReviewRequestService.getLatestMergedReviewRequest.mockResolvedValueOnce( - new RequestNotFoundError() + errAsync(new RequestNotFoundError()) ) // Act @@ -709,7 +666,7 @@ describe("SitesService", () => { it("should return the email of the requestor for the latest merged review request", async () => { // Arrange - const expected = mockEmail + const expected = ok(mockEmail) const commit: GitHubCommitData = { author: { name: MOCK_COMMON_ACCESS_TOKEN_GITHUB_NAME, @@ -725,7 +682,7 @@ describe("SitesService", () => { } MockRepository.findOne.mockResolvedValueOnce(mockSite) MockReviewRequestService.getLatestMergedReviewRequest.mockResolvedValueOnce( - mockReviewRequest + okAsync(mockReviewRequest) ) // Act @@ -744,7 +701,7 @@ describe("SitesService", () => { it("should return the email of the merge commit author if the requestor for the latest merged review request does not have an email", async () => { // Arrange - const expected = MOCK_GITHUB_EMAIL_ADDRESS_ONE + const expected = ok(MOCK_GITHUB_EMAIL_ADDRESS_ONE) const commit: GitHubCommitData = { author: { name: MOCK_COMMON_ACCESS_TOKEN_GITHUB_NAME, @@ -760,7 +717,7 @@ describe("SitesService", () => { } MockRepository.findOne.mockResolvedValueOnce(mockSite) MockReviewRequestService.getLatestMergedReviewRequest.mockResolvedValueOnce( - mockReviewRequest + okAsync(mockReviewRequest) ) // Act @@ -783,18 +740,12 @@ describe("SitesService", () => { stagingUrl: MOCK_STAGING_URL_DB, productionUrl: MOCK_PRODUCTION_URL_DB, } - const emptyDeployment: Partial = { - stagingUrl: "", - productionUrl: "", - } + const emptyDeployment: Partial = {} const configYmlData: Partial = { staging: MOCK_STAGING_URL_CONFIGYML, prod: MOCK_PRODUCTION_URL_CONFIGYML, } - const emptyConfigYmlData: Partial = { - staging: "", - prod: "", - } + const emptyConfigYmlData: Partial = {} const gitHubUrls = { staging: MOCK_STAGING_URL_GITHUB, prod: MOCK_PRODUCTION_URL_GITHUB, @@ -805,10 +756,10 @@ describe("SitesService", () => { it("should return the urls of the site from the deployments table", async () => { // Arrange - const expected = { + const expected = ok({ staging: deployment.stagingUrl, prod: deployment.productionUrl, - } + }) const mockSiteWithDeployment = { ...mockSite, deployment, @@ -830,10 +781,10 @@ describe("SitesService", () => { it("should return the urls of the site from the _config.yml file", async () => { // Arrange - const expected = { + const expected = ok({ staging: configYmlData.staging, prod: configYmlData.prod, - } + }) const mockSiteWithNullDeployment = { ...mockSite, deployment: { @@ -860,10 +811,10 @@ describe("SitesService", () => { it("should return the urls of the site from the GitHub repo description", async () => { // Arrange - const expected = { + const expected = ok({ staging: gitHubUrls.staging, prod: gitHubUrls.prod, - } + }) const mockSiteWithNullDeployment = { ...mockSite, deployment: { @@ -891,7 +842,7 @@ describe("SitesService", () => { expect(MockGithubService.getRepoInfo).toHaveBeenCalled() }) - it("should return a NotFoundError if all fails", async () => { + it("should return a MissingSiteError if all fails", async () => { // Arrange const mockSiteWithNullDeployment = { ...mockSite, @@ -916,7 +867,7 @@ describe("SitesService", () => { ) // Assert - expect(actual).toBeInstanceOf(NotFoundError) + expect(actual).toEqual(err(new MissingSiteError())) expect(MockRepository.findOne).toHaveBeenCalled() expect(MockConfigYmlService.read).toHaveBeenCalled() expect(MockGithubService.getRepoInfo).toHaveBeenCalled() @@ -1111,11 +1062,11 @@ describe("SitesService", () => { ) // Assert - expect(actual).toEqual(MOCK_STAGING_URL_DB) + expect(actual).toEqual(ok(MOCK_STAGING_URL_DB)) expect(MockRepository.findOne).toHaveBeenCalled() }) - it("should return an error when the staging url for a repo is not found", async () => { + it("should return MissingSiteError when the staging url for a repo is not found", async () => { // Arrange MockRepository.findOne.mockResolvedValueOnce(null) MockConfigYmlService.read.mockResolvedValueOnce({ @@ -1128,7 +1079,7 @@ describe("SitesService", () => { // Act await expect( SitesService.getStagingUrl(mockUserWithSiteSessionData) - ).resolves.toBeInstanceOf(NotFoundError) + ).resolves.toEqual(err(new MissingSiteError())) // Assert expect(MockRepository.findOne).toHaveBeenCalled() @@ -1156,11 +1107,11 @@ describe("SitesService", () => { ) // Assert - expect(actual).toEqual(MOCK_PRODUCTION_URL_DB) + expect(actual).toEqual(ok(MOCK_PRODUCTION_URL_DB)) expect(MockRepository.findOne).toHaveBeenCalled() }) - it("should return an error when the site url for a repo is not found", async () => { + it("should return MissingSiteError when the site url for a repo is not found", async () => { // Arrange MockRepository.findOne.mockResolvedValueOnce(null) MockConfigYmlService.read.mockResolvedValueOnce({ @@ -1173,7 +1124,7 @@ describe("SitesService", () => { // Act await expect( SitesService.getSiteUrl(mockUserWithSiteSessionData) - ).resolves.toBeInstanceOf(NotFoundError) + ).resolves.toEqual(err(new MissingSiteError())) // Assert expect(MockRepository.findOne).toHaveBeenCalled() @@ -1215,14 +1166,14 @@ describe("SitesService", () => { const mockProductionCommitAuthor: Partial = { email: MOCK_GITHUB_EMAIL_ADDRESS_TWO, } - const expected: SiteInfo = { + const expected: Ok = ok({ savedAt: new Date(MOCK_GITHUB_DATE_ONE).getTime(), savedBy: MOCK_GITHUB_EMAIL_ADDRESS_ONE, publishedAt: new Date(MOCK_GITHUB_DATE_TWO).getTime(), publishedBy: MOCK_GITHUB_EMAIL_ADDRESS_TWO, stagingUrl: MOCK_STAGING_URL_DB, siteUrl: MOCK_PRODUCTION_URL_DB, - } + }) MockRepository.findOne.mockResolvedValueOnce(mockSiteWithDeployment) MockGithubService.getLatestCommitOfBranch.mockResolvedValueOnce( @@ -1266,14 +1217,14 @@ describe("SitesService", () => { }, message: MOCK_COMMIT_MESSAGE_TWO, } - const expected: SiteInfo = { + const expected: Ok = ok({ savedAt: new Date(MOCK_GITHUB_DATE_ONE).getTime(), savedBy: MOCK_GITHUB_EMAIL_ADDRESS_ONE, publishedAt: new Date(MOCK_GITHUB_DATE_TWO).getTime(), publishedBy: MOCK_GITHUB_EMAIL_ADDRESS_TWO, stagingUrl: MOCK_STAGING_URL_DB, siteUrl: MOCK_PRODUCTION_URL_DB, - } + }) MockRepository.findOne.mockResolvedValueOnce(mockSiteWithDeployment) MockGithubService.getLatestCommitOfBranch.mockResolvedValueOnce( @@ -1295,7 +1246,7 @@ describe("SitesService", () => { expect(MockUsersService.findById).not.toHaveBeenCalled() }) - it("should return UnprocessableError when the site is not found", async () => { + it("should return MissingSiteError when the site is not found", async () => { // Arrange MockRepository.findOne.mockResolvedValueOnce(null) MockConfigYmlService.read.mockResolvedValueOnce({ @@ -1304,11 +1255,27 @@ describe("SitesService", () => { MockGithubService.getRepoInfo.mockResolvedValueOnce({ description: "", }) + MockGithubService.getLatestCommitOfBranch.mockResolvedValueOnce({ + author: { + name: MOCK_COMMON_ACCESS_TOKEN_GITHUB_NAME, + email: MOCK_GITHUB_EMAIL_ADDRESS_ONE, + date: MOCK_GITHUB_DATE_ONE, + }, + message: MOCK_COMMIT_MESSAGE_ONE, + }) + MockGithubService.getLatestCommitOfBranch.mockResolvedValueOnce({ + author: { + name: MOCK_COMMON_ACCESS_TOKEN_GITHUB_NAME, + email: MOCK_GITHUB_EMAIL_ADDRESS_ONE, + date: MOCK_GITHUB_DATE_ONE, + }, + message: MOCK_COMMIT_MESSAGE_ONE, + }) // Act await expect( SitesService.getSiteInfo(mockSessionDataEmailUserWithSite) - ).resolves.toBeInstanceOf(UnprocessableError) + ).resolves.toEqual(err(new MissingSiteError())) // Assert expect(MockRepository.findOne).toHaveBeenCalled() @@ -1319,29 +1286,32 @@ describe("SitesService", () => { // Arrange MockRepository.findOne.mockResolvedValueOnce(mockSiteWithDeployment) MockGithubService.getLatestCommitOfBranch.mockResolvedValueOnce(null) - MockGithubService.getLatestCommitOfBranch.mockResolvedValueOnce(null) // Act await expect( SitesService.getSiteInfo(mockSessionDataEmailUserWithSite) - ).resolves.toBeInstanceOf(UnprocessableError) + ).resolves.toEqual( + err(new UnprocessableError("Unable to retrieve GitHub commit info")) + ) // Assert - expect(MockRepository.findOne).toHaveBeenCalled() - expect(MockGithubService.getLatestCommitOfBranch).toHaveBeenCalledTimes(2) + // After the first call to `staging`, if it returns `null`, + // it short-circuits by returning an error. + expect(MockRepository.findOne).not.toHaveBeenCalled() + expect(MockGithubService.getLatestCommitOfBranch).toHaveBeenCalledTimes(1) expect(MockUsersService.findById).not.toHaveBeenCalled() }) it("should return with unknown author when the GitHub commit is empty", async () => { // Arrange - const expected: SiteInfo = { + const expected: Ok = ok({ savedAt: 0, savedBy: "Unknown Author", publishedAt: 0, publishedBy: "Unknown Author", stagingUrl: MOCK_STAGING_URL_DB, siteUrl: MOCK_PRODUCTION_URL_DB, - } + }) const mockEmptyCommit: GitHubCommitData = { author: { diff --git a/src/services/review/ReviewRequestService.ts b/src/services/review/ReviewRequestService.ts index 78c626bdd..d6ed3101a 100644 --- a/src/services/review/ReviewRequestService.ts +++ b/src/services/review/ReviewRequestService.ts @@ -1,4 +1,6 @@ +import { AxiosResponse } from "axios" import _ from "lodash" +import { errAsync, okAsync, ResultAsync } from "neverthrow" import { ModelStatic } from "sequelize" import UserWithSiteSessionData from "@classes/UserWithSiteSessionData" @@ -10,6 +12,7 @@ import { ReviewRequestStatus } from "@root/constants" import { ReviewRequestView } from "@root/database/models" import { Site } from "@root/database/models/Site" import { User } from "@root/database/models/User" +import { NotFoundError } from "@root/errors/NotFoundError" import RequestNotFoundError from "@root/errors/RequestNotFoundError" import { CommentItem, @@ -21,9 +24,12 @@ import { } from "@root/types/dto/review" import { isIsomerError } from "@root/types/error" import { Commit, fromGithubCommitMessage } from "@root/types/github" +import { StagingPermalink } from "@root/types/pages" import { RequestChangeInfo } from "@root/types/review" import * as ReviewApi from "@services/db/review" +import { PageService } from "../fileServices/MdPageServices/PageService" + /** * NOTE: This class does not belong as a subset of GitHub service. * This is because GitHub service exists to operate on _files_ @@ -47,13 +53,16 @@ export default class ReviewRequestService { private readonly reviewRequestView: ModelStatic + private readonly pageService: PageService + constructor( apiService: typeof ReviewApi, users: ModelStatic, repository: ModelStatic, reviewers: ModelStatic, reviewMeta: ModelStatic, - reviewRequestView: ModelStatic + reviewRequestView: ModelStatic, + pageService: PageService ) { this.apiService = apiService this.users = users @@ -61,10 +70,12 @@ export default class ReviewRequestService { this.reviewers = reviewers this.reviewMeta = reviewMeta this.reviewRequestView = reviewRequestView + this.pageService = pageService } compareDiff = async ( - sessionData: UserWithSiteSessionData + sessionData: UserWithSiteSessionData, + stagingLink: StagingPermalink ): Promise => { // Step 1: Get the site name const { siteName } = sessionData @@ -76,35 +87,46 @@ export default class ReviewRequestService { const mappings = await this.computeShaMappings(commits) - return files.map(({ filename, contents_url }) => { - const fullPath = filename.split("/") - const items = contents_url.split("?ref=") - // NOTE: We have to compute sha this way rather than - // taking the file sha. - // This is because the sha present on the file is - // a checksum of the files contents. - // And the actual commit sha is given by the ref param - const sha = items[items.length - 1] - - return { - type: this.computeFileType(filename), - // NOTE: The string is guaranteed to be non-empty - // and hence this should exist. - name: fullPath.pop() || "", - // NOTE: pop alters in place - path: fullPath, - url: this.computeFileUrl(filename, siteName), - lastEditedBy: mappings[sha]?.author || "Unknown user", - lastEditedTime: mappings[sha]?.unixTime || 0, - } - }) + return Promise.all( + files.map(async ({ filename, contents_url }) => { + const fullPath = filename.split("/") + const items = contents_url.split("?ref=") + // NOTE: We have to compute sha this way rather than + // taking the file sha. + // This is because the sha present on the file is + // a checksum of the files contents. + // And the actual commit sha is given by the ref param + const sha = items[items.length - 1] + const url = await this.pageService + .parsePageName(filename, sessionData) + .andThen((pageName) => + this.pageService.retrieveStagingPermalink( + sessionData, + stagingLink, + pageName + ) + ) + // NOTE: We ignore the errors and use a placeholder + .unwrapOr("") + + return { + type: this.computeFileType(filename), + // NOTE: The string is guaranteed to be non-empty + // and hence this should exist. + name: fullPath.pop()!, + // NOTE: pop alters in place + path: fullPath, + url, + lastEditedBy: mappings[sha]?.author || "Unknown user", + lastEditedTime: mappings[sha]?.unixTime || 0, + } + }) + ) } // TODO computeFileType = (filename: string): FileType[] => ["page"] - computeFileUrl = (filename: string, siteName: string) => "www.google.com" - computeShaMappings = async ( commits: Commit[] ): Promise> => { @@ -304,16 +326,15 @@ export default class ReviewRequestService { await Promise.all( // Using map here to allow creations to be done concurrently // But we do not actually need the result of the view creation - requestIdsToMarkAsViewed.map( - async (requestId) => - await this.reviewRequestView.create({ - reviewRequestId: requestId, - siteId: site.id, - userId, - // This field represents the user opening the review request - // itself, which the user has not done so yet at this stage. - lastViewedAt: null, - }) + requestIdsToMarkAsViewed.map(async (requestId) => + this.reviewRequestView.create({ + reviewRequestId: requestId, + siteId: site.id, + userId, + // This field represents the user opening the review request + // itself, which the user has not done so yet at this stage. + lastViewedAt: null, + }) ) ) } @@ -366,7 +387,7 @@ export default class ReviewRequestService { deleteAllReviewRequestViews = async ( site: Site, pullRequestNumber: number - ): Promise => { + ): Promise => { const possibleReviewRequest = await this.getReviewRequest( site, pullRequestNumber @@ -378,7 +399,7 @@ export default class ReviewRequestService { const { id: reviewRequestId } = possibleReviewRequest - await this.reviewRequestView.destroy({ + return this.reviewRequestView.destroy({ where: { reviewRequestId, siteId: site.id, @@ -386,7 +407,10 @@ export default class ReviewRequestService { }) } - getReviewRequest = async (site: Site, pullRequestNumber: number) => { + getReviewRequest = async ( + site: Site, + pullRequestNumber: number + ): Promise => { const possibleReviewRequest = await this.repository.findOne({ where: { siteId: site.id, @@ -420,114 +444,131 @@ export default class ReviewRequestService { return possibleReviewRequest } - getLatestMergedReviewRequest = async (site: Site) => { - const possibleReviewRequest = await this.repository.findOne({ - where: { - siteId: site.id, - reviewStatus: ReviewRequestStatus.Merged, - }, - include: [ - { - model: ReviewMeta, - as: "reviewMeta", - }, - { - model: User, - as: "requestor", - }, - { - model: User, - as: "reviewers", - }, - { - model: Site, + getLatestMergedReviewRequest = ( + site: Site + ): ResultAsync => + ResultAsync.fromPromise( + this.repository.findOne({ + where: { + siteId: site.id, + reviewStatus: ReviewRequestStatus.Merged, }, - ], - order: [ - [ + include: [ { model: ReviewMeta, as: "reviewMeta", }, - "pullRequestNumber", - "DESC", + { + model: User, + as: "requestor", + }, + { + model: User, + as: "reviewers", + }, + { + model: Site, + }, ], - ], + order: [ + [ + { + model: ReviewMeta, + as: "reviewMeta", + }, + "pullRequestNumber", + "DESC", + ], + ], + }), + () => new RequestNotFoundError() + ).andThen((possibleReviewRequest) => { + if (!possibleReviewRequest) { + return errAsync(new RequestNotFoundError()) + } + return okAsync(possibleReviewRequest) }) - if (!possibleReviewRequest) { - return new RequestNotFoundError() - } - - return possibleReviewRequest - } - - getFullReviewRequest = async ( + getFullReviewRequest = ( userWithSiteSessionData: UserWithSiteSessionData, site: Site, - pullRequestNumber: number - ): Promise => { + pullRequestNumber: number, + stagingLink: StagingPermalink + ): ResultAsync => { const { siteName } = userWithSiteSessionData - const review = await this.repository.findOne({ - where: { - siteId: site.id, - }, - include: [ - { - model: ReviewMeta, - as: "reviewMeta", - where: { - pullRequestNumber, - }, - }, - { - model: User, - as: "requestor", - }, - { - model: User, - as: "reviewers", + return ResultAsync.fromPromise( + this.repository.findOne({ + where: { + siteId: site.id, }, - { - model: Site, - }, - ], - }) - - // As the db stores github's PR # and (siteName, prNumber) - // should be a unique identifier for a specific review request, - // unable to find a RR with the tuple implies that said RR does not exist. - // This could happen when the user queries for an existing PR that is on github, - // but created prior to this feature rolling out. - if (!review) { - return new RequestNotFoundError() - } - - // NOTE: We explicitly destructure as the raw data - // contains ALOT more than these fields, which we want to - // discard to lower retrieval times for FE - const { title, created_at } = await this.apiService.getPullRequest( - siteName, - pullRequestNumber + include: [ + { + model: ReviewMeta, + as: "reviewMeta", + where: { + pullRequestNumber, + }, + }, + { + model: User, + as: "requestor", + }, + { + model: User, + as: "reviewers", + }, + { + model: Site, + }, + ], + }), + () => new RequestNotFoundError() ) - - const changedItems = await this.compareDiff(userWithSiteSessionData) - - return { - reviewUrl: review.reviewMeta.reviewLink, - title, - status: review.reviewStatus, - requestor: review.requestor.email || "", - reviewers: review.reviewers.map(({ email }) => email || ""), - reviewRequestedTime: new Date(created_at).getTime(), - changedItems, - } + .andThen((reviewRequest) => + // As the db stores github's PR # and (siteName, prNumber) + // should be a unique identifier for a specific review request, + // unable to find a RR with the tuple implies that said RR does not exist. + // This could happen when the user queries for an existing PR that is on github, + // but created prior to this feature rolling out. + reviewRequest + ? okAsync(reviewRequest) + : errAsync(new RequestNotFoundError()) + ) + .andThen(({ reviewMeta, reviewStatus, requestor, reviewers }) => + ResultAsync.fromPromise( + this.apiService.getPullRequest(siteName, pullRequestNumber), + // NOTE: Because we validate existence of the pull request + // and the site, the error here is not the fault of the user. + // It might be due to credentials or network issues, both of which + // are hidden behind our backend. + () => new NotFoundError() + ) // NOTE: We explicitly destructure as the raw data + // contains ALOT more than these fields, which we want to + // discard to lower retrieval times for FE + .map(({ title, created_at }) => ({ + reviewUrl: reviewMeta.reviewLink, + title, + status: reviewStatus, + requestor: requestor.email || "", + reviewers: reviewers.map(({ email }) => email || ""), + reviewRequestedTime: new Date(created_at).getTime(), + })) + ) + .andThen((rest) => + ResultAsync.fromPromise( + this.compareDiff(userWithSiteSessionData, stagingLink), + () => new NotFoundError() + ).map((changedItems) => ({ + ...rest, + changedItems, + })) + ) } updateReviewRequest = async ( reviewRequest: ReviewRequest, { reviewers }: RequestChangeInfo - ) => { + ): Promise => { // Update db state with new reviewers await reviewRequest.$set("reviewers", reviewers) await reviewRequest.save() @@ -535,17 +576,21 @@ export default class ReviewRequestService { // NOTE: The semantics of our reviewing system is slightly different from github. // The approval is tied to the request, rather than the user. - approveReviewRequest = async (reviewRequest: ReviewRequest) => { + approveReviewRequest = async ( + reviewRequest: ReviewRequest + ): Promise => { reviewRequest.reviewStatus = ReviewRequestStatus.Approved await reviewRequest.save() } - deleteReviewRequestApproval = async (reviewRequest: ReviewRequest) => { + deleteReviewRequestApproval = async ( + reviewRequest: ReviewRequest + ): Promise => { reviewRequest.reviewStatus = ReviewRequestStatus.Open await reviewRequest.save() } - closeReviewRequest = async (reviewRequest: ReviewRequest) => { + closeReviewRequest = async (reviewRequest: ReviewRequest): Promise => { const siteName = reviewRequest.site.name const { pullRequestNumber } = reviewRequest.reviewMeta await this.apiService.closeReviewRequest(siteName, pullRequestNumber) @@ -571,7 +616,7 @@ export default class ReviewRequestService { sessionData: UserWithSiteSessionData, pullRequestNumber: number, message: string - ) => { + ): Promise> => { const { siteName, isomerUserId } = sessionData return this.apiService.createComment( diff --git a/src/services/review/__tests__/ReviewRequestService.spec.ts b/src/services/review/__tests__/ReviewRequestService.spec.ts index d168fb1e9..e7884043d 100644 --- a/src/services/review/__tests__/ReviewRequestService.spec.ts +++ b/src/services/review/__tests__/ReviewRequestService.spec.ts @@ -1,4 +1,6 @@ +import { ReturnValue } from "aws-sdk/clients/dynamodb" import _ from "lodash" +import { err, ok, okAsync } from "neverthrow" import { Attributes, ModelStatic } from "sequelize" import { @@ -35,6 +37,7 @@ import { MOCK_IDENTITY_EMAIL_THREE, MOCK_IDENTITY_EMAIL_TWO, } from "@root/fixtures/identity" +import { MOCK_STAGING_URL_GITHUB } from "@root/fixtures/repoInfo" import { MOCK_PULL_REQUEST_COMMIT_ONE, MOCK_PULL_REQUEST_COMMIT_TWO, @@ -47,11 +50,20 @@ import { MOCK_REVIEW_REQUEST_VIEW_ONE, } from "@root/fixtures/review" import { mockUserWithSiteSessionData } from "@root/fixtures/sessionData" +import { PageService } from "@root/services/fileServices/MdPageServices/PageService" import { EditedItemDto, GithubCommentData } from "@root/types/dto/review" import { Commit } from "@root/types/github" import * as ReviewApi from "@services/db/review" import _ReviewRequestService from "@services/review/ReviewRequestService" +const MockPageService: { + [K in keyof PageService]: ReturnType +} = { + extractPathInfo: jest.fn(), + extractResourceRoomName: jest.fn(), + parsePageName: jest.fn(), + retrieveStagingPermalink: jest.fn(), +} const MockReviewApi = { approvePullRequest: jest.fn(), closeReviewRequest: jest.fn(), @@ -102,13 +114,13 @@ const ReviewRequestService = new _ReviewRequestService( (MockReviewRequestRepository as unknown) as ModelStatic, (MockReviewersRepository as unknown) as ModelStatic, (MockReviewMetaRepository as unknown) as ModelStatic, - (MockReviewRequestViewRepository as unknown) as ModelStatic + (MockReviewRequestViewRepository as unknown) as ModelStatic, + (MockPageService as unknown) as PageService ) const SpyReviewRequestService = { computeCommentData: jest.spyOn(ReviewRequestService, "computeCommentData"), computeFileType: jest.spyOn(ReviewRequestService, "computeFileType"), - computeFileUrl: jest.spyOn(ReviewRequestService, "computeFileUrl"), computeShaMappings: jest.spyOn(ReviewRequestService, "computeShaMappings"), getComments: jest.spyOn(ReviewRequestService, "getComments"), getReviewRequest: jest.spyOn(ReviewRequestService, "getReviewRequest"), @@ -147,17 +159,22 @@ describe("ReviewRequestService", () => { }, ] MockReviewApi.getCommitDiff.mockResolvedValueOnce(mockCommitDiff) + MockPageService.parsePageName.mockReturnValue(okAsync("mock page name")) + MockPageService.retrieveStagingPermalink.mockReturnValue( + okAsync("www.google.com") + ) // Act const actual = await ReviewRequestService.compareDiff( - mockUserWithSiteSessionData + mockUserWithSiteSessionData, + MOCK_STAGING_URL_GITHUB ) // Assert expect(actual).toEqual(expected) expect(SpyReviewRequestService.computeShaMappings).toHaveBeenCalled() expect(SpyReviewRequestService.computeFileType).toHaveBeenCalled() - expect(SpyReviewRequestService.computeFileUrl).toHaveBeenCalled() + expect(MockPageService.retrieveStagingPermalink).toHaveBeenCalled() }) it("should return an empty array if there are no file changes or commits", async () => { @@ -171,14 +188,15 @@ describe("ReviewRequestService", () => { // Act const actual = await ReviewRequestService.compareDiff( - mockUserWithSiteSessionData + mockUserWithSiteSessionData, + MOCK_STAGING_URL_GITHUB ) // Assert expect(actual).toEqual(expected) expect(SpyReviewRequestService.computeShaMappings).toHaveBeenCalled() expect(SpyReviewRequestService.computeFileType).not.toHaveBeenCalled() - expect(SpyReviewRequestService.computeFileUrl).not.toHaveBeenCalled() + expect(MockPageService.retrieveStagingPermalink).not.toHaveBeenCalled() }) it("should return an empty array if there are no file changes only", async () => { @@ -192,14 +210,15 @@ describe("ReviewRequestService", () => { // Act const actual = await ReviewRequestService.compareDiff( - mockUserWithSiteSessionData + mockUserWithSiteSessionData, + MOCK_STAGING_URL_GITHUB ) // Assert expect(actual).toEqual(expected) expect(SpyReviewRequestService.computeShaMappings).toHaveBeenCalled() expect(SpyReviewRequestService.computeFileType).not.toHaveBeenCalled() - expect(SpyReviewRequestService.computeFileUrl).not.toHaveBeenCalled() + expect(MockPageService.retrieveStagingPermalink).not.toHaveBeenCalled() }) }) @@ -217,20 +236,6 @@ describe("ReviewRequestService", () => { }) }) - describe("computeFileUrl", () => { - // TODO - it("should return the correct file URL", () => { - // Arrange - const expected = "www.google.com" - - // Act - const actual = ReviewRequestService.computeFileUrl("filename", "siteName") - - // Assert - expect(actual).toEqual(expected) - }) - }) - describe("computeShaMappings", () => { it("should return the correct sha mappings for pure identity commits", async () => { // Arrange @@ -896,15 +901,13 @@ describe("ReviewRequestService", () => { ) // Assert - expect(actual).toEqual(mockMergedReviewRequest) + expect(actual).toEqual(ok(mockMergedReviewRequest)) expect(MockReviewRequestRepository.findOne).toHaveBeenCalled() }) it("should return an error if the review request is not found", async () => { // Arrange - MockReviewRequestRepository.findOne.mockResolvedValueOnce( - new RequestNotFoundError() - ) + MockReviewRequestRepository.findOne.mockResolvedValueOnce(null) // Act const actual = await ReviewRequestService.getLatestMergedReviewRequest( @@ -912,7 +915,7 @@ describe("ReviewRequestService", () => { ) // Assert - expect(actual).toBeInstanceOf(RequestNotFoundError) + expect(actual).toEqual(err(new RequestNotFoundError())) expect(MockReviewRequestRepository.findOne).toHaveBeenCalled() }) }) @@ -945,11 +948,12 @@ describe("ReviewRequestService", () => { const actual = await ReviewRequestService.getFullReviewRequest( mockUserWithSiteSessionData, mockSiteOrmResponseWithAllCollaborators as Attributes, - MOCK_REVIEW_REQUEST_ONE.id + MOCK_REVIEW_REQUEST_ONE.id, + MOCK_STAGING_URL_GITHUB ) // Assert - expect(actual).toEqual(expected) + expect(actual).toEqual(ok(expected)) expect(MockReviewRequestRepository.findOne).toHaveBeenCalled() expect(MockReviewApi.getPullRequest).toHaveBeenCalled() expect(MockReviewApi.getCommitDiff).toHaveBeenCalled() @@ -963,11 +967,12 @@ describe("ReviewRequestService", () => { const actual = await ReviewRequestService.getFullReviewRequest( mockUserWithSiteSessionData, mockSiteOrmResponseWithAllCollaborators as Attributes, - MOCK_REVIEW_REQUEST_ONE.id + MOCK_REVIEW_REQUEST_ONE.id, + MOCK_STAGING_URL_GITHUB ) // Assert - expect(actual).toBeInstanceOf(RequestNotFoundError) + expect(actual).toEqual(err(new RequestNotFoundError())) expect(MockReviewRequestRepository.findOne).toHaveBeenCalled() expect(MockReviewApi.getPullRequest).not.toHaveBeenCalled() expect(MockReviewApi.getCommitDiff).not.toHaveBeenCalled() diff --git a/src/types/configYml.ts b/src/types/configYml.ts index 3f48cfa76..7621ca5e7 100644 --- a/src/types/configYml.ts +++ b/src/types/configYml.ts @@ -1,4 +1,6 @@ +import { ProdPermalink, StagingPermalink } from "./pages" + export type ConfigYmlData = { - staging?: string - prod?: string + staging?: StagingPermalink + prod?: ProdPermalink } diff --git a/src/types/pages.ts b/src/types/pages.ts new file mode 100644 index 000000000..c23fb5333 --- /dev/null +++ b/src/types/pages.ts @@ -0,0 +1,118 @@ +import { Result } from "neverthrow" + +import { Brand, ToBrand } from "./util" + +type NameInfo = { + name: string + kind: string +} + +type PageBrand = ToBrand + +export type ResourceRoomName = { + name: string & { __kind: "ResourceRoomName" } + kind: "ResourceRoomName" +} + +export type SubcollectionPageName = { + name: string & { __kind: "SubcollectionPage" } + collection: string + subcollection: string + kind: "SubcollectionPage" +} + +export type CollectionPageName = { + name: string & { __kind: "CollectionPage" } + collection: string + kind: "CollectionPage" +} + +export type ContactUsPageName = { + name: string & { __kind: "ContactUsPage" } + kind: "ContactUsPage" +} + +export type UnlinkedPageName = { + name: string & { __kind: "UnlinkedPage" } + kind: "UnlinkedPage" +} + +export type HomepageName = { + name: string & { __kind: "Homepage" } + kind: "Homepage" +} + +export type ResourceCategoryPageName = { + name: string & { __kind: "ResourceCategoryPage" } + resourceRoom: ToBrand + resourceCategory: string + kind: "ResourceCategoryPage" +} + +export type PageName = + | SubcollectionPageName + | CollectionPageName + | ContactUsPageName + | UnlinkedPageName + | HomepageName + | ResourceCategoryPageName + +// NOTE: This type is a limited sub-type of the raw pages data +// returned by the various page level services. +// This maps to the logical return type of `retrieveDataFromMarkdown` +// in `markdown-utils.js` +// This type **might be wrong**. +export type PageInfo = { + content: { + frontMatter: { + // NOTE: Frontend enforces this hence pages + // created through the CMS should always have + // a permalink (default: `permalink`) or an empty string + // if the parsing fails + permalink: string + } + pageBody: string + } + sha: string +} + +export type ResourcePageInfo = PageInfo & { + content: { + frontMatter: { + layout: "file" | "post" | "link" + } + } +} + +// Homepage also +export type ContactUsPage = Brand + +export type Homepage = Brand + +export type CollectionPage = PageInfo & { + fileName: PageBrand +} + +export type SubcollectionPage = PageInfo & { + fileName: PageBrand +} + +export type ResourceCategoryPage = PageInfo & { + fileName: PageBrand +} + +export type UnlinkedPage = PageInfo & { + fileName: PageBrand +} + +export type StagingPermalink = Brand +export type ProdPermalink = Brand + +// NOTE: This is not `frontMatter.permalink` as this +// also includes the respective base url in front. +export type FullPermalink = StagingPermalink | ProdPermalink + +export type PathInfo = { + name: string + path: Result +} diff --git a/src/types/repoInfo.ts b/src/types/repoInfo.ts index 1bd9e3ec6..a36ce7f9d 100644 --- a/src/types/repoInfo.ts +++ b/src/types/repoInfo.ts @@ -1,3 +1,5 @@ +import { Brand } from "./util" + export type GitHubRepositoryData = { name: string private: boolean @@ -20,4 +22,4 @@ export type RepositoryData = { } type SiteUrlTypes = "staging" | "prod" -export type SiteUrls = { [key in SiteUrlTypes]: string } +export type SiteUrls = Partial<{ [key in SiteUrlTypes]: Brand }> diff --git a/src/types/util.ts b/src/types/util.ts new file mode 100644 index 000000000..7ba118988 --- /dev/null +++ b/src/types/util.ts @@ -0,0 +1,23 @@ +interface Brandable { + kind: string +} + +// Converts an object to a branded object with a key of the object +export type ToBrand< + T extends Brandable & Record, + K extends keyof T +> = T[K] & { __kind: T["kind"] } + +// Brands a base type (eg: string) so that the base type +// is not assignable to the branded type +export type Brand = { + __kind: Branding +} & T + +export const Brand = { + // NOTE: The cast here is required - this type is impossible + // to obtain normally + fromString: (base: string): Brand => base as Brand, + toString: (branded: Brand): string => + (branded as unknown) as string, +} diff --git a/src/utils/json.ts b/src/utils/json.ts new file mode 100644 index 000000000..80252b03f --- /dev/null +++ b/src/utils/json.ts @@ -0,0 +1,8 @@ +import { Result } from "neverthrow" + +import { UnprocessableError } from "@root/errors/UnprocessableError" + +export const safeJsonParse = Result.fromThrowable( + JSON.parse, + () => new UnprocessableError() +)