blitz-js / blitz

⚡️ The Missing Fullstack Toolkit for Next.js
https://Blitzjs.com
MIT License
13.68k stars 798 forks source link

Make it easier to mock `ctx` for tests and `blitz console` #2654

Closed flybayer closed 1 year ago

flybayer commented 3 years ago

What do you want and why?

Currently you have to manually craft a ctx mock for using in tests or running queries/mutations inside blitz console.

We need to make this better.

Current workaround: https://github.com/blitz-js/blitz/discussions/2653

Possible implementation(s)

Maybe add a new createMockCtx() utility to Blitz?

MrLeebo commented 3 years ago

This is something I'm toying with as a solution, but I'm not 100% on it yet. Example usage:

# changePassword.test.ts
  const currentPassword = "Password01"
  const newPassword = "Password02"

  const user = await db.user.create({
    data: {
      email: "user@example.com",
      hashedPassword: await SecurePassword.hash(currentPassword),
    },
  })

+ const { res } = await createMockContext({ userId: user.id })

  const result = changePassword({ currentPassword, newPassword }, res.blitzCtx)
  await expect(result).resolves.toBe(true)

Implementation is below. Still have some testing to do on it.

import {
  sessionMiddleware,
  getSession,
  MiddlewareRequest as Req,
  MiddlewareResponse as Res,
  Ctx,
} from "blitz"
import httpMocks from "node-mocks-http"

interface CreateMockContextOptions {
  userId?: number
  reqOptions?: httpMocks.RequestOptions
  resOptions?: httpMocks.ResponseOptions
}

// Creates a mock context for use in tests and scripts. Attempts to make it the
// "real deal" by calling the same initialization logic that creates actual
// session contexts.
// If you're here because this function isn't working anymore, then the
// commented steps below may require special attention.
export default async function createMockContext<C extends Ctx>(
  options: CreateMockContextOptions = {}
) {
  const { userId, reqOptions, resOptions } = options
  const { req: mockReq, res: mockRes } = httpMocks.createMocks<Req, Res<C>>(reqOptions, resOptions)

  // Constructs global.sessionConfig which is referenced in several utilities.
  // I know, this doesn't actually run the middleware. The initialization logic
  // has the side effect we need.
  sessionMiddleware({ isAuthorized: () => userId != null })

  // Ensures the response has the blitzCtx object which is required for
  // authorization checks.
  await getSession(mockReq, mockRes)

  // Simulate the session is authorized to a user, if an ID was provided.
  if (userId) mockRes.blitzCtx.session.$publicData.userId = userId

  return { req: mockReq, res: mockRes }
}
vincentrolfs commented 3 years ago

This was extremely helpful! I made a couple of changes to your script in order to make sure that the simulation is as close to the real thing as possible. Also, httpMocks.createMocks<Req, Res<C>> did not typecheck for me, so I had to use any.

The most important change I did was to import blitz.config.js instead of calling sessionMiddleware. This ensures that the correct function for isAuthorized is used.

Secondly, I created a function getPublicSessionData which is used by the mock script and by the actual login mutation in app/auth/mutations/login.ts (or wherever you do your login).

The result is the following:

// app/auth/utils/getPublicSessionData.ts
import { Role } from "../../../types"
import { User } from "db"

export function getPublicSessionData(user: Pick<User, "id" | "role">) {
  return { userId: user.id, role: user.role as Role }
}
// test/createMockContext.ts
import { Ctx, getSession, MiddlewareRequest as Req, MiddlewareResponse as Res } from "blitz"
import httpMocks from "node-mocks-http"
import { User } from "db"

// This import is crucial, as it modifies global state by calling sessionMiddleware
// Most importantly, this sets the isAuthorized method in global.sessionConfig
import "../blitz.config"
import { getPublicSessionData } from "../app/auth/utils/getPublicSessionData"

interface CreateMockContextOptions {
  user?: User
  reqOptions?: httpMocks.RequestOptions
  resOptions?: httpMocks.ResponseOptions
}

// Based on https://github.com/blitz-js/blitz/issues/2654#issuecomment-904426530
// Creates a mock context for use in tests and scripts. Attempts to make it the
// "real deal" by calling the same initialization logic that creates actual
// session contexts.
export default async function createMockContext<C extends Ctx>({
  user,
  reqOptions,
  resOptions,
}: CreateMockContextOptions = {}) {
  const mocks = httpMocks.createMocks<any, any>(reqOptions, resOptions)
  const mockReq: Req = mocks.req
  const mockRes: Res<C> = mocks.res

  // Ensures the response has the blitzCtx object which is required for
  // authorization checks
  await getSession(mockReq, mockRes)

  // Simulate login by saving public session data
  if (user) {
    // Need to use Object.assign instead of spread operator,
    // because $publicData is readonly (only has a getter)
    Object.assign(mockRes.blitzCtx.session.$publicData, getPublicSessionData(user))
  }

  return { req: mockReq, res: mockRes, ctx: mockRes.blitzCtx }
}
// app/auth/mutations/login.ts
import { Login } from "../validations"
import { getPublicSessionData } from "../utils/getPublicSessionData"

