Skip to content

Commit

Permalink
fix: page router validations and add test cases (#720)
Browse files Browse the repository at this point in the history
This PR starts the chain of PRs for adding test cases for all our procedures in the Studio application so we can be more confident when shipping code.

## Solution

**Breaking Changes**
- [x] No - this PR is backwards compatible

**Features**:

- Details ...

**Improvements**:

- Add Tagged types to `jsonb` columns so we remember to call `jsonb()` function before insertion into the database. This prevents runtime errors.
- Update `updatePageBlobSchema` to validate the type of `content` in the schema itself.
- Update all `*select` constants to use `satisfies` keyword for better type inference when used.
- Throw 404 errors when resource is not found in all `page.router` procedures
- Validate whether `folderId` is a folder when calling `createPage` procedure and passing the `folderId` arg.
- use transaction when updating page in `updatePage` procedure
- add foreign key violation error handling

**Bug Fixes**:

- use correct `fullPage.publishedVersionId` check in `readPageAndBlob` procedure

## Tests

Tests are added for all procedures in the page router.

A few tests regarding permissions are skipped until permissions are in cc @seaerchin
karrui authored Oct 11, 2024
1 parent d86dd82 commit 5569c85
Showing 20 changed files with 1,792 additions and 209 deletions.
17 changes: 12 additions & 5 deletions apps/studio/prisma/types.ts
Original file line number Diff line number Diff line change
@@ -12,15 +12,22 @@ import type {
IsomerSiteThemeProps as _IsomerSiteThemeProps,
IsomerSiteWideComponentsProps as _IsomerSiteWideComponentsProps,
} from "@opengovsg/isomer-components"
import type { Tagged } from "type-fest"

declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace PrismaJson {
// TODO: Rename all with XXXYYYJson instead of XXXJsonYYY
type SiteJsonConfig = _IsomerSiteConfigProps
type SiteThemeJson = _IsomerSiteThemeProps
type BlobJsonContent = _IsomerSchema
type NavbarJsonContent = _IsomerSiteWideComponentsProps["navBarItems"]
type FooterJsonContent = _IsomerSiteWideComponentsProps["footerItems"]
type SiteJsonConfig = Tagged<_IsomerSiteConfigProps, "JSONB">
type SiteThemeJson = Tagged<_IsomerSiteThemeProps, "JSONB">
type BlobJsonContent = Tagged<_IsomerSchema, "JSONB">
type NavbarJsonContent = Tagged<
_IsomerSiteWideComponentsProps["navBarItems"],
"JSONB"
>
type FooterJsonContent = Tagged<
_IsomerSiteWideComponentsProps["footerItems"],
"JSONB"
>
}
}
44 changes: 33 additions & 11 deletions apps/studio/src/schemas/page.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
import { ResourceType } from "~prisma/generated/generatedEnums"
import type { IsomerSchema } from "@opengovsg/isomer-components"
import { schema } from "@opengovsg/isomer-components"
import { ResourceState, ResourceType } from "~prisma/generated/generatedEnums"
import { z } from "zod"

import { ajv } from "~/utils/ajv"
import { safeJsonParse } from "~/utils/safeJsonParse"
import { generateBasePermalinkSchema } from "./common"

const schemaValidator = ajv.compile<IsomerSchema>(schema)

const NEW_PAGE_LAYOUT_VALUES = [
"article",
"content",
@@ -45,17 +51,19 @@ export const reorderBlobSchema = z.object({
),
})

export const updatePageSchema = basePageSchema.extend({
// NOTE: We allow both to be empty now,
// in which case this is a no-op.
// We are ok w/ this because it doesn't
// incur any db writes
parentId: z.number().min(1).optional(),
pageName: z.string().min(1).optional(),
})

export const updatePageBlobSchema = basePageSchema.extend({
content: z.string(),
content: z.string().transform((value, ctx) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const parsed = safeJsonParse(value)
if (schemaValidator(parsed)) {
return parsed
}
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Invalid page content",
})
return z.NEVER
}),
siteId: z.number().min(1),
})

@@ -127,3 +135,17 @@ export const pageSettingsSchema = z.discriminatedUnion("type", [
}),
rootPageSettingsSchema,
])

