Merge branch 'feature/nextjs-16-upgrade' - TypeScript and test fixes

Merged changes:
- Reverted Next.js 16 upgrade (Payload CMS 3.68.4 incompatible)
- Fixed all TypeScript errors in test files
- ESLint and typecheck now pass

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Martin Porwoll 2025-12-15 10:10:58 +00:00
commit 91d00b016e
11 changed files with 42 additions and 29 deletions

View file

@ -16,7 +16,7 @@ const nextConfig = {
workerThreads: false,
cpus: 1,
},
// Your Next.js config here
// Webpack configuration for TypeScript/ESM compatibility
webpack: (webpackConfig) => {
webpackConfig.resolve.extensionAlias = {
'.cjs': ['.cts', '.cjs'],

View file

@ -73,7 +73,7 @@
"vitest": "4.0.15"
},
"engines": {
"node": "^18.20.2 || >=20.9.0",
"node": ">=20.9.0",
"pnpm": "^9 || ^10"
},
"pnpm": {

View file

@ -26,8 +26,10 @@ export interface MockTenant {
domains?: Array<{ domain: string }>
}
export interface MockPayloadRequest extends Partial<PayloadRequest> {
// Note: Not extending PayloadRequest to allow flexible mock types for testing
export interface MockPayloadRequest {
user?: MockUser | null
// Allow both Headers and plain object for testing different header formats
headers: Headers | Record<string, string | string[] | undefined>
payload: {
find: ReturnType<typeof vi.fn>
@ -126,10 +128,10 @@ export function createMockPayloadRequest(
tenants?: MockTenant[]
} = {},
): MockPayloadRequest {
const headers: Record<string, string | string[] | undefined> = {}
const headers = new Headers()
if (options.host) {
headers['host'] = options.host
headers.set('host', options.host)
}
// Mock payload.find to resolve tenant from host
@ -306,9 +308,11 @@ export async function executeAccess(
data?: Record<string, unknown>
} = {},
): Promise<AccessResult> {
// Convert string ID to number if needed (Payload access functions expect number | undefined)
const numericId = typeof options.id === 'string' ? parseInt(options.id, 10) : options.id
const result = await accessFn({
req: request as unknown as PayloadRequest,
id: options.id,
id: numericId,
data: options.data,
})

View file

@ -3,12 +3,13 @@ import type { Payload } from 'payload'
import type { Tenant } from '@/payload-types'
const mockSendMail = vi.fn(async () => ({ messageId: 'mocked-id' }))
const mockCreateTransport = vi.fn(() => ({ sendMail: mockSendMail }))
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const mockCreateTransport = vi.fn((_options?: unknown) => ({ sendMail: mockSendMail }))
vi.mock('nodemailer', () => ({
__esModule: true,
default: {
createTransport: (...args: unknown[]) => mockCreateTransport(...args),
createTransport: (options: unknown) => mockCreateTransport(options),
},
}))

View file

@ -75,8 +75,13 @@ describe('Payload Localization Integration', () => {
it('payload config has localization enabled', async () => {
const payloadConfig = await config
expect(payloadConfig.localization).toBeDefined()
expect(payloadConfig.localization?.locales).toBeDefined()
expect(payloadConfig.localization?.defaultLocale).toBe('de')
expect(payloadConfig.localization).not.toBe(false)
// Type guard for localization config
const localization = payloadConfig.localization
if (localization && typeof localization === 'object') {
expect(localization.locales).toBeDefined()
expect(localization.defaultLocale).toBe('de')
}
})
it('payload config has i18n enabled', async () => {

View file

@ -242,10 +242,12 @@ describe('Search API Integration', () => {
try {
const post = await payload.create({
collection: 'posts',
draft: false,
data: {
title: 'Searchable Test Post Title',
slug: `searchable-test-post-${Date.now()}`,
excerpt: 'This is a searchable excerpt for testing',
type: 'blog',
status: 'published',
publishedAt: new Date().toISOString(),
tenant: testTenantId,

View file

@ -9,7 +9,7 @@
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import type { Access, PayloadRequest } from 'payload'
import type { Access, PayloadRequest, Where } from 'payload'
import {
createSuperAdmin,
createTenantUser,
@ -122,7 +122,7 @@ describe('EmailLogs Collection Access', () => {
const result = await executeAccess(emailLogsAccess.read, request)
expect(hasFilteredAccess(result)).toBe(true)
const tenantIds = getTenantIdsFromInFilter(result as Record<string, unknown>)
const tenantIds = getTenantIdsFromInFilter(result as Where)
expect(tenantIds).toContain(1) // porwoll tenant ID
})
@ -131,7 +131,7 @@ describe('EmailLogs Collection Access', () => {
const result = await executeAccess(emailLogsAccess.read, request)
expect(hasFilteredAccess(result)).toBe(true)
const tenantIds = getTenantIdsFromInFilter(result as Record<string, unknown>)
const tenantIds = getTenantIdsFromInFilter(result as Where)
expect(tenantIds).toEqual(expect.arrayContaining([1, 4, 5]))
})
@ -141,7 +141,7 @@ describe('EmailLogs Collection Access', () => {
const result = await executeAccess(emailLogsAccess.read, request)
expect(hasFilteredAccess(result)).toBe(true)
const tenantIds = getTenantIdsFromInFilter(result as Record<string, unknown>)
const tenantIds = getTenantIdsFromInFilter(result as Where)
expect(tenantIds).toContain(1)
expect(tenantIds).toContain(4)
})
@ -159,7 +159,7 @@ describe('EmailLogs Collection Access', () => {
const result = await executeAccess(emailLogsAccess.read, request)
expect(hasFilteredAccess(result)).toBe(true)
const tenantIds = getTenantIdsFromInFilter(result as Record<string, unknown>)
const tenantIds = getTenantIdsFromInFilter(result as Where)
expect(tenantIds).toEqual([])
})
})
@ -377,7 +377,7 @@ describe('Access Control Edge Cases', () => {
const result = await executeAccess(emailLogsAccess.read, request)
expect(hasFilteredAccess(result)).toBe(true)
const tenantIds = getTenantIdsFromInFilter(result as Record<string, unknown>)
const tenantIds = getTenantIdsFromInFilter(result as Where)
expect(tenantIds).toHaveLength(0)
})
@ -428,7 +428,7 @@ describe('Access Control Edge Cases', () => {
const result = await executeAccess(emailLogsAccess.read, request)
expect(hasFilteredAccess(result)).toBe(true)
const tenantIds = getTenantIdsFromInFilter(result as Record<string, unknown>)
const tenantIds = getTenantIdsFromInFilter(result as Where)
expect(tenantIds.sort()).toEqual([1, 2, 3])
})
})

View file

@ -6,7 +6,7 @@
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import type { PayloadRequest } from 'payload'
import type { PayloadRequest, Where } from 'payload'
import {
createSuperAdmin,
createTenantUser,
@ -173,7 +173,7 @@ describe('tenantScopedPublicRead', () => {
const result = await executeAccess(tenantScopedPublicRead, request)
expect(hasFilteredAccess(result)).toBe(true)
expect(getTenantIdFromFilter(result as Record<string, unknown>)).toBe(1)
expect(getTenantIdFromFilter(result as Where)).toBe(1)
})
it('returns different tenant filter for different domain', async () => {
@ -181,7 +181,7 @@ describe('tenantScopedPublicRead', () => {
const result = await executeAccess(tenantScopedPublicRead, request)
expect(hasFilteredAccess(result)).toBe(true)
expect(getTenantIdFromFilter(result as Record<string, unknown>)).toBe(4)
expect(getTenantIdFromFilter(result as Where)).toBe(4)
})
it('denies access for unknown domain', async () => {
@ -286,7 +286,7 @@ describe('Access Control Integration Scenarios', () => {
// Should only see porwoll.de posts
expect(hasFilteredAccess(result)).toBe(true)
expect(getTenantIdFromFilter(result as Record<string, unknown>)).toBe(1)
expect(getTenantIdFromFilter(result as Where)).toBe(1)
})
it('admin editing posts from any tenant', async () => {

View file

@ -288,7 +288,7 @@ describe('Data Masking', () => {
it('handles non-Error objects', () => {
const notAnError = { message: 'password=secret', code: 500 }
const masked = maskError(notAnError as Error)
const masked = maskError(notAnError as unknown as Error)
expect(masked).toBeDefined()
})

View file

@ -205,7 +205,7 @@ describe('Rate Limiter', () => {
resetIn: 45000,
}
const headers = rateLimitHeaders(result, 30)
const headers = rateLimitHeaders(result, 30) as Record<string, string>
expect(headers['X-RateLimit-Limit']).toBe('30')
expect(headers['X-RateLimit-Remaining']).toBe('25')
@ -220,7 +220,7 @@ describe('Rate Limiter', () => {
retryAfter: 30,
}
const headers = rateLimitHeaders(result, 10)
const headers = rateLimitHeaders(result, 10) as Record<string, string>
expect(headers['Retry-After']).toBe('30')
expect(headers['X-RateLimit-Remaining']).toBe('0')
@ -233,8 +233,8 @@ describe('Rate Limiter', () => {
resetIn: 60000,
}
const headers = rateLimitHeaders(result, 10)
const resetValue = headers['X-RateLimit-Reset'] as string
const headers = rateLimitHeaders(result, 10) as Record<string, string>
const resetValue = headers['X-RateLimit-Reset']
// The reset value should be a number (either timestamp or seconds)
expect(resetValue).toBeDefined()

View file

@ -30,15 +30,16 @@
"./src/payload.config.ts"
]
},
"target": "ES2022",
"target": "ES2022"
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts"
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
],
]
}