@nextsparkjs/theme-default 0.1.0-beta.22 → 0.1.0-beta.24
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/LICENSE +21 -0
- package/package.json +3 -3
- package/tests/jest/__mocks__/jose.js +22 -0
- package/tests/jest/__mocks__/next-server.js +56 -0
- package/tests/jest/jest.config.cjs +127 -0
- package/tests/jest/services/tasks.service.test.ts +707 -0
- package/tests/jest/setup.ts +170 -0
- package/tests/jest/jest.config.ts +0 -81
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 NextSpark
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nextsparkjs/theme-default",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.24",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./config/theme.config.ts",
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
],
|
|
10
10
|
"dependencies": {},
|
|
11
11
|
"peerDependencies": {
|
|
12
|
-
"@nextsparkjs/core": "
|
|
12
|
+
"@nextsparkjs/core": "0.1.0-beta.24",
|
|
13
13
|
"@tanstack/react-query": "^5.0.0",
|
|
14
14
|
"lucide-react": "^0.539.0",
|
|
15
15
|
"next": "^15.0.0",
|
|
@@ -23,4 +23,4 @@
|
|
|
23
23
|
"type": "theme",
|
|
24
24
|
"name": "default"
|
|
25
25
|
}
|
|
26
|
-
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mock for jose package
|
|
3
|
+
* Resolves ES module import issues in Jest tests
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
module.exports = {
|
|
7
|
+
jwtVerify: jest.fn(),
|
|
8
|
+
SignJWT: jest.fn().mockImplementation(() => ({
|
|
9
|
+
setProtectedHeader: jest.fn().mockReturnThis(),
|
|
10
|
+
setIssuedAt: jest.fn().mockReturnThis(),
|
|
11
|
+
setExpirationTime: jest.fn().mockReturnThis(),
|
|
12
|
+
sign: jest.fn().mockResolvedValue('mock-jwt-token')
|
|
13
|
+
})),
|
|
14
|
+
importJWK: jest.fn(),
|
|
15
|
+
generateSecret: jest.fn(),
|
|
16
|
+
createRemoteJWKSet: jest.fn(),
|
|
17
|
+
errors: {
|
|
18
|
+
JWTExpired: class JWTExpired extends Error {},
|
|
19
|
+
JWTInvalid: class JWTInvalid extends Error {},
|
|
20
|
+
JWTClaimValidationFailed: class JWTClaimValidationFailed extends Error {}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mock for next/server
|
|
3
|
+
* Provides NextRequest and NextResponse mocks for API testing
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
class MockNextRequest {
|
|
7
|
+
constructor(url, options = {}) {
|
|
8
|
+
this.url = url
|
|
9
|
+
this.method = options.method || 'GET'
|
|
10
|
+
this.headers = new Map(Object.entries(options.headers || {}))
|
|
11
|
+
this._body = options.body
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async json() {
|
|
15
|
+
if (!this._body) return {}
|
|
16
|
+
try {
|
|
17
|
+
return typeof this._body === 'string' ? JSON.parse(this._body) : this._body
|
|
18
|
+
} catch {
|
|
19
|
+
throw new SyntaxError('Invalid JSON')
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async text() {
|
|
24
|
+
return this._body || ''
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
class MockNextResponse {
|
|
29
|
+
constructor(body, options = {}) {
|
|
30
|
+
this.body = body
|
|
31
|
+
this.status = options.status || 200
|
|
32
|
+
this.statusText = options.statusText || 'OK'
|
|
33
|
+
this.headers = new Map(Object.entries(options.headers || {}))
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async json() {
|
|
37
|
+
if (!this.body) return {}
|
|
38
|
+
try {
|
|
39
|
+
return typeof this.body === 'string' ? JSON.parse(this.body) : this.body
|
|
40
|
+
} catch {
|
|
41
|
+
throw new SyntaxError('Invalid JSON')
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
static json(data, options = {}) {
|
|
46
|
+
return new MockNextResponse(data, {
|
|
47
|
+
...options,
|
|
48
|
+
headers: { 'Content-Type': 'application/json', ...options.headers }
|
|
49
|
+
})
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
module.exports = {
|
|
54
|
+
NextRequest: MockNextRequest,
|
|
55
|
+
NextResponse: MockNextResponse
|
|
56
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Jest Configuration for Default Theme
|
|
3
|
+
*
|
|
4
|
+
* This config works in both monorepo and npm mode.
|
|
5
|
+
* Run with: pnpm test:theme
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const path = require('path')
|
|
9
|
+
const fs = require('fs')
|
|
10
|
+
|
|
11
|
+
// Paths relative to this config file
|
|
12
|
+
const themeTestsRoot = __dirname
|
|
13
|
+
const themeRoot = path.resolve(__dirname, '../..')
|
|
14
|
+
|
|
15
|
+
// In monorepo: themes/default/tests/jest -> apps/dev (via symlink in contents/)
|
|
16
|
+
// In npm mode: contents/themes/default/tests/jest -> project root (5 levels up)
|
|
17
|
+
const monorepoRepoRoot = path.resolve(__dirname, '../../../..')
|
|
18
|
+
const npmModeRoot = path.resolve(__dirname, '../../../../..')
|
|
19
|
+
|
|
20
|
+
// Detect if running in npm mode (no packages/core folder) vs monorepo
|
|
21
|
+
const isNpmMode = !fs.existsSync(path.join(monorepoRepoRoot, 'packages/core'))
|
|
22
|
+
|
|
23
|
+
// In monorepo, use apps/dev as rootDir since contents/ symlinks to themes/
|
|
24
|
+
const monorepoAppRoot = path.join(monorepoRepoRoot, 'apps/dev')
|
|
25
|
+
const projectRoot = isNpmMode ? npmModeRoot : monorepoAppRoot
|
|
26
|
+
|
|
27
|
+
// Module name mapper based on mode
|
|
28
|
+
const moduleNameMapper = isNpmMode
|
|
29
|
+
? {
|
|
30
|
+
// NPM mode: resolve from node_modules
|
|
31
|
+
'^@nextsparkjs/core/(.*)$': '@nextsparkjs/core/$1',
|
|
32
|
+
'^@nextsparkjs/core$': '@nextsparkjs/core',
|
|
33
|
+
'^@/contents/(.*)$': '<rootDir>/contents/$1',
|
|
34
|
+
'^@/entities/(.*)$': '<rootDir>/contents/entities/$1',
|
|
35
|
+
'^@/plugins/(.*)$': '<rootDir>/contents/plugins/$1',
|
|
36
|
+
'^@/themes/(.*)$': '<rootDir>/contents/themes/$1',
|
|
37
|
+
'^@/(.*)$': '<rootDir>/$1',
|
|
38
|
+
// Mocks from theme-local folder
|
|
39
|
+
'next/server': path.join(themeTestsRoot, '__mocks__/next-server.js'),
|
|
40
|
+
'^jose$': path.join(themeTestsRoot, '__mocks__/jose.js'),
|
|
41
|
+
'^jose/(.*)$': path.join(themeTestsRoot, '__mocks__/jose.js'),
|
|
42
|
+
}
|
|
43
|
+
: {
|
|
44
|
+
// Monorepo mode: resolve from packages/core/src (rootDir is apps/dev)
|
|
45
|
+
'^@nextsparkjs/core/(.*)$': '<rootDir>/../../packages/core/src/$1',
|
|
46
|
+
'^@nextsparkjs/core$': '<rootDir>/../../packages/core/src',
|
|
47
|
+
'^@/contents/(.*)$': '<rootDir>/contents/$1',
|
|
48
|
+
'^@/entities/(.*)$': '<rootDir>/contents/entities/$1',
|
|
49
|
+
'^@/plugins/(.*)$': '<rootDir>/contents/plugins/$1',
|
|
50
|
+
'^@/themes/(.*)$': '<rootDir>/contents/themes/$1',
|
|
51
|
+
'^@/(.*)$': '<rootDir>/$1',
|
|
52
|
+
// Mocks from core
|
|
53
|
+
'next/server': '<rootDir>/../../packages/core/tests/jest/__mocks__/next-server.js',
|
|
54
|
+
'^jose$': '<rootDir>/../../packages/core/tests/jest/__mocks__/jose.js',
|
|
55
|
+
'^jose/(.*)$': '<rootDir>/../../packages/core/tests/jest/__mocks__/jose.js',
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Setup files based on mode
|
|
59
|
+
const setupFilesAfterEnv = isNpmMode
|
|
60
|
+
? [
|
|
61
|
+
// NPM mode: use theme's local setup only (it includes everything needed)
|
|
62
|
+
path.join(themeTestsRoot, 'setup.ts'),
|
|
63
|
+
]
|
|
64
|
+
: [
|
|
65
|
+
// Monorepo mode: use local core setup (rootDir is apps/dev)
|
|
66
|
+
'<rootDir>/../../packages/core/tests/jest/setup.ts',
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
/** @type {import('jest').Config} */
|
|
70
|
+
module.exports = {
|
|
71
|
+
displayName: 'theme-default',
|
|
72
|
+
rootDir: projectRoot,
|
|
73
|
+
|
|
74
|
+
// Use roots to explicitly set test location (handles symlinks better)
|
|
75
|
+
roots: [themeTestsRoot],
|
|
76
|
+
|
|
77
|
+
// Test file patterns
|
|
78
|
+
testMatch: [
|
|
79
|
+
'**/*.{test,spec}.{js,ts,tsx}',
|
|
80
|
+
],
|
|
81
|
+
testPathIgnorePatterns: [
|
|
82
|
+
'<rootDir>/node_modules/',
|
|
83
|
+
'<rootDir>/.next/',
|
|
84
|
+
],
|
|
85
|
+
|
|
86
|
+
// Preset and environment
|
|
87
|
+
preset: 'ts-jest',
|
|
88
|
+
testEnvironment: 'jsdom',
|
|
89
|
+
|
|
90
|
+
// Module resolution
|
|
91
|
+
moduleNameMapper,
|
|
92
|
+
|
|
93
|
+
// Setup files
|
|
94
|
+
setupFilesAfterEnv,
|
|
95
|
+
|
|
96
|
+
// Transform configuration
|
|
97
|
+
transform: {
|
|
98
|
+
'^.+\\.(ts|tsx)$': ['ts-jest', {
|
|
99
|
+
tsconfig: path.join(projectRoot, 'tsconfig.json'),
|
|
100
|
+
}],
|
|
101
|
+
},
|
|
102
|
+
|
|
103
|
+
// Transform ignore patterns - allow TypeScript from core's jest-setup
|
|
104
|
+
transformIgnorePatterns: [
|
|
105
|
+
'node_modules/(?!(uncrypto|better-auth|@noble|.*jose.*|remark.*|unified.*|@nextsparkjs/core/tests|.*\\.mjs$))',
|
|
106
|
+
'node_modules/\\.pnpm/(?!(.*uncrypto.*|.*better-auth.*|.*@noble.*|.*jose.*|.*remark.*|.*unified.*|@nextsparkjs.*core.*tests|.*\\.mjs$))',
|
|
107
|
+
],
|
|
108
|
+
|
|
109
|
+
// File extensions
|
|
110
|
+
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json'],
|
|
111
|
+
|
|
112
|
+
// Test timeout
|
|
113
|
+
testTimeout: 10000,
|
|
114
|
+
|
|
115
|
+
// Verbose output
|
|
116
|
+
verbose: true,
|
|
117
|
+
|
|
118
|
+
// Force exit after tests complete
|
|
119
|
+
forceExit: true,
|
|
120
|
+
|
|
121
|
+
// Disable watchman for symlink support
|
|
122
|
+
watchman: false,
|
|
123
|
+
|
|
124
|
+
// Coverage output directory
|
|
125
|
+
coverageDirectory: path.join(themeTestsRoot, 'coverage'),
|
|
126
|
+
coverageReporters: ['text', 'lcov', 'html'],
|
|
127
|
+
}
|
|
@@ -0,0 +1,707 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit Tests - TasksService
|
|
3
|
+
*
|
|
4
|
+
* Tests for the TasksService class methods:
|
|
5
|
+
* - Input validation (required fields, empty strings)
|
|
6
|
+
* - Data transformation (database null → undefined)
|
|
7
|
+
* - Default values for optional fields
|
|
8
|
+
* - Error handling
|
|
9
|
+
*
|
|
10
|
+
* Focus on business logic WITHOUT actual database calls.
|
|
11
|
+
* Database functions are mocked to isolate the service logic.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
// Mock the database module before importing the service
|
|
15
|
+
jest.mock('@nextsparkjs/core/lib/db', () => ({
|
|
16
|
+
queryOneWithRLS: jest.fn(),
|
|
17
|
+
queryWithRLS: jest.fn(),
|
|
18
|
+
}))
|
|
19
|
+
|
|
20
|
+
import { TasksService } from '@/themes/default/entities/tasks/tasks.service'
|
|
21
|
+
import type { Task, TaskStatus, TaskPriority } from '@/themes/default/entities/tasks/tasks.types'
|
|
22
|
+
|
|
23
|
+
// Get the mocked functions
|
|
24
|
+
const mockQueryOneWithRLS = jest.requireMock('@nextsparkjs/core/lib/db').queryOneWithRLS
|
|
25
|
+
const mockQueryWithRLS = jest.requireMock('@nextsparkjs/core/lib/db').queryWithRLS
|
|
26
|
+
|
|
27
|
+
// Helper to create a mock database task row
|
|
28
|
+
const createMockDbTask = (overrides = {}) => ({
|
|
29
|
+
id: 'task-123',
|
|
30
|
+
title: 'Test Task',
|
|
31
|
+
description: 'Test description',
|
|
32
|
+
status: 'todo' as TaskStatus,
|
|
33
|
+
priority: 'medium' as TaskPriority,
|
|
34
|
+
tags: ['tag1', 'tag2'],
|
|
35
|
+
dueDate: '2025-12-31',
|
|
36
|
+
estimatedHours: 5,
|
|
37
|
+
completed: false,
|
|
38
|
+
createdAt: '2025-01-01T00:00:00Z',
|
|
39
|
+
updatedAt: '2025-01-01T00:00:00Z',
|
|
40
|
+
...overrides,
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
describe('TasksService', () => {
|
|
44
|
+
beforeEach(() => {
|
|
45
|
+
jest.clearAllMocks()
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
// ============================================================
|
|
49
|
+
// getById
|
|
50
|
+
// ============================================================
|
|
51
|
+
describe('getById', () => {
|
|
52
|
+
describe('Input Validation', () => {
|
|
53
|
+
it('should throw error when id is empty', async () => {
|
|
54
|
+
await expect(TasksService.getById('', 'user-123'))
|
|
55
|
+
.rejects.toThrow('Task ID is required')
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('should throw error when id is whitespace only', async () => {
|
|
59
|
+
await expect(TasksService.getById(' ', 'user-123'))
|
|
60
|
+
.rejects.toThrow('Task ID is required')
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('should throw error when userId is empty', async () => {
|
|
64
|
+
await expect(TasksService.getById('task-123', ''))
|
|
65
|
+
.rejects.toThrow('User ID is required for authentication')
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('should throw error when userId is whitespace only', async () => {
|
|
69
|
+
await expect(TasksService.getById('task-123', ' '))
|
|
70
|
+
.rejects.toThrow('User ID is required for authentication')
|
|
71
|
+
})
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
describe('Successful Retrieval', () => {
|
|
75
|
+
it('should return task when found', async () => {
|
|
76
|
+
const mockTask = createMockDbTask()
|
|
77
|
+
mockQueryOneWithRLS.mockResolvedValue(mockTask)
|
|
78
|
+
|
|
79
|
+
const result = await TasksService.getById('task-123', 'user-123')
|
|
80
|
+
|
|
81
|
+
expect(result).not.toBeNull()
|
|
82
|
+
expect(result?.id).toBe('task-123')
|
|
83
|
+
expect(result?.title).toBe('Test Task')
|
|
84
|
+
expect(mockQueryOneWithRLS).toHaveBeenCalledWith(
|
|
85
|
+
expect.stringContaining('SELECT'),
|
|
86
|
+
['task-123'],
|
|
87
|
+
'user-123'
|
|
88
|
+
)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('should return null when task not found', async () => {
|
|
92
|
+
mockQueryOneWithRLS.mockResolvedValue(null)
|
|
93
|
+
|
|
94
|
+
const result = await TasksService.getById('non-existent', 'user-123')
|
|
95
|
+
|
|
96
|
+
expect(result).toBeNull()
|
|
97
|
+
})
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
describe('Data Transformation', () => {
|
|
101
|
+
it('should transform null description to undefined', async () => {
|
|
102
|
+
const mockTask = createMockDbTask({ description: null })
|
|
103
|
+
mockQueryOneWithRLS.mockResolvedValue(mockTask)
|
|
104
|
+
|
|
105
|
+
const result = await TasksService.getById('task-123', 'user-123')
|
|
106
|
+
|
|
107
|
+
expect(result?.description).toBeUndefined()
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('should transform null tags to undefined', async () => {
|
|
111
|
+
const mockTask = createMockDbTask({ tags: null })
|
|
112
|
+
mockQueryOneWithRLS.mockResolvedValue(mockTask)
|
|
113
|
+
|
|
114
|
+
const result = await TasksService.getById('task-123', 'user-123')
|
|
115
|
+
|
|
116
|
+
expect(result?.tags).toBeUndefined()
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('should transform null dueDate to undefined', async () => {
|
|
120
|
+
const mockTask = createMockDbTask({ dueDate: null })
|
|
121
|
+
mockQueryOneWithRLS.mockResolvedValue(mockTask)
|
|
122
|
+
|
|
123
|
+
const result = await TasksService.getById('task-123', 'user-123')
|
|
124
|
+
|
|
125
|
+
expect(result?.dueDate).toBeUndefined()
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('should transform null estimatedHours to undefined', async () => {
|
|
129
|
+
const mockTask = createMockDbTask({ estimatedHours: null })
|
|
130
|
+
mockQueryOneWithRLS.mockResolvedValue(mockTask)
|
|
131
|
+
|
|
132
|
+
const result = await TasksService.getById('task-123', 'user-123')
|
|
133
|
+
|
|
134
|
+
expect(result?.estimatedHours).toBeUndefined()
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('should transform null completed to undefined', async () => {
|
|
138
|
+
const mockTask = createMockDbTask({ completed: null })
|
|
139
|
+
mockQueryOneWithRLS.mockResolvedValue(mockTask)
|
|
140
|
+
|
|
141
|
+
const result = await TasksService.getById('task-123', 'user-123')
|
|
142
|
+
|
|
143
|
+
expect(result?.completed).toBeUndefined()
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it('should preserve non-null values', async () => {
|
|
147
|
+
const mockTask = createMockDbTask()
|
|
148
|
+
mockQueryOneWithRLS.mockResolvedValue(mockTask)
|
|
149
|
+
|
|
150
|
+
const result = await TasksService.getById('task-123', 'user-123')
|
|
151
|
+
|
|
152
|
+
expect(result?.description).toBe('Test description')
|
|
153
|
+
expect(result?.tags).toEqual(['tag1', 'tag2'])
|
|
154
|
+
expect(result?.dueDate).toBe('2025-12-31')
|
|
155
|
+
expect(result?.estimatedHours).toBe(5)
|
|
156
|
+
expect(result?.completed).toBe(false)
|
|
157
|
+
})
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
describe('Error Handling', () => {
|
|
161
|
+
it('should wrap database errors with descriptive message', async () => {
|
|
162
|
+
mockQueryOneWithRLS.mockRejectedValue(new Error('Database connection failed'))
|
|
163
|
+
|
|
164
|
+
await expect(TasksService.getById('task-123', 'user-123'))
|
|
165
|
+
.rejects.toThrow('Database connection failed')
|
|
166
|
+
})
|
|
167
|
+
})
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
// ============================================================
|
|
171
|
+
// list
|
|
172
|
+
// ============================================================
|
|
173
|
+
describe('list', () => {
|
|
174
|
+
describe('Input Validation', () => {
|
|
175
|
+
it('should throw error when userId is empty', async () => {
|
|
176
|
+
await expect(TasksService.list(''))
|
|
177
|
+
.rejects.toThrow('User ID is required for authentication')
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
it('should throw error when userId is whitespace only', async () => {
|
|
181
|
+
await expect(TasksService.list(' '))
|
|
182
|
+
.rejects.toThrow('User ID is required for authentication')
|
|
183
|
+
})
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
describe('Default Options', () => {
|
|
187
|
+
it('should use default limit of 10', async () => {
|
|
188
|
+
mockQueryWithRLS
|
|
189
|
+
.mockResolvedValueOnce([{ count: '5' }]) // count query
|
|
190
|
+
.mockResolvedValueOnce([]) // data query
|
|
191
|
+
|
|
192
|
+
await TasksService.list('user-123')
|
|
193
|
+
|
|
194
|
+
// Second call is the data query with LIMIT
|
|
195
|
+
const dataQueryCall = mockQueryWithRLS.mock.calls[1]
|
|
196
|
+
expect(dataQueryCall[0]).toContain('LIMIT')
|
|
197
|
+
expect(dataQueryCall[1]).toContain(10) // default limit
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it('should use default offset of 0', async () => {
|
|
201
|
+
mockQueryWithRLS
|
|
202
|
+
.mockResolvedValueOnce([{ count: '5' }])
|
|
203
|
+
.mockResolvedValueOnce([])
|
|
204
|
+
|
|
205
|
+
await TasksService.list('user-123')
|
|
206
|
+
|
|
207
|
+
const dataQueryCall = mockQueryWithRLS.mock.calls[1]
|
|
208
|
+
expect(dataQueryCall[1]).toContain(0) // default offset
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
it('should use default orderBy createdAt DESC', async () => {
|
|
212
|
+
mockQueryWithRLS
|
|
213
|
+
.mockResolvedValueOnce([{ count: '5' }])
|
|
214
|
+
.mockResolvedValueOnce([])
|
|
215
|
+
|
|
216
|
+
await TasksService.list('user-123')
|
|
217
|
+
|
|
218
|
+
const dataQueryCall = mockQueryWithRLS.mock.calls[1]
|
|
219
|
+
expect(dataQueryCall[0]).toContain('"createdAt"')
|
|
220
|
+
expect(dataQueryCall[0]).toContain('DESC')
|
|
221
|
+
})
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
describe('Filtering', () => {
|
|
225
|
+
it('should filter by status when provided', async () => {
|
|
226
|
+
mockQueryWithRLS
|
|
227
|
+
.mockResolvedValueOnce([{ count: '2' }])
|
|
228
|
+
.mockResolvedValueOnce([])
|
|
229
|
+
|
|
230
|
+
await TasksService.list('user-123', { status: 'todo' })
|
|
231
|
+
|
|
232
|
+
const countQueryCall = mockQueryWithRLS.mock.calls[0]
|
|
233
|
+
expect(countQueryCall[0]).toContain('status = $1')
|
|
234
|
+
expect(countQueryCall[1]).toContain('todo')
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
it('should filter by priority when provided', async () => {
|
|
238
|
+
mockQueryWithRLS
|
|
239
|
+
.mockResolvedValueOnce([{ count: '2' }])
|
|
240
|
+
.mockResolvedValueOnce([])
|
|
241
|
+
|
|
242
|
+
await TasksService.list('user-123', { priority: 'high' })
|
|
243
|
+
|
|
244
|
+
const countQueryCall = mockQueryWithRLS.mock.calls[0]
|
|
245
|
+
expect(countQueryCall[0]).toContain('priority = $1')
|
|
246
|
+
expect(countQueryCall[1]).toContain('high')
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
it('should filter by teamId when provided', async () => {
|
|
250
|
+
mockQueryWithRLS
|
|
251
|
+
.mockResolvedValueOnce([{ count: '2' }])
|
|
252
|
+
.mockResolvedValueOnce([])
|
|
253
|
+
|
|
254
|
+
await TasksService.list('user-123', { teamId: 'team-456' })
|
|
255
|
+
|
|
256
|
+
const countQueryCall = mockQueryWithRLS.mock.calls[0]
|
|
257
|
+
expect(countQueryCall[0]).toContain('"teamId" = $1')
|
|
258
|
+
expect(countQueryCall[1]).toContain('team-456')
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
it('should combine multiple filters with AND', async () => {
|
|
262
|
+
mockQueryWithRLS
|
|
263
|
+
.mockResolvedValueOnce([{ count: '1' }])
|
|
264
|
+
.mockResolvedValueOnce([])
|
|
265
|
+
|
|
266
|
+
await TasksService.list('user-123', { status: 'todo', priority: 'high' })
|
|
267
|
+
|
|
268
|
+
const countQueryCall = mockQueryWithRLS.mock.calls[0]
|
|
269
|
+
expect(countQueryCall[0]).toContain('status = $1')
|
|
270
|
+
expect(countQueryCall[0]).toContain('priority = $2')
|
|
271
|
+
expect(countQueryCall[0]).toContain('AND')
|
|
272
|
+
})
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
describe('Ordering', () => {
|
|
276
|
+
const validOrderByFields = ['title', 'status', 'priority', 'dueDate', 'createdAt']
|
|
277
|
+
|
|
278
|
+
validOrderByFields.forEach(field => {
|
|
279
|
+
it(`should order by ${field} when specified`, async () => {
|
|
280
|
+
mockQueryWithRLS
|
|
281
|
+
.mockResolvedValueOnce([{ count: '5' }])
|
|
282
|
+
.mockResolvedValueOnce([])
|
|
283
|
+
|
|
284
|
+
await TasksService.list('user-123', { orderBy: field as any })
|
|
285
|
+
|
|
286
|
+
const dataQueryCall = mockQueryWithRLS.mock.calls[1]
|
|
287
|
+
// dueDate and createdAt need quotes in SQL
|
|
288
|
+
const expectedColumn = ['dueDate', 'createdAt'].includes(field)
|
|
289
|
+
? `"${field}"`
|
|
290
|
+
: field
|
|
291
|
+
expect(dataQueryCall[0]).toContain(expectedColumn)
|
|
292
|
+
})
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
it('should fallback to createdAt for invalid orderBy', async () => {
|
|
296
|
+
mockQueryWithRLS
|
|
297
|
+
.mockResolvedValueOnce([{ count: '5' }])
|
|
298
|
+
.mockResolvedValueOnce([])
|
|
299
|
+
|
|
300
|
+
await TasksService.list('user-123', { orderBy: 'invalidField' as any })
|
|
301
|
+
|
|
302
|
+
const dataQueryCall = mockQueryWithRLS.mock.calls[1]
|
|
303
|
+
expect(dataQueryCall[0]).toContain('"createdAt"')
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
it('should order ASC when orderDir is asc', async () => {
|
|
307
|
+
mockQueryWithRLS
|
|
308
|
+
.mockResolvedValueOnce([{ count: '5' }])
|
|
309
|
+
.mockResolvedValueOnce([])
|
|
310
|
+
|
|
311
|
+
await TasksService.list('user-123', { orderDir: 'asc' })
|
|
312
|
+
|
|
313
|
+
const dataQueryCall = mockQueryWithRLS.mock.calls[1]
|
|
314
|
+
expect(dataQueryCall[0]).toContain('ASC')
|
|
315
|
+
})
|
|
316
|
+
|
|
317
|
+
it('should order DESC when orderDir is desc', async () => {
|
|
318
|
+
mockQueryWithRLS
|
|
319
|
+
.mockResolvedValueOnce([{ count: '5' }])
|
|
320
|
+
.mockResolvedValueOnce([])
|
|
321
|
+
|
|
322
|
+
await TasksService.list('user-123', { orderDir: 'desc' })
|
|
323
|
+
|
|
324
|
+
const dataQueryCall = mockQueryWithRLS.mock.calls[1]
|
|
325
|
+
expect(dataQueryCall[0]).toContain('DESC')
|
|
326
|
+
})
|
|
327
|
+
})
|
|
328
|
+
|
|
329
|
+
describe('Result Structure', () => {
|
|
330
|
+
it('should return tasks array and total count', async () => {
|
|
331
|
+
const mockTasks = [createMockDbTask(), createMockDbTask({ id: 'task-456' })]
|
|
332
|
+
mockQueryWithRLS
|
|
333
|
+
.mockResolvedValueOnce([{ count: '10' }])
|
|
334
|
+
.mockResolvedValueOnce(mockTasks)
|
|
335
|
+
|
|
336
|
+
const result = await TasksService.list('user-123')
|
|
337
|
+
|
|
338
|
+
expect(result.tasks).toHaveLength(2)
|
|
339
|
+
expect(result.total).toBe(10)
|
|
340
|
+
})
|
|
341
|
+
|
|
342
|
+
it('should transform database rows to Task type', async () => {
|
|
343
|
+
const mockTask = createMockDbTask({ description: null, tags: null })
|
|
344
|
+
mockQueryWithRLS
|
|
345
|
+
.mockResolvedValueOnce([{ count: '1' }])
|
|
346
|
+
.mockResolvedValueOnce([mockTask])
|
|
347
|
+
|
|
348
|
+
const result = await TasksService.list('user-123')
|
|
349
|
+
|
|
350
|
+
expect(result.tasks[0].description).toBeUndefined()
|
|
351
|
+
expect(result.tasks[0].tags).toBeUndefined()
|
|
352
|
+
})
|
|
353
|
+
|
|
354
|
+
it('should return empty array when no tasks found', async () => {
|
|
355
|
+
mockQueryWithRLS
|
|
356
|
+
.mockResolvedValueOnce([{ count: '0' }])
|
|
357
|
+
.mockResolvedValueOnce([])
|
|
358
|
+
|
|
359
|
+
const result = await TasksService.list('user-123')
|
|
360
|
+
|
|
361
|
+
expect(result.tasks).toEqual([])
|
|
362
|
+
expect(result.total).toBe(0)
|
|
363
|
+
})
|
|
364
|
+
})
|
|
365
|
+
})
|
|
366
|
+
|
|
367
|
+
// ============================================================
|
|
368
|
+
// getByStatus
|
|
369
|
+
// ============================================================
|
|
370
|
+
describe('getByStatus', () => {
|
|
371
|
+
it('should call list with status filter', async () => {
|
|
372
|
+
mockQueryWithRLS
|
|
373
|
+
.mockResolvedValueOnce([{ count: '2' }])
|
|
374
|
+
.mockResolvedValueOnce([createMockDbTask(), createMockDbTask({ id: 'task-456' })])
|
|
375
|
+
|
|
376
|
+
const result = await TasksService.getByStatus('user-123', 'in-progress')
|
|
377
|
+
|
|
378
|
+
expect(result).toHaveLength(2)
|
|
379
|
+
const countQueryCall = mockQueryWithRLS.mock.calls[0]
|
|
380
|
+
expect(countQueryCall[1]).toContain('in-progress')
|
|
381
|
+
})
|
|
382
|
+
|
|
383
|
+
it('should order by priority DESC', async () => {
|
|
384
|
+
mockQueryWithRLS
|
|
385
|
+
.mockResolvedValueOnce([{ count: '1' }])
|
|
386
|
+
.mockResolvedValueOnce([createMockDbTask()])
|
|
387
|
+
|
|
388
|
+
await TasksService.getByStatus('user-123', 'todo')
|
|
389
|
+
|
|
390
|
+
const dataQueryCall = mockQueryWithRLS.mock.calls[1]
|
|
391
|
+
expect(dataQueryCall[0]).toContain('priority')
|
|
392
|
+
expect(dataQueryCall[0]).toContain('DESC')
|
|
393
|
+
})
|
|
394
|
+
|
|
395
|
+
it('should use large limit to get all matching tasks', async () => {
|
|
396
|
+
mockQueryWithRLS
|
|
397
|
+
.mockResolvedValueOnce([{ count: '500' }])
|
|
398
|
+
.mockResolvedValueOnce([])
|
|
399
|
+
|
|
400
|
+
await TasksService.getByStatus('user-123', 'todo')
|
|
401
|
+
|
|
402
|
+
const dataQueryCall = mockQueryWithRLS.mock.calls[1]
|
|
403
|
+
expect(dataQueryCall[1]).toContain(1000) // Large limit
|
|
404
|
+
})
|
|
405
|
+
})
|
|
406
|
+
|
|
407
|
+
// ============================================================
|
|
408
|
+
// getOverdue
|
|
409
|
+
// ============================================================
|
|
410
|
+
describe('getOverdue', () => {
|
|
411
|
+
describe('Input Validation', () => {
|
|
412
|
+
it('should throw error when userId is empty', async () => {
|
|
413
|
+
await expect(TasksService.getOverdue(''))
|
|
414
|
+
.rejects.toThrow('User ID is required for authentication')
|
|
415
|
+
})
|
|
416
|
+
|
|
417
|
+
it('should throw error when userId is whitespace only', async () => {
|
|
418
|
+
await expect(TasksService.getOverdue(' '))
|
|
419
|
+
.rejects.toThrow('User ID is required for authentication')
|
|
420
|
+
})
|
|
421
|
+
})
|
|
422
|
+
|
|
423
|
+
describe('Query', () => {
|
|
424
|
+
it('should query tasks with dueDate before today', async () => {
|
|
425
|
+
mockQueryWithRLS.mockResolvedValue([])
|
|
426
|
+
|
|
427
|
+
await TasksService.getOverdue('user-123')
|
|
428
|
+
|
|
429
|
+
const queryCall = mockQueryWithRLS.mock.calls[0]
|
|
430
|
+
expect(queryCall[0]).toContain('"dueDate" < CURRENT_DATE')
|
|
431
|
+
})
|
|
432
|
+
|
|
433
|
+
it('should exclude done tasks', async () => {
|
|
434
|
+
mockQueryWithRLS.mockResolvedValue([])
|
|
435
|
+
|
|
436
|
+
await TasksService.getOverdue('user-123')
|
|
437
|
+
|
|
438
|
+
const queryCall = mockQueryWithRLS.mock.calls[0]
|
|
439
|
+
expect(queryCall[0]).toContain("status != 'done'")
|
|
440
|
+
})
|
|
441
|
+
|
|
442
|
+
it('should exclude completed tasks', async () => {
|
|
443
|
+
mockQueryWithRLS.mockResolvedValue([])
|
|
444
|
+
|
|
445
|
+
await TasksService.getOverdue('user-123')
|
|
446
|
+
|
|
447
|
+
const queryCall = mockQueryWithRLS.mock.calls[0]
|
|
448
|
+
expect(queryCall[0]).toContain('completed IS NULL OR completed = false')
|
|
449
|
+
})
|
|
450
|
+
|
|
451
|
+
it('should order by dueDate ascending', async () => {
|
|
452
|
+
mockQueryWithRLS.mockResolvedValue([])
|
|
453
|
+
|
|
454
|
+
await TasksService.getOverdue('user-123')
|
|
455
|
+
|
|
456
|
+
const queryCall = mockQueryWithRLS.mock.calls[0]
|
|
457
|
+
expect(queryCall[0]).toContain('"dueDate" ASC')
|
|
458
|
+
})
|
|
459
|
+
})
|
|
460
|
+
|
|
461
|
+
describe('Result', () => {
|
|
462
|
+
it('should return array of overdue tasks', async () => {
|
|
463
|
+
const mockTasks = [
|
|
464
|
+
createMockDbTask({ dueDate: '2024-01-01' }),
|
|
465
|
+
createMockDbTask({ id: 'task-456', dueDate: '2024-06-01' }),
|
|
466
|
+
]
|
|
467
|
+
mockQueryWithRLS.mockResolvedValue(mockTasks)
|
|
468
|
+
|
|
469
|
+
const result = await TasksService.getOverdue('user-123')
|
|
470
|
+
|
|
471
|
+
expect(result).toHaveLength(2)
|
|
472
|
+
})
|
|
473
|
+
|
|
474
|
+
it('should transform database rows correctly', async () => {
|
|
475
|
+
const mockTask = createMockDbTask({ description: null })
|
|
476
|
+
mockQueryWithRLS.mockResolvedValue([mockTask])
|
|
477
|
+
|
|
478
|
+
const result = await TasksService.getOverdue('user-123')
|
|
479
|
+
|
|
480
|
+
expect(result[0].description).toBeUndefined()
|
|
481
|
+
})
|
|
482
|
+
})
|
|
483
|
+
})
|
|
484
|
+
|
|
485
|
+
// ============================================================
|
|
486
|
+
// create
|
|
487
|
+
// ============================================================
|
|
488
|
+
describe('create', () => {
|
|
489
|
+
describe('Input Validation', () => {
|
|
490
|
+
it('should throw error when userId is missing', async () => {
|
|
491
|
+
await expect(TasksService.create('', { title: 'Test', teamId: 'team-123' }))
|
|
492
|
+
.rejects.toThrow('User ID is required')
|
|
493
|
+
})
|
|
494
|
+
|
|
495
|
+
it('should throw error when title is missing', async () => {
|
|
496
|
+
await expect(TasksService.create('user-123', { teamId: 'team-123' } as any))
|
|
497
|
+
.rejects.toThrow('Title is required')
|
|
498
|
+
})
|
|
499
|
+
|
|
500
|
+
it('should throw error when teamId is missing', async () => {
|
|
501
|
+
await expect(TasksService.create('user-123', { title: 'Test' } as any))
|
|
502
|
+
.rejects.toThrow('Team ID is required')
|
|
503
|
+
})
|
|
504
|
+
})
|
|
505
|
+
|
|
506
|
+
describe('Default Values', () => {
|
|
507
|
+
it('should use default status of todo', async () => {
|
|
508
|
+
mockQueryOneWithRLS.mockResolvedValue(createMockDbTask())
|
|
509
|
+
|
|
510
|
+
await TasksService.create('user-123', { title: 'Test', teamId: 'team-123' })
|
|
511
|
+
|
|
512
|
+
const insertCall = mockQueryOneWithRLS.mock.calls[0]
|
|
513
|
+
expect(insertCall[1]).toContain('todo')
|
|
514
|
+
})
|
|
515
|
+
|
|
516
|
+
it('should use default priority of medium', async () => {
|
|
517
|
+
mockQueryOneWithRLS.mockResolvedValue(createMockDbTask())
|
|
518
|
+
|
|
519
|
+
await TasksService.create('user-123', { title: 'Test', teamId: 'team-123' })
|
|
520
|
+
|
|
521
|
+
const insertCall = mockQueryOneWithRLS.mock.calls[0]
|
|
522
|
+
expect(insertCall[1]).toContain('medium')
|
|
523
|
+
})
|
|
524
|
+
|
|
525
|
+
it('should use empty array for tags by default', async () => {
|
|
526
|
+
mockQueryOneWithRLS.mockResolvedValue(createMockDbTask())
|
|
527
|
+
|
|
528
|
+
await TasksService.create('user-123', { title: 'Test', teamId: 'team-123' })
|
|
529
|
+
|
|
530
|
+
const insertCall = mockQueryOneWithRLS.mock.calls[0]
|
|
531
|
+
expect(insertCall[1]).toContainEqual([])
|
|
532
|
+
})
|
|
533
|
+
|
|
534
|
+
it('should use false for completed by default', async () => {
|
|
535
|
+
mockQueryOneWithRLS.mockResolvedValue(createMockDbTask())
|
|
536
|
+
|
|
537
|
+
await TasksService.create('user-123', { title: 'Test', teamId: 'team-123' })
|
|
538
|
+
|
|
539
|
+
const insertCall = mockQueryOneWithRLS.mock.calls[0]
|
|
540
|
+
expect(insertCall[1]).toContain(false)
|
|
541
|
+
})
|
|
542
|
+
})
|
|
543
|
+
|
|
544
|
+
describe('Successful Creation', () => {
|
|
545
|
+
it('should return created task', async () => {
|
|
546
|
+
const mockTask = createMockDbTask({ title: 'New Task' })
|
|
547
|
+
mockQueryOneWithRLS.mockResolvedValue(mockTask)
|
|
548
|
+
|
|
549
|
+
const result = await TasksService.create('user-123', {
|
|
550
|
+
title: 'New Task',
|
|
551
|
+
teamId: 'team-123',
|
|
552
|
+
})
|
|
553
|
+
|
|
554
|
+
expect(result.id).toBe('task-123')
|
|
555
|
+
expect(result.title).toBe('New Task')
|
|
556
|
+
})
|
|
557
|
+
|
|
558
|
+
it('should pass userId and teamId to database', async () => {
|
|
559
|
+
mockQueryOneWithRLS.mockResolvedValue(createMockDbTask())
|
|
560
|
+
|
|
561
|
+
await TasksService.create('user-123', {
|
|
562
|
+
title: 'Test',
|
|
563
|
+
teamId: 'team-456',
|
|
564
|
+
})
|
|
565
|
+
|
|
566
|
+
const insertCall = mockQueryOneWithRLS.mock.calls[0]
|
|
567
|
+
expect(insertCall[1]).toContain('user-123')
|
|
568
|
+
expect(insertCall[1]).toContain('team-456')
|
|
569
|
+
})
|
|
570
|
+
|
|
571
|
+
it('should include all provided fields', async () => {
|
|
572
|
+
mockQueryOneWithRLS.mockResolvedValue(createMockDbTask())
|
|
573
|
+
|
|
574
|
+
await TasksService.create('user-123', {
|
|
575
|
+
title: 'Full Task',
|
|
576
|
+
teamId: 'team-123',
|
|
577
|
+
description: 'A description',
|
|
578
|
+
status: 'in-progress',
|
|
579
|
+
priority: 'high',
|
|
580
|
+
tags: ['urgent', 'important'],
|
|
581
|
+
dueDate: '2025-12-31',
|
|
582
|
+
estimatedHours: 10,
|
|
583
|
+
})
|
|
584
|
+
|
|
585
|
+
const insertCall = mockQueryOneWithRLS.mock.calls[0]
|
|
586
|
+
expect(insertCall[1]).toContain('Full Task')
|
|
587
|
+
expect(insertCall[1]).toContain('A description')
|
|
588
|
+
expect(insertCall[1]).toContain('in-progress')
|
|
589
|
+
expect(insertCall[1]).toContain('high')
|
|
590
|
+
expect(insertCall[1]).toContainEqual(['urgent', 'important'])
|
|
591
|
+
expect(insertCall[1]).toContain('2025-12-31')
|
|
592
|
+
expect(insertCall[1]).toContain(10)
|
|
593
|
+
})
|
|
594
|
+
})
|
|
595
|
+
|
|
596
|
+
describe('Error Handling', () => {
|
|
597
|
+
it('should throw error when database insert fails', async () => {
|
|
598
|
+
mockQueryOneWithRLS.mockResolvedValue(null)
|
|
599
|
+
|
|
600
|
+
await expect(TasksService.create('user-123', {
|
|
601
|
+
title: 'Test',
|
|
602
|
+
teamId: 'team-123',
|
|
603
|
+
})).rejects.toThrow('Failed to create task')
|
|
604
|
+
})
|
|
605
|
+
})
|
|
606
|
+
})
|
|
607
|
+
|
|
608
|
+
// ============================================================
|
|
609
|
+
// update
|
|
610
|
+
// ============================================================
|
|
611
|
+
describe('update', () => {
|
|
612
|
+
describe('Input Validation', () => {
|
|
613
|
+
it('should throw error when userId is missing', async () => {
|
|
614
|
+
await expect(TasksService.update('', 'task-123', { title: 'Updated' }))
|
|
615
|
+
.rejects.toThrow('User ID is required')
|
|
616
|
+
})
|
|
617
|
+
|
|
618
|
+
it('should throw error when id is missing', async () => {
|
|
619
|
+
await expect(TasksService.update('user-123', '', { title: 'Updated' }))
|
|
620
|
+
.rejects.toThrow('Task ID is required')
|
|
621
|
+
})
|
|
622
|
+
})
|
|
623
|
+
|
|
624
|
+
describe('Partial Updates', () => {
|
|
625
|
+
it('should only update provided fields', async () => {
|
|
626
|
+
mockQueryOneWithRLS.mockResolvedValue(createMockDbTask({ title: 'Updated Title' }))
|
|
627
|
+
|
|
628
|
+
await TasksService.update('user-123', 'task-123', { title: 'Updated Title' })
|
|
629
|
+
|
|
630
|
+
const updateCall = mockQueryOneWithRLS.mock.calls[0]
|
|
631
|
+
expect(updateCall[0]).toContain('title = $2')
|
|
632
|
+
expect(updateCall[0]).not.toContain('description =')
|
|
633
|
+
expect(updateCall[0]).not.toContain('status =')
|
|
634
|
+
})
|
|
635
|
+
|
|
636
|
+
it('should always update updatedAt timestamp', async () => {
|
|
637
|
+
mockQueryOneWithRLS.mockResolvedValue(createMockDbTask())
|
|
638
|
+
|
|
639
|
+
await TasksService.update('user-123', 'task-123', { title: 'Updated' })
|
|
640
|
+
|
|
641
|
+
const updateCall = mockQueryOneWithRLS.mock.calls[0]
|
|
642
|
+
expect(updateCall[0]).toContain('"updatedAt" = NOW()')
|
|
643
|
+
})
|
|
644
|
+
|
|
645
|
+
it('should handle updating all fields', async () => {
|
|
646
|
+
mockQueryOneWithRLS.mockResolvedValue(createMockDbTask())
|
|
647
|
+
|
|
648
|
+
await TasksService.update('user-123', 'task-123', {
|
|
649
|
+
title: 'Updated Title',
|
|
650
|
+
description: 'Updated description',
|
|
651
|
+
status: 'done',
|
|
652
|
+
priority: 'urgent',
|
|
653
|
+
tags: ['new-tag'],
|
|
654
|
+
dueDate: '2026-01-01',
|
|
655
|
+
estimatedHours: 20,
|
|
656
|
+
completed: true,
|
|
657
|
+
})
|
|
658
|
+
|
|
659
|
+
const updateCall = mockQueryOneWithRLS.mock.calls[0]
|
|
660
|
+
expect(updateCall[0]).toContain('title = $2')
|
|
661
|
+
expect(updateCall[0]).toContain('description = $3')
|
|
662
|
+
expect(updateCall[0]).toContain('status = $4')
|
|
663
|
+
expect(updateCall[0]).toContain('priority = $5')
|
|
664
|
+
expect(updateCall[0]).toContain('tags = $6')
|
|
665
|
+
expect(updateCall[0]).toContain('"dueDate" = $7')
|
|
666
|
+
expect(updateCall[0]).toContain('"estimatedHours" = $8')
|
|
667
|
+
expect(updateCall[0]).toContain('completed = $9')
|
|
668
|
+
})
|
|
669
|
+
})
|
|
670
|
+
|
|
671
|
+
describe('Empty Update', () => {
|
|
672
|
+
it('should return current task when no fields to update', async () => {
|
|
673
|
+
const mockTask = createMockDbTask()
|
|
674
|
+
mockQueryOneWithRLS.mockResolvedValue(mockTask)
|
|
675
|
+
|
|
676
|
+
const result = await TasksService.update('user-123', 'task-123', {})
|
|
677
|
+
|
|
678
|
+
// When no fields, it should call getById instead
|
|
679
|
+
expect(result.id).toBe('task-123')
|
|
680
|
+
})
|
|
681
|
+
})
|
|
682
|
+
|
|
683
|
+
describe('Successful Update', () => {
|
|
684
|
+
it('should return updated task', async () => {
|
|
685
|
+
const updatedTask = createMockDbTask({ title: 'New Title', status: 'done' })
|
|
686
|
+
mockQueryOneWithRLS.mockResolvedValue(updatedTask)
|
|
687
|
+
|
|
688
|
+
const result = await TasksService.update('user-123', 'task-123', {
|
|
689
|
+
title: 'New Title',
|
|
690
|
+
status: 'done',
|
|
691
|
+
})
|
|
692
|
+
|
|
693
|
+
expect(result.title).toBe('New Title')
|
|
694
|
+
expect(result.status).toBe('done')
|
|
695
|
+
})
|
|
696
|
+
})
|
|
697
|
+
|
|
698
|
+
describe('Error Handling', () => {
|
|
699
|
+
it('should throw error when task not found', async () => {
|
|
700
|
+
mockQueryOneWithRLS.mockResolvedValue(null)
|
|
701
|
+
|
|
702
|
+
await expect(TasksService.update('user-123', 'non-existent', { title: 'Test' }))
|
|
703
|
+
.rejects.toThrow('Task not found or update failed')
|
|
704
|
+
})
|
|
705
|
+
})
|
|
706
|
+
})
|
|
707
|
+
})
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Jest Setup for Default Theme
|
|
3
|
+
*
|
|
4
|
+
* This file provides complete Jest setup for npm mode.
|
|
5
|
+
* It includes all necessary polyfills and mocks.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import '@testing-library/jest-dom'
|
|
9
|
+
import { TextEncoder, TextDecoder } from 'util'
|
|
10
|
+
|
|
11
|
+
// Polyfill TextEncoder/TextDecoder for jsdom
|
|
12
|
+
global.TextEncoder = TextEncoder
|
|
13
|
+
global.TextDecoder = TextDecoder as typeof global.TextDecoder
|
|
14
|
+
|
|
15
|
+
// Polyfill Web APIs for Next.js server components testing
|
|
16
|
+
class MockHeaders {
|
|
17
|
+
private _headers: Map<string, string> = new Map()
|
|
18
|
+
|
|
19
|
+
constructor(init?: HeadersInit) {
|
|
20
|
+
if (init) {
|
|
21
|
+
if (init instanceof MockHeaders) {
|
|
22
|
+
init._headers.forEach((value, key) => this._headers.set(key, value))
|
|
23
|
+
} else if (Array.isArray(init)) {
|
|
24
|
+
init.forEach(([key, value]) => this._headers.set(key.toLowerCase(), value))
|
|
25
|
+
} else {
|
|
26
|
+
Object.entries(init).forEach(([key, value]) => this._headers.set(key.toLowerCase(), value))
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
get(name: string) { return this._headers.get(name.toLowerCase()) || null }
|
|
32
|
+
set(name: string, value: string) { this._headers.set(name.toLowerCase(), value) }
|
|
33
|
+
has(name: string) { return this._headers.has(name.toLowerCase()) }
|
|
34
|
+
delete(name: string) { this._headers.delete(name.toLowerCase()) }
|
|
35
|
+
forEach(cb: (value: string, key: string) => void) { this._headers.forEach(cb) }
|
|
36
|
+
entries() { return this._headers.entries() }
|
|
37
|
+
keys() { return this._headers.keys() }
|
|
38
|
+
values() { return this._headers.values() }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
class MockRequest {
|
|
42
|
+
url: string
|
|
43
|
+
method: string
|
|
44
|
+
headers: MockHeaders
|
|
45
|
+
private _body: any
|
|
46
|
+
|
|
47
|
+
constructor(input: string | URL, init?: RequestInit) {
|
|
48
|
+
this.url = typeof input === 'string' ? input : input.toString()
|
|
49
|
+
this.method = init?.method || 'GET'
|
|
50
|
+
this.headers = new MockHeaders(init?.headers as HeadersInit)
|
|
51
|
+
this._body = init?.body
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async json() {
|
|
55
|
+
if (!this._body) return {}
|
|
56
|
+
return typeof this._body === 'string' ? JSON.parse(this._body) : this._body
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async text() {
|
|
60
|
+
return this._body?.toString() || ''
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
clone() {
|
|
64
|
+
return new MockRequest(this.url, {
|
|
65
|
+
method: this.method,
|
|
66
|
+
headers: Object.fromEntries(this.headers.entries()),
|
|
67
|
+
body: this._body,
|
|
68
|
+
})
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
class MockResponse {
|
|
73
|
+
body: any
|
|
74
|
+
status: number
|
|
75
|
+
statusText: string
|
|
76
|
+
headers: MockHeaders
|
|
77
|
+
ok: boolean
|
|
78
|
+
|
|
79
|
+
constructor(body?: any, init?: ResponseInit) {
|
|
80
|
+
this.body = body
|
|
81
|
+
this.status = init?.status || 200
|
|
82
|
+
this.statusText = init?.statusText || 'OK'
|
|
83
|
+
this.headers = new MockHeaders(init?.headers as HeadersInit)
|
|
84
|
+
this.ok = this.status >= 200 && this.status < 300
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async json() {
|
|
88
|
+
if (!this.body) return {}
|
|
89
|
+
return typeof this.body === 'string' ? JSON.parse(this.body) : this.body
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async text() {
|
|
93
|
+
return this.body?.toString() || ''
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
clone() {
|
|
97
|
+
return new MockResponse(this.body, {
|
|
98
|
+
status: this.status,
|
|
99
|
+
statusText: this.statusText,
|
|
100
|
+
headers: Object.fromEntries(this.headers.entries()),
|
|
101
|
+
})
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
static json(data: any, init?: ResponseInit) {
|
|
105
|
+
return new MockResponse(JSON.stringify(data), {
|
|
106
|
+
...init,
|
|
107
|
+
headers: { 'Content-Type': 'application/json', ...(init?.headers || {}) as object },
|
|
108
|
+
})
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Assign to global scope
|
|
113
|
+
global.Headers = MockHeaders as any
|
|
114
|
+
global.Request = MockRequest as any
|
|
115
|
+
global.Response = MockResponse as any
|
|
116
|
+
|
|
117
|
+
// Mock fetch
|
|
118
|
+
global.fetch = jest.fn().mockResolvedValue(new MockResponse('{}', { status: 200 }))
|
|
119
|
+
|
|
120
|
+
// Mock Web Crypto API
|
|
121
|
+
const crypto = require('crypto')
|
|
122
|
+
Object.defineProperty(global, 'crypto', {
|
|
123
|
+
value: {
|
|
124
|
+
getRandomValues: (arr: Uint8Array) => crypto.randomFillSync(arr),
|
|
125
|
+
randomUUID: () => crypto.randomUUID(),
|
|
126
|
+
subtle: {
|
|
127
|
+
digest: async (algorithm: string, data: BufferSource) => {
|
|
128
|
+
const hash = crypto.createHash(algorithm.toLowerCase().replace('-', ''))
|
|
129
|
+
hash.update(Buffer.from(data as ArrayBuffer))
|
|
130
|
+
return hash.digest().buffer
|
|
131
|
+
},
|
|
132
|
+
encrypt: jest.fn(),
|
|
133
|
+
decrypt: jest.fn(),
|
|
134
|
+
sign: jest.fn(),
|
|
135
|
+
verify: jest.fn(),
|
|
136
|
+
generateKey: jest.fn(),
|
|
137
|
+
importKey: jest.fn(),
|
|
138
|
+
exportKey: jest.fn(),
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
// Mock matchMedia for component tests with media queries
|
|
144
|
+
Object.defineProperty(window, 'matchMedia', {
|
|
145
|
+
writable: true,
|
|
146
|
+
value: jest.fn().mockImplementation((query) => ({
|
|
147
|
+
matches: false,
|
|
148
|
+
media: query,
|
|
149
|
+
onchange: null,
|
|
150
|
+
addListener: jest.fn(),
|
|
151
|
+
removeListener: jest.fn(),
|
|
152
|
+
addEventListener: jest.fn(),
|
|
153
|
+
removeEventListener: jest.fn(),
|
|
154
|
+
dispatchEvent: jest.fn(),
|
|
155
|
+
})),
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
// Mock ResizeObserver
|
|
159
|
+
global.ResizeObserver = jest.fn().mockImplementation(() => ({
|
|
160
|
+
observe: jest.fn(),
|
|
161
|
+
unobserve: jest.fn(),
|
|
162
|
+
disconnect: jest.fn(),
|
|
163
|
+
}))
|
|
164
|
+
|
|
165
|
+
// Mock IntersectionObserver
|
|
166
|
+
global.IntersectionObserver = jest.fn().mockImplementation(() => ({
|
|
167
|
+
observe: jest.fn(),
|
|
168
|
+
unobserve: jest.fn(),
|
|
169
|
+
disconnect: jest.fn(),
|
|
170
|
+
}))
|
|
@@ -1,81 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Jest Configuration for Default Theme
|
|
3
|
-
*
|
|
4
|
-
* This config is automatically loaded by the theme test runner.
|
|
5
|
-
*
|
|
6
|
-
* Usage:
|
|
7
|
-
* pnpm test:theme # Run theme tests
|
|
8
|
-
* pnpm test:theme:watch # Watch mode
|
|
9
|
-
* pnpm test:theme:coverage # With coverage
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
import type { Config } from 'jest'
|
|
13
|
-
|
|
14
|
-
const config: Config = {
|
|
15
|
-
displayName: 'theme-default',
|
|
16
|
-
rootDir: '../../../../..',
|
|
17
|
-
|
|
18
|
-
// Test file patterns
|
|
19
|
-
testMatch: [
|
|
20
|
-
'<rootDir>/contents/themes/default/tests/jest/**/*.{test,spec}.{js,ts,tsx}',
|
|
21
|
-
],
|
|
22
|
-
testPathIgnorePatterns: [
|
|
23
|
-
'<rootDir>/node_modules/',
|
|
24
|
-
'<rootDir>/.next/',
|
|
25
|
-
],
|
|
26
|
-
|
|
27
|
-
// Preset and environment
|
|
28
|
-
preset: 'ts-jest',
|
|
29
|
-
testEnvironment: 'jsdom',
|
|
30
|
-
|
|
31
|
-
// Module resolution (aliases @/)
|
|
32
|
-
// IMPORTANT: More specific patterns MUST come before generic ones
|
|
33
|
-
moduleNameMapper: {
|
|
34
|
-
'^@nextsparkjs/core/(.*)$': '<rootDir>/packages/core/$1',
|
|
35
|
-
'^@/contents/(.*)$': '<rootDir>/contents/$1',
|
|
36
|
-
'^@/entities/(.*)$': '<rootDir>/contents/entities/$1',
|
|
37
|
-
'^@/plugins/(.*)$': '<rootDir>/contents/plugins/$1',
|
|
38
|
-
'^@/themes/(.*)$': '<rootDir>/contents/themes/$1',
|
|
39
|
-
'^@/(.*)$': '<rootDir>/$1',
|
|
40
|
-
'next/server': '<rootDir>/packages/core/tests/jest/__mocks__/next-server.js',
|
|
41
|
-
'^jose$': '<rootDir>/packages/core/tests/jest/__mocks__/jose.js',
|
|
42
|
-
'^jose/(.*)$': '<rootDir>/packages/core/tests/jest/__mocks__/jose.js',
|
|
43
|
-
},
|
|
44
|
-
|
|
45
|
-
// Setup files
|
|
46
|
-
setupFilesAfterEnv: ['<rootDir>/packages/core/tests/setup.ts'],
|
|
47
|
-
|
|
48
|
-
// Transform configuration
|
|
49
|
-
transform: {
|
|
50
|
-
'^.+\\.(ts|tsx)$': [
|
|
51
|
-
'ts-jest',
|
|
52
|
-
{
|
|
53
|
-
tsconfig: 'tsconfig.test.json',
|
|
54
|
-
},
|
|
55
|
-
],
|
|
56
|
-
},
|
|
57
|
-
|
|
58
|
-
// Transform ignore patterns
|
|
59
|
-
transformIgnorePatterns: [
|
|
60
|
-
'node_modules/(?!(uncrypto|better-auth|@noble|.*jose.*|remark.*|unified.*|.*\\.mjs$))',
|
|
61
|
-
'node_modules/\\.pnpm/(?!(.*uncrypto.*|.*better-auth.*|.*@noble.*|.*jose.*|.*remark.*|.*unified.*|.*\\.mjs$))',
|
|
62
|
-
],
|
|
63
|
-
|
|
64
|
-
// File extensions
|
|
65
|
-
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json'],
|
|
66
|
-
|
|
67
|
-
// Test timeout
|
|
68
|
-
testTimeout: 10000,
|
|
69
|
-
|
|
70
|
-
// Verbose output
|
|
71
|
-
verbose: true,
|
|
72
|
-
|
|
73
|
-
// Force exit after tests complete
|
|
74
|
-
forceExit: true,
|
|
75
|
-
|
|
76
|
-
// Coverage output directory
|
|
77
|
-
coverageDirectory: '<rootDir>/contents/themes/default/tests/jest/coverage',
|
|
78
|
-
coverageReporters: ['text', 'lcov', 'html'],
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
export default config
|