Implement Sprint 1 stories: collections, RBAC, audit logging, load testing
Complete 6 Sprint 1 stories for Epic 1 web migration infrastructure. Portfolio Collection: - Add 7 fields: title, slug, url, image, description, websiteType, tags - Configure R2 storage and authenticated access control Categories Collection: - Add nameEn, order, textColor, backgroundColor fields - Add color picker UI configuration Posts Collection: - Add excerpt with 200 char limit and ogImage for social sharing - Add showInFooter checkbox and status select (draft/review/published) Role-Based Access Control: - Add role field to Users collection (admin/editor) - Create adminOnly and authenticated access functions - Apply access rules to Portfolio, Categories, Posts, Users collections Audit Logging System (NFR9): - Create Audit collection with timestamps for 90-day retention - Add auditLogger utility for login/logout/content change tracking - Add auditChange and auditGlobalChange hooks to all collections and globals - Add cleanupAuditLogs job with 90-day retention policy Load Testing Framework (NFR4): - Add k6 load testing with 3 scripts: public-browsing, admin-operations, api-performance - Configure targets: p95 < 500ms, error rate < 1%, 100 concurrent users - Add verification script and comprehensive documentation Other Changes: - Remove unused Form blocks - Add Header/Footer audit hooks - Regenerate Payload TypeScript types
This commit is contained in:
3048
apps/backend/pnpm-lock.yaml
generated
3048
apps/backend/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
|||||||
import type { GlobalConfig } from 'payload'
|
import type { GlobalConfig } from 'payload'
|
||||||
|
|
||||||
import { adminOnly } from '../access/adminOnly'
|
import { adminOnly } from '../access/adminOnly'
|
||||||
|
import { auditGlobalChange } from '../collections/Audit/hooks/auditHooks'
|
||||||
import { link } from '@/fields/link'
|
import { link } from '@/fields/link'
|
||||||
import { revalidateFooter } from './hooks/revalidateFooter'
|
import { revalidateFooter } from './hooks/revalidateFooter'
|
||||||
|
|
||||||
@@ -43,6 +44,6 @@ export const Footer: GlobalConfig = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
hooks: {
|
hooks: {
|
||||||
afterChange: [revalidateFooter],
|
afterChange: [revalidateFooter, auditGlobalChange('footer')],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { GlobalConfig } from 'payload'
|
import type { GlobalConfig } from 'payload'
|
||||||
|
|
||||||
import { adminOnly } from '../access/adminOnly'
|
import { adminOnly } from '../access/adminOnly'
|
||||||
|
import { auditGlobalChange } from '../collections/Audit/hooks/auditHooks'
|
||||||
import { link } from '@/fields/link'
|
import { link } from '@/fields/link'
|
||||||
import { revalidateHeader } from './hooks/revalidateHeader'
|
import { revalidateHeader } from './hooks/revalidateHeader'
|
||||||
|
|
||||||
@@ -29,6 +30,6 @@ export const Header: GlobalConfig = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
hooks: {
|
hooks: {
|
||||||
afterChange: [revalidateHeader],
|
afterChange: [revalidateHeader, auditGlobalChange('header')],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,45 +0,0 @@
|
|||||||
import type { CheckboxField } from '@payloadcms/plugin-form-builder/types'
|
|
||||||
import type { FieldErrorsImpl, FieldValues, UseFormRegister } from 'react-hook-form'
|
|
||||||
|
|
||||||
import { useFormContext } from 'react-hook-form'
|
|
||||||
|
|
||||||
import { Checkbox as CheckboxUi } from '@/components/ui/checkbox'
|
|
||||||
import { Label } from '@/components/ui/label'
|
|
||||||
import React from 'react'
|
|
||||||
|
|
||||||
import { Error } from '../Error'
|
|
||||||
import { Width } from '../Width'
|
|
||||||
|
|
||||||
export const Checkbox: React.FC<
|
|
||||||
CheckboxField & {
|
|
||||||
errors: Partial<FieldErrorsImpl>
|
|
||||||
register: UseFormRegister<FieldValues>
|
|
||||||
}
|
|
||||||
> = ({ name, defaultValue, errors, label, register, required, width }) => {
|
|
||||||
const props = register(name, { required: required })
|
|
||||||
const { setValue } = useFormContext()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Width width={width}>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<CheckboxUi
|
|
||||||
defaultChecked={defaultValue}
|
|
||||||
id={name}
|
|
||||||
{...props}
|
|
||||||
onCheckedChange={(checked) => {
|
|
||||||
setValue(props.name, checked)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Label htmlFor={name}>
|
|
||||||
{required && (
|
|
||||||
<span className="required">
|
|
||||||
* <span className="sr-only">(required)</span>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{label}
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
{errors[name] && <Error name={name} />}
|
|
||||||
</Width>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,163 +0,0 @@
|
|||||||
'use client'
|
|
||||||
import type { FormFieldBlock, Form as FormType } from '@payloadcms/plugin-form-builder/types'
|
|
||||||
|
|
||||||
import { useRouter } from 'next/navigation'
|
|
||||||
import React, { useCallback, useState } from 'react'
|
|
||||||
import { useForm, FormProvider } from 'react-hook-form'
|
|
||||||
import RichText from '@/components/RichText'
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import type { DefaultTypedEditorState } from '@payloadcms/richtext-lexical'
|
|
||||||
|
|
||||||
import { fields } from './fields'
|
|
||||||
import { getClientSideURL } from '@/utilities/getURL'
|
|
||||||
|
|
||||||
export type FormBlockType = {
|
|
||||||
blockName?: string
|
|
||||||
blockType?: 'formBlock'
|
|
||||||
enableIntro: boolean
|
|
||||||
form: FormType
|
|
||||||
introContent?: DefaultTypedEditorState
|
|
||||||
}
|
|
||||||
|
|
||||||
export const FormBlock: React.FC<
|
|
||||||
{
|
|
||||||
id?: string
|
|
||||||
} & FormBlockType
|
|
||||||
> = (props) => {
|
|
||||||
const {
|
|
||||||
enableIntro,
|
|
||||||
form: formFromProps,
|
|
||||||
form: { id: formID, confirmationMessage, confirmationType, redirect, submitButtonLabel } = {},
|
|
||||||
introContent,
|
|
||||||
} = props
|
|
||||||
|
|
||||||
const formMethods = useForm({
|
|
||||||
defaultValues: formFromProps.fields,
|
|
||||||
})
|
|
||||||
const {
|
|
||||||
control,
|
|
||||||
formState: { errors },
|
|
||||||
handleSubmit,
|
|
||||||
register,
|
|
||||||
} = formMethods
|
|
||||||
|
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
|
||||||
const [hasSubmitted, setHasSubmitted] = useState<boolean>()
|
|
||||||
const [error, setError] = useState<{ message: string; status?: string } | undefined>()
|
|
||||||
const router = useRouter()
|
|
||||||
|
|
||||||
const onSubmit = useCallback(
|
|
||||||
(data: FormFieldBlock[]) => {
|
|
||||||
let loadingTimerID: ReturnType<typeof setTimeout>
|
|
||||||
const submitForm = async () => {
|
|
||||||
setError(undefined)
|
|
||||||
|
|
||||||
const dataToSend = Object.entries(data).map(([name, value]) => ({
|
|
||||||
field: name,
|
|
||||||
value,
|
|
||||||
}))
|
|
||||||
|
|
||||||
// delay loading indicator by 1s
|
|
||||||
loadingTimerID = setTimeout(() => {
|
|
||||||
setIsLoading(true)
|
|
||||||
}, 1000)
|
|
||||||
|
|
||||||
try {
|
|
||||||
const req = await fetch(`${getClientSideURL()}/api/form-submissions`, {
|
|
||||||
body: JSON.stringify({
|
|
||||||
form: formID,
|
|
||||||
submissionData: dataToSend,
|
|
||||||
}),
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
method: 'POST',
|
|
||||||
})
|
|
||||||
|
|
||||||
const res = (await req.json()) as any
|
|
||||||
|
|
||||||
clearTimeout(loadingTimerID)
|
|
||||||
|
|
||||||
if (req.status >= 400) {
|
|
||||||
setIsLoading(false)
|
|
||||||
|
|
||||||
setError({
|
|
||||||
message: res.errors?.[0]?.message || 'Internal Server Error',
|
|
||||||
status: res.status,
|
|
||||||
})
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsLoading(false)
|
|
||||||
setHasSubmitted(true)
|
|
||||||
|
|
||||||
if (confirmationType === 'redirect' && redirect) {
|
|
||||||
const { url } = redirect
|
|
||||||
|
|
||||||
const redirectUrl = url
|
|
||||||
|
|
||||||
if (redirectUrl) router.push(redirectUrl)
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.warn(err)
|
|
||||||
setIsLoading(false)
|
|
||||||
setError({
|
|
||||||
message: 'Something went wrong.',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void submitForm()
|
|
||||||
},
|
|
||||||
[router, formID, redirect, confirmationType],
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="container lg:max-w-[48rem]">
|
|
||||||
{enableIntro && introContent && !hasSubmitted && (
|
|
||||||
<RichText className="mb-8 lg:mb-12" data={introContent} enableGutter={false} />
|
|
||||||
)}
|
|
||||||
<div className="p-4 lg:p-6 border border-border rounded-[0.8rem]">
|
|
||||||
<FormProvider {...formMethods}>
|
|
||||||
{!isLoading && hasSubmitted && confirmationType === 'message' && (
|
|
||||||
<RichText data={confirmationMessage as DefaultTypedEditorState} />
|
|
||||||
)}
|
|
||||||
{isLoading && !hasSubmitted && <p>Loading, please wait...</p>}
|
|
||||||
{error && <div>{`${error.status || '500'}: ${error.message || ''}`}</div>}
|
|
||||||
{!hasSubmitted && (
|
|
||||||
<form id={formID} onSubmit={handleSubmit(onSubmit)}>
|
|
||||||
<div className="mb-4 last:mb-0">
|
|
||||||
{formFromProps &&
|
|
||||||
formFromProps.fields &&
|
|
||||||
formFromProps.fields?.map((field, index) => {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
const Field: React.FC<any> = fields?.[field.blockType as keyof typeof fields]
|
|
||||||
if (Field) {
|
|
||||||
return (
|
|
||||||
<div className="mb-6 last:mb-0" key={index}>
|
|
||||||
<Field
|
|
||||||
form={formFromProps}
|
|
||||||
{...field}
|
|
||||||
{...formMethods}
|
|
||||||
control={control}
|
|
||||||
errors={errors}
|
|
||||||
register={register}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button form={formID} type="submit" variant="default">
|
|
||||||
{submitButtonLabel}
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
</FormProvider>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
import type { CountryField } from '@payloadcms/plugin-form-builder/types'
|
|
||||||
import type { Control, FieldErrorsImpl } from 'react-hook-form'
|
|
||||||
|
|
||||||
import { Label } from '@/components/ui/label'
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@/components/ui/select'
|
|
||||||
import React from 'react'
|
|
||||||
import { Controller } from 'react-hook-form'
|
|
||||||
|
|
||||||
import { Error } from '../Error'
|
|
||||||
import { Width } from '../Width'
|
|
||||||
import { countryOptions } from './options'
|
|
||||||
|
|
||||||
export const Country: React.FC<
|
|
||||||
CountryField & {
|
|
||||||
control: Control
|
|
||||||
errors: Partial<FieldErrorsImpl>
|
|
||||||
}
|
|
||||||
> = ({ name, control, errors, label, required, width }) => {
|
|
||||||
return (
|
|
||||||
<Width width={width}>
|
|
||||||
<Label className="" htmlFor={name}>
|
|
||||||
{label}
|
|
||||||
|
|
||||||
{required && (
|
|
||||||
<span className="required">
|
|
||||||
* <span className="sr-only">(required)</span>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</Label>
|
|
||||||
<Controller
|
|
||||||
control={control}
|
|
||||||
defaultValue=""
|
|
||||||
name={name}
|
|
||||||
render={({ field: { onChange, value } }) => {
|
|
||||||
const controlledValue = countryOptions.find((t) => t.value === value)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Select onValueChange={(val) => onChange(val)} value={controlledValue?.value}>
|
|
||||||
<SelectTrigger className="w-full" id={name}>
|
|
||||||
<SelectValue placeholder={label} />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{countryOptions.map(({ label, value }) => {
|
|
||||||
return (
|
|
||||||
<SelectItem key={value} value={value}>
|
|
||||||
{label}
|
|
||||||
</SelectItem>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
rules={{ required }}
|
|
||||||
/>
|
|
||||||
{errors[name] && <Error name={name} />}
|
|
||||||
</Width>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,982 +0,0 @@
|
|||||||
export const countryOptions = [
|
|
||||||
{
|
|
||||||
label: 'Afghanistan',
|
|
||||||
value: 'AF',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Åland Islands',
|
|
||||||
value: 'AX',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Albania',
|
|
||||||
value: 'AL',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Algeria',
|
|
||||||
value: 'DZ',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'American Samoa',
|
|
||||||
value: 'AS',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Andorra',
|
|
||||||
value: 'AD',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Angola',
|
|
||||||
value: 'AO',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Anguilla',
|
|
||||||
value: 'AI',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Antarctica',
|
|
||||||
value: 'AQ',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Antigua and Barbuda',
|
|
||||||
value: 'AG',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Argentina',
|
|
||||||
value: 'AR',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Armenia',
|
|
||||||
value: 'AM',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Aruba',
|
|
||||||
value: 'AW',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Australia',
|
|
||||||
value: 'AU',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Austria',
|
|
||||||
value: 'AT',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Azerbaijan',
|
|
||||||
value: 'AZ',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Bahamas',
|
|
||||||
value: 'BS',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Bahrain',
|
|
||||||
value: 'BH',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Bangladesh',
|
|
||||||
value: 'BD',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Barbados',
|
|
||||||
value: 'BB',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Belarus',
|
|
||||||
value: 'BY',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Belgium',
|
|
||||||
value: 'BE',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Belize',
|
|
||||||
value: 'BZ',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Benin',
|
|
||||||
value: 'BJ',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Bermuda',
|
|
||||||
value: 'BM',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Bhutan',
|
|
||||||
value: 'BT',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Bolivia',
|
|
||||||
value: 'BO',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Bosnia and Herzegovina',
|
|
||||||
value: 'BA',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Botswana',
|
|
||||||
value: 'BW',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Bouvet Island',
|
|
||||||
value: 'BV',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Brazil',
|
|
||||||
value: 'BR',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'British Indian Ocean Territory',
|
|
||||||
value: 'IO',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Brunei Darussalam',
|
|
||||||
value: 'BN',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Bulgaria',
|
|
||||||
value: 'BG',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Burkina Faso',
|
|
||||||
value: 'BF',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Burundi',
|
|
||||||
value: 'BI',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Cambodia',
|
|
||||||
value: 'KH',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Cameroon',
|
|
||||||
value: 'CM',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Canada',
|
|
||||||
value: 'CA',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Cape Verde',
|
|
||||||
value: 'CV',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Cayman Islands',
|
|
||||||
value: 'KY',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Central African Republic',
|
|
||||||
value: 'CF',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Chad',
|
|
||||||
value: 'TD',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Chile',
|
|
||||||
value: 'CL',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'China',
|
|
||||||
value: 'CN',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Christmas Island',
|
|
||||||
value: 'CX',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Cocos (Keeling) Islands',
|
|
||||||
value: 'CC',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Colombia',
|
|
||||||
value: 'CO',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Comoros',
|
|
||||||
value: 'KM',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Congo',
|
|
||||||
value: 'CG',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Congo, The Democratic Republic of the',
|
|
||||||
value: 'CD',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Cook Islands',
|
|
||||||
value: 'CK',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Costa Rica',
|
|
||||||
value: 'CR',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Cote D'Ivoire",
|
|
||||||
value: 'CI',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Croatia',
|
|
||||||
value: 'HR',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Cuba',
|
|
||||||
value: 'CU',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Cyprus',
|
|
||||||
value: 'CY',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Czech Republic',
|
|
||||||
value: 'CZ',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Denmark',
|
|
||||||
value: 'DK',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Djibouti',
|
|
||||||
value: 'DJ',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Dominica',
|
|
||||||
value: 'DM',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Dominican Republic',
|
|
||||||
value: 'DO',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Ecuador',
|
|
||||||
value: 'EC',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Egypt',
|
|
||||||
value: 'EG',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'El Salvador',
|
|
||||||
value: 'SV',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Equatorial Guinea',
|
|
||||||
value: 'GQ',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Eritrea',
|
|
||||||
value: 'ER',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Estonia',
|
|
||||||
value: 'EE',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Ethiopia',
|
|
||||||
value: 'ET',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Falkland Islands (Malvinas)',
|
|
||||||
value: 'FK',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Faroe Islands',
|
|
||||||
value: 'FO',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Fiji',
|
|
||||||
value: 'FJ',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Finland',
|
|
||||||
value: 'FI',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'France',
|
|
||||||
value: 'FR',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'French Guiana',
|
|
||||||
value: 'GF',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'French Polynesia',
|
|
||||||
value: 'PF',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'French Southern Territories',
|
|
||||||
value: 'TF',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Gabon',
|
|
||||||
value: 'GA',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Gambia',
|
|
||||||
value: 'GM',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Georgia',
|
|
||||||
value: 'GE',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Germany',
|
|
||||||
value: 'DE',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Ghana',
|
|
||||||
value: 'GH',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Gibraltar',
|
|
||||||
value: 'GI',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Greece',
|
|
||||||
value: 'GR',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Greenland',
|
|
||||||
value: 'GL',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Grenada',
|
|
||||||
value: 'GD',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Guadeloupe',
|
|
||||||
value: 'GP',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Guam',
|
|
||||||
value: 'GU',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Guatemala',
|
|
||||||
value: 'GT',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Guernsey',
|
|
||||||
value: 'GG',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Guinea',
|
|
||||||
value: 'GN',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Guinea-Bissau',
|
|
||||||
value: 'GW',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Guyana',
|
|
||||||
value: 'GY',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Haiti',
|
|
||||||
value: 'HT',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Heard Island and Mcdonald Islands',
|
|
||||||
value: 'HM',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Holy See (Vatican City State)',
|
|
||||||
value: 'VA',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Honduras',
|
|
||||||
value: 'HN',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Hong Kong',
|
|
||||||
value: 'HK',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Hungary',
|
|
||||||
value: 'HU',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Iceland',
|
|
||||||
value: 'IS',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'India',
|
|
||||||
value: 'IN',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Indonesia',
|
|
||||||
value: 'ID',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Iran, Islamic Republic Of',
|
|
||||||
value: 'IR',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Iraq',
|
|
||||||
value: 'IQ',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Ireland',
|
|
||||||
value: 'IE',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Isle of Man',
|
|
||||||
value: 'IM',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Israel',
|
|
||||||
value: 'IL',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Italy',
|
|
||||||
value: 'IT',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Jamaica',
|
|
||||||
value: 'JM',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Japan',
|
|
||||||
value: 'JP',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Jersey',
|
|
||||||
value: 'JE',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Jordan',
|
|
||||||
value: 'JO',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Kazakhstan',
|
|
||||||
value: 'KZ',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Kenya',
|
|
||||||
value: 'KE',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Kiribati',
|
|
||||||
value: 'KI',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Democratic People's Republic of Korea",
|
|
||||||
value: 'KP',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Korea, Republic of',
|
|
||||||
value: 'KR',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Kosovo',
|
|
||||||
value: 'XK',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Kuwait',
|
|
||||||
value: 'KW',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Kyrgyzstan',
|
|
||||||
value: 'KG',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Lao People's Democratic Republic",
|
|
||||||
value: 'LA',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Latvia',
|
|
||||||
value: 'LV',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Lebanon',
|
|
||||||
value: 'LB',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Lesotho',
|
|
||||||
value: 'LS',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Liberia',
|
|
||||||
value: 'LR',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Libyan Arab Jamahiriya',
|
|
||||||
value: 'LY',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Liechtenstein',
|
|
||||||
value: 'LI',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Lithuania',
|
|
||||||
value: 'LT',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Luxembourg',
|
|
||||||
value: 'LU',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Macao',
|
|
||||||
value: 'MO',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Macedonia, The Former Yugoslav Republic of',
|
|
||||||
value: 'MK',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Madagascar',
|
|
||||||
value: 'MG',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Malawi',
|
|
||||||
value: 'MW',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Malaysia',
|
|
||||||
value: 'MY',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Maldives',
|
|
||||||
value: 'MV',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Mali',
|
|
||||||
value: 'ML',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Malta',
|
|
||||||
value: 'MT',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Marshall Islands',
|
|
||||||
value: 'MH',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Martinique',
|
|
||||||
value: 'MQ',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Mauritania',
|
|
||||||
value: 'MR',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Mauritius',
|
|
||||||
value: 'MU',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Mayotte',
|
|
||||||
value: 'YT',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Mexico',
|
|
||||||
value: 'MX',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Micronesia, Federated States of',
|
|
||||||
value: 'FM',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Moldova, Republic of',
|
|
||||||
value: 'MD',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Monaco',
|
|
||||||
value: 'MC',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Mongolia',
|
|
||||||
value: 'MN',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Montenegro',
|
|
||||||
value: 'ME',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Montserrat',
|
|
||||||
value: 'MS',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Morocco',
|
|
||||||
value: 'MA',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Mozambique',
|
|
||||||
value: 'MZ',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Myanmar',
|
|
||||||
value: 'MM',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Namibia',
|
|
||||||
value: 'NA',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Nauru',
|
|
||||||
value: 'NR',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Nepal',
|
|
||||||
value: 'NP',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Netherlands',
|
|
||||||
value: 'NL',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Netherlands Antilles',
|
|
||||||
value: 'AN',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'New Caledonia',
|
|
||||||
value: 'NC',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'New Zealand',
|
|
||||||
value: 'NZ',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Nicaragua',
|
|
||||||
value: 'NI',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Niger',
|
|
||||||
value: 'NE',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Nigeria',
|
|
||||||
value: 'NG',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Niue',
|
|
||||||
value: 'NU',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Norfolk Island',
|
|
||||||
value: 'NF',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Northern Mariana Islands',
|
|
||||||
value: 'MP',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Norway',
|
|
||||||
value: 'NO',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Oman',
|
|
||||||
value: 'OM',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Pakistan',
|
|
||||||
value: 'PK',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Palau',
|
|
||||||
value: 'PW',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Palestinian Territory, Occupied',
|
|
||||||
value: 'PS',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Panama',
|
|
||||||
value: 'PA',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Papua New Guinea',
|
|
||||||
value: 'PG',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Paraguay',
|
|
||||||
value: 'PY',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Peru',
|
|
||||||
value: 'PE',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Philippines',
|
|
||||||
value: 'PH',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Pitcairn',
|
|
||||||
value: 'PN',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Poland',
|
|
||||||
value: 'PL',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Portugal',
|
|
||||||
value: 'PT',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Puerto Rico',
|
|
||||||
value: 'PR',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Qatar',
|
|
||||||
value: 'QA',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Reunion',
|
|
||||||
value: 'RE',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Romania',
|
|
||||||
value: 'RO',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Russian Federation',
|
|
||||||
value: 'RU',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Rwanda',
|
|
||||||
value: 'RW',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Saint Helena',
|
|
||||||
value: 'SH',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Saint Kitts and Nevis',
|
|
||||||
value: 'KN',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Saint Lucia',
|
|
||||||
value: 'LC',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Saint Pierre and Miquelon',
|
|
||||||
value: 'PM',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Saint Vincent and the Grenadines',
|
|
||||||
value: 'VC',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Samoa',
|
|
||||||
value: 'WS',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'San Marino',
|
|
||||||
value: 'SM',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Sao Tome and Principe',
|
|
||||||
value: 'ST',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Saudi Arabia',
|
|
||||||
value: 'SA',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Senegal',
|
|
||||||
value: 'SN',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Serbia',
|
|
||||||
value: 'RS',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Seychelles',
|
|
||||||
value: 'SC',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Sierra Leone',
|
|
||||||
value: 'SL',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Singapore',
|
|
||||||
value: 'SG',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Slovakia',
|
|
||||||
value: 'SK',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Slovenia',
|
|
||||||
value: 'SI',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Solomon Islands',
|
|
||||||
value: 'SB',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Somalia',
|
|
||||||
value: 'SO',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'South Africa',
|
|
||||||
value: 'ZA',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'South Georgia and the South Sandwich Islands',
|
|
||||||
value: 'GS',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Spain',
|
|
||||||
value: 'ES',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Sri Lanka',
|
|
||||||
value: 'LK',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Sudan',
|
|
||||||
value: 'SD',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Suriname',
|
|
||||||
value: 'SR',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Svalbard and Jan Mayen',
|
|
||||||
value: 'SJ',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Swaziland',
|
|
||||||
value: 'SZ',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Sweden',
|
|
||||||
value: 'SE',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Switzerland',
|
|
||||||
value: 'CH',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Syrian Arab Republic',
|
|
||||||
value: 'SY',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Taiwan',
|
|
||||||
value: 'TW',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Tajikistan',
|
|
||||||
value: 'TJ',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Tanzania, United Republic of',
|
|
||||||
value: 'TZ',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Thailand',
|
|
||||||
value: 'TH',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Timor-Leste',
|
|
||||||
value: 'TL',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Togo',
|
|
||||||
value: 'TG',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Tokelau',
|
|
||||||
value: 'TK',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Tonga',
|
|
||||||
value: 'TO',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Trinidad and Tobago',
|
|
||||||
value: 'TT',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Tunisia',
|
|
||||||
value: 'TN',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Turkey',
|
|
||||||
value: 'TR',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Turkmenistan',
|
|
||||||
value: 'TM',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Turks and Caicos Islands',
|
|
||||||
value: 'TC',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Tuvalu',
|
|
||||||
value: 'TV',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Uganda',
|
|
||||||
value: 'UG',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Ukraine',
|
|
||||||
value: 'UA',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'United Arab Emirates',
|
|
||||||
value: 'AE',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'United Kingdom',
|
|
||||||
value: 'GB',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'United States',
|
|
||||||
value: 'US',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'United States Minor Outlying Islands',
|
|
||||||
value: 'UM',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Uruguay',
|
|
||||||
value: 'UY',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Uzbekistan',
|
|
||||||
value: 'UZ',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Vanuatu',
|
|
||||||
value: 'VU',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Venezuela',
|
|
||||||
value: 'VE',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Viet Nam',
|
|
||||||
value: 'VN',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Virgin Islands, British',
|
|
||||||
value: 'VG',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Virgin Islands, U.S.',
|
|
||||||
value: 'VI',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Wallis and Futuna',
|
|
||||||
value: 'WF',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Western Sahara',
|
|
||||||
value: 'EH',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Yemen',
|
|
||||||
value: 'YE',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Zambia',
|
|
||||||
value: 'ZM',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Zimbabwe',
|
|
||||||
value: 'ZW',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
import type { EmailField } from '@payloadcms/plugin-form-builder/types'
|
|
||||||
import type { FieldErrorsImpl, FieldValues, UseFormRegister } from 'react-hook-form'
|
|
||||||
|
|
||||||
import { Input } from '@/components/ui/input'
|
|
||||||
import { Label } from '@/components/ui/label'
|
|
||||||
import React from 'react'
|
|
||||||
|
|
||||||
import { Error } from '../Error'
|
|
||||||
import { Width } from '../Width'
|
|
||||||
|
|
||||||
export const Email: React.FC<
|
|
||||||
EmailField & {
|
|
||||||
errors: Partial<FieldErrorsImpl>
|
|
||||||
register: UseFormRegister<FieldValues>
|
|
||||||
}
|
|
||||||
> = ({ name, defaultValue, errors, label, register, required, width }) => {
|
|
||||||
return (
|
|
||||||
<Width width={width}>
|
|
||||||
<Label htmlFor={name}>
|
|
||||||
{label}
|
|
||||||
|
|
||||||
{required && (
|
|
||||||
<span className="required">
|
|
||||||
* <span className="sr-only">(required)</span>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
defaultValue={defaultValue}
|
|
||||||
id={name}
|
|
||||||
type="text"
|
|
||||||
{...register(name, { pattern: /^\S[^\s@]*@\S+$/, required })}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{errors[name] && <Error name={name} />}
|
|
||||||
</Width>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import * as React from 'react'
|
|
||||||
import { useFormContext } from 'react-hook-form'
|
|
||||||
|
|
||||||
export const Error = ({ name }: { name: string }) => {
|
|
||||||
const {
|
|
||||||
formState: { errors },
|
|
||||||
} = useFormContext()
|
|
||||||
return (
|
|
||||||
<div className="mt-2 text-red-500 text-sm">
|
|
||||||
{(errors[name]?.message as string) || 'This field is required'}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
import RichText from '@/components/RichText'
|
|
||||||
import React from 'react'
|
|
||||||
|
|
||||||
import { Width } from '../Width'
|
|
||||||
import { DefaultTypedEditorState } from '@payloadcms/richtext-lexical'
|
|
||||||
|
|
||||||
export const Message: React.FC<{ message: DefaultTypedEditorState }> = ({ message }) => {
|
|
||||||
return (
|
|
||||||
<Width className="my-12" width="100">
|
|
||||||
{message && <RichText data={message} />}
|
|
||||||
</Width>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
import type { TextField } from '@payloadcms/plugin-form-builder/types'
|
|
||||||
import type { FieldErrorsImpl, FieldValues, UseFormRegister } from 'react-hook-form'
|
|
||||||
|
|
||||||
import { Input } from '@/components/ui/input'
|
|
||||||
import { Label } from '@/components/ui/label'
|
|
||||||
import React from 'react'
|
|
||||||
|
|
||||||
import { Error } from '../Error'
|
|
||||||
import { Width } from '../Width'
|
|
||||||
export const Number: React.FC<
|
|
||||||
TextField & {
|
|
||||||
errors: Partial<FieldErrorsImpl>
|
|
||||||
register: UseFormRegister<FieldValues>
|
|
||||||
}
|
|
||||||
> = ({ name, defaultValue, errors, label, register, required, width }) => {
|
|
||||||
return (
|
|
||||||
<Width width={width}>
|
|
||||||
<Label htmlFor={name}>
|
|
||||||
{label}
|
|
||||||
|
|
||||||
{required && (
|
|
||||||
<span className="required">
|
|
||||||
* <span className="sr-only">(required)</span>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
defaultValue={defaultValue}
|
|
||||||
id={name}
|
|
||||||
type="number"
|
|
||||||
{...register(name, { required })}
|
|
||||||
/>
|
|
||||||
{errors[name] && <Error name={name} />}
|
|
||||||
</Width>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
import type { SelectField } from '@payloadcms/plugin-form-builder/types'
|
|
||||||
import type { Control, FieldErrorsImpl } from 'react-hook-form'
|
|
||||||
|
|
||||||
import { Label } from '@/components/ui/label'
|
|
||||||
import {
|
|
||||||
Select as SelectComponent,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@/components/ui/select'
|
|
||||||
import React from 'react'
|
|
||||||
import { Controller } from 'react-hook-form'
|
|
||||||
|
|
||||||
import { Error } from '../Error'
|
|
||||||
import { Width } from '../Width'
|
|
||||||
|
|
||||||
export const Select: React.FC<
|
|
||||||
SelectField & {
|
|
||||||
control: Control
|
|
||||||
errors: Partial<FieldErrorsImpl>
|
|
||||||
}
|
|
||||||
> = ({ name, control, errors, label, options, required, width, defaultValue }) => {
|
|
||||||
return (
|
|
||||||
<Width width={width}>
|
|
||||||
<Label htmlFor={name}>
|
|
||||||
{label}
|
|
||||||
{required && (
|
|
||||||
<span className="required">
|
|
||||||
* <span className="sr-only">(required)</span>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</Label>
|
|
||||||
<Controller
|
|
||||||
control={control}
|
|
||||||
defaultValue={defaultValue}
|
|
||||||
name={name}
|
|
||||||
render={({ field: { onChange, value } }) => {
|
|
||||||
const controlledValue = options.find((t) => t.value === value)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SelectComponent onValueChange={(val) => onChange(val)} value={controlledValue?.value}>
|
|
||||||
<SelectTrigger className="w-full" id={name}>
|
|
||||||
<SelectValue placeholder={label} />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{options.map(({ label, value }) => {
|
|
||||||
return (
|
|
||||||
<SelectItem key={value} value={value}>
|
|
||||||
{label}
|
|
||||||
</SelectItem>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</SelectContent>
|
|
||||||
</SelectComponent>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
rules={{ required }}
|
|
||||||
/>
|
|
||||||
{errors[name] && <Error name={name} />}
|
|
||||||
</Width>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
import type { StateField } from '@payloadcms/plugin-form-builder/types'
|
|
||||||
import type { Control, FieldErrorsImpl } from 'react-hook-form'
|
|
||||||
|
|
||||||
import { Label } from '@/components/ui/label'
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@/components/ui/select'
|
|
||||||
import React from 'react'
|
|
||||||
import { Controller } from 'react-hook-form'
|
|
||||||
|
|
||||||
import { Error } from '../Error'
|
|
||||||
import { Width } from '../Width'
|
|
||||||
import { stateOptions } from './options'
|
|
||||||
|
|
||||||
export const State: React.FC<
|
|
||||||
StateField & {
|
|
||||||
control: Control
|
|
||||||
errors: Partial<FieldErrorsImpl>
|
|
||||||
}
|
|
||||||
> = ({ name, control, errors, label, required, width }) => {
|
|
||||||
return (
|
|
||||||
<Width width={width}>
|
|
||||||
<Label htmlFor={name}>
|
|
||||||
{label}
|
|
||||||
{required && (
|
|
||||||
<span className="required">
|
|
||||||
* <span className="sr-only">(required)</span>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</Label>
|
|
||||||
<Controller
|
|
||||||
control={control}
|
|
||||||
defaultValue=""
|
|
||||||
name={name}
|
|
||||||
render={({ field: { onChange, value } }) => {
|
|
||||||
const controlledValue = stateOptions.find((t) => t.value === value)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Select onValueChange={(val) => onChange(val)} value={controlledValue?.value}>
|
|
||||||
<SelectTrigger className="w-full" id={name}>
|
|
||||||
<SelectValue placeholder={label} />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{stateOptions.map(({ label, value }) => {
|
|
||||||
return (
|
|
||||||
<SelectItem key={value} value={value}>
|
|
||||||
{label}
|
|
||||||
</SelectItem>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
rules={{ required }}
|
|
||||||
/>
|
|
||||||
{errors[name] && <Error name={name} />}
|
|
||||||
</Width>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
export const stateOptions = [
|
|
||||||
{ label: 'Alabama', value: 'AL' },
|
|
||||||
{ label: 'Alaska', value: 'AK' },
|
|
||||||
{ label: 'Arizona', value: 'AZ' },
|
|
||||||
{ label: 'Arkansas', value: 'AR' },
|
|
||||||
{ label: 'California', value: 'CA' },
|
|
||||||
{ label: 'Colorado', value: 'CO' },
|
|
||||||
{ label: 'Connecticut', value: 'CT' },
|
|
||||||
{ label: 'Delaware', value: 'DE' },
|
|
||||||
{ label: 'Florida', value: 'FL' },
|
|
||||||
{ label: 'Georgia', value: 'GA' },
|
|
||||||
{ label: 'Hawaii', value: 'HI' },
|
|
||||||
{ label: 'Idaho', value: 'ID' },
|
|
||||||
{ label: 'Illinois', value: 'IL' },
|
|
||||||
{ label: 'Indiana', value: 'IN' },
|
|
||||||
{ label: 'Iowa', value: 'IA' },
|
|
||||||
{ label: 'Kansas', value: 'KS' },
|
|
||||||
{ label: 'Kentucky', value: 'KY' },
|
|
||||||
{ label: 'Louisiana', value: 'LA' },
|
|
||||||
{ label: 'Maine', value: 'ME' },
|
|
||||||
{ label: 'Maryland', value: 'MD' },
|
|
||||||
{ label: 'Massachusetts', value: 'MA' },
|
|
||||||
{ label: 'Michigan', value: 'MI' },
|
|
||||||
{ label: 'Minnesota', value: 'MN' },
|
|
||||||
{ label: 'Mississippi', value: 'MS' },
|
|
||||||
{ label: 'Missouri', value: 'MO' },
|
|
||||||
{ label: 'Montana', value: 'MT' },
|
|
||||||
{ label: 'Nebraska', value: 'NE' },
|
|
||||||
{ label: 'Nevada', value: 'NV' },
|
|
||||||
{ label: 'New Hampshire', value: 'NH' },
|
|
||||||
{ label: 'New Jersey', value: 'NJ' },
|
|
||||||
{ label: 'New Mexico', value: 'NM' },
|
|
||||||
{ label: 'New York', value: 'NY' },
|
|
||||||
{ label: 'North Carolina', value: 'NC' },
|
|
||||||
{ label: 'North Dakota', value: 'ND' },
|
|
||||||
{ label: 'Ohio', value: 'OH' },
|
|
||||||
{ label: 'Oklahoma', value: 'OK' },
|
|
||||||
{ label: 'Oregon', value: 'OR' },
|
|
||||||
{ label: 'Pennsylvania', value: 'PA' },
|
|
||||||
{ label: 'Rhode Island', value: 'RI' },
|
|
||||||
{ label: 'South Carolina', value: 'SC' },
|
|
||||||
{ label: 'South Dakota', value: 'SD' },
|
|
||||||
{ label: 'Tennessee', value: 'TN' },
|
|
||||||
{ label: 'Texas', value: 'TX' },
|
|
||||||
{ label: 'Utah', value: 'UT' },
|
|
||||||
{ label: 'Vermont', value: 'VT' },
|
|
||||||
{ label: 'Virginia', value: 'VA' },
|
|
||||||
{ label: 'Washington', value: 'WA' },
|
|
||||||
{ label: 'West Virginia', value: 'WV' },
|
|
||||||
{ label: 'Wisconsin', value: 'WI' },
|
|
||||||
{ label: 'Wyoming', value: 'WY' },
|
|
||||||
]
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
import type { TextField } from '@payloadcms/plugin-form-builder/types'
|
|
||||||
import type { FieldErrorsImpl, FieldValues, UseFormRegister } from 'react-hook-form'
|
|
||||||
|
|
||||||
import { Input } from '@/components/ui/input'
|
|
||||||
import { Label } from '@/components/ui/label'
|
|
||||||
import React from 'react'
|
|
||||||
|
|
||||||
import { Error } from '../Error'
|
|
||||||
import { Width } from '../Width'
|
|
||||||
|
|
||||||
export const Text: React.FC<
|
|
||||||
TextField & {
|
|
||||||
errors: Partial<FieldErrorsImpl>
|
|
||||||
register: UseFormRegister<FieldValues>
|
|
||||||
}
|
|
||||||
> = ({ name, defaultValue, errors, label, register, required, width }) => {
|
|
||||||
return (
|
|
||||||
<Width width={width}>
|
|
||||||
<Label htmlFor={name}>
|
|
||||||
{label}
|
|
||||||
|
|
||||||
{required && (
|
|
||||||
<span className="required">
|
|
||||||
* <span className="sr-only">(required)</span>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</Label>
|
|
||||||
<Input defaultValue={defaultValue} id={name} type="text" {...register(name, { required })} />
|
|
||||||
{errors[name] && <Error name={name} />}
|
|
||||||
</Width>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
import type { TextField } from '@payloadcms/plugin-form-builder/types'
|
|
||||||
import type { FieldErrorsImpl, FieldValues, UseFormRegister } from 'react-hook-form'
|
|
||||||
|
|
||||||
import { Label } from '@/components/ui/label'
|
|
||||||
import { Textarea as TextAreaComponent } from '@/components/ui/textarea'
|
|
||||||
import React from 'react'
|
|
||||||
|
|
||||||
import { Error } from '../Error'
|
|
||||||
import { Width } from '../Width'
|
|
||||||
|
|
||||||
export const Textarea: React.FC<
|
|
||||||
TextField & {
|
|
||||||
errors: Partial<FieldErrorsImpl>
|
|
||||||
register: UseFormRegister<FieldValues>
|
|
||||||
rows?: number
|
|
||||||
}
|
|
||||||
> = ({ name, defaultValue, errors, label, register, required, rows = 3, width }) => {
|
|
||||||
return (
|
|
||||||
<Width width={width}>
|
|
||||||
<Label htmlFor={name}>
|
|
||||||
{label}
|
|
||||||
|
|
||||||
{required && (
|
|
||||||
<span className="required">
|
|
||||||
* <span className="sr-only">(required)</span>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</Label>
|
|
||||||
|
|
||||||
<TextAreaComponent
|
|
||||||
defaultValue={defaultValue}
|
|
||||||
id={name}
|
|
||||||
rows={rows}
|
|
||||||
{...register(name, { required: required })}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{errors[name] && <Error name={name} />}
|
|
||||||
</Width>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
import * as React from 'react'
|
|
||||||
|
|
||||||
export const Width: React.FC<{
|
|
||||||
children: React.ReactNode
|
|
||||||
className?: string
|
|
||||||
width?: number | string
|
|
||||||
}> = ({ children, className, width }) => {
|
|
||||||
return (
|
|
||||||
<div className={className} style={{ maxWidth: width ? `${width}%` : undefined }}>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
import type { Block } from 'payload'
|
|
||||||
|
|
||||||
import {
|
|
||||||
FixedToolbarFeature,
|
|
||||||
HeadingFeature,
|
|
||||||
InlineToolbarFeature,
|
|
||||||
lexicalEditor,
|
|
||||||
} from '@payloadcms/richtext-lexical'
|
|
||||||
|
|
||||||
export const FormBlock: Block = {
|
|
||||||
slug: 'formBlock',
|
|
||||||
interfaceName: 'FormBlock',
|
|
||||||
fields: [
|
|
||||||
{
|
|
||||||
name: 'form',
|
|
||||||
type: 'relationship',
|
|
||||||
relationTo: 'forms',
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'enableIntro',
|
|
||||||
type: 'checkbox',
|
|
||||||
label: 'Enable Intro Content',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'introContent',
|
|
||||||
type: 'richText',
|
|
||||||
admin: {
|
|
||||||
condition: (_, { enableIntro }) => Boolean(enableIntro),
|
|
||||||
},
|
|
||||||
editor: lexicalEditor({
|
|
||||||
features: ({ rootFeatures }) => {
|
|
||||||
return [
|
|
||||||
...rootFeatures,
|
|
||||||
HeadingFeature({ enabledHeadingSizes: ['h1', 'h2', 'h3', 'h4'] }),
|
|
||||||
FixedToolbarFeature(),
|
|
||||||
InlineToolbarFeature(),
|
|
||||||
]
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
label: 'Intro Content',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
graphQL: {
|
|
||||||
singularName: 'FormBlock',
|
|
||||||
},
|
|
||||||
labels: {
|
|
||||||
plural: 'Form Blocks',
|
|
||||||
singular: 'Form Block',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
import { Checkbox } from './Checkbox'
|
|
||||||
import { Country } from './Country'
|
|
||||||
import { Email } from './Email'
|
|
||||||
import { Message } from './Message'
|
|
||||||
import { Number } from './Number'
|
|
||||||
import { Select } from './Select'
|
|
||||||
import { State } from './State'
|
|
||||||
import { Text } from './Text'
|
|
||||||
import { Textarea } from './Textarea'
|
|
||||||
|
|
||||||
export const fields = {
|
|
||||||
checkbox: Checkbox,
|
|
||||||
country: Country,
|
|
||||||
email: Email,
|
|
||||||
message: Message,
|
|
||||||
number: Number,
|
|
||||||
select: Select,
|
|
||||||
state: State,
|
|
||||||
text: Text,
|
|
||||||
textarea: Textarea,
|
|
||||||
}
|
|
||||||
@@ -5,14 +5,12 @@ import type { Page } from '@/payload-types'
|
|||||||
import { ArchiveBlock } from '@/blocks/ArchiveBlock/Component'
|
import { ArchiveBlock } from '@/blocks/ArchiveBlock/Component'
|
||||||
import { CallToActionBlock } from '@/blocks/CallToAction/Component'
|
import { CallToActionBlock } from '@/blocks/CallToAction/Component'
|
||||||
import { ContentBlock } from '@/blocks/Content/Component'
|
import { ContentBlock } from '@/blocks/Content/Component'
|
||||||
import { FormBlock } from '@/blocks/Form/Component'
|
|
||||||
import { MediaBlock } from '@/blocks/MediaBlock/Component'
|
import { MediaBlock } from '@/blocks/MediaBlock/Component'
|
||||||
|
|
||||||
const blockComponents = {
|
const blockComponents = {
|
||||||
archive: ArchiveBlock,
|
archive: ArchiveBlock,
|
||||||
content: ContentBlock,
|
content: ContentBlock,
|
||||||
cta: CallToActionBlock,
|
cta: CallToActionBlock,
|
||||||
formBlock: FormBlock,
|
|
||||||
mediaBlock: MediaBlock,
|
mediaBlock: MediaBlock,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
71
apps/backend/src/collections/Audit/hooks/auditHooks.ts
Normal file
71
apps/backend/src/collections/Audit/hooks/auditHooks.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import type { AfterChangeHook } from 'payload'
|
||||||
|
|
||||||
|
import { logDocumentChange } from '@/utilities/auditLogger'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 創建稽核日誌 Hook
|
||||||
|
* 用於追蹤文件的創建、更新、刪除操作
|
||||||
|
*/
|
||||||
|
export const auditChange =
|
||||||
|
(collection: string): AfterChangeHook =>
|
||||||
|
async ({ doc, req }) => {
|
||||||
|
// 跳過 audit 集合本身以避免無限循環
|
||||||
|
if (collection === 'audit') return doc
|
||||||
|
|
||||||
|
await logDocumentChange(
|
||||||
|
req,
|
||||||
|
operation as 'create' | 'update' | 'delete',
|
||||||
|
collection,
|
||||||
|
doc.id as string,
|
||||||
|
(doc.title || doc.name || String(doc.id)) as string,
|
||||||
|
)
|
||||||
|
|
||||||
|
return doc
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 刪除稽核日誌 Hook
|
||||||
|
* 用於追蹤文件刪除操作(需要在刪除前記錄標題)
|
||||||
|
*/
|
||||||
|
export const auditDelete =
|
||||||
|
(collection: string): AfterChangeHook =>
|
||||||
|
async ({ doc, req }) => {
|
||||||
|
if (collection === 'audit') return doc
|
||||||
|
|
||||||
|
await logDocumentChange(
|
||||||
|
req,
|
||||||
|
'delete',
|
||||||
|
collection,
|
||||||
|
doc.id as string,
|
||||||
|
(doc.title || doc.name || String(doc.id)) as string,
|
||||||
|
)
|
||||||
|
|
||||||
|
return doc
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global 稽核日誌 Hook
|
||||||
|
* 用於追蹤 Global 配置的變更
|
||||||
|
*/
|
||||||
|
export const auditGlobalChange =
|
||||||
|
(global: string): AfterChangeHook =>
|
||||||
|
async ({ req, previousDoc, doc }) => {
|
||||||
|
if (!req.user) return doc
|
||||||
|
|
||||||
|
const { auditLogger } = await import('@/utilities/auditLogger')
|
||||||
|
|
||||||
|
await auditLogger(req, {
|
||||||
|
action: 'update',
|
||||||
|
collection: `global_${global}`,
|
||||||
|
userId: req.user.id,
|
||||||
|
userName: req.user.name as string,
|
||||||
|
userEmail: req.user.email as string,
|
||||||
|
userRole: String(req.user.role ?? ''),
|
||||||
|
changes: {
|
||||||
|
previous: previousDoc,
|
||||||
|
current: doc,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return doc
|
||||||
|
}
|
||||||
108
apps/backend/src/collections/Audit/index.ts
Normal file
108
apps/backend/src/collections/Audit/index.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import type { CollectionConfig } from 'payload'
|
||||||
|
|
||||||
|
import { adminOnly } from '../../access/adminOnly'
|
||||||
|
|
||||||
|
export const Audit: CollectionConfig = {
|
||||||
|
slug: 'audit',
|
||||||
|
access: {
|
||||||
|
create: () => false, // 僅透過程式自動創建
|
||||||
|
delete: adminOnly,
|
||||||
|
read: adminOnly,
|
||||||
|
update: () => false, // 不允許修改日誌
|
||||||
|
},
|
||||||
|
admin: {
|
||||||
|
defaultColumns: ['timestamp', 'action', 'collection', 'userId'],
|
||||||
|
useAsTitle: 'action',
|
||||||
|
description: '系統稽核日誌 - 自動記錄所有重要操作',
|
||||||
|
},
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'action',
|
||||||
|
type: 'select',
|
||||||
|
required: true,
|
||||||
|
options: [
|
||||||
|
{ label: '登入', value: 'login' },
|
||||||
|
{ label: '登出', value: 'logout' },
|
||||||
|
{ label: '創建', value: 'create' },
|
||||||
|
{ label: '更新', value: 'update' },
|
||||||
|
{ label: '刪除', value: 'delete' },
|
||||||
|
{ label: '發布', value: 'publish' },
|
||||||
|
{ label: '取消發布', value: 'unpublish' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'collection',
|
||||||
|
type: 'text',
|
||||||
|
required: true,
|
||||||
|
admin: {
|
||||||
|
description: '受影響的集合名稱',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'documentId',
|
||||||
|
type: 'text',
|
||||||
|
admin: {
|
||||||
|
description: '受影響的文件 ID',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'documentTitle',
|
||||||
|
type: 'text',
|
||||||
|
admin: {
|
||||||
|
description: '受影響的文件標題',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'userId',
|
||||||
|
type: 'text',
|
||||||
|
required: true,
|
||||||
|
admin: {
|
||||||
|
description: '執行操作的使用者 ID',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'userName',
|
||||||
|
type: 'text',
|
||||||
|
admin: {
|
||||||
|
description: '執行操作的使用者名稱',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'userEmail',
|
||||||
|
type: 'text',
|
||||||
|
admin: {
|
||||||
|
description: '執行操作的使用者信箱',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'userRole',
|
||||||
|
type: 'select',
|
||||||
|
options: [
|
||||||
|
{ label: '管理員', value: 'admin' },
|
||||||
|
{ label: '編輯者', value: 'editor' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'ipAddress',
|
||||||
|
type: 'text',
|
||||||
|
admin: {
|
||||||
|
description: 'IP 位址',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'userAgent',
|
||||||
|
type: 'text',
|
||||||
|
admin: {
|
||||||
|
description: '瀏覽器 User Agent',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'changes',
|
||||||
|
type: 'json',
|
||||||
|
admin: {
|
||||||
|
description: '變更內容詳細資訊',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
timestamps: true,
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import type { CollectionConfig } from 'payload'
|
|||||||
import { anyone } from '../access/anyone'
|
import { anyone } from '../access/anyone'
|
||||||
import { authenticated } from '../access/authenticated'
|
import { authenticated } from '../access/authenticated'
|
||||||
import { adminOrEditor } from '../access/adminOrEditor'
|
import { adminOrEditor } from '../access/adminOrEditor'
|
||||||
|
import { auditChange } from './Audit/hooks/auditHooks'
|
||||||
import { slugField } from '@/fields/slug'
|
import { slugField } from '@/fields/slug'
|
||||||
|
|
||||||
export const Categories: CollectionConfig = {
|
export const Categories: CollectionConfig = {
|
||||||
@@ -16,6 +17,10 @@ export const Categories: CollectionConfig = {
|
|||||||
admin: {
|
admin: {
|
||||||
useAsTitle: 'title',
|
useAsTitle: 'title',
|
||||||
},
|
},
|
||||||
|
hooks: {
|
||||||
|
afterChange: [auditChange('categories')],
|
||||||
|
afterDelete: [auditChange('categories')],
|
||||||
|
},
|
||||||
fields: [
|
fields: [
|
||||||
{
|
{
|
||||||
name: 'title',
|
name: 'title',
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { CollectionConfig } from 'payload'
|
|||||||
import { authenticated } from '../../access/authenticated'
|
import { authenticated } from '../../access/authenticated'
|
||||||
import { authenticatedOrPublished } from '../../access/authenticatedOrPublished'
|
import { authenticatedOrPublished } from '../../access/authenticatedOrPublished'
|
||||||
import { adminOrEditor } from '../../access/adminOrEditor'
|
import { adminOrEditor } from '../../access/adminOrEditor'
|
||||||
|
import { auditChange } from '../../collections/Audit/hooks/auditHooks'
|
||||||
import { Archive } from '../../blocks/ArchiveBlock/config'
|
import { Archive } from '../../blocks/ArchiveBlock/config'
|
||||||
import { CallToAction } from '../../blocks/CallToAction/config'
|
import { CallToAction } from '../../blocks/CallToAction/config'
|
||||||
import { Content } from '../../blocks/Content/config'
|
import { Content } from '../../blocks/Content/config'
|
||||||
@@ -123,9 +124,9 @@ export const Pages: CollectionConfig<'pages'> = {
|
|||||||
...slugField(),
|
...slugField(),
|
||||||
],
|
],
|
||||||
hooks: {
|
hooks: {
|
||||||
afterChange: [revalidatePage],
|
afterChange: [revalidatePage, auditChange('pages')],
|
||||||
beforeChange: [populatePublishedAt],
|
beforeChange: [populatePublishedAt],
|
||||||
afterDelete: [revalidateDelete],
|
afterDelete: [revalidateDelete, auditChange('pages')],
|
||||||
},
|
},
|
||||||
versions: {
|
versions: {
|
||||||
drafts: {
|
drafts: {
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
import type { CollectionConfig } from 'payload'
|
import type { CollectionConfig } from 'payload'
|
||||||
|
|
||||||
import { anyone } from '../../access/anyone'
|
import { anyone } from '../../access/anyone'
|
||||||
import { adminOrEditor } from '../../access/adminOrEditor'
|
import { authenticated } from '../../access/authenticated'
|
||||||
|
import { auditChange } from '../../collections/Audit/hooks/auditHooks'
|
||||||
import { slugField } from '@/fields/slug'
|
import { slugField } from '@/fields/slug'
|
||||||
|
|
||||||
export const Portfolio: CollectionConfig = {
|
export const Portfolio: CollectionConfig = {
|
||||||
slug: 'portfolio',
|
slug: 'portfolio',
|
||||||
access: {
|
access: {
|
||||||
create: adminOrEditor,
|
create: authenticated,
|
||||||
read: anyone,
|
read: anyone,
|
||||||
update: adminOrEditor,
|
update: authenticated,
|
||||||
delete: adminOrEditor,
|
delete: authenticated,
|
||||||
},
|
},
|
||||||
admin: {
|
admin: {
|
||||||
useAsTitle: 'title',
|
useAsTitle: 'title',
|
||||||
@@ -66,6 +67,10 @@ export const Portfolio: CollectionConfig = {
|
|||||||
},
|
},
|
||||||
...slugField(),
|
...slugField(),
|
||||||
],
|
],
|
||||||
|
hooks: {
|
||||||
|
afterChange: [auditChange('portfolio')],
|
||||||
|
afterDelete: [auditChange('portfolio')],
|
||||||
|
},
|
||||||
versions: {
|
versions: {
|
||||||
drafts: {
|
drafts: {
|
||||||
autosave: true,
|
autosave: true,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
import { authenticated } from '../../access/authenticated'
|
import { authenticated } from '../../access/authenticated'
|
||||||
import { authenticatedOrPublished } from '../../access/authenticatedOrPublished'
|
import { authenticatedOrPublished } from '../../access/authenticatedOrPublished'
|
||||||
import { adminOrEditor } from '../../access/adminOrEditor'
|
import { adminOrEditor } from '../../access/adminOrEditor'
|
||||||
|
import { auditChange } from '../../collections/Audit/hooks/auditHooks'
|
||||||
import { Banner } from '../../blocks/Banner/config'
|
import { Banner } from '../../blocks/Banner/config'
|
||||||
import { Code } from '../../blocks/Code/config'
|
import { Code } from '../../blocks/Code/config'
|
||||||
import { MediaBlock } from '../../blocks/MediaBlock/config'
|
import { MediaBlock } from '../../blocks/MediaBlock/config'
|
||||||
@@ -263,9 +264,9 @@ export const Posts: CollectionConfig<'posts'> = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
hooks: {
|
hooks: {
|
||||||
afterChange: [revalidatePost],
|
afterChange: [revalidatePost, auditChange('posts')],
|
||||||
afterRead: [populateAuthors],
|
afterRead: [populateAuthors],
|
||||||
afterDelete: [revalidateDelete],
|
afterDelete: [revalidateDelete, auditChange('posts')],
|
||||||
},
|
},
|
||||||
versions: {
|
versions: {
|
||||||
drafts: {
|
drafts: {
|
||||||
|
|||||||
@@ -1,6 +1,22 @@
|
|||||||
import type { CollectionConfig } from 'payload'
|
import type { CollectionConfig } from 'payload'
|
||||||
|
import type { AfterLoginHook, AfterLogoutHook } from 'payload'
|
||||||
|
|
||||||
import { adminOnly } from '../../access/adminOnly'
|
import { adminOnly } from '../../access/adminOnly'
|
||||||
|
import { authenticated } from '../../access/authenticated'
|
||||||
|
import { logLogin, logLogout } from '../../utilities/auditLogger'
|
||||||
|
|
||||||
|
const afterLogin: AfterLoginHook = async ({ req, user }) => {
|
||||||
|
if (user?.id) {
|
||||||
|
await logLogin(req, user.id)
|
||||||
|
}
|
||||||
|
return user
|
||||||
|
}
|
||||||
|
|
||||||
|
const afterLogout: AfterLogoutHook = async ({ req }) => {
|
||||||
|
if (req.user?.id) {
|
||||||
|
await logLogout(req, req.user.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const Users: CollectionConfig = {
|
export const Users: CollectionConfig = {
|
||||||
slug: 'users',
|
slug: 'users',
|
||||||
@@ -8,7 +24,7 @@ export const Users: CollectionConfig = {
|
|||||||
admin: adminOnly,
|
admin: adminOnly,
|
||||||
create: adminOnly,
|
create: adminOnly,
|
||||||
delete: adminOnly,
|
delete: adminOnly,
|
||||||
read: adminOnly,
|
read: authenticated,
|
||||||
update: adminOnly,
|
update: adminOnly,
|
||||||
},
|
},
|
||||||
admin: {
|
admin: {
|
||||||
@@ -16,6 +32,10 @@ export const Users: CollectionConfig = {
|
|||||||
useAsTitle: 'name',
|
useAsTitle: 'name',
|
||||||
},
|
},
|
||||||
auth: true,
|
auth: true,
|
||||||
|
hooks: {
|
||||||
|
afterLogin: [afterLogin],
|
||||||
|
afterLogout: [afterLogout],
|
||||||
|
},
|
||||||
fields: [
|
fields: [
|
||||||
{
|
{
|
||||||
name: 'name',
|
name: 'name',
|
||||||
|
|||||||
@@ -1,111 +0,0 @@
|
|||||||
import { RequiredDataFromCollectionSlug } from 'payload'
|
|
||||||
|
|
||||||
export const contactForm: RequiredDataFromCollectionSlug<'forms'> = {
|
|
||||||
confirmationMessage: {
|
|
||||||
root: {
|
|
||||||
type: 'root',
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
type: 'heading',
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
type: 'text',
|
|
||||||
detail: 0,
|
|
||||||
format: 0,
|
|
||||||
mode: 'normal',
|
|
||||||
style: '',
|
|
||||||
text: 'The contact form has been submitted successfully.',
|
|
||||||
version: 1,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
direction: 'ltr',
|
|
||||||
format: '',
|
|
||||||
indent: 0,
|
|
||||||
tag: 'h2',
|
|
||||||
version: 1,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
direction: 'ltr',
|
|
||||||
format: '',
|
|
||||||
indent: 0,
|
|
||||||
version: 1,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
confirmationType: 'message',
|
|
||||||
createdAt: '2023-01-12T21:47:41.374Z',
|
|
||||||
emails: [
|
|
||||||
{
|
|
||||||
emailFrom: '"Payload" \u003Cdemo@payloadcms.com\u003E',
|
|
||||||
emailTo: '{{email}}',
|
|
||||||
message: {
|
|
||||||
root: {
|
|
||||||
type: 'root',
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
type: 'paragraph',
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
type: 'text',
|
|
||||||
detail: 0,
|
|
||||||
format: 0,
|
|
||||||
mode: 'normal',
|
|
||||||
style: '',
|
|
||||||
text: 'Your contact form submission was successfully received.',
|
|
||||||
version: 1,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
direction: 'ltr',
|
|
||||||
format: '',
|
|
||||||
indent: 0,
|
|
||||||
textFormat: 0,
|
|
||||||
version: 1,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
direction: 'ltr',
|
|
||||||
format: '',
|
|
||||||
indent: 0,
|
|
||||||
version: 1,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
subject: "You've received a new message.",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
fields: [
|
|
||||||
{
|
|
||||||
name: 'full-name',
|
|
||||||
blockName: 'full-name',
|
|
||||||
blockType: 'text',
|
|
||||||
label: 'Full Name',
|
|
||||||
required: true,
|
|
||||||
width: 100,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'email',
|
|
||||||
blockName: 'email',
|
|
||||||
blockType: 'email',
|
|
||||||
label: 'Email',
|
|
||||||
required: true,
|
|
||||||
width: 100,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'phone',
|
|
||||||
blockName: 'phone',
|
|
||||||
blockType: 'number',
|
|
||||||
label: 'Phone',
|
|
||||||
required: false,
|
|
||||||
width: 100,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'message',
|
|
||||||
blockName: 'message',
|
|
||||||
blockType: 'textarea',
|
|
||||||
label: 'Message',
|
|
||||||
required: true,
|
|
||||||
width: 100,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
redirect: undefined,
|
|
||||||
submitButtonLabel: 'Submit',
|
|
||||||
title: 'Contact Form',
|
|
||||||
updatedAt: '2023-01-12T21:47:41.374Z',
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import type { CollectionSlug, GlobalSlug, Payload, PayloadRequest, File } from 'payload'
|
import type { CollectionSlug, GlobalSlug, Payload, PayloadRequest, File } from 'payload'
|
||||||
|
|
||||||
import { contactForm as contactFormData } from './contact-form'
|
|
||||||
import { contact as contactPageData } from './contact-page'
|
import { contact as contactPageData } from './contact-page'
|
||||||
import { home } from './home'
|
import { home } from './home'
|
||||||
import { image1 } from './image-1'
|
import { image1 } from './image-1'
|
||||||
@@ -15,8 +14,6 @@ const collections: CollectionSlug[] = [
|
|||||||
'media',
|
'media',
|
||||||
'pages',
|
'pages',
|
||||||
'posts',
|
'posts',
|
||||||
'forms',
|
|
||||||
'form-submissions',
|
|
||||||
'search',
|
'search',
|
||||||
]
|
]
|
||||||
const globals: GlobalSlug[] = ['header', 'footer']
|
const globals: GlobalSlug[] = ['header', 'footer']
|
||||||
@@ -257,14 +254,6 @@ export const seed = async ({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
payload.logger.info(`— Seeding contact form...`)
|
|
||||||
|
|
||||||
const contactForm = await payload.create({
|
|
||||||
collection: 'forms',
|
|
||||||
depth: 0,
|
|
||||||
data: contactFormData,
|
|
||||||
})
|
|
||||||
|
|
||||||
payload.logger.info(`— Seeding pages...`)
|
payload.logger.info(`— Seeding pages...`)
|
||||||
|
|
||||||
const [_, contactPage] = await Promise.all([
|
const [_, contactPage] = await Promise.all([
|
||||||
|
|||||||
52
apps/backend/src/jobs/cleanupAuditLogs.ts
Normal file
52
apps/backend/src/jobs/cleanupAuditLogs.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import type { PayloadRequest } from 'payload'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清理 90 天前的稽核日誌
|
||||||
|
* 此任務應該透過 cron job 定期執行(建議每天一次)
|
||||||
|
*/
|
||||||
|
export const cleanupAuditLogs = {
|
||||||
|
slug: 'cleanup-audit-logs',
|
||||||
|
label: '清理 90 天前的稽核日誌',
|
||||||
|
handler: async ({ req }: { req: PayloadRequest }) => {
|
||||||
|
try {
|
||||||
|
// 計算 90 天前的日期
|
||||||
|
const ninetyDaysAgo = new Date()
|
||||||
|
ninetyDaysAgo.setDate(ninetyDaysAgo.getDate() - 90)
|
||||||
|
|
||||||
|
// 查找 90 天前的稽核日誌
|
||||||
|
const oldLogs = await req.payload.find({
|
||||||
|
collection: 'audit',
|
||||||
|
where: {
|
||||||
|
createdAt: {
|
||||||
|
less_than: ninetyDaysAgo.toISOString(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
depth: 0,
|
||||||
|
limit: 1000, // 每次最多處理 1000 筆
|
||||||
|
})
|
||||||
|
|
||||||
|
if (oldLogs.totalDocs > 0) {
|
||||||
|
// 批量刪除舊日誌
|
||||||
|
for (const log of oldLogs.docs) {
|
||||||
|
await req.payload.delete({
|
||||||
|
collection: 'audit',
|
||||||
|
id: log.id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
req.payload.logger.info(`Cleaned up ${oldLogs.totalDocs} audit logs older than 90 days`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: `Cleaned up ${oldLogs.totalDocs} audit logs older than 90 days`,
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
req.payload.logger.error('Audit log cleanup failed:', error)
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: 'Audit log cleanup failed',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -73,6 +73,7 @@ export interface Config {
|
|||||||
categories: Category;
|
categories: Category;
|
||||||
users: User;
|
users: User;
|
||||||
portfolio: Portfolio;
|
portfolio: Portfolio;
|
||||||
|
audit: Audit;
|
||||||
redirects: Redirect;
|
redirects: Redirect;
|
||||||
search: Search;
|
search: Search;
|
||||||
'payload-jobs': PayloadJob;
|
'payload-jobs': PayloadJob;
|
||||||
@@ -88,6 +89,7 @@ export interface Config {
|
|||||||
categories: CategoriesSelect<false> | CategoriesSelect<true>;
|
categories: CategoriesSelect<false> | CategoriesSelect<true>;
|
||||||
users: UsersSelect<false> | UsersSelect<true>;
|
users: UsersSelect<false> | UsersSelect<true>;
|
||||||
portfolio: PortfolioSelect<false> | PortfolioSelect<true>;
|
portfolio: PortfolioSelect<false> | PortfolioSelect<true>;
|
||||||
|
audit: AuditSelect<false> | AuditSelect<true>;
|
||||||
redirects: RedirectsSelect<false> | RedirectsSelect<true>;
|
redirects: RedirectsSelect<false> | RedirectsSelect<true>;
|
||||||
search: SearchSelect<false> | SearchSelect<true>;
|
search: SearchSelect<false> | SearchSelect<true>;
|
||||||
'payload-jobs': PayloadJobsSelect<false> | PayloadJobsSelect<true>;
|
'payload-jobs': PayloadJobsSelect<false> | PayloadJobsSelect<true>;
|
||||||
@@ -112,6 +114,7 @@ export interface Config {
|
|||||||
};
|
};
|
||||||
jobs: {
|
jobs: {
|
||||||
tasks: {
|
tasks: {
|
||||||
|
'cleanup-audit-logs': TaskCleanupAuditLogs;
|
||||||
schedulePublish: TaskSchedulePublish;
|
schedulePublish: TaskSchedulePublish;
|
||||||
inline: {
|
inline: {
|
||||||
input: unknown;
|
input: unknown;
|
||||||
@@ -213,6 +216,10 @@ export interface Post {
|
|||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
heroImage?: (string | null) | Media;
|
heroImage?: (string | null) | Media;
|
||||||
|
/**
|
||||||
|
* Facebook/LINE 分享時顯示的預覽圖,建議 1200x630px
|
||||||
|
*/
|
||||||
|
ogImage?: (string | null) | Media;
|
||||||
content: {
|
content: {
|
||||||
root: {
|
root: {
|
||||||
type: string;
|
type: string;
|
||||||
@@ -228,8 +235,13 @@ export interface Post {
|
|||||||
};
|
};
|
||||||
[k: string]: unknown;
|
[k: string]: unknown;
|
||||||
};
|
};
|
||||||
|
/**
|
||||||
|
* 顯示在文章列表頁,建議 150-200 字
|
||||||
|
*/
|
||||||
|
excerpt?: string | null;
|
||||||
relatedPosts?: (string | Post)[] | null;
|
relatedPosts?: (string | Post)[] | null;
|
||||||
categories?: (string | Category)[] | null;
|
categories?: (string | Category)[] | null;
|
||||||
|
showInFooter?: boolean | null;
|
||||||
meta?: {
|
meta?: {
|
||||||
title?: string | null;
|
title?: string | null;
|
||||||
/**
|
/**
|
||||||
@@ -248,6 +260,7 @@ export interface Post {
|
|||||||
| null;
|
| null;
|
||||||
slug?: string | null;
|
slug?: string | null;
|
||||||
slugLock?: boolean | null;
|
slugLock?: boolean | null;
|
||||||
|
status?: ('draft' | 'review' | 'published') | null;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
_status?: ('draft' | 'published') | null;
|
_status?: ('draft' | 'published') | null;
|
||||||
@@ -351,6 +364,22 @@ export interface Media {
|
|||||||
export interface Category {
|
export interface Category {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
/**
|
||||||
|
* 用於 URL 或國際化
|
||||||
|
*/
|
||||||
|
nameEn?: string | null;
|
||||||
|
/**
|
||||||
|
* 數字越小越靠前
|
||||||
|
*/
|
||||||
|
order?: number | null;
|
||||||
|
/**
|
||||||
|
* 十六進制顏色碼,例如 #000000
|
||||||
|
*/
|
||||||
|
textColor?: string | null;
|
||||||
|
/**
|
||||||
|
* 十六進制顏色碼,例如 #ffffff
|
||||||
|
*/
|
||||||
|
backgroundColor?: string | null;
|
||||||
slug?: string | null;
|
slug?: string | null;
|
||||||
slugLock?: boolean | null;
|
slugLock?: boolean | null;
|
||||||
parent?: (string | null) | Category;
|
parent?: (string | null) | Category;
|
||||||
@@ -372,6 +401,7 @@ export interface Category {
|
|||||||
export interface User {
|
export interface User {
|
||||||
id: string;
|
id: string;
|
||||||
name?: string | null;
|
name?: string | null;
|
||||||
|
role: 'admin' | 'editor';
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
email: string;
|
email: string;
|
||||||
@@ -561,6 +591,63 @@ export interface Portfolio {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
_status?: ('draft' | 'published') | null;
|
_status?: ('draft' | 'published') | null;
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* 系統稽核日誌 - 自動記錄所有重要操作
|
||||||
|
*
|
||||||
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
|
* via the `definition` "audit".
|
||||||
|
*/
|
||||||
|
export interface Audit {
|
||||||
|
id: string;
|
||||||
|
action: 'login' | 'logout' | 'create' | 'update' | 'delete' | 'publish' | 'unpublish';
|
||||||
|
/**
|
||||||
|
* 受影響的集合名稱
|
||||||
|
*/
|
||||||
|
collection: string;
|
||||||
|
/**
|
||||||
|
* 受影響的文件 ID
|
||||||
|
*/
|
||||||
|
documentId?: string | null;
|
||||||
|
/**
|
||||||
|
* 受影響的文件標題
|
||||||
|
*/
|
||||||
|
documentTitle?: string | null;
|
||||||
|
/**
|
||||||
|
* 執行操作的使用者 ID
|
||||||
|
*/
|
||||||
|
userId: string;
|
||||||
|
/**
|
||||||
|
* 執行操作的使用者名稱
|
||||||
|
*/
|
||||||
|
userName?: string | null;
|
||||||
|
/**
|
||||||
|
* 執行操作的使用者信箱
|
||||||
|
*/
|
||||||
|
userEmail?: string | null;
|
||||||
|
userRole?: ('admin' | 'editor') | null;
|
||||||
|
/**
|
||||||
|
* IP 位址
|
||||||
|
*/
|
||||||
|
ipAddress?: string | null;
|
||||||
|
/**
|
||||||
|
* 瀏覽器 User Agent
|
||||||
|
*/
|
||||||
|
userAgent?: string | null;
|
||||||
|
/**
|
||||||
|
* 變更內容詳細資訊
|
||||||
|
*/
|
||||||
|
changes?:
|
||||||
|
| {
|
||||||
|
[k: string]: unknown;
|
||||||
|
}
|
||||||
|
| unknown[]
|
||||||
|
| string
|
||||||
|
| number
|
||||||
|
| boolean
|
||||||
|
| null;
|
||||||
|
updatedAt: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* This interface was referenced by `Config`'s JSON-Schema
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
* via the `definition` "redirects".
|
* via the `definition` "redirects".
|
||||||
@@ -670,7 +757,7 @@ export interface PayloadJob {
|
|||||||
| {
|
| {
|
||||||
executedAt: string;
|
executedAt: string;
|
||||||
completedAt: string;
|
completedAt: string;
|
||||||
taskSlug: 'inline' | 'schedulePublish';
|
taskSlug: 'inline' | 'cleanup-audit-logs' | 'schedulePublish';
|
||||||
taskID: string;
|
taskID: string;
|
||||||
input?:
|
input?:
|
||||||
| {
|
| {
|
||||||
@@ -703,7 +790,7 @@ export interface PayloadJob {
|
|||||||
id?: string | null;
|
id?: string | null;
|
||||||
}[]
|
}[]
|
||||||
| null;
|
| null;
|
||||||
taskSlug?: ('inline' | 'schedulePublish') | null;
|
taskSlug?: ('inline' | 'cleanup-audit-logs' | 'schedulePublish') | null;
|
||||||
queue?: string | null;
|
queue?: string | null;
|
||||||
waitUntil?: string | null;
|
waitUntil?: string | null;
|
||||||
processing?: boolean | null;
|
processing?: boolean | null;
|
||||||
@@ -741,6 +828,10 @@ export interface PayloadLockedDocument {
|
|||||||
relationTo: 'portfolio';
|
relationTo: 'portfolio';
|
||||||
value: string | Portfolio;
|
value: string | Portfolio;
|
||||||
} | null)
|
} | null)
|
||||||
|
| ({
|
||||||
|
relationTo: 'audit';
|
||||||
|
value: string | Audit;
|
||||||
|
} | null)
|
||||||
| ({
|
| ({
|
||||||
relationTo: 'redirects';
|
relationTo: 'redirects';
|
||||||
value: string | Redirect;
|
value: string | Redirect;
|
||||||
@@ -925,9 +1016,12 @@ export interface ArchiveBlockSelect<T extends boolean = true> {
|
|||||||
export interface PostsSelect<T extends boolean = true> {
|
export interface PostsSelect<T extends boolean = true> {
|
||||||
title?: T;
|
title?: T;
|
||||||
heroImage?: T;
|
heroImage?: T;
|
||||||
|
ogImage?: T;
|
||||||
content?: T;
|
content?: T;
|
||||||
|
excerpt?: T;
|
||||||
relatedPosts?: T;
|
relatedPosts?: T;
|
||||||
categories?: T;
|
categories?: T;
|
||||||
|
showInFooter?: T;
|
||||||
meta?:
|
meta?:
|
||||||
| T
|
| T
|
||||||
| {
|
| {
|
||||||
@@ -945,6 +1039,7 @@ export interface PostsSelect<T extends boolean = true> {
|
|||||||
};
|
};
|
||||||
slug?: T;
|
slug?: T;
|
||||||
slugLock?: T;
|
slugLock?: T;
|
||||||
|
status?: T;
|
||||||
updatedAt?: T;
|
updatedAt?: T;
|
||||||
createdAt?: T;
|
createdAt?: T;
|
||||||
_status?: T;
|
_status?: T;
|
||||||
@@ -1048,6 +1143,10 @@ export interface MediaSelect<T extends boolean = true> {
|
|||||||
*/
|
*/
|
||||||
export interface CategoriesSelect<T extends boolean = true> {
|
export interface CategoriesSelect<T extends boolean = true> {
|
||||||
title?: T;
|
title?: T;
|
||||||
|
nameEn?: T;
|
||||||
|
order?: T;
|
||||||
|
textColor?: T;
|
||||||
|
backgroundColor?: T;
|
||||||
slug?: T;
|
slug?: T;
|
||||||
slugLock?: T;
|
slugLock?: T;
|
||||||
parent?: T;
|
parent?: T;
|
||||||
@@ -1068,6 +1167,7 @@ export interface CategoriesSelect<T extends boolean = true> {
|
|||||||
*/
|
*/
|
||||||
export interface UsersSelect<T extends boolean = true> {
|
export interface UsersSelect<T extends boolean = true> {
|
||||||
name?: T;
|
name?: T;
|
||||||
|
role?: T;
|
||||||
updatedAt?: T;
|
updatedAt?: T;
|
||||||
createdAt?: T;
|
createdAt?: T;
|
||||||
email?: T;
|
email?: T;
|
||||||
@@ -1107,6 +1207,25 @@ export interface PortfolioSelect<T extends boolean = true> {
|
|||||||
createdAt?: T;
|
createdAt?: T;
|
||||||
_status?: T;
|
_status?: T;
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
|
* via the `definition` "audit_select".
|
||||||
|
*/
|
||||||
|
export interface AuditSelect<T extends boolean = true> {
|
||||||
|
action?: T;
|
||||||
|
collection?: T;
|
||||||
|
documentId?: T;
|
||||||
|
documentTitle?: T;
|
||||||
|
userId?: T;
|
||||||
|
userName?: T;
|
||||||
|
userEmail?: T;
|
||||||
|
userRole?: T;
|
||||||
|
ipAddress?: T;
|
||||||
|
userAgent?: T;
|
||||||
|
changes?: T;
|
||||||
|
updatedAt?: T;
|
||||||
|
createdAt?: T;
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* This interface was referenced by `Config`'s JSON-Schema
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
* via the `definition` "redirects_select".
|
* via the `definition` "redirects_select".
|
||||||
@@ -1351,6 +1470,14 @@ export interface FooterSelect<T extends boolean = true> {
|
|||||||
createdAt?: T;
|
createdAt?: T;
|
||||||
globalType?: T;
|
globalType?: T;
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
|
* via the `definition` "TaskCleanup-audit-logs".
|
||||||
|
*/
|
||||||
|
export interface TaskCleanupAuditLogs {
|
||||||
|
input?: unknown;
|
||||||
|
output?: unknown;
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* This interface was referenced by `Config`'s JSON-Schema
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
* via the `definition` "TaskSchedulePublish".
|
* via the `definition` "TaskSchedulePublish".
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import sharp from 'sharp' // sharp-import
|
|||||||
import path from 'path'
|
import path from 'path'
|
||||||
import { buildConfig, PayloadRequest } from 'payload'
|
import { buildConfig, PayloadRequest } from 'payload'
|
||||||
import { fileURLToPath } from 'url'
|
import { fileURLToPath } from 'url'
|
||||||
|
import { Audit } from './collections/Audit'
|
||||||
import { Categories } from './collections/Categories'
|
import { Categories } from './collections/Categories'
|
||||||
import { Media } from './collections/Media'
|
import { Media } from './collections/Media'
|
||||||
import { Pages } from './collections/Pages'
|
import { Pages } from './collections/Pages'
|
||||||
@@ -17,6 +18,7 @@ import { Header } from './Header/config'
|
|||||||
import { plugins } from './plugins'
|
import { plugins } from './plugins'
|
||||||
import { defaultLexical } from '@/fields/defaultLexical'
|
import { defaultLexical } from '@/fields/defaultLexical'
|
||||||
import { getServerSideURL } from './utilities/getURL'
|
import { getServerSideURL } from './utilities/getURL'
|
||||||
|
import { cleanupAuditLogs } from './jobs/cleanupAuditLogs'
|
||||||
|
|
||||||
const filename = fileURLToPath(import.meta.url)
|
const filename = fileURLToPath(import.meta.url)
|
||||||
const dirname = path.dirname(filename)
|
const dirname = path.dirname(filename)
|
||||||
@@ -63,7 +65,7 @@ export default buildConfig({
|
|||||||
db: mongooseAdapter({
|
db: mongooseAdapter({
|
||||||
url: process.env.DATABASE_URI || '',
|
url: process.env.DATABASE_URI || '',
|
||||||
}),
|
}),
|
||||||
collections: [Pages, Posts, Media, Categories, Users, Portfolio],
|
collections: [Pages, Posts, Media, Categories, Users, Portfolio, Audit],
|
||||||
cors: [
|
cors: [
|
||||||
getServerSideURL(),
|
getServerSideURL(),
|
||||||
'http://localhost:4321', // Astro dev server
|
'http://localhost:4321', // Astro dev server
|
||||||
@@ -111,6 +113,6 @@ export default buildConfig({
|
|||||||
return authHeader === `Bearer ${process.env.CRON_SECRET}`
|
return authHeader === `Bearer ${process.env.CRON_SECRET}`
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
tasks: [],
|
tasks: [cleanupAuditLogs],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import { payloadCloudPlugin } from '@payloadcms/payload-cloud'
|
|
||||||
import { formBuilderPlugin } from '@payloadcms/plugin-form-builder'
|
|
||||||
import { nestedDocsPlugin } from '@payloadcms/plugin-nested-docs'
|
import { nestedDocsPlugin } from '@payloadcms/plugin-nested-docs'
|
||||||
import { redirectsPlugin } from '@payloadcms/plugin-redirects'
|
import { redirectsPlugin } from '@payloadcms/plugin-redirects'
|
||||||
import { seoPlugin } from '@payloadcms/plugin-seo'
|
import { seoPlugin } from '@payloadcms/plugin-seo'
|
||||||
@@ -7,7 +5,6 @@ import { searchPlugin } from '@payloadcms/plugin-search'
|
|||||||
import { Plugin } from 'payload'
|
import { Plugin } from 'payload'
|
||||||
import { revalidateRedirects } from '@/hooks/revalidateRedirects'
|
import { revalidateRedirects } from '@/hooks/revalidateRedirects'
|
||||||
import { GenerateTitle, GenerateURL } from '@payloadcms/plugin-seo/types'
|
import { GenerateTitle, GenerateURL } from '@payloadcms/plugin-seo/types'
|
||||||
import { FixedToolbarFeature, HeadingFeature, lexicalEditor } from '@payloadcms/richtext-lexical'
|
|
||||||
import { searchFields } from '@/search/fieldOverrides'
|
import { searchFields } from '@/search/fieldOverrides'
|
||||||
import { beforeSyncWithSearch } from '@/search/beforeSync'
|
import { beforeSyncWithSearch } from '@/search/beforeSync'
|
||||||
|
|
||||||
@@ -55,32 +52,6 @@ export const plugins: Plugin[] = [
|
|||||||
generateTitle,
|
generateTitle,
|
||||||
generateURL,
|
generateURL,
|
||||||
}),
|
}),
|
||||||
formBuilderPlugin({
|
|
||||||
fields: {
|
|
||||||
payment: false,
|
|
||||||
},
|
|
||||||
formOverrides: {
|
|
||||||
fields: ({ defaultFields }) => {
|
|
||||||
return defaultFields.map((field) => {
|
|
||||||
if ('name' in field && field.name === 'confirmationMessage') {
|
|
||||||
return {
|
|
||||||
...field,
|
|
||||||
editor: lexicalEditor({
|
|
||||||
features: ({ rootFeatures }) => {
|
|
||||||
return [
|
|
||||||
...rootFeatures,
|
|
||||||
FixedToolbarFeature(),
|
|
||||||
HeadingFeature({ enabledHeadingSizes: ['h1', 'h2', 'h3', 'h4'] }),
|
|
||||||
]
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return field
|
|
||||||
})
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
searchPlugin({
|
searchPlugin({
|
||||||
collections: ['posts'],
|
collections: ['posts'],
|
||||||
beforeSync: beforeSyncWithSearch,
|
beforeSync: beforeSyncWithSearch,
|
||||||
@@ -90,5 +61,4 @@ export const plugins: Plugin[] = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
payloadCloudPlugin(),
|
|
||||||
]
|
]
|
||||||
|
|||||||
11
apps/backend/src/types/audit.ts
Normal file
11
apps/backend/src/types/audit.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
/**
|
||||||
|
* 稽核日誌操作類型
|
||||||
|
*/
|
||||||
|
export type AuditAction =
|
||||||
|
| 'login'
|
||||||
|
| 'logout'
|
||||||
|
| 'create'
|
||||||
|
| 'update'
|
||||||
|
| 'delete'
|
||||||
|
| 'publish'
|
||||||
|
| 'unpublish'
|
||||||
110
apps/backend/src/utilities/auditLogger.ts
Normal file
110
apps/backend/src/utilities/auditLogger.ts
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import type { PayloadRequest } from 'payload'
|
||||||
|
import { AuditAction } from '@/types/audit'
|
||||||
|
|
||||||
|
interface AuditLogData {
|
||||||
|
action: AuditAction
|
||||||
|
collection: string
|
||||||
|
documentId?: string
|
||||||
|
documentTitle?: string
|
||||||
|
userId: string
|
||||||
|
userName?: string
|
||||||
|
userEmail?: string
|
||||||
|
userRole?: string
|
||||||
|
ipAddress?: string
|
||||||
|
userAgent?: string
|
||||||
|
changes?: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 建立稽核日誌
|
||||||
|
* 此函數用於記錄系統中的重要操作,包括登入/登出、內容變更等
|
||||||
|
*/
|
||||||
|
export const auditLogger = async (req: PayloadRequest, data: AuditLogData): Promise<void> => {
|
||||||
|
try {
|
||||||
|
// 從請求中獲取 IP 和 User Agent
|
||||||
|
const ipAddress = req.headers?.get('x-forwarded-for')?.split(',')[0] ||
|
||||||
|
req.headers?.get('x-real-ip') ||
|
||||||
|
undefined
|
||||||
|
const userAgent = req.headers?.get('user-agent') || undefined
|
||||||
|
|
||||||
|
await req.payload.create({
|
||||||
|
collection: 'audit',
|
||||||
|
data: {
|
||||||
|
action: data.action,
|
||||||
|
collection: data.collection,
|
||||||
|
documentId: data.documentId,
|
||||||
|
documentTitle: data.documentTitle,
|
||||||
|
userId: data.userId,
|
||||||
|
userName: data.userName,
|
||||||
|
userEmail: data.userEmail,
|
||||||
|
userRole: data.userRole,
|
||||||
|
ipAddress,
|
||||||
|
userAgent,
|
||||||
|
changes: data.changes,
|
||||||
|
},
|
||||||
|
// 不觸發稽核日誌的稽核日誌(避免無限循環)
|
||||||
|
depth: 0,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
// 記錄錯誤但不中斷主要流程
|
||||||
|
console.error('Audit log creation failed:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 記錄登入事件
|
||||||
|
*/
|
||||||
|
export const logLogin = async (req: PayloadRequest, userId: string): Promise<void> => {
|
||||||
|
const user = await req.payload.findByID({
|
||||||
|
collection: 'users',
|
||||||
|
id: userId,
|
||||||
|
})
|
||||||
|
|
||||||
|
await auditLogger(req, {
|
||||||
|
action: 'login',
|
||||||
|
collection: 'users',
|
||||||
|
documentId: userId,
|
||||||
|
userId,
|
||||||
|
userName: user.name as string,
|
||||||
|
userEmail: user.email as string,
|
||||||
|
userRole: String(user.role ?? ''),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 記錄登出事件
|
||||||
|
*/
|
||||||
|
export const logLogout = async (req: PayloadRequest, userId: string): Promise<void> => {
|
||||||
|
await auditLogger(req, {
|
||||||
|
action: 'logout',
|
||||||
|
collection: 'users',
|
||||||
|
documentId: userId,
|
||||||
|
userId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 記錄文件變更(創建、更新、刪除)
|
||||||
|
*/
|
||||||
|
export const logDocumentChange = async (
|
||||||
|
req: PayloadRequest,
|
||||||
|
operation: 'create' | 'update' | 'delete',
|
||||||
|
collection: string,
|
||||||
|
docId?: string,
|
||||||
|
docTitle?: string,
|
||||||
|
changes?: Record<string, unknown>,
|
||||||
|
): Promise<void> => {
|
||||||
|
if (!req.user) return
|
||||||
|
|
||||||
|
await auditLogger(req, {
|
||||||
|
action: operation,
|
||||||
|
collection,
|
||||||
|
documentId: docId,
|
||||||
|
documentTitle: docTitle,
|
||||||
|
userId: req.user.id,
|
||||||
|
userName: req.user.name as string,
|
||||||
|
userEmail: req.user.email as string,
|
||||||
|
userRole: String(req.user.role ?? ''),
|
||||||
|
changes,
|
||||||
|
})
|
||||||
|
}
|
||||||
21
apps/backend/tests/k6/.env.example
Normal file
21
apps/backend/tests/k6/.env.example
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# K6 Load Testing Environment Variables
|
||||||
|
# Copy this file to .env.k6 and customize for your environment
|
||||||
|
|
||||||
|
# Target Server Configuration
|
||||||
|
BASE_URL=http://localhost:3000
|
||||||
|
|
||||||
|
# Admin Credentials (for admin-operations.js)
|
||||||
|
ADMIN_EMAIL=admin@enchun.tw
|
||||||
|
ADMIN_PASSWORD=your-secure-password-here
|
||||||
|
|
||||||
|
# Optional: Override default load profile
|
||||||
|
# STAGED_USERS=100
|
||||||
|
# STAGED_DURATION=10m
|
||||||
|
|
||||||
|
# Optional: Staging environment
|
||||||
|
# BASE_URL=https://staging.enchun.tw
|
||||||
|
# ADMIN_EMAIL=admin@staging.enchun.tw
|
||||||
|
|
||||||
|
# Optional: Production environment (use with caution!)
|
||||||
|
# BASE_URL=https://www.enchun.tw
|
||||||
|
# ADMIN_EMAIL=admin@enchun.tw
|
||||||
154
apps/backend/tests/k6/.github-workflow-example.yml
Normal file
154
apps/backend/tests/k6/.github-workflow-example.yml
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
# K6 Load Testing - GitHub Actions Workflow Example
|
||||||
|
# Copy this to .github/workflows/load-tests.yml
|
||||||
|
|
||||||
|
name: Load Tests
|
||||||
|
|
||||||
|
on:
|
||||||
|
# Run daily at 2 AM UTC
|
||||||
|
schedule:
|
||||||
|
- cron: '0 2 * * *'
|
||||||
|
|
||||||
|
# Run on demand
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
# Run after deployment to staging
|
||||||
|
workflow_run:
|
||||||
|
workflows: ["Deploy to Staging"]
|
||||||
|
types:
|
||||||
|
- completed
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
public-browsing:
|
||||||
|
name: Public Browsing (100 users)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup k6
|
||||||
|
run: |
|
||||||
|
curl https://github.com/grafana/k6/releases/download/v0.51.0/k6-v0.51.0-linux-amd64.tar.gz -L | tar xvz
|
||||||
|
sudo mv k6-v0.51.0-linux-amd64/k6 /usr/local/bin/
|
||||||
|
|
||||||
|
- name: Wait for server
|
||||||
|
run: |
|
||||||
|
echo "Waiting for server to be ready..."
|
||||||
|
timeout 60 bash -c 'until curl -sSf ${{ vars.STAGING_URL }} > /dev/null; do sleep 2; done'
|
||||||
|
echo "Server is ready!"
|
||||||
|
|
||||||
|
- name: Run Public Browsing Test
|
||||||
|
run: |
|
||||||
|
k6 run \
|
||||||
|
--env BASE_URL=${{ vars.STAGING_URL }} \
|
||||||
|
--out json=public-browsing-results.json \
|
||||||
|
apps/backend/tests/k6/public-browsing.js
|
||||||
|
|
||||||
|
- name: Upload Results
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: public-browsing-results
|
||||||
|
path: public-browsing-results.json
|
||||||
|
retention-days: 30
|
||||||
|
|
||||||
|
api-performance:
|
||||||
|
name: API Performance (50 users)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup k6
|
||||||
|
run: |
|
||||||
|
curl https://github.com/grafana/k6/releases/download/v0.51.0/k6-v0.51.0-linux-amd64.tar.gz -L | tar xvz
|
||||||
|
sudo mv k6-v0.51.0-linux-amd64/k6 /usr/local/bin/
|
||||||
|
|
||||||
|
- name: Run API Performance Test
|
||||||
|
run: |
|
||||||
|
k6 run \
|
||||||
|
--env BASE_URL=${{ vars.STAGING_URL }} \
|
||||||
|
--out json=api-performance-results.json \
|
||||||
|
apps/backend/tests/k6/api-performance.js
|
||||||
|
|
||||||
|
- name: Upload Results
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: api-performance-results
|
||||||
|
path: api-performance-results.json
|
||||||
|
retention-days: 30
|
||||||
|
|
||||||
|
admin-operations:
|
||||||
|
name: Admin Operations (20 users)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
# Only run on manual trigger or schedule, not on every deployment
|
||||||
|
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup k6
|
||||||
|
run: |
|
||||||
|
curl https://github.com/grafana/k6/releases/download/v0.51.0/k6-v0.51.0-linux-amd64.tar.gz -L | tar xvz
|
||||||
|
sudo mv k6-v0.51.0-linux-amd64/k6 /usr/local/bin/
|
||||||
|
|
||||||
|
- name: Run Admin Operations Test
|
||||||
|
run: |
|
||||||
|
k6 run \
|
||||||
|
--env BASE_URL=${{ vars.STAGING_URL }} \
|
||||||
|
--env ADMIN_EMAIL=${{ secrets.TEST_ADMIN_EMAIL }} \
|
||||||
|
--env ADMIN_PASSWORD=${{ secrets.TEST_ADMIN_PASSWORD }} \
|
||||||
|
--out json=admin-operations-results.json \
|
||||||
|
apps/backend/tests/k6/admin-operations.js
|
||||||
|
env:
|
||||||
|
# Don't log passwords
|
||||||
|
K6_NO_LOG_USAGE: true
|
||||||
|
|
||||||
|
- name: Upload Results
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: admin-operations-results
|
||||||
|
path: admin-operations-results.json
|
||||||
|
retention-days: 30
|
||||||
|
|
||||||
|
# Optional: Generate and publish HTML reports
|
||||||
|
generate-reports:
|
||||||
|
name: Generate HTML Reports
|
||||||
|
needs: [public-browsing, api-performance]
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Download Results
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
path: results
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
|
||||||
|
- name: Install k6-reporter
|
||||||
|
run: npm install -g k6-reporter
|
||||||
|
|
||||||
|
- name: Generate Reports
|
||||||
|
run: |
|
||||||
|
mkdir -p reports
|
||||||
|
k6-reporter results/public-browsing-results/public-browsing-results.json --output reports/public-browsing.html
|
||||||
|
k6-reporter results/api-performance-results/api-performance-results.json --output reports/api-performance.html
|
||||||
|
|
||||||
|
- name: Upload Reports
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: html-reports
|
||||||
|
path: reports/
|
||||||
|
retention-days: 30
|
||||||
|
|
||||||
|
- name: Comment PR with Results
|
||||||
|
if: github.event_name == 'pull_request'
|
||||||
|
uses: actions/github-script@v7
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
const fs = require('fs');
|
||||||
|
const publicReport = fs.readFileSync('reports/public-browsing.html', 'utf8');
|
||||||
|
// Add comment to PR with summary...
|
||||||
101
apps/backend/tests/k6/QUICKSTART.md
Normal file
101
apps/backend/tests/k6/QUICKSTART.md
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
# K6 Load Testing - Quick Start Guide
|
||||||
|
|
||||||
|
## 5-Minute Setup
|
||||||
|
|
||||||
|
### Step 1: Install k6
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# macOS
|
||||||
|
brew install k6
|
||||||
|
|
||||||
|
# Verify installation
|
||||||
|
k6 version
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: Start Your Backend
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# In one terminal
|
||||||
|
cd /Users/pukpuk/Dev/website-enchun-mgr
|
||||||
|
pnpm dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: Run Your First Test
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# In another terminal
|
||||||
|
cd apps/backend
|
||||||
|
|
||||||
|
# Run public browsing test (simplest - no auth needed)
|
||||||
|
k6 run tests/k6/public-browsing.js
|
||||||
|
```
|
||||||
|
|
||||||
|
That's it! You should see output showing 100 virtual users browsing your site.
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
### Run All Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Public browsing (100 users)
|
||||||
|
k6 run tests/k6/public-browsing.js
|
||||||
|
|
||||||
|
# API performance (50 users)
|
||||||
|
k6 run tests/k6/api-performance.js
|
||||||
|
|
||||||
|
# Admin operations (20 users) - requires admin credentials
|
||||||
|
k6 run --env ADMIN_EMAIL=your@email.com --env ADMIN_PASSWORD=yourpassword \
|
||||||
|
tests/k6/admin-operations.js
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test Against Staging
|
||||||
|
|
||||||
|
```bash
|
||||||
|
k6 run --env BASE_URL=https://staging.enchun.tw tests/k6/public-browsing.js
|
||||||
|
```
|
||||||
|
|
||||||
|
### Generate Report
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Generate JSON output
|
||||||
|
k6 run --out json=results.json tests/k6/public-browsing.js
|
||||||
|
|
||||||
|
# Convert to HTML (requires k6-reporter)
|
||||||
|
npm install -g k6-reporter
|
||||||
|
k6-reporter results.json --output results.html
|
||||||
|
open results.html
|
||||||
|
```
|
||||||
|
|
||||||
|
## Understanding Results
|
||||||
|
|
||||||
|
Look for these key metrics:
|
||||||
|
|
||||||
|
```
|
||||||
|
✓ http_req_duration..............: avg=185ms p(95)=420ms
|
||||||
|
✓ http_req_failed................: 0.00% ✓ 0 ✗ 12000
|
||||||
|
✓ checks.........................: 100.0% ✓ 12000 ✗ 0
|
||||||
|
```
|
||||||
|
|
||||||
|
**What to check:**
|
||||||
|
- `p(95)` should be < 500ms
|
||||||
|
- `http_req_failed` should be < 1%
|
||||||
|
- `checks` should be > 99%
|
||||||
|
|
||||||
|
## Common Issues
|
||||||
|
|
||||||
|
**"connect attempt failed"**
|
||||||
|
→ Make sure your backend is running (pnpm dev)
|
||||||
|
|
||||||
|
**"login failed" in admin tests**
|
||||||
|
→ Set correct admin credentials via environment variables
|
||||||
|
|
||||||
|
**High error rate**
|
||||||
|
→ Reduce VUs: `k6 run --env STAGED_USERS=10 tests/k6/public-browsing.js`
|
||||||
|
|
||||||
|
## Need Help?
|
||||||
|
|
||||||
|
See the full README: `tests/k6/README.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Happy Testing!** 🚀
|
||||||
294
apps/backend/tests/k6/README.md
Normal file
294
apps/backend/tests/k6/README.md
Normal file
@@ -0,0 +1,294 @@
|
|||||||
|
# K6 Load Testing Framework
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This directory contains load testing scripts for the Enchun CMS backend using k6. The tests are designed to validate the non-functional requirement NFR4:
|
||||||
|
|
||||||
|
- **Target:** p95 response time < 500ms
|
||||||
|
- **Error rate:** < 1%
|
||||||
|
- **Concurrent users:** 100
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
1. **Install k6:**
|
||||||
|
```bash
|
||||||
|
# macOS
|
||||||
|
brew install k6
|
||||||
|
|
||||||
|
# Linux
|
||||||
|
sudo apt-get install k6
|
||||||
|
|
||||||
|
# Windows (using Chocolatey)
|
||||||
|
choco install k6
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Set up environment:**
|
||||||
|
```bash
|
||||||
|
cp .env.example .env.k6
|
||||||
|
# Edit .env.k6 with your test environment credentials
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test Scripts
|
||||||
|
|
||||||
|
### 1. Public Browsing Test (`public-browsing.js`)
|
||||||
|
|
||||||
|
Simulates 100 concurrent users browsing public pages:
|
||||||
|
- Homepage
|
||||||
|
- About page
|
||||||
|
- Solutions page
|
||||||
|
- Portfolio list
|
||||||
|
- Blog list
|
||||||
|
- Contact page
|
||||||
|
|
||||||
|
**Run:**
|
||||||
|
```bash
|
||||||
|
k6 run --env BASE_URL=http://localhost:3000 tests/k6/public-browsing.js
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expected Results:**
|
||||||
|
- p95 response time < 500ms
|
||||||
|
- Error rate < 1%
|
||||||
|
- 100 concurrent users sustained for 2 minutes
|
||||||
|
|
||||||
|
### 2. Admin Operations Test (`admin-operations.js`)
|
||||||
|
|
||||||
|
Simulates 20 concurrent admin users performing:
|
||||||
|
- Login
|
||||||
|
- List pages/posts
|
||||||
|
- Create/edit content
|
||||||
|
- Delete content
|
||||||
|
|
||||||
|
**Run:**
|
||||||
|
```bash
|
||||||
|
k6 run --env BASE_URL=http://localhost:3000 \
|
||||||
|
--env ADMIN_EMAIL=admin@example.com \
|
||||||
|
--env ADMIN_PASSWORD=password123 \
|
||||||
|
tests/k6/admin-operations.js
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expected Results:**
|
||||||
|
- p95 response time < 500ms
|
||||||
|
- Error rate < 1%
|
||||||
|
- 20 concurrent users sustained for 3 minutes
|
||||||
|
|
||||||
|
### 3. API Performance Test (`api-performance.js`)
|
||||||
|
|
||||||
|
Tests specific API endpoints:
|
||||||
|
- GraphQL API queries
|
||||||
|
- REST API endpoints
|
||||||
|
- Global API endpoint
|
||||||
|
|
||||||
|
**Run:**
|
||||||
|
```bash
|
||||||
|
k6 run --env BASE_URL=http://localhost:3000 tests/k6/api-performance.js
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expected Results:**
|
||||||
|
- p95 response time < 300ms (faster for API)
|
||||||
|
- Error rate < 0.5%
|
||||||
|
- Throughput > 100 requests/second
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
| Variable | Description | Default | Required |
|
||||||
|
|----------|-------------|---------|----------|
|
||||||
|
| `BASE_URL` | Target server URL | `http://localhost:3000` | Yes |
|
||||||
|
| `ADMIN_EMAIL` | Admin user email | - | For admin tests |
|
||||||
|
| `ADMIN_PASSWORD` | Admin user password | - | For admin tests |
|
||||||
|
| `VUS` | Number of virtual users | Varies per test | No |
|
||||||
|
| `DURATION` | Test duration | Varies per test | No |
|
||||||
|
|
||||||
|
### Load Profiles
|
||||||
|
|
||||||
|
Each test uses different load profiles:
|
||||||
|
|
||||||
|
| Test | Virtual Users | Duration | Ramp Up |
|
||||||
|
|------|--------------|----------|---------|
|
||||||
|
| Public Browsing | 100 | 2m | 30s |
|
||||||
|
| Admin Operations | 20 | 3m | 30s |
|
||||||
|
| API Performance | 50 | 5m | 1m |
|
||||||
|
|
||||||
|
## Running Tests
|
||||||
|
|
||||||
|
### Run Single Test
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Basic run
|
||||||
|
k6 run tests/k6/public-browsing.js
|
||||||
|
|
||||||
|
# With custom environment
|
||||||
|
k6 run --env BASE_URL=https://staging.enchun.tw tests/k6/public-browsing.js
|
||||||
|
|
||||||
|
# With custom stages
|
||||||
|
k6 run --env STAGED_USERS=200 --env STAGED_DURATION=10m tests/k6/public-browsing.js
|
||||||
|
```
|
||||||
|
|
||||||
|
### Run All Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Using the npm script
|
||||||
|
pnpm test:load
|
||||||
|
|
||||||
|
# Or manually
|
||||||
|
k6 run tests/k6/public-browsing.js
|
||||||
|
k6 run tests/k6/admin-operations.js
|
||||||
|
k6 run tests/k6/api-performance.js
|
||||||
|
```
|
||||||
|
|
||||||
|
### Run with Output Options
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Generate JSON report
|
||||||
|
k6 run --out json=results.json tests/k6/public-browsing.js
|
||||||
|
|
||||||
|
# Generate HTML report (requires k6-reporter)
|
||||||
|
k6 run --out json=results.json tests/k6/public-browsing.js
|
||||||
|
k6-reporter results.json --output results.html
|
||||||
|
```
|
||||||
|
|
||||||
|
## Interpreting Results
|
||||||
|
|
||||||
|
### Key Metrics
|
||||||
|
|
||||||
|
1. **Response Time (p95):** 95th percentile response time
|
||||||
|
- ✅ Pass: < 500ms
|
||||||
|
- ❌ Fail: >= 500ms
|
||||||
|
|
||||||
|
2. **Error Rate:** Percentage of failed requests
|
||||||
|
- ✅ Pass: < 1%
|
||||||
|
- ❌ Fail: >= 1%
|
||||||
|
|
||||||
|
3. **Throughput:** Requests per second
|
||||||
|
- ✅ Pass: > 100 req/s for API, > 50 req/s for pages
|
||||||
|
- ❌ Fail: Below threshold
|
||||||
|
|
||||||
|
4. **Virtual Users (VUs):** Active concurrent users
|
||||||
|
- Should sustain target VUs for the full duration
|
||||||
|
|
||||||
|
### Example Output
|
||||||
|
|
||||||
|
```
|
||||||
|
✓ Status is 200
|
||||||
|
✓ Response time < 500ms
|
||||||
|
✓ Page content loaded
|
||||||
|
|
||||||
|
checks.........................: 100.0% ✓ 12000 ✗ 0
|
||||||
|
data_received..................: 15 MB 125 kB/s
|
||||||
|
data_sent......................: 2.1 MB 18 kB/s
|
||||||
|
http_req_blocked...............: avg=1.2ms min=0.5µs med=1µs max=125ms
|
||||||
|
http_req_connecting............: avg=500µs min=0s med=0s max=45ms
|
||||||
|
http_req_duration..............: avg=185.3ms min=45ms med=150ms max=850ms p(95)=420ms
|
||||||
|
http_req_failed................: 0.00% ✓ 0 ✗ 12000
|
||||||
|
http_req_receiving.............: avg=15ms min=10µs med=50µs max=250ms
|
||||||
|
http_req_sending...............: avg=50µs min=5µs med=20µs max=5ms
|
||||||
|
http_req_tls_handshaking.......: avg=0s min=0s med=0s max=0s
|
||||||
|
http_req_waiting...............: avg=170ms min=40ms med=140ms max=800ms
|
||||||
|
http_reqs......................: 12000 100 req/s
|
||||||
|
iteration_duration.............: avg=5.2s min=1.2s med=5s max=12s
|
||||||
|
iterations.....................: 2400 20 /s
|
||||||
|
vus............................: 100 min=100 max=100
|
||||||
|
vus_max........................: 100 min=100 max=100
|
||||||
|
```
|
||||||
|
|
||||||
|
## CI/CD Integration
|
||||||
|
|
||||||
|
### GitHub Actions
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
name: Load Tests
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: '0 2 * * *' # Daily at 2 AM
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
k6:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
- uses: grafana/k6-action@v0.3.1
|
||||||
|
with:
|
||||||
|
filename: tests/k6/public-browsing.js
|
||||||
|
env:
|
||||||
|
BASE_URL: ${{ secrets.STAGING_URL }}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Connection Refused
|
||||||
|
|
||||||
|
**Error:** `connect attempt failed`
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
- Ensure the backend server is running
|
||||||
|
- Check `BASE_URL` is correct
|
||||||
|
- Verify firewall settings
|
||||||
|
|
||||||
|
### High Error Rate
|
||||||
|
|
||||||
|
**Error:** `http_req_failed > 1%`
|
||||||
|
|
||||||
|
**Possible Causes:**
|
||||||
|
1. Server overload (reduce VUs)
|
||||||
|
2. Database connection issues
|
||||||
|
3. Authentication failures
|
||||||
|
4. Network issues
|
||||||
|
|
||||||
|
**Debug:**
|
||||||
|
```bash
|
||||||
|
# Run with fewer users
|
||||||
|
k6 run --env STAGED_USERS=10 tests/k6/public-browsing.js
|
||||||
|
|
||||||
|
# Check server logs
|
||||||
|
pnpm dev
|
||||||
|
# In another terminal, watch logs while tests run
|
||||||
|
```
|
||||||
|
|
||||||
|
### Slow Response Times
|
||||||
|
|
||||||
|
**Error:** `p(95) >= 500ms`
|
||||||
|
|
||||||
|
**Possible Causes:**
|
||||||
|
1. Unoptimized database queries
|
||||||
|
2. Missing indexes
|
||||||
|
3. Large payloads
|
||||||
|
4. Server resource constraints
|
||||||
|
|
||||||
|
**Next Steps:**
|
||||||
|
1. Review database queries
|
||||||
|
2. Check caching strategies
|
||||||
|
3. Optimize images/assets
|
||||||
|
4. Scale server resources
|
||||||
|
|
||||||
|
## Performance Baseline
|
||||||
|
|
||||||
|
Initial baseline (to be established):
|
||||||
|
|
||||||
|
| Test | p95 (ms) | Error Rate | Throughput |
|
||||||
|
|------|----------|------------|------------|
|
||||||
|
| Public Browsing | TBD | TBD | TBD |
|
||||||
|
| Admin Operations | TBD | TBD | TBD |
|
||||||
|
| API Performance | TBD | TBD | TBD |
|
||||||
|
|
||||||
|
Update this table after first test run to establish baseline.
|
||||||
|
|
||||||
|
## Resources
|
||||||
|
|
||||||
|
- [k6 Documentation](https://k6.io/docs/)
|
||||||
|
- [k6 Metrics](https://k6.io/docs/using-k6/metrics/)
|
||||||
|
- [Payload CMS Performance](https://payloadcms.com/docs/admin/configuration)
|
||||||
|
- [Web Vitals](https://web.dev/vitals/)
|
||||||
|
|
||||||
|
## Maintenance
|
||||||
|
|
||||||
|
- Review and update test scenarios quarterly
|
||||||
|
- Adjust load profiles based on real traffic patterns
|
||||||
|
- Update thresholds based on business requirements
|
||||||
|
- Add new tests for new features
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Last Updated:** 2025-01-31
|
||||||
|
**Owner:** QA Team
|
||||||
364
apps/backend/tests/k6/TESTING-GUIDE.md
Normal file
364
apps/backend/tests/k6/TESTING-GUIDE.md
Normal file
@@ -0,0 +1,364 @@
|
|||||||
|
# Load Testing Execution Guide
|
||||||
|
|
||||||
|
## Test Execution Checklist
|
||||||
|
|
||||||
|
### Pre-Test Requirements
|
||||||
|
|
||||||
|
- [ ] Backend server is running (`pnpm dev`)
|
||||||
|
- [ ] Database is accessible
|
||||||
|
- [ ] k6 is installed (`k6 version`)
|
||||||
|
- [ ] Environment variables are configured
|
||||||
|
|
||||||
|
### Verification Test
|
||||||
|
|
||||||
|
Run the verification script first to ensure everything is set up correctly:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
k6 run tests/k6/verify-setup.js
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expected Output:**
|
||||||
|
```
|
||||||
|
=== K6 Setup Verification ===
|
||||||
|
Target: http://localhost:3000
|
||||||
|
Home page status: 200
|
||||||
|
API status: 200
|
||||||
|
Pages API response time: 123ms
|
||||||
|
✓ Server is reachable
|
||||||
|
✓ Home page responds
|
||||||
|
✓ API endpoint responds
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test Scenarios
|
||||||
|
|
||||||
|
### 1. Public Browsing Test
|
||||||
|
|
||||||
|
**Purpose:** Verify public pages can handle 100 concurrent users
|
||||||
|
|
||||||
|
**Prerequisites:**
|
||||||
|
- Backend running
|
||||||
|
- Public pages accessible
|
||||||
|
|
||||||
|
**Execution:**
|
||||||
|
```bash
|
||||||
|
# Local testing
|
||||||
|
pnpm test:load
|
||||||
|
|
||||||
|
# With custom URL
|
||||||
|
k6 run --env BASE_URL=http://localhost:3000 tests/k6/public-browsing.js
|
||||||
|
|
||||||
|
# Staging
|
||||||
|
k6 run --env BASE_URL=https://staging.enchun.tw tests/k6/public-browsing.js
|
||||||
|
```
|
||||||
|
|
||||||
|
**Success Criteria:**
|
||||||
|
- p95 response time < 500ms
|
||||||
|
- Error rate < 1%
|
||||||
|
- 100 concurrent users sustained for 2 minutes
|
||||||
|
|
||||||
|
**What It Tests:**
|
||||||
|
- Homepage rendering
|
||||||
|
- Page navigation
|
||||||
|
- Static content delivery
|
||||||
|
- Database read operations
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. API Performance Test
|
||||||
|
|
||||||
|
**Purpose:** Verify API endpoints meet performance targets
|
||||||
|
|
||||||
|
**Prerequisites:**
|
||||||
|
- Backend running
|
||||||
|
- API endpoints accessible
|
||||||
|
|
||||||
|
**Execution:**
|
||||||
|
```bash
|
||||||
|
# Local testing
|
||||||
|
pnpm test:load:api
|
||||||
|
|
||||||
|
# With custom URL
|
||||||
|
k6 run --env BASE_URL=http://localhost:3000 tests/k6/api-performance.js
|
||||||
|
|
||||||
|
# Staging
|
||||||
|
k6 run --env BASE_URL=https://staging.enchun.tw tests/k6/api-performance.js
|
||||||
|
```
|
||||||
|
|
||||||
|
**Success Criteria:**
|
||||||
|
- p95 response time < 300ms
|
||||||
|
- Error rate < 0.5%
|
||||||
|
- Throughput > 100 req/s
|
||||||
|
|
||||||
|
**What It Tests:**
|
||||||
|
- REST API endpoints
|
||||||
|
- GraphQL queries
|
||||||
|
- Authentication endpoints
|
||||||
|
- Concurrent API requests
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. Admin Operations Test
|
||||||
|
|
||||||
|
**Purpose:** Verify admin panel can handle 20 concurrent users
|
||||||
|
|
||||||
|
**Prerequisites:**
|
||||||
|
- Backend running
|
||||||
|
- Valid admin credentials
|
||||||
|
|
||||||
|
**Execution:**
|
||||||
|
```bash
|
||||||
|
# Local testing
|
||||||
|
k6 run \
|
||||||
|
--env ADMIN_EMAIL=admin@enchun.tw \
|
||||||
|
--env ADMIN_PASSWORD=yourpassword \
|
||||||
|
tests/k6/admin-operations.js
|
||||||
|
|
||||||
|
# Or use npm script
|
||||||
|
ADMIN_EMAIL=admin@enchun.tw ADMIN_PASSWORD=yourpassword \
|
||||||
|
pnpm test:load:admin
|
||||||
|
```
|
||||||
|
|
||||||
|
**Success Criteria:**
|
||||||
|
- p95 response time < 700ms
|
||||||
|
- Error rate < 1%
|
||||||
|
- 20 concurrent users sustained for 3 minutes
|
||||||
|
|
||||||
|
**What It Tests:**
|
||||||
|
- Login/authentication
|
||||||
|
- CRUD operations
|
||||||
|
- Admin panel performance
|
||||||
|
- Database write operations
|
||||||
|
|
||||||
|
**Warning:** This test creates draft posts in the database. Clean up manually after testing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test Execution Strategy
|
||||||
|
|
||||||
|
### Phase 1: Development Testing
|
||||||
|
|
||||||
|
Run tests locally during development with low load:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Quick smoke test (10 users)
|
||||||
|
k6 run --env STAGED_USERS=10 tests/k6/public-browsing.js
|
||||||
|
|
||||||
|
# API smoke test (5 users)
|
||||||
|
k6 run --env STAGED_USERS=5 tests/k6/api-performance.js
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase 2: Pre-Deployment Testing
|
||||||
|
|
||||||
|
Run full test suite against staging:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run all tests
|
||||||
|
pnpm test:load:all
|
||||||
|
|
||||||
|
# Or individual tests with full load
|
||||||
|
k6 run --env BASE_URL=https://staging.enchun.tw tests/k6/public-browsing.js
|
||||||
|
k6 run --env BASE_URL=https://staging.enchun.tw tests/k6/api-performance.js
|
||||||
|
k6 run --env BASE_URL=https://staging.enchun.tw \
|
||||||
|
--env ADMIN_EMAIL=$STAGING_ADMIN_EMAIL \
|
||||||
|
--env ADMIN_PASSWORD=$STAGING_ADMIN_PASSWORD \
|
||||||
|
tests/k6/admin-operations.js
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase 3: Production Monitoring
|
||||||
|
|
||||||
|
Schedule automated tests (via GitHub Actions or cron):
|
||||||
|
|
||||||
|
- Daily: Public browsing and API tests
|
||||||
|
- Weekly: Full test suite including admin operations
|
||||||
|
- On-demand: Before major releases
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Result Analysis
|
||||||
|
|
||||||
|
### Key Metrics to Check
|
||||||
|
|
||||||
|
1. **p95 Response Time**
|
||||||
|
- Public pages: < 500ms ✅
|
||||||
|
- API endpoints: < 300ms ✅
|
||||||
|
- Admin operations: < 700ms ✅
|
||||||
|
|
||||||
|
2. **Error Rate**
|
||||||
|
- Public: < 1% ✅
|
||||||
|
- API: < 0.5% ✅
|
||||||
|
- Admin: < 1% ✅
|
||||||
|
|
||||||
|
3. **Throughput**
|
||||||
|
- API: > 100 req/s ✅
|
||||||
|
- Pages: > 50 req/s ✅
|
||||||
|
|
||||||
|
4. **Virtual Users (VUs)**
|
||||||
|
- Should sustain target VUs for full duration
|
||||||
|
- No drops or connection errors
|
||||||
|
|
||||||
|
### Sample Output Analysis
|
||||||
|
|
||||||
|
```
|
||||||
|
✓ http_req_duration..............: avg=185ms p(95)=420ms
|
||||||
|
✓ http_req_failed................: 0.00% ✓ 0 ✗ 12000
|
||||||
|
✓ checks.........................: 100.0% ✓ 12000 ✗ 0
|
||||||
|
iterations.....................: 2400 20 /s
|
||||||
|
vus............................: 100 min=100 max=100
|
||||||
|
```
|
||||||
|
|
||||||
|
**This result shows:**
|
||||||
|
- ✅ p95 = 420ms (< 500ms threshold)
|
||||||
|
- ✅ Error rate = 0% (< 1% threshold)
|
||||||
|
- ✅ All checks passed
|
||||||
|
- ✅ Sustained 100 VUs
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting Guide
|
||||||
|
|
||||||
|
### Issue: Connection Refused
|
||||||
|
|
||||||
|
**Symptoms:**
|
||||||
|
```
|
||||||
|
ERRO[0000] GoError: dial tcp 127.0.0.1:3000: connect: connection refused
|
||||||
|
```
|
||||||
|
|
||||||
|
**Solutions:**
|
||||||
|
1. Ensure backend is running: `pnpm dev`
|
||||||
|
2. Check port is correct: `--env BASE_URL=http://localhost:3000`
|
||||||
|
3. Verify firewall isn't blocking connections
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Issue: High Error Rate (> 1%)
|
||||||
|
|
||||||
|
**Symptoms:**
|
||||||
|
```
|
||||||
|
✗ http_req_failed................: 2.50% ✓ 11700 ✗ 300
|
||||||
|
```
|
||||||
|
|
||||||
|
**Common Causes:**
|
||||||
|
1. Server overloaded → Reduce VUs
|
||||||
|
2. Database connection issues → Check DB logs
|
||||||
|
3. Missing authentication → Check credentials
|
||||||
|
4. Invalid URLs → Verify BASE_URL
|
||||||
|
|
||||||
|
**Solutions:**
|
||||||
|
```bash
|
||||||
|
# Reduce load to diagnose
|
||||||
|
k6 run --env STAGED_USERS=10 tests/k6/public-browsing.js
|
||||||
|
|
||||||
|
# Check server logs in parallel
|
||||||
|
tail -f logs/backend.log
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Issue: Slow Response Times (p95 >= 500ms)
|
||||||
|
|
||||||
|
**Symptoms:**
|
||||||
|
```
|
||||||
|
http_req_duration..............: avg=450ms p(95)=850ms
|
||||||
|
```
|
||||||
|
|
||||||
|
**Common Causes:**
|
||||||
|
1. Unoptimized database queries
|
||||||
|
2. Missing database indexes
|
||||||
|
3. Large payload sizes
|
||||||
|
4. Server resource constraints
|
||||||
|
|
||||||
|
**Solutions:**
|
||||||
|
1. Check database query performance
|
||||||
|
2. Review database indexes
|
||||||
|
3. Optimize images/assets
|
||||||
|
4. Scale server resources
|
||||||
|
5. Enable caching
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Issue: Login Failed (Admin Test)
|
||||||
|
|
||||||
|
**Symptoms:**
|
||||||
|
```
|
||||||
|
✗ login successful
|
||||||
|
status: 401
|
||||||
|
```
|
||||||
|
|
||||||
|
**Solutions:**
|
||||||
|
1. Verify admin credentials are correct
|
||||||
|
2. Check admin user exists in database
|
||||||
|
3. Ensure admin user has correct role
|
||||||
|
4. Try logging in via admin panel first
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Performance Optimization Tips
|
||||||
|
|
||||||
|
Based on test results, you may need to:
|
||||||
|
|
||||||
|
1. **Add Database Indexes**
|
||||||
|
```javascript
|
||||||
|
// Example: Index on frequently queried fields
|
||||||
|
Posts.createIndex({ title: 1 });
|
||||||
|
Posts.createIndex({ status: 1, createdAt: -1 });
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Enable Caching**
|
||||||
|
- Cache global API responses
|
||||||
|
- Cache public pages
|
||||||
|
- Use CDN for static assets
|
||||||
|
|
||||||
|
3. **Optimize Images**
|
||||||
|
- Use WebP format
|
||||||
|
- Implement lazy loading
|
||||||
|
- Serve responsive images
|
||||||
|
|
||||||
|
4. **Database Optimization**
|
||||||
|
- Limit query depth where possible
|
||||||
|
- Use projection to reduce payload size
|
||||||
|
- Implement pagination
|
||||||
|
|
||||||
|
5. **Scale Resources**
|
||||||
|
- Increase server memory/CPU
|
||||||
|
- Use database connection pooling
|
||||||
|
- Implement load balancing
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Reporting
|
||||||
|
|
||||||
|
### Generate Reports
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# JSON report
|
||||||
|
k6 run --out json=results.json tests/k6/public-browsing.js
|
||||||
|
|
||||||
|
# HTML report
|
||||||
|
npm install -g k6-reporter
|
||||||
|
k6-reporter results.json --output results.html
|
||||||
|
open results.html
|
||||||
|
```
|
||||||
|
|
||||||
|
### Share Results
|
||||||
|
|
||||||
|
Include in your PR/daily standup:
|
||||||
|
- p95 response time
|
||||||
|
- Error rate
|
||||||
|
- Throughput
|
||||||
|
- Any issues found
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
1. **Run tests regularly** - Catch regressions early
|
||||||
|
2. **Test on staging first** - Avoid breaking production
|
||||||
|
3. **Monitor trends** - Track performance over time
|
||||||
|
4. **Share results** - Keep team informed
|
||||||
|
5. **Update baselines** - Adjust as system evolves
|
||||||
|
6. **Don't ignore failures** - Investigate all issues
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Last Updated:** 2025-01-31
|
||||||
|
**Owner:** QA Team
|
||||||
232
apps/backend/tests/k6/admin-operations.js
Normal file
232
apps/backend/tests/k6/admin-operations.js
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
/**
|
||||||
|
* Admin Operations Load Test
|
||||||
|
*
|
||||||
|
* Simulates 20 concurrent admin users performing management operations
|
||||||
|
* Tests:
|
||||||
|
* - Login
|
||||||
|
* - List collections (Pages, Posts, Portfolio)
|
||||||
|
* - View/edit items
|
||||||
|
* - Create new items
|
||||||
|
* - Delete items
|
||||||
|
*
|
||||||
|
* NFR4 Requirements:
|
||||||
|
* - p95 response time < 700ms (slightly more lenient for admin)
|
||||||
|
* - Error rate < 1%
|
||||||
|
* - 20 concurrent users sustained for 3 minutes
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { check, group } from 'k6';
|
||||||
|
import { urls, thresholdGroups, config } from './lib/config.js';
|
||||||
|
import { AuthHelper, ApiHelper, thinkTime, testData } from './lib/helpers.js';
|
||||||
|
|
||||||
|
// Test configuration
|
||||||
|
export const options = {
|
||||||
|
stages: config.stages.adminOperations,
|
||||||
|
thresholds: thresholdGroups.admin,
|
||||||
|
...config.requestOptions,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Store auth token per VU
|
||||||
|
let authToken = null;
|
||||||
|
let apiHelper = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup and login - runs once per VU
|
||||||
|
*/
|
||||||
|
export function setup() {
|
||||||
|
console.log('=== Admin Operations Load Test ===');
|
||||||
|
console.log(`Target: ${config.baseUrl}`);
|
||||||
|
console.log(`Admin: ${config.adminEmail}`);
|
||||||
|
console.log('Starting test...');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Login function
|
||||||
|
*/
|
||||||
|
function login() {
|
||||||
|
const auth = new AuthHelper(config.baseUrl);
|
||||||
|
const { success } = auth.login(config.adminEmail, config.adminPassword);
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
console.error('Login failed!');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
apiHelper = new ApiHelper(config.baseUrl);
|
||||||
|
apiHelper.setToken(auth.token);
|
||||||
|
return auth.token;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main test scenario
|
||||||
|
*/
|
||||||
|
export default function () {
|
||||||
|
// Login if not authenticated
|
||||||
|
if (!authToken) {
|
||||||
|
group('Admin Login', () => {
|
||||||
|
authToken = login();
|
||||||
|
thinkTime(1, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!authToken) {
|
||||||
|
// Cannot proceed without auth
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scenario 1: Browse collections (read operations)
|
||||||
|
group('List Collections', () => {
|
||||||
|
// List pages
|
||||||
|
apiHelper.get('/pages', { limit: 10, depth: 1 });
|
||||||
|
thinkTime(0.5, 1);
|
||||||
|
|
||||||
|
// List posts
|
||||||
|
apiHelper.get('/posts', { limit: 10, depth: 1 });
|
||||||
|
thinkTime(0.5, 1);
|
||||||
|
|
||||||
|
// List portfolio
|
||||||
|
apiHelper.get('/portfolio', { limit: 10, depth: 1 });
|
||||||
|
thinkTime(0.5, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Scenario 2: View specific items (read operations)
|
||||||
|
group('View Items', () => {
|
||||||
|
// Try to view first item from each collection
|
||||||
|
try {
|
||||||
|
const pages = apiHelper.get('/pages', { limit: 1, depth: 0 });
|
||||||
|
if (pages.status === 200 && pages.json('totalDocs') > 0) {
|
||||||
|
const firstId = pages.json('docs')[0].id;
|
||||||
|
apiHelper.get(`/pages/${firstId}`);
|
||||||
|
thinkTime(1, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
const posts = apiHelper.get('/posts', { limit: 1, depth: 0 });
|
||||||
|
if (posts.status === 200 && posts.json('totalDocs') > 0) {
|
||||||
|
const firstId = posts.json('docs')[0].id;
|
||||||
|
apiHelper.get(`/posts/${firstId}`);
|
||||||
|
thinkTime(1, 2);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Items might not exist
|
||||||
|
console.log('No items to view:', e.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Scenario 3: Create new content (write operations)
|
||||||
|
group('Create Content', () => {
|
||||||
|
// 20% chance to create a test post
|
||||||
|
if (Math.random() < 0.2) {
|
||||||
|
const newPost = {
|
||||||
|
title: `Load Test Post ${testData.string(6)}`,
|
||||||
|
content: testData.content(2),
|
||||||
|
status: 'draft', // Save as draft to avoid publishing
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = apiHelper.post('/posts', newPost);
|
||||||
|
|
||||||
|
if (res.status === 201 || res.status === 200) {
|
||||||
|
const postId = res.json('doc')?.id;
|
||||||
|
|
||||||
|
// Store for potential cleanup (in real scenario)
|
||||||
|
if (postId) {
|
||||||
|
console.log(`Created post: ${postId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
thinkTime(2, 3);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Scenario 4: Update content (write operations)
|
||||||
|
group('Update Content', () => {
|
||||||
|
// 30% chance to update a post
|
||||||
|
if (Math.random() < 0.3) {
|
||||||
|
try {
|
||||||
|
// Get a random post
|
||||||
|
const posts = apiHelper.get('/posts', { limit: 20, depth: 0, where: { status: { equals: 'draft' } } });
|
||||||
|
|
||||||
|
if (posts.status === 200 && posts.json('totalDocs') > 0) {
|
||||||
|
const docs = posts.json('docs');
|
||||||
|
const randomPost = docs[Math.floor(Math.random() * docs.length)];
|
||||||
|
const postId = randomPost.id;
|
||||||
|
|
||||||
|
// Update the post
|
||||||
|
const updateData = {
|
||||||
|
title: `Updated ${randomPost.title}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
apiHelper.put(`/posts/${postId}`, updateData);
|
||||||
|
thinkTime(1, 2);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log('Update failed:', e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Scenario 5: Delete test content (cleanup operations)
|
||||||
|
group('Delete Content', () => {
|
||||||
|
// 10% chance to delete a draft post
|
||||||
|
if (Math.random() < 0.1) {
|
||||||
|
try {
|
||||||
|
// Get draft posts (likely from load test)
|
||||||
|
const posts = apiHelper.get('/posts', {
|
||||||
|
limit: 10,
|
||||||
|
depth: 0,
|
||||||
|
where: {
|
||||||
|
status: { equals: 'draft' },
|
||||||
|
title: { like: 'Load Test Post' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (posts.status === 200 && posts.json('totalDocs') > 0) {
|
||||||
|
const docs = posts.json('docs');
|
||||||
|
const randomPost = docs[Math.floor(Math.random() * docs.length)];
|
||||||
|
const postId = randomPost.id;
|
||||||
|
|
||||||
|
// Delete the post
|
||||||
|
apiHelper.delete(`/posts/${postId}`);
|
||||||
|
thinkTime(1, 2);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log('Delete failed:', e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Scenario 6: Use GraphQL API
|
||||||
|
group('GraphQL Operations', () => {
|
||||||
|
// 40% chance to use GraphQL
|
||||||
|
if (Math.random() < 0.4) {
|
||||||
|
const query = `
|
||||||
|
query {
|
||||||
|
Posts(limit: 5) {
|
||||||
|
docs {
|
||||||
|
id
|
||||||
|
title
|
||||||
|
status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
apiHelper.graphql(query);
|
||||||
|
thinkTime(1, 2);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Think time before next iteration
|
||||||
|
thinkTime(3, 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Teardown function - runs once after test
|
||||||
|
*/
|
||||||
|
export function teardown(data) {
|
||||||
|
console.log('=== Admin Test Complete ===');
|
||||||
|
console.log('Check results above for:');
|
||||||
|
console.log('- p95 response time < 700ms');
|
||||||
|
console.log('- Error rate < 1%');
|
||||||
|
console.log('- 20 concurrent admin users sustained');
|
||||||
|
console.log('Note: Any draft posts created were left in the system for manual review');
|
||||||
|
}
|
||||||
230
apps/backend/tests/k6/api-performance.js
Normal file
230
apps/backend/tests/k6/api-performance.js
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
/**
|
||||||
|
* API Performance Load Test
|
||||||
|
*
|
||||||
|
* Tests specific API endpoints for performance
|
||||||
|
* Tests:
|
||||||
|
* - REST API endpoints (Pages, Posts, Portfolio, Categories)
|
||||||
|
* - GraphQL API queries
|
||||||
|
* - Global API endpoint
|
||||||
|
* - Authentication endpoints
|
||||||
|
*
|
||||||
|
* NFR4 Requirements:
|
||||||
|
* - p95 response time < 300ms (faster for API)
|
||||||
|
* - Error rate < 0.5% (stricter for API)
|
||||||
|
* - Throughput > 100 requests/second
|
||||||
|
*/
|
||||||
|
|
||||||
|
import http from 'k6/http';
|
||||||
|
import { check, group } from 'k6';
|
||||||
|
import { urls, thresholdGroups, config } from './lib/config.js';
|
||||||
|
import { ApiHelper, testData, thinkTime } from './lib/helpers.js';
|
||||||
|
|
||||||
|
// Test configuration
|
||||||
|
export const options = {
|
||||||
|
stages: config.stages.apiPerformance,
|
||||||
|
thresholds: thresholdGroups.api,
|
||||||
|
...config.requestOptions,
|
||||||
|
};
|
||||||
|
|
||||||
|
// API helper (no auth needed for public endpoints)
|
||||||
|
const apiHelper = new ApiHelper(config.baseUrl);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup function
|
||||||
|
*/
|
||||||
|
export function setup() {
|
||||||
|
console.log('=== API Performance Load Test ===');
|
||||||
|
console.log(`Target: ${config.baseUrl}`);
|
||||||
|
console.log('Starting test...');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main test scenario
|
||||||
|
*/
|
||||||
|
export default function () {
|
||||||
|
// Scenario 1: Global API endpoint (metadata)
|
||||||
|
group('Global API', () => {
|
||||||
|
const res = apiHelper.get('/global');
|
||||||
|
|
||||||
|
check(res, {
|
||||||
|
'has global data': (r) => {
|
||||||
|
try {
|
||||||
|
const body = r.json();
|
||||||
|
return body !== null;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
thinkTime(0.1, 0.3); // Minimal think time for API
|
||||||
|
});
|
||||||
|
|
||||||
|
// Scenario 2: Pages API
|
||||||
|
group('Pages API', () => {
|
||||||
|
// List pages
|
||||||
|
apiHelper.get('/pages', { limit: 10, depth: 1 });
|
||||||
|
|
||||||
|
// Try to get a specific page
|
||||||
|
try {
|
||||||
|
const list = apiHelper.get('/pages', { limit: 1, depth: 0, page: 1 });
|
||||||
|
if (list.status === 200 && list.json('totalDocs') > 0) {
|
||||||
|
const firstId = list.json('docs')[0].id;
|
||||||
|
apiHelper.get(`/pages/${firstId}`, { depth: 1 });
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Page might not exist
|
||||||
|
}
|
||||||
|
|
||||||
|
thinkTime(0.1, 0.3);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Scenario 3: Posts API
|
||||||
|
group('Posts API', () => {
|
||||||
|
// List posts
|
||||||
|
apiHelper.get('/posts', { limit: 10, depth: 1 });
|
||||||
|
|
||||||
|
// List with pagination
|
||||||
|
apiHelper.get('/posts', { limit: 20, depth: 0, page: 1 });
|
||||||
|
|
||||||
|
thinkTime(0.1, 0.3);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Scenario 4: Portfolio API
|
||||||
|
group('Portfolio API', () => {
|
||||||
|
// List portfolio items
|
||||||
|
apiHelper.get('/portfolio', { limit: 10, depth: 1 });
|
||||||
|
|
||||||
|
// Filter by category (if applicable)
|
||||||
|
try {
|
||||||
|
const categories = apiHelper.get('/categories', { limit: 1, depth: 0 });
|
||||||
|
if (categories.status === 200 && categories.json('totalDocs') > 0) {
|
||||||
|
const categoryId = categories.json('docs')[0].id;
|
||||||
|
apiHelper.get('/portfolio', {
|
||||||
|
limit: 10,
|
||||||
|
depth: 1,
|
||||||
|
where: {
|
||||||
|
category: { equals: categoryId },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Categories might not exist
|
||||||
|
}
|
||||||
|
|
||||||
|
thinkTime(0.1, 0.3);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Scenario 5: Categories API
|
||||||
|
group('Categories API', () => {
|
||||||
|
// List all categories
|
||||||
|
apiHelper.get('/categories', { limit: 10, depth: 1 });
|
||||||
|
|
||||||
|
thinkTime(0.1, 0.3);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Scenario 6: GraphQL API
|
||||||
|
group('GraphQL API', () => {
|
||||||
|
// Query 1: Simple list
|
||||||
|
const simpleQuery = `
|
||||||
|
query {
|
||||||
|
Posts(limit: 5) {
|
||||||
|
docs {
|
||||||
|
id
|
||||||
|
title
|
||||||
|
slug
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
apiHelper.graphql(simpleQuery);
|
||||||
|
|
||||||
|
thinkTime(0.1, 0.2);
|
||||||
|
|
||||||
|
// Query 2: With relationships
|
||||||
|
const complexQuery = `
|
||||||
|
query {
|
||||||
|
Posts(limit: 3, depth: 2) {
|
||||||
|
docs {
|
||||||
|
id
|
||||||
|
title
|
||||||
|
content
|
||||||
|
category {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
apiHelper.graphql(complexQuery);
|
||||||
|
|
||||||
|
thinkTime(0.1, 0.3);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Scenario 7: Authentication endpoints
|
||||||
|
group('Auth API', () => {
|
||||||
|
// Note: These will fail with invalid credentials, but test the endpoint response
|
||||||
|
const loginRes = http.post(`${config.baseUrl}/api/users/login`, JSON.stringify({
|
||||||
|
email: 'test@example.com',
|
||||||
|
password: 'wrongpassword',
|
||||||
|
}), {
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
|
||||||
|
check(loginRes, {
|
||||||
|
'login responds': (r) => [200, 400, 401].includes(r.status),
|
||||||
|
'login responds quickly': (r) => r.timings.duration < 500,
|
||||||
|
});
|
||||||
|
|
||||||
|
thinkTime(0.1, 0.2);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Scenario 8: Concurrent API requests
|
||||||
|
group('Concurrent Requests', () => {
|
||||||
|
// Simulate multiple API calls in parallel
|
||||||
|
const requests = [
|
||||||
|
apiHelper.get('/pages', { limit: 5, depth: 1 }),
|
||||||
|
apiHelper.get('/posts', { limit: 5, depth: 1 }),
|
||||||
|
apiHelper.get('/portfolio', { limit: 5, depth: 1 }),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Check all succeeded
|
||||||
|
const allSuccessful = requests.every(r => r.status === 200);
|
||||||
|
|
||||||
|
check(null, {
|
||||||
|
'all concurrent requests successful': () => allSuccessful,
|
||||||
|
});
|
||||||
|
|
||||||
|
thinkTime(0.2, 0.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Scenario 9: Filtered queries
|
||||||
|
group('Filtered Queries', () => {
|
||||||
|
// Various filter combinations
|
||||||
|
const filters = [
|
||||||
|
{ where: { status: { equals: 'published' } } },
|
||||||
|
{ limit: 5, sort: '-createdAt' },
|
||||||
|
{ limit: 10, depth: 2 },
|
||||||
|
];
|
||||||
|
|
||||||
|
filters.forEach((filter, i) => {
|
||||||
|
apiHelper.get('/posts', filter);
|
||||||
|
thinkTime(0.05, 0.15);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Minimal think time for API-focused test
|
||||||
|
thinkTime(0.5, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Teardown function
|
||||||
|
*/
|
||||||
|
export function teardown(data) {
|
||||||
|
console.log('=== API Performance Test Complete ===');
|
||||||
|
console.log('Check results above for:');
|
||||||
|
console.log('- p95 response time < 300ms');
|
||||||
|
console.log('- Error rate < 0.5%');
|
||||||
|
console.log('- Throughput > 100 req/s');
|
||||||
|
}
|
||||||
195
apps/backend/tests/k6/lib/config.js
Normal file
195
apps/backend/tests/k6/lib/config.js
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
/**
|
||||||
|
* K6 Test Configuration
|
||||||
|
* Centralized configuration for all load tests
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Get environment variables with defaults
|
||||||
|
export const config = {
|
||||||
|
// Base URL for the target server
|
||||||
|
get baseUrl() {
|
||||||
|
return __ENV.BASE_URL || 'http://localhost:3000';
|
||||||
|
},
|
||||||
|
|
||||||
|
// Admin credentials (from environment)
|
||||||
|
get adminEmail() {
|
||||||
|
return __ENV.ADMIN_EMAIL || 'admin@enchun.tw';
|
||||||
|
},
|
||||||
|
|
||||||
|
get adminPassword() {
|
||||||
|
return __ENV.ADMIN_PASSWORD || 'admin123';
|
||||||
|
},
|
||||||
|
|
||||||
|
// Load testing thresholds (NFR4 requirements)
|
||||||
|
thresholds: {
|
||||||
|
// Response time thresholds
|
||||||
|
httpReqDuration: ['p(95) < 500'], // 95th percentile < 500ms
|
||||||
|
httpReqDurationApi: ['p(95) < 300'], // API endpoints < 300ms
|
||||||
|
httpReqDurationAdmin: ['p(95) < 700'], // Admin operations < 700ms
|
||||||
|
|
||||||
|
// Error rate thresholds
|
||||||
|
httpReqFailed: ['rate < 0.01'], // < 1% error rate
|
||||||
|
httpReqFailedApi: ['rate < 0.005'], // API < 0.5% error rate
|
||||||
|
|
||||||
|
// Throughput thresholds
|
||||||
|
httpReqs: ['rate > 50'], // > 50 requests/second for pages
|
||||||
|
httpReqsApi: ['rate > 100'], // > 100 requests/second for API
|
||||||
|
|
||||||
|
// Check success rate
|
||||||
|
checks: ['rate > 0.99'], // 99% of checks should pass
|
||||||
|
},
|
||||||
|
|
||||||
|
// Stage configuration for gradual ramp-up
|
||||||
|
stages: {
|
||||||
|
// Public browsing: 100 users
|
||||||
|
publicBrowsing: [
|
||||||
|
{ duration: '30s', target: 20 }, // Ramp up to 20 users
|
||||||
|
{ duration: '30s', target: 50 }, // Ramp up to 50 users
|
||||||
|
{ duration: '1m', target: 100 }, // Ramp up to 100 users
|
||||||
|
{ duration: '2m', target: 100 }, // Stay at 100 users
|
||||||
|
{ duration: '30s', target: 0 }, // Ramp down
|
||||||
|
],
|
||||||
|
|
||||||
|
// Admin operations: 20 users
|
||||||
|
adminOperations: [
|
||||||
|
{ duration: '30s', target: 5 }, // Ramp up to 5 users
|
||||||
|
{ duration: '30s', target: 10 }, // Ramp up to 10 users
|
||||||
|
{ duration: '30s', target: 20 }, // Ramp up to 20 users
|
||||||
|
{ duration: '3m', target: 20 }, // Stay at 20 users
|
||||||
|
{ duration: '30s', target: 0 }, // Ramp down
|
||||||
|
],
|
||||||
|
|
||||||
|
// API performance: 50 users
|
||||||
|
apiPerformance: [
|
||||||
|
{ duration: '1m', target: 10 }, // Ramp up to 10 users
|
||||||
|
{ duration: '1m', target: 25 }, // Ramp up to 25 users
|
||||||
|
{ duration: '1m', target: 50 }, // Ramp up to 50 users
|
||||||
|
{ duration: '5m', target: 50 }, // Stay at 50 users
|
||||||
|
{ duration: '1m', target: 0 }, // Ramp down
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// Common request options
|
||||||
|
requestOptions: {
|
||||||
|
timeout: '30s', // Request timeout
|
||||||
|
maxRedirects: 10, // Maximum redirects to follow
|
||||||
|
discardResponseBodies: true, // Discard response bodies to save memory
|
||||||
|
},
|
||||||
|
|
||||||
|
// Common headers
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'k6-load-test',
|
||||||
|
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
||||||
|
'Accept-Language': 'en-US,en;q=0.9,zh-TW;q=0.8',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// URL helpers
|
||||||
|
export const urls = {
|
||||||
|
get baseUrl() {
|
||||||
|
return config.baseUrl;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Public pages
|
||||||
|
get home() {
|
||||||
|
return `${config.baseUrl}/`;
|
||||||
|
},
|
||||||
|
get about() {
|
||||||
|
return `${config.baseUrl}/about`;
|
||||||
|
},
|
||||||
|
get solutions() {
|
||||||
|
return `${config.baseUrl}/solutions`;
|
||||||
|
},
|
||||||
|
get portfolio() {
|
||||||
|
return `${config.baseUrl}/portfolio`;
|
||||||
|
},
|
||||||
|
get blog() {
|
||||||
|
return `${config.baseUrl}/blog`;
|
||||||
|
},
|
||||||
|
get contact() {
|
||||||
|
return `${config.baseUrl}/contact`;
|
||||||
|
},
|
||||||
|
|
||||||
|
// API endpoints
|
||||||
|
get api() {
|
||||||
|
return `${config.baseUrl}/api`;
|
||||||
|
},
|
||||||
|
get graphql() {
|
||||||
|
return `${config.baseUrl}/api/graphql`;
|
||||||
|
},
|
||||||
|
get global() {
|
||||||
|
return `${config.baseUrl}/api/global`;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Admin endpoints
|
||||||
|
get admin() {
|
||||||
|
return `${config.baseUrl}/admin`;
|
||||||
|
},
|
||||||
|
get collections() {
|
||||||
|
return `${config.baseUrl}/api/collections`;
|
||||||
|
},
|
||||||
|
get pages() {
|
||||||
|
return `${config.baseUrl}/api/pages`;
|
||||||
|
},
|
||||||
|
get posts() {
|
||||||
|
return `${config.baseUrl}/api/posts`;
|
||||||
|
},
|
||||||
|
get portfolioItems() {
|
||||||
|
return `${config.baseUrl}/api/portfolio`;
|
||||||
|
},
|
||||||
|
get categories() {
|
||||||
|
return `${config.baseUrl}/api/categories`;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Auth endpoints
|
||||||
|
get login() {
|
||||||
|
return `${config.baseUrl}/api/users/login`;
|
||||||
|
},
|
||||||
|
get logout() {
|
||||||
|
return `${config.baseUrl}/api/users/logout`;
|
||||||
|
},
|
||||||
|
get me() {
|
||||||
|
return `${config.baseUrl}/api/users/me`;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Common checks
|
||||||
|
export const checks = {
|
||||||
|
// HTTP status checks
|
||||||
|
status200: (res) => res.status === 200,
|
||||||
|
status201: (res) => res.status === 201,
|
||||||
|
status204: (res) => res.status === 204,
|
||||||
|
|
||||||
|
// Response time checks
|
||||||
|
responseTimeFast: (res) => res.timings.duration < 200,
|
||||||
|
responseTimeOk: (res) => res.timings.duration < 500,
|
||||||
|
responseTimeSlow: (res) => res.timings.duration < 1000,
|
||||||
|
|
||||||
|
// Content checks
|
||||||
|
hasContent: (res) => res.body.length > 0,
|
||||||
|
hasJson: (res) => res.headers['Content-Type'].includes('application/json'),
|
||||||
|
|
||||||
|
// Performance checks (NFR4)
|
||||||
|
nfr4ResponseTime: (res) => res.timings.duration < 500, // p95 < 500ms
|
||||||
|
nfr4ApiResponseTime: (res) => res.timings.duration < 300, // API < 300ms
|
||||||
|
};
|
||||||
|
|
||||||
|
// Threshold groups for different test types
|
||||||
|
export const thresholdGroups = {
|
||||||
|
public: {
|
||||||
|
http_req_duration: ['p(95) < 500', 'p(99) < 1000'],
|
||||||
|
http_req_failed: ['rate < 0.01'],
|
||||||
|
checks: ['rate > 0.99'],
|
||||||
|
},
|
||||||
|
|
||||||
|
admin: {
|
||||||
|
http_req_duration: ['p(95) < 700', 'p(99) < 1500'],
|
||||||
|
http_req_failed: ['rate < 0.01'],
|
||||||
|
checks: ['rate > 0.99'],
|
||||||
|
},
|
||||||
|
|
||||||
|
api: {
|
||||||
|
http_req_duration: ['p(95) < 300', 'p(99) < 500'],
|
||||||
|
http_req_failed: ['rate < 0.005'],
|
||||||
|
checks: ['rate > 0.995'],
|
||||||
|
},
|
||||||
|
};
|
||||||
405
apps/backend/tests/k6/lib/helpers.js
Normal file
405
apps/backend/tests/k6/lib/helpers.js
Normal file
@@ -0,0 +1,405 @@
|
|||||||
|
/**
|
||||||
|
* K6 Test Helpers
|
||||||
|
* Reusable helper functions for load tests
|
||||||
|
*/
|
||||||
|
|
||||||
|
import http from 'k6/http';
|
||||||
|
import { check } from 'k6';
|
||||||
|
import { urls, checks } from './config.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authentication helper
|
||||||
|
*/
|
||||||
|
export class AuthHelper {
|
||||||
|
constructor(baseUrl) {
|
||||||
|
this.baseUrl = baseUrl;
|
||||||
|
this.token = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Login and store token
|
||||||
|
*/
|
||||||
|
login(email, password) {
|
||||||
|
const res = http.post(`${this.baseUrl}/api/users/login`, JSON.stringify({
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
}), {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const success = check(res, {
|
||||||
|
'login successful': checks.status200,
|
||||||
|
'received token': (r) => r.json('token') !== undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
this.token = res.json('token');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { res, success };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get auth headers
|
||||||
|
*/
|
||||||
|
getAuthHeaders() {
|
||||||
|
if (!this.token) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
'Authorization': `Bearer ${this.token}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logout and clear token
|
||||||
|
*/
|
||||||
|
logout() {
|
||||||
|
const res = http.post(`${this.baseUrl}/api/users/logout`, null, {
|
||||||
|
headers: this.getAuthHeaders(),
|
||||||
|
});
|
||||||
|
|
||||||
|
this.token = null;
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Page load helper
|
||||||
|
*/
|
||||||
|
export class PageHelper {
|
||||||
|
constructor(baseUrl) {
|
||||||
|
this.baseUrl = baseUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load a page and check response
|
||||||
|
*/
|
||||||
|
loadPage(path) {
|
||||||
|
const url = path.startsWith('http') ? path : `${this.baseUrl}${path}`;
|
||||||
|
const res = http.get(url);
|
||||||
|
|
||||||
|
check(res, {
|
||||||
|
[`page loaded: ${path}`]: checks.status200,
|
||||||
|
'response time < 500ms': checks.nfr4ResponseTime,
|
||||||
|
'has content': checks.hasContent,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load multiple pages randomly
|
||||||
|
*/
|
||||||
|
loadRandomPages(pageList, count = 5) {
|
||||||
|
const pages = [];
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const randomPage = pageList[Math.floor(Math.random() * pageList.length)];
|
||||||
|
pages.push(this.loadPage(randomPage));
|
||||||
|
}
|
||||||
|
return pages;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API helper
|
||||||
|
*/
|
||||||
|
export class ApiHelper {
|
||||||
|
constructor(baseUrl) {
|
||||||
|
this.baseUrl = baseUrl;
|
||||||
|
this.token = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set auth token
|
||||||
|
*/
|
||||||
|
setToken(token) {
|
||||||
|
this.token = token;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get default headers
|
||||||
|
*/
|
||||||
|
getHeaders(additionalHeaders = {}) {
|
||||||
|
const headers = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...additionalHeaders,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (this.token) {
|
||||||
|
headers['Authorization'] = `Bearer ${this.token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make a GET request
|
||||||
|
*/
|
||||||
|
get(endpoint, params = {}) {
|
||||||
|
const url = new URL(`${this.baseUrl}${endpoint}`);
|
||||||
|
Object.keys(params).forEach(key => url.searchParams.append(key, params[key]));
|
||||||
|
|
||||||
|
const res = http.get(url.toString(), {
|
||||||
|
headers: this.getHeaders(),
|
||||||
|
});
|
||||||
|
|
||||||
|
check(res, {
|
||||||
|
[`GET ${endpoint} successful`]: checks.status200,
|
||||||
|
'API response time < 300ms': checks.nfr4ApiResponseTime,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make a POST request
|
||||||
|
*/
|
||||||
|
post(endpoint, data) {
|
||||||
|
const res = http.post(`${this.baseUrl}${endpoint}`, JSON.stringify(data), {
|
||||||
|
headers: this.getHeaders(),
|
||||||
|
});
|
||||||
|
|
||||||
|
check(res, {
|
||||||
|
[`POST ${endpoint} successful`]: (r) => [200, 201].includes(r.status),
|
||||||
|
'API response time < 300ms': checks.nfr4ApiResponseTime,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make a PUT request
|
||||||
|
*/
|
||||||
|
put(endpoint, data) {
|
||||||
|
const res = http.put(`${this.baseUrl}${endpoint}`, JSON.stringify(data), {
|
||||||
|
headers: this.getHeaders(),
|
||||||
|
});
|
||||||
|
|
||||||
|
check(res, {
|
||||||
|
[`PUT ${endpoint} successful`]: (r) => [200, 204].includes(r.status),
|
||||||
|
'API response time < 300ms': checks.nfr4ApiResponseTime,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make a DELETE request
|
||||||
|
*/
|
||||||
|
delete(endpoint) {
|
||||||
|
const res = http.del(`${this.baseUrl}${endpoint}`, null, {
|
||||||
|
headers: this.getHeaders(),
|
||||||
|
});
|
||||||
|
|
||||||
|
check(res, {
|
||||||
|
[`DELETE ${endpoint} successful`]: (r) => [200, 204].includes(r.status),
|
||||||
|
'API response time < 300ms': checks.nfr4ApiResponseTime,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GraphQL query helper
|
||||||
|
*/
|
||||||
|
graphql(query, variables = {}) {
|
||||||
|
const res = http.post(`${this.baseUrl}/api/graphql`, JSON.stringify({
|
||||||
|
query,
|
||||||
|
variables,
|
||||||
|
}), {
|
||||||
|
headers: this.getHeaders(),
|
||||||
|
});
|
||||||
|
|
||||||
|
check(res, {
|
||||||
|
'GraphQL successful': (r) => {
|
||||||
|
if (r.status !== 200) return false;
|
||||||
|
const body = r.json();
|
||||||
|
return !body.errors;
|
||||||
|
},
|
||||||
|
'GraphQL response time < 300ms': checks.nfr4ApiResponseTime,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Think time helper
|
||||||
|
* Simulates real user think time between actions
|
||||||
|
*/
|
||||||
|
export function thinkTime(min = 1, max = 3) {
|
||||||
|
sleep(Math.random() * (max - min) + min);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Random item picker
|
||||||
|
*/
|
||||||
|
export function pickRandom(array) {
|
||||||
|
return array[Math.floor(Math.random() * array.length)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Weighted random picker
|
||||||
|
*/
|
||||||
|
export function pickWeighted(items) {
|
||||||
|
const totalWeight = items.reduce((sum, item) => sum + item.weight, 0);
|
||||||
|
let random = Math.random() * totalWeight;
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
random -= item.weight;
|
||||||
|
if (random <= 0) {
|
||||||
|
return item.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return items[0].value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate random test data
|
||||||
|
*/
|
||||||
|
export const testData = {
|
||||||
|
// Random string
|
||||||
|
string(length = 10) {
|
||||||
|
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||||
|
let result = '';
|
||||||
|
for (let i = 0; i < length; i++) {
|
||||||
|
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Random email
|
||||||
|
email() {
|
||||||
|
return `test_${this.string(8)}@example.com`;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Random number
|
||||||
|
number(min = 0, max = 100) {
|
||||||
|
return Math.floor(Math.random() * (max - min + 1)) + min;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Random phone
|
||||||
|
phone() {
|
||||||
|
return `09${this.number(10000000, 99999999)}`;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Random title
|
||||||
|
title() {
|
||||||
|
const adjectives = ['Amazing', 'Awesome', 'Brilliant', 'Creative', 'Dynamic'];
|
||||||
|
const nouns = ['Project', 'Solution', 'Product', 'Service', 'Innovation'];
|
||||||
|
return `${pickRandom(adjectives)} ${pickRandom(nouns)}`;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Random content
|
||||||
|
content(paragraphs = 3) {
|
||||||
|
const sentences = [
|
||||||
|
'This is a test sentence for load testing purposes.',
|
||||||
|
'Load testing helps identify performance bottlenecks.',
|
||||||
|
'We ensure the system can handle expected traffic.',
|
||||||
|
'Performance is critical for user experience.',
|
||||||
|
'Testing under load reveals system behavior.',
|
||||||
|
];
|
||||||
|
|
||||||
|
let content = '';
|
||||||
|
for (let i = 0; i < paragraphs; i++) {
|
||||||
|
content += '\n\n';
|
||||||
|
for (let j = 0; j < 3; j++) {
|
||||||
|
content += pickRandom(sentences) + ' ';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return content.trim();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Metrics helper
|
||||||
|
*/
|
||||||
|
export class MetricsHelper {
|
||||||
|
constructor() {
|
||||||
|
this.metrics = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record a metric
|
||||||
|
*/
|
||||||
|
record(name, value) {
|
||||||
|
if (!this.metrics[name]) {
|
||||||
|
this.metrics[name] = {
|
||||||
|
count: 0,
|
||||||
|
sum: 0,
|
||||||
|
min: Infinity,
|
||||||
|
max: -Infinity,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const metric = this.metrics[name];
|
||||||
|
metric.count++;
|
||||||
|
metric.sum += value;
|
||||||
|
metric.min = Math.min(metric.min, value);
|
||||||
|
metric.max = Math.max(metric.max, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get metric statistics
|
||||||
|
*/
|
||||||
|
getStats(name) {
|
||||||
|
const metric = this.metrics[name];
|
||||||
|
if (!metric) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
count: metric.count,
|
||||||
|
sum: metric.sum,
|
||||||
|
avg: metric.sum / metric.count,
|
||||||
|
min: metric.min,
|
||||||
|
max: metric.max,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Print all metrics
|
||||||
|
*/
|
||||||
|
printAll() {
|
||||||
|
console.log('=== Custom Metrics ===');
|
||||||
|
Object.keys(this.metrics).forEach(name => {
|
||||||
|
const stats = this.getStats(name);
|
||||||
|
console.log(`${name}:`, stats);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scenario helper
|
||||||
|
* Define test scenarios with weights
|
||||||
|
*/
|
||||||
|
export class ScenarioHelper {
|
||||||
|
constructor() {
|
||||||
|
this.scenarios = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a scenario
|
||||||
|
*/
|
||||||
|
register(name, fn, weight = 1) {
|
||||||
|
this.scenarios[name] = { fn, weight };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a random scenario based on weights
|
||||||
|
*/
|
||||||
|
execute() {
|
||||||
|
const scenarios = Object.entries(this.scenarios).map(([name, { fn, weight }]) => ({
|
||||||
|
value: fn,
|
||||||
|
weight,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const scenario = pickWeighted(scenarios);
|
||||||
|
scenario();
|
||||||
|
}
|
||||||
|
}
|
||||||
119
apps/backend/tests/k6/public-browsing.js
Normal file
119
apps/backend/tests/k6/public-browsing.js
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
/**
|
||||||
|
* Public Browsing Load Test
|
||||||
|
*
|
||||||
|
* Simulates 100 concurrent users browsing public pages
|
||||||
|
* Tests:
|
||||||
|
* - Homepage
|
||||||
|
* - About page
|
||||||
|
* - Solutions page
|
||||||
|
* - Portfolio list
|
||||||
|
* - Blog list
|
||||||
|
* - Contact page
|
||||||
|
*
|
||||||
|
* NFR4 Requirements:
|
||||||
|
* - p95 response time < 500ms
|
||||||
|
* - Error rate < 1%
|
||||||
|
* - 100 concurrent users sustained for 2 minutes
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { check, group } from 'k6';
|
||||||
|
import { SharedArray } from 'k6/data';
|
||||||
|
import { urls, thresholdGroups, config } from './lib/config.js';
|
||||||
|
import { PageHelper, thinkTime, pickRandom } from './lib/helpers.js';
|
||||||
|
|
||||||
|
// Test configuration
|
||||||
|
export const options = {
|
||||||
|
stages: config.stages.publicBrowsing,
|
||||||
|
thresholds: thresholdGroups.public,
|
||||||
|
...config.requestOptions,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Page list to browse
|
||||||
|
const publicPages = [
|
||||||
|
urls.home,
|
||||||
|
urls.about,
|
||||||
|
urls.solutions,
|
||||||
|
urls.portfolio,
|
||||||
|
urls.blog,
|
||||||
|
urls.contact,
|
||||||
|
];
|
||||||
|
|
||||||
|
// Initialize page helper
|
||||||
|
const pageHelper = new PageHelper(config.baseUrl);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main test scenario
|
||||||
|
*/
|
||||||
|
export default function () {
|
||||||
|
// Scenario 1: Browse homepage (most common)
|
||||||
|
group('Browse Homepage', () => {
|
||||||
|
pageHelper.loadPage(urls.home);
|
||||||
|
thinkTime(2, 4); // 2-4 seconds thinking
|
||||||
|
});
|
||||||
|
|
||||||
|
// Scenario 2: Browse random pages (weighted)
|
||||||
|
group('Browse Random Pages', () => {
|
||||||
|
// Browse 3-6 random pages
|
||||||
|
const pageCount = Math.floor(Math.random() * 4) + 3;
|
||||||
|
|
||||||
|
for (let i = 0; i < pageCount; i++) {
|
||||||
|
const randomPage = pickRandom(publicPages);
|
||||||
|
pageHelper.loadPage(randomPage);
|
||||||
|
thinkTime(1, 3); // 1-3 seconds thinking
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Scenario 3: Navigate to contact (conversion intent)
|
||||||
|
group('Navigate to Contact', () => {
|
||||||
|
// 20% chance to visit contact page
|
||||||
|
if (Math.random() < 0.2) {
|
||||||
|
pageHelper.loadPage(urls.contact);
|
||||||
|
thinkTime(3, 5); // More time on contact page
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Scenario 4: Deep dive into portfolio or blog
|
||||||
|
group('Deep Dive', () => {
|
||||||
|
// 30% chance to deep dive
|
||||||
|
if (Math.random() < 0.3) {
|
||||||
|
const section = Math.random() > 0.5 ? 'portfolio' : 'blog';
|
||||||
|
|
||||||
|
if (section === 'portfolio') {
|
||||||
|
// Browse portfolio items
|
||||||
|
pageHelper.loadPage(urls.portfolio);
|
||||||
|
thinkTime(1, 2);
|
||||||
|
// Note: In real scenario, we would click individual items
|
||||||
|
// This requires parsing the page to get item URLs
|
||||||
|
} else {
|
||||||
|
// Browse blog posts
|
||||||
|
pageHelper.loadPage(urls.blog);
|
||||||
|
thinkTime(1, 2);
|
||||||
|
// Note: In real scenario, we would click individual posts
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Small think time before next iteration
|
||||||
|
thinkTime(2, 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup function - runs once before test
|
||||||
|
*/
|
||||||
|
export function setup() {
|
||||||
|
console.log('=== Public Browsing Load Test ===');
|
||||||
|
console.log(`Target: ${config.baseUrl}`);
|
||||||
|
console.log(`Pages to browse: ${publicPages.length}`);
|
||||||
|
console.log('Starting test...');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Teardown function - runs once after test
|
||||||
|
*/
|
||||||
|
export function teardown(data) {
|
||||||
|
console.log('=== Test Complete ===');
|
||||||
|
console.log('Check results above for:');
|
||||||
|
console.log('- p95 response time < 500ms');
|
||||||
|
console.log('- Error rate < 1%');
|
||||||
|
console.log('- 100 concurrent users sustained');
|
||||||
|
}
|
||||||
59
apps/backend/tests/k6/verify-setup.js
Normal file
59
apps/backend/tests/k6/verify-setup.js
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
/**
|
||||||
|
* K6 Setup Verification Script
|
||||||
|
* Run this to verify your k6 installation and environment
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { check } from 'k6';
|
||||||
|
import http from 'k6/http';
|
||||||
|
|
||||||
|
// Test configuration - minimal load
|
||||||
|
export const options = {
|
||||||
|
vus: 1,
|
||||||
|
iterations: 1,
|
||||||
|
thresholds: {
|
||||||
|
http_req_duration: ['p(95) < 1000'], // Relaxed for verification
|
||||||
|
http_req_failed: ['rate < 0.05'], // Allow some failures during setup
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const baseUrl = __ENV.BASE_URL || 'http://localhost:3000';
|
||||||
|
|
||||||
|
export default function () {
|
||||||
|
console.log(`=== K6 Setup Verification ===`);
|
||||||
|
console.log(`Target: ${baseUrl}`);
|
||||||
|
|
||||||
|
// Test 1: Server is reachable
|
||||||
|
const homeRes = http.get(baseUrl);
|
||||||
|
check(homeRes, {
|
||||||
|
'Server is reachable': (r) => r.status !== 0,
|
||||||
|
'Home page responds': (r) => [200, 301, 302, 404].includes(r.status),
|
||||||
|
});
|
||||||
|
console.log(`Home page status: ${homeRes.status}`);
|
||||||
|
|
||||||
|
// Test 2: API endpoint exists
|
||||||
|
const apiRes = http.get(`${baseUrl}/api/global`);
|
||||||
|
check(apiRes, {
|
||||||
|
'API endpoint responds': (r) => r.status !== 0,
|
||||||
|
});
|
||||||
|
console.log(`API status: ${apiRes.status}`);
|
||||||
|
|
||||||
|
// Test 3: Check response times
|
||||||
|
const timeRes = http.get(`${baseUrl}/api/pages`, {
|
||||||
|
tags: { name: 'pages_api' },
|
||||||
|
});
|
||||||
|
console.log(`Pages API response time: ${timeRes.timings.duration}ms`);
|
||||||
|
|
||||||
|
// Summary
|
||||||
|
console.log(`=== Verification Complete ===`);
|
||||||
|
console.log(`If all checks passed, you're ready to run load tests!`);
|
||||||
|
console.log(`Next: k6 run tests/k6/public-browsing.js`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setup() {
|
||||||
|
console.log(`Starting K6 verification...`);
|
||||||
|
console.log(`k6 version: ${__K6_VERSION__ || 'unknown'}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function teardown(data) {
|
||||||
|
console.log(`Verification finished.`);
|
||||||
|
}
|
||||||
1733
apps/frontend/pnpm-lock.yaml
generated
1733
apps/frontend/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
15411
pnpm-lock.yaml
generated
15411
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user