export const readPageOutputSchema = z.object({
id: z.string(),
title: z.string(),
permalink: z.string(),
siteId: z.number(),
parentId: z.string().nullable(),
publishedVersionId: z.string().nullable(),
draftBlobId: z.string().nullable(),
state: z.nativeEnum(ResourceState).nullable(),
type: z.nativeEnum(ResourceType),
createdAt: z.date(),
updatedAt: z.date(),
})
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import type { UnwrapTagged } from "type-fest"
import { TRPCError } from "@trpc/server"
import { get } from "lodash"

import { createCollectionSchema } from "~/schemas/collection"
import { readFolderSchema } from "~/schemas/folder"
import { createCollectionPageSchema } from "~/schemas/page"
import { protectedProcedure, router } from "~/server/trpc"
import { db, ResourceType } from "../database"
import { db, jsonb, ResourceType } from "../database"
import {
defaultResourceSelect,
getSiteResourceById,
@@ -59,7 +60,7 @@ export const collectionRouter = router({
createCollectionPage: protectedProcedure
.input(createCollectionPageSchema)
.mutation(async ({ input }) => {
let newPage: PrismaJson.BlobJsonContent
let newPage: UnwrapTagged<PrismaJson.BlobJsonContent>
const { title, type, permalink, siteId, collectionId } = input
if (type === "page") {
newPage = createCollectionPageJson({ type })
@@ -74,7 +75,7 @@ export const collectionRouter = router({
const blob = await tx
.insertInto("Blob")
.values({
content: newPage,
content: jsonb(newPage),
})
.returning("Blob.id")
.executeTakeFirstOrThrow()
Original file line number Diff line number Diff line change
@@ -2,10 +2,10 @@ import type { SelectExpression } from "kysely"

import type { DB } from "../database"

export const defaultCollectionSelect: SelectExpression<DB, "Resource">[] = [
export const defaultCollectionSelect = [
"Resource.id",
"Resource.siteId",
"Resource.title",
"Resource.type",
"Resource.permalink",
]
] satisfies SelectExpression<DB, "Resource">[]
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { UnwrapTagged } from "type-fest"
import { format } from "date-fns"

export const createCollectionPageJson = ({}: {
@@ -16,7 +17,7 @@ export const createCollectionPageJson = ({}: {
},
content: [],
version: "0.1.0",
} satisfies PrismaJson.BlobJsonContent
} satisfies UnwrapTagged<PrismaJson.BlobJsonContent>
}

export const createCollectionPdfJson = ({
@@ -35,5 +36,5 @@ export const createCollectionPdfJson = ({
// TODO: Add pdf blob to content
content: [],
version: "0.1.0",
} satisfies PrismaJson.BlobJsonContent
} satisfies UnwrapTagged<PrismaJson.BlobJsonContent>
}
2 changes: 1 addition & 1 deletion apps/studio/src/server/modules/database/utils.ts
Original file line number Diff line number Diff line change
@@ -52,7 +52,7 @@ export function typesafeJsonObjectFromStrict<O>(
}

// Create a jsonb object from a plain object.
export function jsonb<T>(value: T): RawBuilder<T> {
export function jsonb<T>(value: T): RawBuilder<Tagged<T, "JSONB">> {
return sql`CAST(${JSON.stringify(value)} AS JSONB)`
}

23 changes: 12 additions & 11 deletions apps/studio/src/server/modules/folder/folder.select.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import type { SelectExpression } from "kysely"

import type { DB } from "~prisma/generated/generatedTypes"

type ResourceProperties = keyof DB["Resource"]
export const defaultFolderSelect: readonly ResourceProperties[] = [
"id",
"parentId",
"permalink",
"title",
"siteId",
"state",
"type",
"draftBlobId",
] as const
export const defaultFolderSelect = [
"Resource.id",
"Resource.parentId",
"Resource.permalink",
"Resource.title",
"Resource.siteId",
"Resource.state",
"Resource.type",
"Resource.draftBlobId",
] satisfies SelectExpression<DB, "Resource">[]
Loading

0 comments on commit 5569c85

Please sign in to comment.