// .. more code ...

export default resolver.pipe(resolver.zod(Login), async ({ email, password }, ctx) => {
  // .. more code ...

  await ctx.session.$create(getPublicSessionData(user))

  return user
})

Then you can use it like this:

import db from "db"
import getUsers from "./getUsers"
import createMockContext from "../../../test/createMockContext"

beforeEach(async () => {
  await db.$reset()
})

describe("getUsers query", () => {
  it("rejects access for regular users", async () => {
    const user = await db.user.create({
      data: {
        email: "user@example.com",
        role: "USER",
      },
    })
    const { ctx } = await createMockContext({ user })

    await expect(async () => {
      await getUsers({}, ctx)
    }).rejects.toThrow("You are not authorized to access this")
  })
})
MrLeebo commented 3 years ago

@vincentrolfs Looks nice. What typescript error did you see? I didn't get any type errors when I tried it.

If this is going to be a core utility, I think it has to avoid importing User from db since there's no guarantee that client applications will have that model and I don't think a core utility would be able to import the app's db client anyway. I suppose we could make it a recipe instead?

ospfranco commented 2 years ago

@vincentrolfs thanks a lot for the snippet! really helpful, would be great to have this documented on the blitz website, for those who want to test the queries/mutations, independently from UI

beerose commented 2 years ago

That's a good idea, @ospfranco! Do you want to add it to the docs?

ospfranco commented 2 years ago

Glad to help: https://github.com/blitz-js/blitzjs.com/pull/642

harjis commented 2 years ago

Thanks a lot for these examples! With a quick look I didn't find an example of a happy path. Nothing special about it so more like documentation for people who end up reading this conversation in the future 😄

  it("returns customers if user has authenticated", async () => {
    const customer = await db.customer.create({ data: { name: "test" } })
    const user = await db.user.create({
      data: {
        email: "user@example.com",
        role: "USER",
      },
    })

    const { ctx } = await createMockContext({ user })
    await ctx.session.$create({ userId: user.id, role: user.role as Role })

    await expect(getCustomers({}, ctx)).resolves.toEqual({
      count: 1,
      customers: [customer],
      hasMore: false,
      nextPage: null,
    })
  })

For some reason this throws en error Internal Blitz Error: process.env.BLITZ_APP_DIR is not set. To fix this I had to add BLITZ_APP_DIR=. in .env.test.local. I guess adding this env variable shouldn't be needed?

❯ blitz -v

macOS Monterey | darwin-x64 | Node: v16.14.0

blitz: 0.45.4 (global)
blitz: 0.45.4 (local)

  Package manager: npm 
  System:
    OS: macOS 12.4
    CPU: (12) x64 Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz
    Memory: 3.23 GB / 32.00 GB
    Shell: 5.8.1 - /bin/zsh
  Binaries:
    Node: 16.14.0 - ~/.asdf/installs/nodejs/16.14.0/bin/node
    Yarn: 1.22.18 - ~/.asdf/installs/nodejs/16.14.0/.npm/bin/yarn
    npm: 8.3.1 - ~/.asdf/installs/nodejs/16.14.0/bin/npm
    Watchman: Not Found
  npmPackages:
    @prisma/client: 3.16.0-integration-tmp-revert-node14.1 => 3.16.0-integration-tmp-revert-node14.1 
    blitz: 0.45.4 => 0.45.4 
    prisma: 3.16.0-integration-tmp-revert-node14.1 => 3.16.0-integration-tmp-revert-node14.1 
    react: 18.0.0 => 18.0.0 
    react-dom: 18.0.0 => 18.0.0 
    typescript: ~4.5 => 4.5.5 
madhenry commented 2 years ago

Anyone knows if/how this could work with the new Blitz 2.0 next toolkit?

Got as far as this with a couple of edits (mainly to imports and types) to the latest example above:

Internal Blitz Error: globalThis.__BLITZ_SESSION_COOKIE_PREFIX is not set

      31 |   // Ensures the response has the blitzCtx object which is required for
      32 |   // authorization checks
    > 33 |   await getSession(mockReq, mockRes)
         |                   ^
      34 |
      35 |   // Simulate login by saving public session data
      36 |   if (user) {
// createMockContext.ts
import { Ctx, MiddlewareResponse as Res } from 'blitz'
import { getSession } from '@blitzjs/auth'
import { NextApiRequest as Req } from 'next'

...

import '../next.config'

EDIT:

Seems like adding

import { api } from 'app/blitz-server'

...

await api(() => null)
await getSession(mockReq, mockRes)

Fixes these issues.. though not sure if that's a correct way at all

ourmaninamsterdam commented 6 months ago

Thank you - this works great using createMockContext() for my query and mutation tests.

I'm now trying to mock Blitz context on some React component tests that call mutations/queries via useQuery. One of these mutations uses ctx.session.$setPublicData. Unfortunately ctx.session equals { fromInvoke: true } and is missing the $setPublicData method. I'm using Blitz 2.0.6 and pages router. Any ideas how I can populate this?