Skip to content

Commit

Permalink
chore(core): add general builder and cli-fixture tests (#96)
Browse files Browse the repository at this point in the history
* add general builder tests

* add node resolution

* add example fixture test

* add build test

* update gitignore

* update vitest to v1

* remove cjs warning
  • Loading branch information
JoviDeCroock authored Dec 16, 2023
1 parent 2b4073e commit 59979fc
Show file tree
Hide file tree
Showing 17 changed files with 705 additions and 134 deletions.
2 changes: 1 addition & 1 deletion .changeset/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"access": "public",
"baseBranch": "main",
"updateInternalDependencies": "minor",
"ignore": ["@fuse-examples/*", "@fuse/website"],
"ignore": ["@fuse-examples/*", "@fuse/website", "@fuse-fixtures/*"],
"___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH": {
"onlyUpdatePeerDependentsWhenOutOfRange": true,
"updateInternalDependents": "out-of-range"
Expand Down
5 changes: 5 additions & 0 deletions .changeset/little-masks-grab.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'fuse': patch
---

Remove log from list-plugin
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
node_modules
dist
packages/core/test/fixtures/**/build
packages/core/test/fixtures/**/schema.graphql
3 changes: 2 additions & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -102,10 +102,11 @@
"devDependencies": {
"@types/node": "^20.8.10",
"@types/react": "^18",
"execa": "^8.0.1",
"next": "14.0.3",
"tsup": "^7.2.0",
"typescript": "^5.2.2",
"vitest": "^0.34.6"
"vitest": "^1.0.0"
},
"publishConfig": {
"access": "public",
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,8 @@ prog
}),
],
})

console.log('Server build output created in ./build')
}

