@stacksjs/cms 0.70.53 → 0.70.55
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -2
- package/src/authors/destroy.ts +55 -0
- package/src/authors/fetch.ts +70 -0
- package/src/authors/index.ts +21 -0
- package/src/authors/store.ts +150 -0
- package/src/authors/update.ts +42 -0
- package/src/build.ts +1382 -0
- package/src/categorizables/destroy.ts +64 -0
- package/src/categorizables/fetch.ts +121 -0
- package/src/categorizables/index.ts +22 -0
- package/src/categorizables/store.ts +140 -0
- package/src/categorizables/update.ts +57 -0
- package/src/commentables/destroy.ts +64 -0
- package/src/commentables/fetch.ts +319 -0
- package/src/commentables/index.ts +25 -0
- package/src/commentables/store.ts +156 -0
- package/src/commentables/update.ts +75 -0
- package/src/database.ts +9 -0
- package/src/index.ts +42 -0
- package/src/pages/destroy.ts +64 -0
- package/src/pages/fetch.ts +82 -0
- package/src/pages/index.ts +21 -0
- package/src/pages/store.ts +42 -0
- package/src/pages/update.ts +39 -0
- package/src/posts/destroy.ts +90 -0
- package/src/posts/fetch.ts +82 -0
- package/src/posts/index.ts +25 -0
- package/src/posts/store.ts +213 -0
- package/src/posts/update.ts +46 -0
- package/src/taggables/destroy.ts +49 -0
- package/src/taggables/fetch.ts +282 -0
- package/src/taggables/index.ts +17 -0
- package/src/taggables/store.ts +122 -0
- package/src/taggables/update.ts +74 -0
- package/src/tests/categorizables.test.ts +66 -0
- package/src/tests/setup.ts +120 -0
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import type { TaggableTable } from '@stacksjs/orm'
|
|
2
|
+
import { getDb } from '../database'
|
|
3
|
+
import { findOrCreate } from './store'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Fetch a tag by its ID
|
|
7
|
+
*
|
|
8
|
+
* @param id The ID of the tag to fetch
|
|
9
|
+
* @returns The tag record if found
|
|
10
|
+
*/
|
|
11
|
+
export async function fetchTagById(id: number): Promise<TaggableTable | undefined> {
|
|
12
|
+
const db = await getDb()
|
|
13
|
+
try {
|
|
14
|
+
const result = await db
|
|
15
|
+
.selectFrom('taggables')
|
|
16
|
+
.where('id', '=', id)
|
|
17
|
+
.selectAll()
|
|
18
|
+
.executeTakeFirst()
|
|
19
|
+
|
|
20
|
+
if (!result) {
|
|
21
|
+
return undefined
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return result as unknown as TaggableTable
|
|
25
|
+
}
|
|
26
|
+
catch (error) {
|
|
27
|
+
if (error instanceof Error) {
|
|
28
|
+
throw new TypeError(`Failed to fetch tag: ${error.message}`)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
throw error
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Fetch all tags
|
|
37
|
+
*
|
|
38
|
+
* @returns An array of tag records
|
|
39
|
+
*/
|
|
40
|
+
export async function fetchTags(): Promise<TaggableTable[]> {
|
|
41
|
+
const db = await getDb()
|
|
42
|
+
try {
|
|
43
|
+
return await db
|
|
44
|
+
.selectFrom('taggables')
|
|
45
|
+
.where('is_active', '=', true)
|
|
46
|
+
.selectAll()
|
|
47
|
+
.execute() as unknown as unknown as TaggableTable[]
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
if (error instanceof Error) {
|
|
51
|
+
throw new TypeError(`Failed to fetch tags: ${error.message}`)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
throw error
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Find a tag by name or create it if it doesn't exist
|
|
60
|
+
*
|
|
61
|
+
* @param name The name of the tag to find or create
|
|
62
|
+
* @param taggableType Type of the taggable entity
|
|
63
|
+
* @param description Optional description for the tag
|
|
64
|
+
* @returns The existing or newly created tag
|
|
65
|
+
*/
|
|
66
|
+
export async function firstOrCreate(
|
|
67
|
+
name: string,
|
|
68
|
+
taggableType: string,
|
|
69
|
+
description?: string,
|
|
70
|
+
): Promise<TaggableTable> {
|
|
71
|
+
try {
|
|
72
|
+
return await findOrCreate({
|
|
73
|
+
name,
|
|
74
|
+
taggable_type: taggableType,
|
|
75
|
+
description,
|
|
76
|
+
})
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
if (error instanceof Error) {
|
|
80
|
+
throw new TypeError(`Failed to find or create tag: ${error.message}`)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
throw error
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Count the number of posts that have been tagged
|
|
89
|
+
*
|
|
90
|
+
* @param taggableType The type of entity to count (e.g. 'posts')
|
|
91
|
+
* @returns The count of tagged posts
|
|
92
|
+
*/
|
|
93
|
+
export async function countTaggedPosts(taggableType: string): Promise<number> {
|
|
94
|
+
const db = await getDb()
|
|
95
|
+
try {
|
|
96
|
+
const result = await db
|
|
97
|
+
.selectFrom('taggable_models')
|
|
98
|
+
.where('taggable_type', '=', taggableType)
|
|
99
|
+
.count()
|
|
100
|
+
|
|
101
|
+
return Number(result) || 0
|
|
102
|
+
}
|
|
103
|
+
catch (error) {
|
|
104
|
+
if (error instanceof Error) {
|
|
105
|
+
throw new TypeError(`Failed to count tagged posts: ${error.message}`)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
throw error
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Count the total number of tags in the system
|
|
114
|
+
*
|
|
115
|
+
* @returns The total count of tags
|
|
116
|
+
*/
|
|
117
|
+
export async function countTotalTags(): Promise<number> {
|
|
118
|
+
const db = await getDb()
|
|
119
|
+
try {
|
|
120
|
+
const result = await db
|
|
121
|
+
.selectFrom('taggables')
|
|
122
|
+
.count()
|
|
123
|
+
|
|
124
|
+
return Number(result) || 0
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
if (error instanceof Error) {
|
|
128
|
+
throw new TypeError(`Failed to count total tags: ${error.message}`)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
throw error
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Find the most used tag in the system
|
|
137
|
+
*
|
|
138
|
+
* @param taggableType Optional type to filter by (e.g. 'posts', 'articles')
|
|
139
|
+
* @returns The most used tag name and its count
|
|
140
|
+
*/
|
|
141
|
+
export async function findMostUsedTag(taggableType?: string): Promise<{ name: string, count: number } | null> {
|
|
142
|
+
const db = await getDb()
|
|
143
|
+
try {
|
|
144
|
+
let query = db
|
|
145
|
+
.selectFrom('taggable_models')
|
|
146
|
+
.innerJoin('taggables', 'taggables.id', '=', 'taggable_models.tag_id')
|
|
147
|
+
.select([
|
|
148
|
+
'taggables.name',
|
|
149
|
+
])
|
|
150
|
+
.groupBy('taggables.name') as any
|
|
151
|
+
|
|
152
|
+
if (taggableType)
|
|
153
|
+
query = query.where('taggable_models.taggable_type', '=', taggableType)
|
|
154
|
+
|
|
155
|
+
const result = await query
|
|
156
|
+
.orderBy('taggables.name', 'asc')
|
|
157
|
+
.executeTakeFirst()
|
|
158
|
+
|
|
159
|
+
if (!result) {
|
|
160
|
+
return null
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
name: (result as Record<string, unknown>).name as string,
|
|
165
|
+
count: Number((result as Record<string, unknown>).usage_count || 0),
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
catch (error) {
|
|
169
|
+
if (error instanceof Error) {
|
|
170
|
+
throw new TypeError(`Failed to find most used tag: ${error.message}`)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
throw error
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Find the least used tag in the system
|
|
179
|
+
*
|
|
180
|
+
* @returns The least used tag name and its count
|
|
181
|
+
*/
|
|
182
|
+
export async function findLeastUsedTag(): Promise<{ name: string, count: number } | null> {
|
|
183
|
+
const db = await getDb()
|
|
184
|
+
try {
|
|
185
|
+
const result = await (db
|
|
186
|
+
.selectFrom('taggable_models')
|
|
187
|
+
.innerJoin('taggables', 'taggables.id', '=', 'taggable_models.tag_id')
|
|
188
|
+
.select([
|
|
189
|
+
'taggables.name',
|
|
190
|
+
])
|
|
191
|
+
.groupBy('taggables.name') as any)
|
|
192
|
+
.orderBy('taggables.name', 'asc')
|
|
193
|
+
.executeTakeFirst()
|
|
194
|
+
|
|
195
|
+
if (!result) {
|
|
196
|
+
return null
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return {
|
|
200
|
+
name: (result as Record<string, unknown>).name as string,
|
|
201
|
+
count: Number((result as Record<string, unknown>).usage_count || 0),
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
catch (error) {
|
|
205
|
+
if (error instanceof Error) {
|
|
206
|
+
throw new TypeError(`Failed to find least used tag: ${error.message}`)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
throw error
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Fetch tags with their post counts
|
|
215
|
+
*
|
|
216
|
+
* @returns Array of tags with their post counts
|
|
217
|
+
*/
|
|
218
|
+
export async function fetchTagsWithPostCounts(): Promise<Array<{ name: string, postCount: number }>> {
|
|
219
|
+
const db = await getDb()
|
|
220
|
+
try {
|
|
221
|
+
const result = await (db
|
|
222
|
+
.selectFrom('taggables')
|
|
223
|
+
.leftJoin('taggable_models', 'taggables.id', '=', 'taggable_models.tag_id')
|
|
224
|
+
.select([
|
|
225
|
+
'taggables.name',
|
|
226
|
+
])
|
|
227
|
+
.groupBy('taggables.name') as any)
|
|
228
|
+
.orderBy('taggables.name', 'desc')
|
|
229
|
+
.limit(10)
|
|
230
|
+
.execute()
|
|
231
|
+
|
|
232
|
+
return (result as Record<string, unknown>[]).map((row: Record<string, unknown>) => ({
|
|
233
|
+
name: row.name as string,
|
|
234
|
+
postCount: Number(row.post_count || 0),
|
|
235
|
+
}))
|
|
236
|
+
}
|
|
237
|
+
catch (error) {
|
|
238
|
+
if (error instanceof Error) {
|
|
239
|
+
throw new TypeError(`Failed to fetch tags with post counts: ${error.message}`)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
throw error
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Fetch tag distribution data for a donut graph
|
|
248
|
+
*
|
|
249
|
+
* @returns Array of tags with their counts and percentages
|
|
250
|
+
*/
|
|
251
|
+
export async function fetchTagDistribution(): Promise<Array<{ name: string, count: number, percentage: number }>> {
|
|
252
|
+
const db = await getDb()
|
|
253
|
+
try {
|
|
254
|
+
const result = await (db
|
|
255
|
+
.selectFrom('taggables')
|
|
256
|
+
.leftJoin('taggable_models', 'taggables.id', '=', 'taggable_models.tag_id')
|
|
257
|
+
.select([
|
|
258
|
+
'taggables.name',
|
|
259
|
+
])
|
|
260
|
+
.groupBy('taggables.name') as any)
|
|
261
|
+
.orderBy('taggables.name', 'desc')
|
|
262
|
+
.execute()
|
|
263
|
+
|
|
264
|
+
const typedResult = result as Record<string, unknown>[]
|
|
265
|
+
|
|
266
|
+
// Calculate total count for percentage calculation
|
|
267
|
+
const totalCount = typedResult.reduce((sum: number, row: Record<string, unknown>) => sum + Number(row.count || 0), 0)
|
|
268
|
+
|
|
269
|
+
return typedResult.map((row: Record<string, unknown>) => ({
|
|
270
|
+
name: row.name as string,
|
|
271
|
+
count: Number(row.count || 0),
|
|
272
|
+
percentage: totalCount > 0 ? (Number(row.count || 0) / totalCount) * 100 : 0,
|
|
273
|
+
}))
|
|
274
|
+
}
|
|
275
|
+
catch (error) {
|
|
276
|
+
if (error instanceof Error) {
|
|
277
|
+
throw new TypeError(`Failed to fetch tag distribution: ${error.message}`)
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
throw error
|
|
281
|
+
}
|
|
282
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import type { TaggableTable } from '@stacksjs/orm'
|
|
2
|
+
import { getDb } from '../database'
|
|
3
|
+
import { slugify } from 'ts-slug'
|
|
4
|
+
|
|
5
|
+
interface TagData {
|
|
6
|
+
name: string
|
|
7
|
+
description?: string
|
|
8
|
+
is_active?: boolean
|
|
9
|
+
taggable_id?: number
|
|
10
|
+
taggable_type: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Find or create multiple tags by their names
|
|
15
|
+
*
|
|
16
|
+
* @param names Array of tag names to process
|
|
17
|
+
* @param taggableType The type of model these tags belong to
|
|
18
|
+
* @returns Array of tag IDs
|
|
19
|
+
*/
|
|
20
|
+
export async function findOrCreateMany(names: string[], taggableType: string): Promise<number[]> {
|
|
21
|
+
const tagIds: number[] = []
|
|
22
|
+
|
|
23
|
+
for (const name of names) {
|
|
24
|
+
const tag = await findOrCreate({ name, taggable_type: taggableType })
|
|
25
|
+
tagIds.push(tag.id!)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return tagIds
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Find or create a single tag
|
|
33
|
+
*
|
|
34
|
+
* @param data The tag data
|
|
35
|
+
* @returns The found or created tag
|
|
36
|
+
*/
|
|
37
|
+
export async function findOrCreate(data: TagData): Promise<TaggableTable> {
|
|
38
|
+
const db = await getDb()
|
|
39
|
+
try {
|
|
40
|
+
// Try to find existing tag
|
|
41
|
+
const existingTag = await db
|
|
42
|
+
.selectFrom('taggables')
|
|
43
|
+
.selectAll()
|
|
44
|
+
.where('name', '=', data.name)
|
|
45
|
+
.executeTakeFirst()
|
|
46
|
+
|
|
47
|
+
if (existingTag)
|
|
48
|
+
return existingTag as unknown as TaggableTable
|
|
49
|
+
|
|
50
|
+
// If not found, create new tag
|
|
51
|
+
return await store(data)
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
if (error instanceof Error) {
|
|
55
|
+
throw new TypeError(`Failed to find or create tag: ${error.message}`)
|
|
56
|
+
}
|
|
57
|
+
throw error
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Create a new tag
|
|
63
|
+
*
|
|
64
|
+
* @param data The tag data to store
|
|
65
|
+
* @returns The newly created tag record
|
|
66
|
+
*/
|
|
67
|
+
export async function store(data: TagData): Promise<TaggableTable> {
|
|
68
|
+
const db = await getDb()
|
|
69
|
+
try {
|
|
70
|
+
if (!data.name || data.name.trim() === '') {
|
|
71
|
+
throw new Error('Tag name is required')
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (!data.taggable_type || data.taggable_type.trim() === '') {
|
|
75
|
+
throw new Error('Tag taggable_type is required')
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const slug = slugify(data.name)
|
|
79
|
+
|
|
80
|
+
// Enforce unique slugs
|
|
81
|
+
const existingSlug = await db
|
|
82
|
+
.selectFrom('taggables')
|
|
83
|
+
.selectAll()
|
|
84
|
+
.where('slug', '=', slug)
|
|
85
|
+
.executeTakeFirst()
|
|
86
|
+
|
|
87
|
+
if (existingSlug) {
|
|
88
|
+
throw new Error(`Tag with unique slug "${slug}" already exists`)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const tagData: Record<string, unknown> = {
|
|
92
|
+
name: data.name,
|
|
93
|
+
slug,
|
|
94
|
+
description: data.description,
|
|
95
|
+
is_active: data.is_active ?? true,
|
|
96
|
+
taggable_type: data.taggable_type,
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (data.taggable_id !== undefined) {
|
|
100
|
+
tagData.taggable_id = data.taggable_id
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const result = await db
|
|
104
|
+
.insertInto('taggables')
|
|
105
|
+
.values(tagData)
|
|
106
|
+
.returningAll()
|
|
107
|
+
.executeTakeFirst()
|
|
108
|
+
|
|
109
|
+
if (!result) {
|
|
110
|
+
throw new Error('Failed to create tag')
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return result as unknown as TaggableTable
|
|
114
|
+
}
|
|
115
|
+
catch (error) {
|
|
116
|
+
if (error instanceof Error) {
|
|
117
|
+
throw new TypeError(`Failed to store tag: ${error.message}`)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
throw error
|
|
121
|
+
}
|
|
122
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { TaggableTable } from '@stacksjs/orm'
|
|
2
|
+
import { getDb } from '../database'
|
|
3
|
+
import { uniqueSlug } from '@stacksjs/slug'
|
|
4
|
+
|
|
5
|
+
interface UpdateTagData {
|
|
6
|
+
id: number
|
|
7
|
+
name?: string
|
|
8
|
+
slug?: string
|
|
9
|
+
description?: string
|
|
10
|
+
is_active?: boolean
|
|
11
|
+
taggable_id?: number
|
|
12
|
+
taggable_type?: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Update a tag
|
|
17
|
+
*
|
|
18
|
+
* @param id The tag id
|
|
19
|
+
* @param data The tag data to update
|
|
20
|
+
* @returns The updated tag record
|
|
21
|
+
*/
|
|
22
|
+
export async function update(data: UpdateTagData): Promise<TaggableTable> {
|
|
23
|
+
const db = await getDb()
|
|
24
|
+
try {
|
|
25
|
+
const id = data.id
|
|
26
|
+
|
|
27
|
+
if (!id) {
|
|
28
|
+
throw new Error('Tag ID is required for update')
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (data.name !== undefined) {
|
|
32
|
+
if (data.name.trim() === '') {
|
|
33
|
+
throw new Error('Tag name cannot be empty')
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const slug = await uniqueSlug(data.name, { table: 'taggables', column: 'slug' })
|
|
37
|
+
|
|
38
|
+
// Enforce unique slugs on update
|
|
39
|
+
const existingSlug = await db
|
|
40
|
+
.selectFrom('taggables')
|
|
41
|
+
.selectAll()
|
|
42
|
+
.where('slug', '=', slug)
|
|
43
|
+
.where('id', '!=', id)
|
|
44
|
+
.executeTakeFirst()
|
|
45
|
+
|
|
46
|
+
if (existingSlug) {
|
|
47
|
+
throw new Error(`Tag with unique slug "${slug}" already exists`)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
data.slug = slug
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Remove id from the data to avoid updating it
|
|
54
|
+
const { id: _id, ...updateData } = data
|
|
55
|
+
|
|
56
|
+
const result = await db
|
|
57
|
+
.updateTable('taggables')
|
|
58
|
+
.set(updateData as unknown as Record<string, unknown>)
|
|
59
|
+
.where('id', '=', id)
|
|
60
|
+
.returningAll()
|
|
61
|
+
.executeTakeFirst()
|
|
62
|
+
|
|
63
|
+
if (!result)
|
|
64
|
+
throw new Error('Failed to update tag')
|
|
65
|
+
|
|
66
|
+
return result as unknown as TaggableTable
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
if (error instanceof Error)
|
|
70
|
+
throw new TypeError(`Failed to update tag: ${error.message}`)
|
|
71
|
+
|
|
72
|
+
throw error
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it } from 'bun:test'
|
|
2
|
+
import { refreshDatabase } from './setup'
|
|
3
|
+
import { destroy } from '../categorizables/destroy'
|
|
4
|
+
import { store } from '../categorizables/store'
|
|
5
|
+
|
|
6
|
+
beforeEach(async () => {
|
|
7
|
+
await refreshDatabase()
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
describe('Category Module', () => {
|
|
11
|
+
describe('store', () => {
|
|
12
|
+
it('should create multiple categories for the same post', async () => {
|
|
13
|
+
const firstCategory = await store({
|
|
14
|
+
name: 'Technology',
|
|
15
|
+
description: 'Technology related content',
|
|
16
|
+
categorizable_type: 'posts',
|
|
17
|
+
is_active: true,
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
const secondCategory = await store({
|
|
21
|
+
name: 'Programming',
|
|
22
|
+
description: 'Programming related content',
|
|
23
|
+
categorizable_type: 'posts',
|
|
24
|
+
is_active: true,
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
expect(firstCategory).toBeDefined()
|
|
28
|
+
expect(secondCategory).toBeDefined()
|
|
29
|
+
expect(firstCategory?.categorizable_type).toBe(secondCategory?.categorizable_type)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('should throw an error when trying to create a category with invalid data', async () => {
|
|
33
|
+
const categoryData = {
|
|
34
|
+
name: '', // Empty name should fail
|
|
35
|
+
description: '',
|
|
36
|
+
categorizable_type: '',
|
|
37
|
+
is_active: false,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
await store(categoryData)
|
|
42
|
+
expect(true).toBe(false) // This line should not be reached
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
expect(error).toBeDefined()
|
|
46
|
+
expect(error instanceof Error).toBe(true)
|
|
47
|
+
}
|
|
48
|
+
})
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
describe('destroy', () => {
|
|
52
|
+
it('should throw an error when trying to delete a non-existent category', async () => {
|
|
53
|
+
const nonExistentId = 99999999
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
await destroy(nonExistentId)
|
|
57
|
+
expect(true).toBe(false) // This line should not be reached
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
expect(error).toBeDefined()
|
|
61
|
+
expect(error instanceof Error).toBe(true)
|
|
62
|
+
expect((error as Error).message).toContain(`Category with ID ${nonExistentId} not found`)
|
|
63
|
+
}
|
|
64
|
+
})
|
|
65
|
+
})
|
|
66
|
+
})
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CMS package test harness.
|
|
3
|
+
*
|
|
4
|
+
* This suite used to import `refreshDatabase` from
|
|
5
|
+
* `@stacksjs/testing/database`, which (pre-existing breakage) never
|
|
6
|
+
* worked from a package directory: `bun test` run from
|
|
7
|
+
* storage/framework/core/cms doesn't load the repo-root `.env`, so the
|
|
8
|
+
* driver fell back to mysql and the helper's dead kysely-era
|
|
9
|
+
* `sql\`...\`.execute(db)` calls threw in every `beforeEach`.
|
|
10
|
+
*
|
|
11
|
+
* Strategy (mirrors auth/tests/password-reset-revocation.test.ts): pin
|
|
12
|
+
* env to a throwaway SQLite file BEFORE any framework module loads,
|
|
13
|
+
* then hand-create the two trait tables the categorizables module
|
|
14
|
+
* touches. `categorizables`/`categorizable_models` are trait tables
|
|
15
|
+
* (created by the categorizable model trait, not a model definition),
|
|
16
|
+
* so there is no model file to derive them from.
|
|
17
|
+
*
|
|
18
|
+
* Wired up via this package's bunfig.toml `[test] preload`, so the
|
|
19
|
+
* env pin always lands before any test file's own imports evaluate.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { existsSync, unlinkSync } from 'node:fs'
|
|
23
|
+
import { tmpdir } from 'node:os'
|
|
24
|
+
import { join } from 'node:path'
|
|
25
|
+
import process from 'node:process'
|
|
26
|
+
|
|
27
|
+
const DB_PATH = join(tmpdir(), `stacks-cms-${process.pid}.sqlite`)
|
|
28
|
+
process.env.DB_CONNECTION = 'sqlite'
|
|
29
|
+
process.env.DB_DATABASE_PATH = DB_PATH
|
|
30
|
+
process.env.APP_ENV = 'testing'
|
|
31
|
+
|
|
32
|
+
// Dynamic import AFTER the env pin so the lazy `db` proxy and the
|
|
33
|
+
// config loader can't capture a different connection first.
|
|
34
|
+
const { acquireDbConfigLock, db, ensureDatabaseConfigLoaded, initializeDbConfig } = await import('@stacksjs/database')
|
|
35
|
+
|
|
36
|
+
// Holds `initializeDbConfig`'s process-wide config mutex (stacksjs/stacks#1862)
|
|
37
|
+
// for this module's entire lifetime — no `describe`/`afterAll` boundary exists
|
|
38
|
+
// here (this is a shared fixture imported by many test files, not a test file
|
|
39
|
+
// itself), so it's released on process exit alongside the file cleanup below.
|
|
40
|
+
const releaseDbConfigLock = await acquireDbConfigLock()
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Drain the one-shot async config reload, then force our temp SQLite
|
|
44
|
+
* config so a late-resolving override can't re-point the shared `db`
|
|
45
|
+
* proxy at a different database mid-test.
|
|
46
|
+
*/
|
|
47
|
+
async function forceConfig(): Promise<void> {
|
|
48
|
+
await ensureDatabaseConfigLoaded()
|
|
49
|
+
initializeDbConfig({
|
|
50
|
+
app: { env: 'testing' },
|
|
51
|
+
database: {
|
|
52
|
+
default: 'sqlite',
|
|
53
|
+
connections: { sqlite: { database: DB_PATH, prefix: '' } },
|
|
54
|
+
},
|
|
55
|
+
})
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Stale file from a recycled pid would otherwise leak a previous run's
|
|
59
|
+
// schema/rows into this one.
|
|
60
|
+
for (const suffix of ['', '-wal', '-shm']) {
|
|
61
|
+
if (existsSync(`${DB_PATH}${suffix}`))
|
|
62
|
+
unlinkSync(`${DB_PATH}${suffix}`)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
await forceConfig()
|
|
66
|
+
|
|
67
|
+
await db.unsafe(`
|
|
68
|
+
CREATE TABLE IF NOT EXISTS categorizables (
|
|
69
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
70
|
+
name VARCHAR(255) NOT NULL,
|
|
71
|
+
slug VARCHAR(255) NOT NULL,
|
|
72
|
+
description TEXT,
|
|
73
|
+
categorizable_type VARCHAR(255) NOT NULL,
|
|
74
|
+
is_active BOOLEAN NOT NULL DEFAULT 1,
|
|
75
|
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
76
|
+
updated_at TIMESTAMP
|
|
77
|
+
)
|
|
78
|
+
`).execute()
|
|
79
|
+
|
|
80
|
+
await db.unsafe(`
|
|
81
|
+
CREATE TABLE IF NOT EXISTS categorizable_models (
|
|
82
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
83
|
+
category_id INTEGER NOT NULL,
|
|
84
|
+
categorizable_type VARCHAR(255) NOT NULL,
|
|
85
|
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
86
|
+
updated_at TIMESTAMP
|
|
87
|
+
)
|
|
88
|
+
`).execute()
|
|
89
|
+
|
|
90
|
+
const tableNames = ['categorizables', 'categorizable_models']
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Wipe the trait tables between tests. DELETE (not DROP) keeps the
|
|
94
|
+
* schema warm.
|
|
95
|
+
*/
|
|
96
|
+
export async function refreshDatabase(): Promise<void> {
|
|
97
|
+
await forceConfig()
|
|
98
|
+
for (const table of tableNames) {
|
|
99
|
+
await db.unsafe(`DELETE FROM ${table}`).execute()
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Cleanup on process exit, NOT in an `afterAll`: when this module is
|
|
104
|
+
// imported by a test file mid-run (e.g. `bun test tests/` from the
|
|
105
|
+
// repo root, where the path filter also matches this suite), an
|
|
106
|
+
// `afterAll` registered during module evaluation attaches to the FIRST
|
|
107
|
+
// importing file's scope and unlinks the shared DB while later files'
|
|
108
|
+
// connections still use it (SQLITE_IOERR_VNODE).
|
|
109
|
+
process.on('exit', () => {
|
|
110
|
+
for (const suffix of ['', '-wal', '-shm']) {
|
|
111
|
+
try {
|
|
112
|
+
if (existsSync(`${DB_PATH}${suffix}`))
|
|
113
|
+
unlinkSync(`${DB_PATH}${suffix}`)
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
// Best effort — pid-named file in tmpdir, the OS reclaims it.
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
releaseDbConfigLock()
|
|
120
|
+
})
|