This commit is contained in:
@@ -0,0 +1,115 @@
|
||||
# Ghost Data Factory
|
||||
|
||||
A minimal test data factory for Ghost e2e tests, written in TypeScript.
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
e2e/data-factory/ # Source files (TypeScript) - committed to git
|
||||
├── factory.ts # Base factory class
|
||||
├── factories/ # Factory implementations
|
||||
│ ├── post-factory.ts
|
||||
│ ├── tag-factory.ts
|
||||
│ └── user-factory.ts
|
||||
├── persistence/
|
||||
│ ├── adapter.ts # Persistence interface
|
||||
│ └── adapters/ # Adapter implementations (API, Knex, etc)
|
||||
├── setup.ts # Setup helper functions
|
||||
├── index.ts # Main exports
|
||||
└── utils.ts # Utility functions
|
||||
```
|
||||
|
||||
## Setup
|
||||
|
||||
This is part of the Ghost e2e test suite. All dependencies are managed by the main Ghost monorepo.
|
||||
|
||||
1. **Start Ghost development server** (provides database):
|
||||
```bash
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
2. **Configure database connection** (optional - uses Ghost's database by default):
|
||||
```bash
|
||||
cp e2e/data-factory/.env.example e2e/data-factory/.env
|
||||
# Edit .env if using different database credentials
|
||||
```
|
||||
|
||||
3. **Build the e2e package** (includes data-factory):
|
||||
```bash
|
||||
cd e2e && pnpm build
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### In Tests
|
||||
|
||||
**Option 1: Use setup helpers (recommended)**
|
||||
```typescript
|
||||
import {createPostFactory, PostFactory} from '../data-factory';
|
||||
|
||||
// Create factory with API persistence
|
||||
const postFactory: PostFactory = createPostFactory(page.request);
|
||||
|
||||
// Build in-memory only (not persisted)
|
||||
const draftPost = postFactory.build({
|
||||
title: 'My Draft',
|
||||
status: 'draft'
|
||||
});
|
||||
|
||||
// Create and persist to database
|
||||
const publishedPost = await postFactory.create({
|
||||
title: 'My Published Post',
|
||||
status: 'published'
|
||||
});
|
||||
```
|
||||
|
||||
**Option 2: Manual setup**
|
||||
```typescript
|
||||
import {PostFactory} from '../data-factory/factories/post-factory';
|
||||
import {GhostAdminApiAdapter} from '../data-factory/persistence/adapters/ghost-api';
|
||||
|
||||
const adapter = new GhostAdminApiAdapter(page.request, 'posts');
|
||||
const postFactory = new PostFactory(adapter);
|
||||
|
||||
// Now you can build or create
|
||||
const post = await postFactory.create({
|
||||
title: 'My Published Post',
|
||||
status: 'published'
|
||||
});
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
### Adding New Factories
|
||||
|
||||
1. Create a new factory class extending `Factory<TOptions, TResult>`
|
||||
2. Implement the `build()` method (returns in-memory object)
|
||||
3. Set `entityType` property (used for persistence)
|
||||
4. Create a setup helper in `setup.ts` for convenient usage in tests
|
||||
|
||||
Example:
|
||||
```typescript
|
||||
import {Factory} from '../factory';
|
||||
|
||||
export class MemberFactory extends Factory<Partial<Member>, Member> {
|
||||
entityType = 'members';
|
||||
|
||||
build(options: Partial<Member> = {}): Member {
|
||||
return {
|
||||
id: generateId(),
|
||||
email: options.email || faker.internet.email(),
|
||||
name: options.name || faker.person.fullName(),
|
||||
// ... more fields
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Then create a setup helper:
|
||||
```typescript
|
||||
// In setup.ts
|
||||
export function createMemberFactory(httpClient: HttpClient): MemberFactory {
|
||||
const adapter = new GhostAdminApiAdapter(httpClient, 'members');
|
||||
return new MemberFactory(adapter);
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,60 @@
|
||||
import {Factory} from '@/data-factory';
|
||||
import {generateId} from '@/data-factory';
|
||||
|
||||
export interface AutomatedEmail {
|
||||
id: string;
|
||||
status: 'active' | 'inactive';
|
||||
name: string;
|
||||
slug: string;
|
||||
subject: string;
|
||||
lexical: string;
|
||||
sender_name: string | null;
|
||||
sender_email: string | null;
|
||||
sender_reply_to: string | null;
|
||||
created_at: Date;
|
||||
updated_at: Date | null;
|
||||
}
|
||||
|
||||
export class AutomatedEmailFactory extends Factory<Partial<AutomatedEmail>, AutomatedEmail> {
|
||||
entityType = 'automated_emails';
|
||||
|
||||
build(options: Partial<AutomatedEmail> = {}): AutomatedEmail {
|
||||
const now = new Date();
|
||||
|
||||
const defaults: AutomatedEmail = {
|
||||
id: generateId(),
|
||||
status: 'active',
|
||||
name: 'Welcome Email (Free)',
|
||||
slug: 'member-welcome-email-free',
|
||||
subject: 'Welcome to {site_title}!',
|
||||
lexical: JSON.stringify(this.defaultLexicalContent()),
|
||||
sender_name: null,
|
||||
sender_email: null,
|
||||
sender_reply_to: null,
|
||||
created_at: now,
|
||||
updated_at: null
|
||||
};
|
||||
|
||||
return {...defaults, ...options} as AutomatedEmail;
|
||||
}
|
||||
|
||||
private defaultLexicalContent() {
|
||||
return {
|
||||
root: {
|
||||
children: [{
|
||||
type: 'paragraph',
|
||||
children: [{
|
||||
type: 'text',
|
||||
text: 'Welcome to {site_title}!'
|
||||
}]
|
||||
}],
|
||||
direction: null,
|
||||
format: '',
|
||||
indent: 0,
|
||||
type: 'root',
|
||||
version: 1
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import {Factory} from '@/data-factory';
|
||||
import {faker} from '@faker-js/faker';
|
||||
import {generateId} from '@/data-factory';
|
||||
|
||||
export interface Comment {
|
||||
id: string;
|
||||
post_id: string;
|
||||
member_id: string;
|
||||
parent_id?: string;
|
||||
in_reply_to_id?: string;
|
||||
status: 'published' | 'hidden' | 'deleted';
|
||||
html: string;
|
||||
created_at?: string;
|
||||
edited_at?: string;
|
||||
}
|
||||
|
||||
export class CommentFactory extends Factory<Partial<Comment>, Comment> {
|
||||
entityType = 'comments';
|
||||
|
||||
build(options: Partial<Comment> = {}): Comment {
|
||||
const content = options.html || `<p>${faker.lorem.sentence()}</p>`;
|
||||
|
||||
return {
|
||||
id: generateId(),
|
||||
post_id: options.post_id || '',
|
||||
member_id: options.member_id || '',
|
||||
status: 'published',
|
||||
html: content,
|
||||
...options
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import {faker} from '@faker-js/faker';
|
||||
|
||||
interface LexicalTextNode {
|
||||
detail: number;
|
||||
format: number;
|
||||
mode: string;
|
||||
style: string;
|
||||
text: string;
|
||||
type: 'text';
|
||||
version: number;
|
||||
}
|
||||
|
||||
interface LexicalParagraphNode {
|
||||
children: LexicalTextNode[];
|
||||
direction: string;
|
||||
format: string;
|
||||
indent: number;
|
||||
type: 'paragraph';
|
||||
version: number;
|
||||
}
|
||||
|
||||
interface CardNode {
|
||||
type: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
const CARD_DEFAULTS: Record<string, CardNode> = {
|
||||
transistor: {
|
||||
type: 'transistor',
|
||||
version: 1,
|
||||
accentColor: '#15171A',
|
||||
backgroundColor: '#FFFFFF',
|
||||
visibility: {
|
||||
web: {
|
||||
nonMember: false,
|
||||
memberSegment: 'status:free,status:-free'
|
||||
},
|
||||
email: {
|
||||
memberSegment: 'status:free,status:-free'
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export type CardSpec = string | {[cardType: string]: Record<string, unknown>};
|
||||
|
||||
function resolveCard(spec: CardSpec): CardNode {
|
||||
if (typeof spec === 'string') {
|
||||
const defaults = CARD_DEFAULTS[spec];
|
||||
if (!defaults) {
|
||||
throw new Error(`Unknown card type: "${spec}". Register it in CARD_DEFAULTS in lexical.ts.`);
|
||||
}
|
||||
return {...defaults};
|
||||
}
|
||||
|
||||
const [cardType, overrides] = Object.entries(spec)[0];
|
||||
const defaults = CARD_DEFAULTS[cardType];
|
||||
if (!defaults) {
|
||||
throw new Error(`Unknown card type: "${cardType}". Register it in CARD_DEFAULTS in lexical.ts.`);
|
||||
}
|
||||
return {...defaults, ...overrides};
|
||||
}
|
||||
|
||||
function buildParagraphNode(text: string): LexicalParagraphNode {
|
||||
return {
|
||||
children: [{
|
||||
detail: 0,
|
||||
format: 0,
|
||||
mode: 'normal',
|
||||
style: '',
|
||||
text,
|
||||
type: 'text',
|
||||
version: 1
|
||||
}],
|
||||
direction: 'ltr',
|
||||
format: '',
|
||||
indent: 0,
|
||||
type: 'paragraph',
|
||||
version: 1
|
||||
};
|
||||
}
|
||||
|
||||
export function buildLexical(...cards: CardSpec[]): string {
|
||||
let children: (LexicalParagraphNode | CardNode)[];
|
||||
|
||||
if (cards.length === 0) {
|
||||
children = [buildParagraphNode(faker.lorem.paragraphs(3))];
|
||||
} else {
|
||||
children = [];
|
||||
for (const spec of cards) {
|
||||
const card = resolveCard(spec);
|
||||
children.push(buildParagraphNode(`Before ${card.type}`));
|
||||
children.push(card);
|
||||
children.push(buildParagraphNode(`After ${card.type}`));
|
||||
}
|
||||
}
|
||||
|
||||
return JSON.stringify({
|
||||
root: {
|
||||
children,
|
||||
direction: 'ltr',
|
||||
format: '',
|
||||
indent: 0,
|
||||
type: 'root',
|
||||
version: 1
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import {Factory} from '@/data-factory';
|
||||
import {faker} from '@faker-js/faker';
|
||||
import {generateId, generateUuid} from '@/data-factory';
|
||||
|
||||
export interface Tier {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
type: 'free' | 'paid';
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
export interface Member {
|
||||
id: string;
|
||||
uuid: string;
|
||||
name: string | null;
|
||||
email: string;
|
||||
note?: string | null;
|
||||
geolocation: string | null;
|
||||
labels?: string[];
|
||||
email_count: number;
|
||||
email_opened_count: number;
|
||||
email_open_rate: number | null;
|
||||
status: 'free' | 'paid' | 'comped';
|
||||
last_seen_at: Date | null;
|
||||
last_commented_at: Date | null;
|
||||
newsletters: string[];
|
||||
tiers?: Partial<Tier>[];
|
||||
created_at?: string; // ISO 8601 format for backdating
|
||||
complimentary_plan?: boolean;
|
||||
stripe_customer_id?: string;
|
||||
subscribed_to_emails?: string;
|
||||
}
|
||||
|
||||
export class MemberFactory extends Factory<Partial<Member>, Member> {
|
||||
entityType = 'members';
|
||||
|
||||
build(options: Partial<Member> = {}): Member {
|
||||
return {
|
||||
...this.buildDefaultMember(),
|
||||
...options
|
||||
};
|
||||
}
|
||||
|
||||
private buildDefaultMember(): Member {
|
||||
const firstName = faker.person.firstName();
|
||||
const lastName = faker.person.lastName();
|
||||
const name = `${firstName} ${lastName}`;
|
||||
|
||||
return {
|
||||
id: generateId(),
|
||||
uuid: generateUuid(),
|
||||
name: name,
|
||||
email: faker.internet.email({firstName, lastName}).toLowerCase(),
|
||||
note: faker.lorem.sentence(),
|
||||
geolocation: null,
|
||||
labels: [],
|
||||
email_count: 0,
|
||||
email_opened_count: 0,
|
||||
email_open_rate: null,
|
||||
status: 'free',
|
||||
last_seen_at: null,
|
||||
last_commented_at: null,
|
||||
newsletters: [],
|
||||
subscribed_to_emails: 'false'
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import {Factory, generateId, generateSlug} from '@/data-factory';
|
||||
import {faker} from '@faker-js/faker';
|
||||
import type {HttpClient, PersistenceAdapter} from '@/data-factory';
|
||||
|
||||
export interface AdminOffer {
|
||||
id: string;
|
||||
name: string;
|
||||
code: string;
|
||||
cadence: 'month' | 'year';
|
||||
redemption_type?: 'signup' | 'retention';
|
||||
status: 'active' | 'archived';
|
||||
display_title: string | null;
|
||||
display_description: string | null;
|
||||
type: 'fixed' | 'percent' | 'trial';
|
||||
amount: number;
|
||||
duration: 'once' | 'repeating' | 'forever' | 'trial';
|
||||
duration_in_months: number | null;
|
||||
currency: string | null;
|
||||
stripe_coupon_id?: string | null;
|
||||
tier: {
|
||||
id: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface OfferCreateInput {
|
||||
[key: string]: unknown;
|
||||
name: string;
|
||||
code: string;
|
||||
cadence: 'month' | 'year';
|
||||
amount: number;
|
||||
duration: 'once' | 'repeating' | 'forever' | 'trial';
|
||||
type: 'fixed' | 'percent' | 'trial';
|
||||
tierId?: string | null;
|
||||
currency?: string | null;
|
||||
display_title?: string | null;
|
||||
display_description?: string | null;
|
||||
duration_in_months?: number | null;
|
||||
redemption_type?: 'signup' | 'retention';
|
||||
status?: 'active' | 'archived';
|
||||
}
|
||||
|
||||
export interface OfferUpdateInput {
|
||||
status?: 'active' | 'archived';
|
||||
}
|
||||
|
||||
export class OfferFactory extends Factory<OfferCreateInput, AdminOffer> {
|
||||
entityType = 'offers';
|
||||
private readonly request?: HttpClient;
|
||||
|
||||
constructor(adapter?: PersistenceAdapter, request?: HttpClient) {
|
||||
super(adapter);
|
||||
this.request = request;
|
||||
}
|
||||
|
||||
build(options: Partial<OfferCreateInput> = {}): AdminOffer {
|
||||
const name = options.name ?? `Offer ${faker.commerce.productName()}`;
|
||||
const code = options.code ?? `${generateSlug(name)}-${faker.string.alphanumeric(6).toLowerCase()}`;
|
||||
const redemptionType = options.redemption_type ?? (options.tierId ? 'signup' : 'retention');
|
||||
|
||||
return {
|
||||
id: generateId(),
|
||||
name,
|
||||
code,
|
||||
cadence: options.cadence ?? 'month',
|
||||
redemption_type: redemptionType,
|
||||
status: options.status ?? 'active',
|
||||
display_title: options.display_title ?? name,
|
||||
display_description: options.display_description ?? null,
|
||||
type: options.type ?? 'percent',
|
||||
amount: options.amount ?? 10,
|
||||
duration: options.duration ?? 'once',
|
||||
duration_in_months: options.duration_in_months ?? null,
|
||||
currency: options.currency ?? null,
|
||||
stripe_coupon_id: null,
|
||||
tier: options.tierId ? {id: options.tierId} : null
|
||||
};
|
||||
}
|
||||
|
||||
async create(options: Partial<OfferCreateInput> = {}): Promise<AdminOffer> {
|
||||
if (!this.request) {
|
||||
throw new Error('Cannot create without an HTTP client. Use createOfferFactory() for persisted test data access.');
|
||||
}
|
||||
|
||||
const offer = this.build(options);
|
||||
const response = await this.request.post('/ghost/api/admin/offers', {
|
||||
data: {
|
||||
offers: [{
|
||||
name: offer.name,
|
||||
code: offer.code,
|
||||
cadence: offer.cadence,
|
||||
status: offer.status,
|
||||
redemption_type: offer.redemption_type ?? 'signup',
|
||||
currency: offer.currency,
|
||||
type: offer.type,
|
||||
amount: offer.amount,
|
||||
duration: offer.duration,
|
||||
duration_in_months: offer.duration_in_months,
|
||||
display_title: offer.display_title,
|
||||
display_description: offer.display_description,
|
||||
tier: offer.tier
|
||||
}]
|
||||
}
|
||||
});
|
||||
|
||||
return await this.extractFirstOfferOrThrow('create offer', response.status(), response);
|
||||
}
|
||||
|
||||
async update(id: string, input: OfferUpdateInput): Promise<AdminOffer> {
|
||||
if (!this.request) {
|
||||
throw new Error('Cannot update without an HTTP client. Use createOfferFactory() for persisted test data access.');
|
||||
}
|
||||
|
||||
const response = await this.request.put(`/ghost/api/admin/offers/${id}`, {
|
||||
data: {
|
||||
offers: [input]
|
||||
}
|
||||
});
|
||||
|
||||
return await this.extractFirstOfferOrThrow('update offer', response.status(), response);
|
||||
}
|
||||
|
||||
async getOffers(): Promise<AdminOffer[]> {
|
||||
if (!this.request) {
|
||||
throw new Error('Cannot fetch offers without an HTTP client. Use createOfferFactory() for persisted test data access.');
|
||||
}
|
||||
|
||||
const response = await this.request.get('/ghost/api/admin/offers');
|
||||
if (!response.ok()) {
|
||||
throw new Error(`Failed to fetch offers: ${response.status()}`);
|
||||
}
|
||||
|
||||
const data = await response.json() as {offers: AdminOffer[]};
|
||||
return data.offers;
|
||||
}
|
||||
|
||||
async getById(id: string): Promise<AdminOffer> {
|
||||
if (!this.request) {
|
||||
throw new Error('Cannot fetch an offer without an HTTP client. Use createOfferFactory() for persisted test data access.');
|
||||
}
|
||||
|
||||
const response = await this.request.get(`/ghost/api/admin/offers/${id}`);
|
||||
return await this.extractFirstOfferOrThrow('fetch offer', response.status(), response);
|
||||
}
|
||||
|
||||
private async extractFirstOfferOrThrow(action: string, status: number, response: {ok(): boolean; json(): Promise<unknown>}): Promise<AdminOffer> {
|
||||
if (!response.ok()) {
|
||||
throw new Error(`Failed to ${action}: ${status}`);
|
||||
}
|
||||
|
||||
const data = await response.json() as {offers?: AdminOffer[]};
|
||||
const offers = data.offers;
|
||||
|
||||
if (!Array.isArray(offers) || offers.length === 0) {
|
||||
let responseBody = '[unserializable]';
|
||||
|
||||
try {
|
||||
responseBody = JSON.stringify(data);
|
||||
} catch {
|
||||
// Ignore serialization errors and keep fallback marker.
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Failed to ${action}: expected response.offers to be a non-empty array (status ${status}). Response: ${responseBody}`
|
||||
);
|
||||
}
|
||||
|
||||
return offers[0];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import {Factory} from '@/data-factory';
|
||||
import {buildLexical} from './lexical';
|
||||
import {faker} from '@faker-js/faker';
|
||||
import {generateId, generateSlug, generateUuid} from '@/data-factory';
|
||||
import type {CardSpec} from './lexical';
|
||||
|
||||
export interface Post {
|
||||
id: string;
|
||||
uuid: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
mobiledoc: string | null;
|
||||
lexical: string | null;
|
||||
html: string;
|
||||
comment_id: string;
|
||||
plaintext: string;
|
||||
feature_image: string | null;
|
||||
featured: boolean;
|
||||
type: string;
|
||||
status: 'draft' | 'published' | 'scheduled';
|
||||
locale: string | null;
|
||||
visibility: string;
|
||||
email_recipient_filter: string;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
published_at: Date | null;
|
||||
custom_excerpt: string;
|
||||
codeinjection_head: string | null;
|
||||
codeinjection_foot: string | null;
|
||||
custom_template: string | null;
|
||||
canonical_url: string | null;
|
||||
newsletter_id: string | null;
|
||||
show_title_and_feature_image: boolean;
|
||||
tags?: Array<{id: string}>;
|
||||
tiers?: Array<{id: string}>;
|
||||
}
|
||||
|
||||
export class PostFactory extends Factory<Partial<Post>, Post> {
|
||||
entityType = 'posts'; // Entity name (for adapter; currently API endpoint)
|
||||
|
||||
build(options: Partial<Post> = {}): Post {
|
||||
const now = new Date();
|
||||
const title = options.title || faker.lorem.sentence();
|
||||
const content = faker.lorem.paragraphs(3);
|
||||
|
||||
const defaults: Post = {
|
||||
id: generateId(),
|
||||
uuid: generateUuid(),
|
||||
title: title,
|
||||
slug: options.slug || generateSlug(title) + '-' + Date.now().toString(16),
|
||||
mobiledoc: null,
|
||||
lexical: buildLexical(),
|
||||
html: `<p>${content}</p>`,
|
||||
comment_id: generateId(),
|
||||
plaintext: content,
|
||||
feature_image: null,
|
||||
featured: faker.datatype.boolean(),
|
||||
type: 'post',
|
||||
status: 'draft',
|
||||
locale: null,
|
||||
visibility: 'public',
|
||||
email_recipient_filter: 'none',
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
published_at: null,
|
||||
custom_excerpt: faker.lorem.paragraph(),
|
||||
codeinjection_head: null,
|
||||
codeinjection_foot: null,
|
||||
custom_template: null,
|
||||
canonical_url: null,
|
||||
newsletter_id: null,
|
||||
show_title_and_feature_image: true,
|
||||
tags: undefined
|
||||
};
|
||||
|
||||
// Determine published_at based on status and user options
|
||||
let publishedAt = options.published_at ?? defaults.published_at;
|
||||
if (options.status === 'published' && !options.published_at) {
|
||||
publishedAt = now;
|
||||
}
|
||||
|
||||
return {...defaults, ...options, published_at: publishedAt} as Post;
|
||||
}
|
||||
|
||||
async createWithCards(cards: CardSpec | CardSpec[], options: Partial<Post> = {}): Promise<Post> {
|
||||
const cardArray = Array.isArray(cards) ? cards : [cards];
|
||||
return this.create({...options, lexical: buildLexical(...cardArray)});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import {Factory} from '@/data-factory';
|
||||
import {faker} from '@faker-js/faker';
|
||||
import {generateId, generateSlug} from '@/data-factory';
|
||||
|
||||
export interface Tag {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
description: string | null;
|
||||
feature_image: string | null;
|
||||
parent_id: string | null;
|
||||
visibility: 'public' | 'internal';
|
||||
url?: string;
|
||||
og_image: string | null;
|
||||
og_title: string | null;
|
||||
og_description: string | null;
|
||||
twitter_image: string | null;
|
||||
twitter_title: string | null;
|
||||
twitter_description: string | null;
|
||||
meta_title: string | null;
|
||||
meta_description: string | null;
|
||||
codeinjection_head: string | null;
|
||||
codeinjection_foot: string | null;
|
||||
canonical_url: string | null;
|
||||
accent_color: string | null;
|
||||
count?: {
|
||||
posts: number;
|
||||
};
|
||||
created_at: Date;
|
||||
updated_at: Date | null;
|
||||
}
|
||||
export class TagFactory extends Factory<Partial<Tag>, Tag> {
|
||||
entityType = 'tags';
|
||||
|
||||
build(options: Partial<Tag> = {}): Tag {
|
||||
return {
|
||||
...this.buildDefaultTag(),
|
||||
...options
|
||||
};
|
||||
}
|
||||
|
||||
private buildDefaultTag(): Tag {
|
||||
const now = new Date();
|
||||
const tagName = faker.commerce.department();
|
||||
|
||||
return {
|
||||
id: generateId(),
|
||||
name: tagName,
|
||||
slug: `${generateSlug(tagName)}-${faker.string.alphanumeric(6).toLowerCase()}`,
|
||||
description: faker.lorem.sentence(),
|
||||
feature_image: `https://picsum.photos/seed/tag-${faker.string.alphanumeric(8)}/1200/630`,
|
||||
parent_id: null,
|
||||
visibility: 'public',
|
||||
url: undefined,
|
||||
og_image: null,
|
||||
og_title: null,
|
||||
og_description: faker.lorem.sentence(),
|
||||
twitter_image: null,
|
||||
twitter_title: null,
|
||||
twitter_description: faker.lorem.sentence(),
|
||||
meta_title: null,
|
||||
meta_description: faker.lorem.sentence(),
|
||||
codeinjection_head: null,
|
||||
codeinjection_foot: null,
|
||||
canonical_url: null,
|
||||
accent_color: null,
|
||||
count: {posts: 0},
|
||||
created_at: now,
|
||||
updated_at: now
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import {Factory} from '@/data-factory';
|
||||
import {faker} from '@faker-js/faker';
|
||||
import {generateId, generateSlug} from '@/data-factory';
|
||||
import type {HttpClient, PersistenceAdapter} from '@/data-factory';
|
||||
import type {Tier} from './member-factory';
|
||||
|
||||
export interface AdminTier extends Tier {
|
||||
description?: string | null;
|
||||
visibility?: 'public' | 'none';
|
||||
welcome_page_url?: string | null;
|
||||
benefits?: string[] | null;
|
||||
currency?: string;
|
||||
monthly_price?: number;
|
||||
yearly_price?: number;
|
||||
trial_days?: number;
|
||||
created_at?: Date;
|
||||
updated_at?: Date | null;
|
||||
}
|
||||
|
||||
export interface TierCreateInput {
|
||||
name: string;
|
||||
description?: string;
|
||||
visibility?: 'public' | 'none';
|
||||
welcome_page_url?: string;
|
||||
benefits?: string[];
|
||||
currency: string;
|
||||
monthly_price: number;
|
||||
yearly_price: number;
|
||||
trial_days?: number;
|
||||
}
|
||||
|
||||
export class TierFactory extends Factory<Partial<AdminTier>, AdminTier> {
|
||||
entityType = 'tiers';
|
||||
private readonly request?: HttpClient;
|
||||
|
||||
constructor(adapter?: PersistenceAdapter, request?: HttpClient) {
|
||||
super(adapter);
|
||||
this.request = request;
|
||||
}
|
||||
|
||||
build(options: Partial<AdminTier> = {}): AdminTier {
|
||||
const tierName = options.name ?? `Tier ${faker.commerce.productName()}`;
|
||||
const now = new Date();
|
||||
|
||||
const defaults: AdminTier = {
|
||||
id: generateId(),
|
||||
name: tierName,
|
||||
slug: `${generateSlug(tierName)}-${faker.string.alphanumeric(6).toLowerCase()}`,
|
||||
type: 'paid',
|
||||
active: true,
|
||||
description: faker.lorem.sentence(),
|
||||
visibility: 'public',
|
||||
welcome_page_url: null,
|
||||
benefits: [],
|
||||
currency: 'usd',
|
||||
monthly_price: 500,
|
||||
yearly_price: 5000,
|
||||
trial_days: 0,
|
||||
created_at: now,
|
||||
updated_at: now
|
||||
};
|
||||
|
||||
return {...defaults, ...options};
|
||||
}
|
||||
|
||||
async getFirstPaidTier(): Promise<AdminTier> {
|
||||
if (!this.request) {
|
||||
throw new Error('Cannot fetch tiers without an HTTP client. Use createTierFactory() for persisted test data access.');
|
||||
}
|
||||
|
||||
const response = await this.request.get('/ghost/api/admin/tiers');
|
||||
if (!response.ok()) {
|
||||
throw new Error(`Failed to fetch tiers: ${response.status()}`);
|
||||
}
|
||||
|
||||
const {tiers} = await response.json() as {tiers: AdminTier[]};
|
||||
const paidTier = tiers.find(tier => tier.type === 'paid' && tier.active);
|
||||
if (!paidTier) {
|
||||
throw new Error('No paid tiers found');
|
||||
}
|
||||
return paidTier;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import {Factory} from '@/data-factory';
|
||||
|
||||
export interface User {
|
||||
name: string;
|
||||
email: string;
|
||||
password: string;
|
||||
blogTitle: string;
|
||||
}
|
||||
|
||||
export class UserFactory extends Factory<Partial<User>, User> {
|
||||
entityType = 'users';
|
||||
|
||||
public build(overrides: Partial<User> = {}): User {
|
||||
return {
|
||||
...this.defaults,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
private defaults: User = {
|
||||
name: 'Test Admin',
|
||||
email: 'test@example.com',
|
||||
password: 'test123',
|
||||
blogTitle: 'Test Blog'
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import type {PersistenceAdapter} from './persistence/adapter';
|
||||
|
||||
export abstract class Factory<TOptions extends Record<string, unknown> = Record<string, unknown>, TResult = TOptions> {
|
||||
abstract entityType: string;
|
||||
|
||||
protected adapter?: PersistenceAdapter;
|
||||
|
||||
constructor(adapter?: PersistenceAdapter) {
|
||||
this.adapter = adapter;
|
||||
}
|
||||
|
||||
abstract build(options?: Partial<TOptions>): TResult;
|
||||
|
||||
buildMany(optionsList: Partial<TOptions>[]): TResult[] {
|
||||
return optionsList.map(options => this.build(options));
|
||||
}
|
||||
|
||||
async create(options?: Partial<TOptions>): Promise<TResult> {
|
||||
if (!this.adapter) {
|
||||
throw new Error('Cannot create without a persistence adapter. Use build() for in-memory objects.');
|
||||
}
|
||||
const data = this.build(options);
|
||||
return await this.adapter.insert(this.entityType, data) as Promise<TResult>;
|
||||
}
|
||||
|
||||
async createMany(optionsList: Partial<TOptions>[]): Promise<TResult[]> {
|
||||
if (!this.adapter) {
|
||||
throw new Error('Cannot create without a persistence adapter. Use buildMany() for in-memory objects.');
|
||||
}
|
||||
|
||||
const results: TResult[] = [];
|
||||
for (const options of optionsList) {
|
||||
const result = await this.create(options);
|
||||
results.push(result);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// Core Factory exports
|
||||
export {Factory} from './factory';
|
||||
export {PostFactory} from './factories/post-factory';
|
||||
export type {Post} from './factories/post-factory';
|
||||
export {TagFactory} from './factories/tag-factory';
|
||||
export type {Tag} from './factories/tag-factory';
|
||||
export {MemberFactory} from './factories/member-factory';
|
||||
export type {Member, Tier} from './factories/member-factory';
|
||||
export {TierFactory} from './factories/tier-factory';
|
||||
export type {AdminTier, TierCreateInput} from './factories/tier-factory';
|
||||
export {OfferFactory} from './factories/offer-factory';
|
||||
export type {AdminOffer, OfferCreateInput, OfferUpdateInput} from './factories/offer-factory';
|
||||
export {AutomatedEmailFactory} from './factories/automated-email-factory';
|
||||
export type {AutomatedEmail} from './factories/automated-email-factory';
|
||||
export {CommentFactory} from './factories/comment-factory';
|
||||
export type {Comment} from './factories/comment-factory';
|
||||
export * from './factories/user-factory';
|
||||
|
||||
// Persistence Adapters
|
||||
export {KnexPersistenceAdapter} from './persistence/adapters/knex';
|
||||
export {ApiPersistenceAdapter} from './persistence/adapters/api';
|
||||
export type {HttpClient, HttpResponse} from './persistence/adapters/http-client';
|
||||
export {GhostAdminApiAdapter} from './persistence/adapters/ghost-api';
|
||||
export type {PersistenceAdapter} from './persistence/adapter';
|
||||
|
||||
// Utilities
|
||||
export {generateId, generateUuid, generateSlug} from './utils';
|
||||
|
||||
// Factory Setup Helpers
|
||||
export {createPostFactory} from './setup';
|
||||
export {createTagFactory} from './setup';
|
||||
export {createMemberFactory} from './setup';
|
||||
export {createTierFactory} from './setup';
|
||||
export {createOfferFactory} from './setup';
|
||||
export {createAutomatedEmailFactory} from './setup';
|
||||
export {createCommentFactory} from './setup';
|
||||
export {createFactories} from './setup';
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Core persistence adapter interface
|
||||
*/
|
||||
export interface PersistenceAdapter {
|
||||
insert<T>(entityType: string, data: T): Promise<T>;
|
||||
update<T>(entityType: string, id: string, data: Partial<T>): Promise<T>;
|
||||
delete(entityType: string, id: string): Promise<void>;
|
||||
findById<T>(entityType: string, id: string): Promise<T>;
|
||||
// Optional methods - implement as needed
|
||||
deleteMany?(entityType: string, ids: string[]): Promise<void>;
|
||||
findMany?<T>(entityType: string, query?: Record<string, unknown>): Promise<T[]>;
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import {AutomatedEmailFactory} from './factories/automated-email-factory';
|
||||
import {CommentFactory} from './factories/comment-factory';
|
||||
import {GhostAdminApiAdapter} from './persistence/adapters/ghost-api';
|
||||
import {HttpClient} from './persistence/adapters/http-client';
|
||||
import {MemberFactory} from './factories/member-factory';
|
||||
import {OfferFactory} from './factories/offer-factory';
|
||||
import {PostFactory} from './factories/post-factory';
|
||||
import {TagFactory} from './factories/tag-factory';
|
||||
import {TierFactory} from './factories/tier-factory';
|
||||
|
||||
/**
|
||||
* Create a new PostFactory with API persistence
|
||||
* Uses the http client which already has the proper authentication headers and baseURL
|
||||
* configured (this would be Playwright's page.request)
|
||||
*
|
||||
* @param httpClient - client for requests with pre-defined authorization and base url
|
||||
* @returns PostFactory ready to use with the specified Ghost backend
|
||||
*/
|
||||
export function createPostFactory(httpClient: HttpClient): PostFactory {
|
||||
const adapter = new GhostAdminApiAdapter(
|
||||
httpClient,
|
||||
'posts',
|
||||
{formats: 'mobiledoc,lexical,html'}
|
||||
);
|
||||
return new PostFactory(adapter);
|
||||
}
|
||||
|
||||
export function createTagFactory(httpClient: HttpClient): TagFactory {
|
||||
const adapter = new GhostAdminApiAdapter(
|
||||
httpClient,
|
||||
'tags'
|
||||
);
|
||||
return new TagFactory(adapter);
|
||||
}
|
||||
|
||||
export function createMemberFactory(httpClient: HttpClient): MemberFactory {
|
||||
const adapter = new GhostAdminApiAdapter(
|
||||
httpClient,
|
||||
'members'
|
||||
);
|
||||
return new MemberFactory(adapter);
|
||||
}
|
||||
|
||||
export function createTierFactory(httpClient: HttpClient): TierFactory {
|
||||
const adapter = new GhostAdminApiAdapter(
|
||||
httpClient,
|
||||
'tiers'
|
||||
);
|
||||
return new TierFactory(adapter, httpClient);
|
||||
}
|
||||
|
||||
export function createOfferFactory(httpClient: HttpClient): OfferFactory {
|
||||
const adapter = new GhostAdminApiAdapter(
|
||||
httpClient,
|
||||
'offers'
|
||||
);
|
||||
return new OfferFactory(adapter, httpClient);
|
||||
}
|
||||
|
||||
export function createAutomatedEmailFactory(httpClient: HttpClient): AutomatedEmailFactory {
|
||||
const adapter = new GhostAdminApiAdapter(
|
||||
httpClient,
|
||||
'automated_emails'
|
||||
);
|
||||
return new AutomatedEmailFactory(adapter);
|
||||
}
|
||||
|
||||
export function createCommentFactory(httpClient: HttpClient): CommentFactory {
|
||||
const adapter = new GhostAdminApiAdapter(
|
||||
httpClient,
|
||||
'comments'
|
||||
);
|
||||
return new CommentFactory(adapter);
|
||||
}
|
||||
|
||||
export interface Factories {
|
||||
postFactory: PostFactory;
|
||||
tagFactory: TagFactory;
|
||||
memberFactory: MemberFactory;
|
||||
tierFactory: TierFactory;
|
||||
offerFactory: OfferFactory;
|
||||
automatedEmailFactory: AutomatedEmailFactory;
|
||||
commentFactory: CommentFactory;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper for creating all factories with the same http client
|
||||
* @param httpClient - client for requests with pre-defined authorization and base url
|
||||
*
|
||||
* @returns All factories ready to use with the specified Ghost backend
|
||||
*/
|
||||
export function createFactories(httpClient: HttpClient): Factories {
|
||||
return {
|
||||
postFactory: createPostFactory(httpClient),
|
||||
tagFactory: createTagFactory(httpClient),
|
||||
memberFactory: createMemberFactory(httpClient),
|
||||
tierFactory: createTierFactory(httpClient),
|
||||
offerFactory: createOfferFactory(httpClient),
|
||||
automatedEmailFactory: createAutomatedEmailFactory(httpClient),
|
||||
commentFactory: createCommentFactory(httpClient)
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import {faker} from '@faker-js/faker';
|
||||
import {randomBytes} from 'crypto';
|
||||
|
||||
/**
|
||||
* Generate a MongoDB-style ObjectId
|
||||
*/
|
||||
export function generateId(): string {
|
||||
const timestamp = Math.floor(Date.now() / 1000).toString(16);
|
||||
const randomHex = randomBytes(8).toString('hex');
|
||||
return timestamp + randomHex;
|
||||
}
|
||||
|
||||
export function generateUuid(): string {
|
||||
return faker.string.uuid();
|
||||
}
|
||||
|
||||
export function generateSlug(text: string): string {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(/[^\w\s-]/g, '')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/--+/g, '-')
|
||||
.replace(/^-+/, '')
|
||||
.replace(/-+$/, '');
|
||||
}
|
||||
Reference in New Issue
Block a user