if (opts.client) {
Expand Down
2 changes: 0 additions & 2 deletions packages/core/src/pothos-list/schema-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,6 @@ schemaBuilderProto.listObject = function listObject({
const listRef =
this.objectRef<ListShape<SchemaTypes, unknown, false>>(listName)

console.log('node nullability', nullable)

this.objectType(listRef, {
fields: (t) => ({
totalCount: t.int({
Expand Down
108 changes: 108 additions & 0 deletions packages/core/test/cli.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import path from 'node:path'
import fs, { existsSync } from 'node:fs'
import { afterAll, beforeAll, describe, expect, test } from 'vitest'
import { ExecaChildProcess, execa } from 'execa'
import { afterEach } from 'node:test'

const fixturesDir = path.join(__dirname, 'fixtures')
const allFixtures = fs.readdirSync(fixturesDir)

const wait = () => {
return new Promise((resolve) => {
setTimeout(() => {
resolve(null)
}, 500)
})
}

describe.each(allFixtures)('%s', (fixtureName) => {
const fixtureDir = path.join(fixturesDir, fixtureName)
let process: ExecaChildProcess<string> | undefined

beforeAll(async () => {
await execa('pnpm', ['install'], { cwd: fixtureDir })
}, 25_000)

afterAll(async () => {
await fs.promises.rm(path.join(fixtureDir, 'node_modules'), {
recursive: true,
})
await fs.promises.rm(path.join(fixtureDir, 'build'), {
recursive: true,
})
await fs.promises.rm(path.join(fixtureDir, 'schema.graphql'))
}, 25_000)

afterEach(async () => {
if (process) {
process.kill('SIGTERM')
await wait()
process = undefined
}
})

test('Should run the dev command', async () => {
process = execa('pnpm', ['fuse', 'dev', '--server'], {
cwd: fixtureDir,
})

await new Promise((resolve) => {
process!.stdout?.on('data', (data) => {
const msg = data.toString()
if (msg.includes('Server listening on')) {
resolve(null)
}
})
})

const result = await fetch('http://localhost:4000/graphql', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
query: '{ _version }',
}),
}).then((x) => x.json())
expect(result.data._version).toBeDefined()
}, 10_000)

test('Should run the build command', async () => {
process = execa('pnpm', ['fuse', 'build', '--server'], {
cwd: fixtureDir,
})

await new Promise((resolve) => {
process!.stdout?.on('data', (data) => {
const msg = data.toString()
if (msg.includes('Server build output created')) {
resolve(null)
}
})
})

expect(existsSync(path.join(fixtureDir, 'build'))).toBe(true)

process = execa('node', ['./build/node.js'], {
cwd: fixtureDir,
env: {
NODE_ENV: 'production',
PORT: '3000',
},
})

// Our built node output does not log its start
await wait()

const result = await fetch('http://localhost:3000/graphql', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
query: '{ _version }',
}),
}).then((x) => x.json())
expect(result.data._version).toBeDefined()
}, 10_000)
})
167 changes: 167 additions & 0 deletions packages/core/test/errors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import { test, expect } from 'vitest'
import { execute, parse } from 'graphql'

let counter = 0
const importFuse = () => {
return import('../src/builder?test-errors=' + counter++)
}

test('Should correctly format an authentication error', async () => {
const mod = await importFuse()
const { builder, addQueryFields, AuthenticationError } = mod

addQueryFields((t) => ({
authn: t.field({
type: 'String',
description: 'An authentication check',
resolve: () => {
throw new AuthenticationError('You are not authenticated')
},
}),
}))

const schema = builder.toSchema()

const document = parse(`query { authn }`)
const result = (await execute({
document,
schema,
contextValue: {},
})) as { data?: any; errors?: any }

expect(result.data).toEqual({ authn: null })
expect(result.errors).toBeDefined()
expect(result.errors).toHaveLength(1)
expect(result.errors[0].message).toEqual('You are not authenticated')
expect(result.errors[0].extensions).toEqual({
code: 'UNAUTHENTICATED',
})
})

test('Should correctly format an authorization error', async () => {
const mod = await importFuse()
const { builder, addQueryFields, ForbiddenError } = mod

addQueryFields((t) => ({
authz: t.field({
type: 'String',
description: 'An authorization check',
resolve: () => {
throw new ForbiddenError('You are not authorized')
},
}),
}))

const schema = builder.toSchema()

const document = parse(`query { authz }`)
const result = (await execute({
document,
schema,
contextValue: {},
})) as { data?: any; errors?: any }

expect(result.data).toEqual({ authz: null })
expect(result.errors).toBeDefined()
expect(result.errors).toHaveLength(1)
expect(result.errors[0].message).toEqual('You are not authorized')
expect(result.errors[0].extensions).toEqual({
code: 'FORBIDDEN',
})
})

test('Should correctly format a not-found error', async () => {
const mod = await importFuse()
const { builder, addQueryFields, NotFoundError } = mod

addQueryFields((t) => ({
notFound: t.field({
type: 'String',
description: 'An authorization check',
resolve: () => {
throw new NotFoundError('Entity cannot be found')
},
}),
}))

const schema = builder.toSchema()

const document = parse(`query { notFound }`)
const result = (await execute({
document,
schema,
contextValue: {},
})) as { data?: any; errors?: any }

expect(result.data).toEqual({ notFound: null })
expect(result.errors).toBeDefined()
expect(result.errors).toHaveLength(1)
expect(result.errors[0].message).toEqual('Entity cannot be found')
expect(result.errors[0].extensions).toEqual({
code: 'NOT_FOUND',
})
})

test('Should correctly format an authorization error', async () => {
const mod = await importFuse()
const { builder, addQueryFields, ForbiddenError } = mod

addQueryFields((t) => ({
authz: t.field({
type: 'String',
description: 'A not-found check',
resolve: () => {
throw new ForbiddenError('You are not authorized')
},
}),
}))

const schema = builder.toSchema()

const document = parse(`query { authz }`)
const result = (await execute({
document,
schema,
contextValue: {},
})) as { data?: any; errors?: any }

expect(result.data).toEqual({ authz: null })
expect(result.errors).toBeDefined()
expect(result.errors).toHaveLength(1)
expect(result.errors[0].message).toEqual('You are not authorized')
expect(result.errors[0].extensions).toEqual({
code: 'FORBIDDEN',
})
})

test('Should correctly format a bad-request error', async () => {
const mod = await importFuse()
const { builder, addQueryFields, BadRequestError } = mod

addQueryFields((t) => ({
badRequest: t.field({
type: 'String',
description: 'A bad-request check',
resolve: () => {
throw new BadRequestError('Missing id')
},
}),
}))

const schema = builder.toSchema()

const document = parse(`query { badRequest }`)
const result = (await execute({
document,
schema,
contextValue: {},
})) as { data?: any; errors?: any }

expect(result.data).toEqual({ badRequest: null })
expect(result.errors).toBeDefined()
expect(result.errors).toHaveLength(1)
expect(result.errors[0].message).toEqual('Missing id')
expect(result.errors[0].extensions).toEqual({
code: 'BAD_REQUEST',
})
})
10 changes: 10 additions & 0 deletions packages/core/test/fixtures/simple/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"name": "@fuse-fixtures/simple",
"private": true,
"version": "0.0.0",
"type": "module",
"devDependencies": {
"fuse": "workspace:*",
"typescript": "^5.2.2"
}
}
16 changes: 16 additions & 0 deletions packages/core/test/fixtures/simple/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020"],
"module": "ESNext",
"skipLibCheck": true,

/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true
}
}
35 changes: 35 additions & 0 deletions packages/core/test/fixtures/simple/types/Test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { node } from 'fuse'

type UserSource = {
id: string
name: string
avatar_url: string
}

// "Nodes" are the core abstraction of Fuse.js. Each node represents
// a resource/entity with multiple fields and has to define two things:
// 1. load(): How to fetch from the underlying data source
// 2. fields: What fields should be exposed and added for clients
export const UserNode = node<UserSource>({
name: 'User',
load: async (ids) => getUsers(ids),
fields: (t) => ({
name: t.exposeString('name'),
// rename to camel-case
avatarUrl: t.exposeString('avatar_url'),
// Add an additional firstName field
firstName: t.string({
resolve: (user) => user.name.split(' ')[0],
}),
}),
})

// Fake function to fetch users. In real applications, this would
// talk to an underlying REST API/gRPC service/third-party API/…
async function getUsers(ids: string[]): Promise<UserSource[]> {
return ids.map((id) => ({
id,
name: `Peter #${id}`,
avatar_url: `https://i.pravatar.cc/300?u=${id}`,
}))
}
Loading

1 comment on commit 59979fc

@vercel
Copy link

@vercel vercel bot commented on 59979fc Dec 16, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Successfully deployed to the following URLs:

spacex-fuse – ./examples/spacex

spacex-fuse-stellate.vercel.app
spacex-fuse-git-main-stellate.vercel.app
spacex-fuse.vercel.app

Please sign in to comment.