@owlmeans/server-planning 0.1.18-rc.0
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/README.md +150 -0
- package/agent-meta/manifest.json +16 -0
- package/agent-meta/skills/server-planning/SKILL.md +203 -0
- package/build/actions/cards.d.ts +16 -0
- package/build/actions/cards.d.ts.map +1 -0
- package/build/actions/cards.js +32 -0
- package/build/actions/cards.js.map +1 -0
- package/build/actions/commit.d.ts +14 -0
- package/build/actions/commit.d.ts.map +1 -0
- package/build/actions/commit.js +48 -0
- package/build/actions/commit.js.map +1 -0
- package/build/actions/execute.d.ts +13 -0
- package/build/actions/execute.d.ts.map +1 -0
- package/build/actions/execute.js +35 -0
- package/build/actions/execute.js.map +1 -0
- package/build/actions/index.d.ts +9 -0
- package/build/actions/index.d.ts.map +1 -0
- package/build/actions/index.js +9 -0
- package/build/actions/index.js.map +1 -0
- package/build/actions/links.d.ts +5 -0
- package/build/actions/links.d.ts.map +1 -0
- package/build/actions/links.js +8 -0
- package/build/actions/links.js.map +1 -0
- package/build/actions/schemas.d.ts +6 -0
- package/build/actions/schemas.d.ts.map +1 -0
- package/build/actions/schemas.js +9 -0
- package/build/actions/schemas.js.map +1 -0
- package/build/actions/specs.d.ts +10 -0
- package/build/actions/specs.d.ts.map +1 -0
- package/build/actions/specs.js +14 -0
- package/build/actions/specs.js.map +1 -0
- package/build/actions/transitions.d.ts +6 -0
- package/build/actions/transitions.d.ts.map +1 -0
- package/build/actions/transitions.js +8 -0
- package/build/actions/transitions.js.map +1 -0
- package/build/actions/watch.d.ts +12 -0
- package/build/actions/watch.d.ts.map +1 -0
- package/build/actions/watch.js +45 -0
- package/build/actions/watch.js.map +1 -0
- package/build/consts.d.ts +18 -0
- package/build/consts.d.ts.map +1 -0
- package/build/consts.js +19 -0
- package/build/consts.js.map +1 -0
- package/build/executor/changes.d.ts +31 -0
- package/build/executor/changes.d.ts.map +1 -0
- package/build/executor/changes.js +55 -0
- package/build/executor/changes.js.map +1 -0
- package/build/executor/code.d.ts +15 -0
- package/build/executor/code.d.ts.map +1 -0
- package/build/executor/code.js +59 -0
- package/build/executor/code.js.map +1 -0
- package/build/executor/receipt.d.ts +12 -0
- package/build/executor/receipt.d.ts.map +1 -0
- package/build/executor/receipt.js +56 -0
- package/build/executor/receipt.js.map +1 -0
- package/build/executor/resolve.d.ts +35 -0
- package/build/executor/resolve.d.ts.map +1 -0
- package/build/executor/resolve.js +136 -0
- package/build/executor/resolve.js.map +1 -0
- package/build/executor/validate.d.ts +13 -0
- package/build/executor/validate.d.ts.map +1 -0
- package/build/executor/validate.js +222 -0
- package/build/executor/validate.js.map +1 -0
- package/build/executor.d.ts +15 -0
- package/build/executor.d.ts.map +1 -0
- package/build/executor.js +113 -0
- package/build/executor.js.map +1 -0
- package/build/facade.d.ts +12 -0
- package/build/facade.d.ts.map +1 -0
- package/build/facade.js +131 -0
- package/build/facade.js.map +1 -0
- package/build/helper.d.ts +18 -0
- package/build/helper.d.ts.map +1 -0
- package/build/helper.js +32 -0
- package/build/helper.js.map +1 -0
- package/build/index.d.ts +13 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +11 -0
- package/build/index.js.map +1 -0
- package/build/projection.d.ts +28 -0
- package/build/projection.d.ts.map +1 -0
- package/build/projection.js +40 -0
- package/build/projection.js.map +1 -0
- package/build/registry.d.ts +30 -0
- package/build/registry.d.ts.map +1 -0
- package/build/registry.js +103 -0
- package/build/registry.js.map +1 -0
- package/build/service.d.ts +40 -0
- package/build/service.d.ts.map +1 -0
- package/build/service.js +109 -0
- package/build/service.js.map +1 -0
- package/build/store/commits.d.ts +15 -0
- package/build/store/commits.d.ts.map +1 -0
- package/build/store/commits.js +147 -0
- package/build/store/commits.js.map +1 -0
- package/build/store/composite.d.ts +12 -0
- package/build/store/composite.d.ts.map +1 -0
- package/build/store/composite.js +150 -0
- package/build/store/composite.js.map +1 -0
- package/build/store/fold.d.ts +39 -0
- package/build/store/fold.d.ts.map +1 -0
- package/build/store/fold.js +220 -0
- package/build/store/fold.js.map +1 -0
- package/build/store/index.d.ts +6 -0
- package/build/store/index.d.ts.map +1 -0
- package/build/store/index.js +5 -0
- package/build/store/index.js.map +1 -0
- package/build/store/memory.d.ts +19 -0
- package/build/store/memory.d.ts.map +1 -0
- package/build/store/memory.js +0 -0
- package/build/store/memory.js.map +1 -0
- package/build/store/types.d.ts +82 -0
- package/build/store/types.d.ts.map +1 -0
- package/build/store/types.js +2 -0
- package/build/store/types.js.map +1 -0
- package/build/types.d.ts +46 -0
- package/build/types.d.ts.map +1 -0
- package/build/types.js +2 -0
- package/build/types.js.map +1 -0
- package/build/utils/guard.d.ts +19 -0
- package/build/utils/guard.d.ts.map +1 -0
- package/build/utils/guard.js +35 -0
- package/build/utils/guard.js.map +1 -0
- package/build/utils/index.d.ts +3 -0
- package/build/utils/index.d.ts.map +1 -0
- package/build/utils/index.js +3 -0
- package/build/utils/index.js.map +1 -0
- package/build/utils/scope.d.ts +20 -0
- package/build/utils/scope.d.ts.map +1 -0
- package/build/utils/scope.js +36 -0
- package/build/utils/scope.js.map +1 -0
- package/package.json +66 -0
- package/src/actions/cards.ts +52 -0
- package/src/actions/commit.ts +55 -0
- package/src/actions/execute.ts +47 -0
- package/src/actions/index.ts +8 -0
- package/src/actions/links.ts +14 -0
- package/src/actions/schemas.ts +15 -0
- package/src/actions/specs.ts +23 -0
- package/src/actions/transitions.ts +14 -0
- package/src/actions/watch.ts +52 -0
- package/src/consts.ts +22 -0
- package/src/executor/changes.ts +78 -0
- package/src/executor/code.ts +83 -0
- package/src/executor/receipt.ts +66 -0
- package/src/executor/resolve.ts +174 -0
- package/src/executor/validate.ts +253 -0
- package/src/executor.ts +149 -0
- package/src/facade.ts +162 -0
- package/src/helper.ts +44 -0
- package/src/index.ts +13 -0
- package/src/projection.ts +65 -0
- package/src/registry.ts +143 -0
- package/src/service.ts +157 -0
- package/src/store/commits.ts +174 -0
- package/src/store/composite.ts +171 -0
- package/src/store/fold.ts +257 -0
- package/src/store/index.ts +6 -0
- package/src/store/memory.ts +0 -0
- package/src/store/types.ts +94 -0
- package/src/types.ts +48 -0
- package/src/utils/guard.ts +40 -0
- package/src/utils/index.ts +2 -0
- package/src/utils/scope.ts +51 -0
- package/tests/commits.spec.ts +78 -0
- package/tests/context.ts +198 -0
- package/tests/entrypoints.spec.ts +72 -0
- package/tests/executor.spec.ts +69 -0
- package/tests/memory-store.spec.ts +84 -0
- package/tests/plugins.spec.ts +86 -0
- package/tests/specifications.spec.ts +61 -0
- package/tests/tsconfig.json +17 -0
- package/tests/validation.spec.ts +62 -0
- package/tsconfig.json +16 -0
package/tests/context.ts
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { AppType, makeBasicContext } from '@owlmeans/context'
|
|
2
|
+
import type { BasicConfig, BasicContext } from '@owlmeans/context'
|
|
3
|
+
import {
|
|
4
|
+
CodeScope, CodeStyle, IntrinsicStatus, SpecificationFormat, TransitionAction, WorkcardKind,
|
|
5
|
+
} from '@owlmeans/planning'
|
|
6
|
+
import type {
|
|
7
|
+
AnyTypeSchema, PlanningFacade, PlanningPlugin, PlanningService, StatusFlowSchema, TransitionReceipt,
|
|
8
|
+
WithPlanningService, Workcard,
|
|
9
|
+
} from '@owlmeans/planning'
|
|
10
|
+
import { appendPlanningService } from '../src/service.js'
|
|
11
|
+
import { makeMemoryPlanningStore } from '../src/store/memory.js'
|
|
12
|
+
import type { MemoryPlanningStore, MemoryPlanningStoreOptions } from '../src/store/types.js'
|
|
13
|
+
import type { PlanningServiceOptions } from '../src/types.js'
|
|
14
|
+
|
|
15
|
+
export const ENTITY = 'entity-1'
|
|
16
|
+
export const OTHER_ENTITY = 'entity-2'
|
|
17
|
+
export const PROFILE = 'profile-1'
|
|
18
|
+
|
|
19
|
+
export const PROJECT = 'test:project'
|
|
20
|
+
export const TASK = 'test:task'
|
|
21
|
+
export const BUG = 'test:bug'
|
|
22
|
+
export const NOTE = 'test:note'
|
|
23
|
+
export const SPEC = 'test:spec'
|
|
24
|
+
|
|
25
|
+
const flows: StatusFlowSchema[] = [
|
|
26
|
+
{
|
|
27
|
+
id: 'test:task',
|
|
28
|
+
version: 1,
|
|
29
|
+
statuses: [
|
|
30
|
+
{ key: 'todo', intrinsic: IntrinsicStatus.Planned, initial: true },
|
|
31
|
+
{ key: 'doing', intrinsic: IntrinsicStatus.InProgress },
|
|
32
|
+
{ key: 'done', intrinsic: IntrinsicStatus.Closed, terminal: true },
|
|
33
|
+
],
|
|
34
|
+
transitions: [
|
|
35
|
+
{ name: 'start', from: ['todo'], to: 'doing', explicit: true },
|
|
36
|
+
{ name: 'finish', from: ['doing'], to: 'done', explicit: true },
|
|
37
|
+
{ name: 'reopen', from: '*', to: 'todo' },
|
|
38
|
+
],
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
id: 'test:review',
|
|
42
|
+
version: 1,
|
|
43
|
+
statuses: [
|
|
44
|
+
{ key: 'waiting', intrinsic: IntrinsicStatus.Planned, initial: true },
|
|
45
|
+
{ key: 'approved', intrinsic: IntrinsicStatus.Closed },
|
|
46
|
+
],
|
|
47
|
+
transitions: [{ name: 'approve', from: ['waiting'], to: 'approved' }],
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
id: 'test:project',
|
|
51
|
+
version: 1,
|
|
52
|
+
statuses: [
|
|
53
|
+
{ key: 'open', intrinsic: IntrinsicStatus.Planned, initial: true },
|
|
54
|
+
{ key: 'active', intrinsic: IntrinsicStatus.InProgress },
|
|
55
|
+
],
|
|
56
|
+
transitions: [{ name: 'activate', from: ['open'], to: 'active' }],
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
id: 'test:doc',
|
|
60
|
+
version: 1,
|
|
61
|
+
statuses: [{ key: 'draft', intrinsic: IntrinsicStatus.Planned, initial: true }],
|
|
62
|
+
transitions: [],
|
|
63
|
+
},
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
const types: AnyTypeSchema[] = [
|
|
67
|
+
{
|
|
68
|
+
type: PROJECT,
|
|
69
|
+
kind: WorkcardKind.Project,
|
|
70
|
+
version: 1,
|
|
71
|
+
fields: { type: 'object', additionalProperties: true },
|
|
72
|
+
flows: ['test:project'],
|
|
73
|
+
cardTypes: [TASK, BUG],
|
|
74
|
+
projectTypes: [PROJECT],
|
|
75
|
+
specifications: [
|
|
76
|
+
{ category: 'brief', format: SpecificationFormat.Markdown },
|
|
77
|
+
{
|
|
78
|
+
category: 'plan',
|
|
79
|
+
format: SpecificationFormat.Json,
|
|
80
|
+
revisioned: true,
|
|
81
|
+
keepRevisions: 3,
|
|
82
|
+
schema: { type: 'object', required: ['steps'], properties: { steps: { type: 'array' } } },
|
|
83
|
+
},
|
|
84
|
+
],
|
|
85
|
+
code: { style: CodeStyle.Slug, uniqueWithin: CodeScope.Entity, mutable: true },
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
type: TASK,
|
|
89
|
+
kind: WorkcardKind.Card,
|
|
90
|
+
version: 1,
|
|
91
|
+
fields: {
|
|
92
|
+
type: 'object',
|
|
93
|
+
properties: { area: { type: 'string', enum: ['guest', 'user'] }, primary: { type: 'boolean' } },
|
|
94
|
+
additionalProperties: false,
|
|
95
|
+
},
|
|
96
|
+
flows: ['test:task', 'test:review'],
|
|
97
|
+
specifications: [{ category: 'design', format: SpecificationFormat.Json, revisioned: true }],
|
|
98
|
+
relationships: [{ name: 'follows', to: [TASK], single: true }, { name: 'blocks' }],
|
|
99
|
+
labels: ['urgent', 'later'],
|
|
100
|
+
code: { prefix: 'T-', style: CodeStyle.Random, length: 5, uppercase: true, uniqueWithin: CodeScope.Parent },
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
type: BUG,
|
|
104
|
+
kind: WorkcardKind.Card,
|
|
105
|
+
version: 1,
|
|
106
|
+
fields: { type: 'object' },
|
|
107
|
+
flows: ['test:task'],
|
|
108
|
+
specifications: [],
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
type: NOTE,
|
|
112
|
+
kind: WorkcardKind.Card,
|
|
113
|
+
version: 1,
|
|
114
|
+
fields: { type: 'object' },
|
|
115
|
+
flows: ['test:task'],
|
|
116
|
+
specifications: [],
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
type: SPEC,
|
|
120
|
+
kind: WorkcardKind.Specification,
|
|
121
|
+
version: 1,
|
|
122
|
+
fields: { type: 'object' },
|
|
123
|
+
flows: ['test:doc'],
|
|
124
|
+
specifications: [],
|
|
125
|
+
},
|
|
126
|
+
]
|
|
127
|
+
|
|
128
|
+
/** The fixture types and flows, as a plugin contributes them. */
|
|
129
|
+
export const fixturePlugin: PlanningPlugin = { name: 'fixtures', order: 0, schemas: { types, flows } }
|
|
130
|
+
|
|
131
|
+
export interface TestPlanning {
|
|
132
|
+
context: BasicContext<BasicConfig> & WithPlanningService
|
|
133
|
+
service: PlanningService
|
|
134
|
+
store: MemoryPlanningStore
|
|
135
|
+
facade: (entityId?: string) => PlanningFacade
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* One real context the way a backend wires planning: a memory store, the fixture plugin, and a
|
|
140
|
+
* clock that never repeats a timestamp, so ordering by `at` is deterministic.
|
|
141
|
+
*/
|
|
142
|
+
export const makeTestPlanning = async (
|
|
143
|
+
options: PlanningServiceOptions = {}, storeOptions: MemoryPlanningStoreOptions = {}
|
|
144
|
+
): Promise<TestPlanning> => {
|
|
145
|
+
let tick = Date.parse('2026-01-01T00:00:00.000Z')
|
|
146
|
+
const now = () => new Date(tick++).toISOString()
|
|
147
|
+
const store = makeMemoryPlanningStore({ now, ...storeOptions })
|
|
148
|
+
|
|
149
|
+
const cfg: BasicConfig = { ready: false, service: 'server-planning-tests', type: AppType.Backend, services: {} }
|
|
150
|
+
const context = appendPlanningService(makeBasicContext(cfg), {
|
|
151
|
+
now,
|
|
152
|
+
store,
|
|
153
|
+
...options,
|
|
154
|
+
plugins: [fixturePlugin, ...(options.plugins ?? [])],
|
|
155
|
+
})
|
|
156
|
+
context.configure()
|
|
157
|
+
await context.init()
|
|
158
|
+
|
|
159
|
+
const service = context.planning()
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
context,
|
|
163
|
+
service,
|
|
164
|
+
store,
|
|
165
|
+
facade: (entityId = ENTITY) => service.for({ entityId, profileId: PROFILE, channel: 'test' }),
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export const createProject = async (
|
|
170
|
+
facade: PlanningFacade, title: string = 'Test Project', extra: Record<string, unknown> = {}
|
|
171
|
+
): Promise<Workcard> => (await facade.execute({
|
|
172
|
+
card: { kind: WorkcardKind.Project, type: PROJECT, title, ...extra },
|
|
173
|
+
action: TransitionAction.Create,
|
|
174
|
+
}, { wait: true })).card!
|
|
175
|
+
|
|
176
|
+
export const createTask = async (
|
|
177
|
+
facade: PlanningFacade, project: string, title: string = 'A task', extra: Record<string, unknown> = {}
|
|
178
|
+
): Promise<TransitionReceipt> => await facade.execute({
|
|
179
|
+
card: { kind: WorkcardKind.Card, type: TASK, parent: project, title, ...extra },
|
|
180
|
+
action: TransitionAction.Create,
|
|
181
|
+
}, { wait: true })
|
|
182
|
+
|
|
183
|
+
/** A protocol-bound handler run with the smallest binding context the transport needs. */
|
|
184
|
+
export const invoke = async (handler: any, context: unknown, req: Record<string, unknown>): Promise<any> => {
|
|
185
|
+
const res: any = { resolve: (value: unknown) => { res.value = value }, reject: (e: Error) => { res.error = e } }
|
|
186
|
+
await handler.bind({ ref: { ctx: context } })({ headers: {}, params: {}, query: {}, body: {}, ...req }, res)
|
|
187
|
+
if (res.error != null) {
|
|
188
|
+
throw res.error
|
|
189
|
+
}
|
|
190
|
+
return res.value
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** An authenticated request of one organization. */
|
|
194
|
+
export const session = (entityId: string = ENTITY, patch: Record<string, unknown> = {}): Record<string, unknown> => ({
|
|
195
|
+
entity: { id: entityId, slug: entityId, iamKey: entityId },
|
|
196
|
+
auth: { type: 'test', userId: 'user-1', profileId: PROFILE, entitySlug: entityId, scopes: ['*'] },
|
|
197
|
+
...patch,
|
|
198
|
+
})
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { protocols } from '@owlmeans/entrypoint'
|
|
3
|
+
import { CommitState, makePlanningProtocols, TransitionAction, WorkcardKind, WorkcardNotFound } from '@owlmeans/planning'
|
|
4
|
+
import { executePlanning, getCard, getCommit, listCards } from '../src/actions/index.js'
|
|
5
|
+
import { servePlanningEntrypoints } from '../src/helper.js'
|
|
6
|
+
import {
|
|
7
|
+
createProject, createTask, invoke, makeTestPlanning, OTHER_ENTITY, PROFILE, PROJECT, session, TASK,
|
|
8
|
+
} from './context.js'
|
|
9
|
+
|
|
10
|
+
const tree = makePlanningProtocols({ base: { alias: 'test:planning' }, guards: 'test-guard' })
|
|
11
|
+
|
|
12
|
+
describe('@owlmeans/server-planning — entrypoints', () => {
|
|
13
|
+
test('one binding per protocol of the tree', () => {
|
|
14
|
+
const declared = protocols(tree as never)
|
|
15
|
+
const bindings = servePlanningEntrypoints(tree)
|
|
16
|
+
|
|
17
|
+
expect(bindings.length).toBe(declared.length)
|
|
18
|
+
expect(new Set(bindings.map(binding => binding.alias))).toEqual(new Set(declared.map(protocol => protocol.alias)))
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
test('execute takes the actor and createdBy from the request, never from the body', async () => {
|
|
22
|
+
const { context } = await makeTestPlanning()
|
|
23
|
+
const execute = executePlanning(tree.execute, { scope: () => ({ channel: 'web' }) })
|
|
24
|
+
|
|
25
|
+
const view = await invoke(execute, context, {
|
|
26
|
+
...session(),
|
|
27
|
+
body: {
|
|
28
|
+
card: { kind: WorkcardKind.Project, type: PROJECT, title: 'Wire', createdBy: 'intruder' },
|
|
29
|
+
action: TransitionAction.Create,
|
|
30
|
+
actor: { profileId: 'intruder', channel: 'forged', runId: 'forged' },
|
|
31
|
+
wait: true,
|
|
32
|
+
},
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
expect(view.transition.actor).toEqual({ profileId: PROFILE, userId: 'user-1', channel: 'web' })
|
|
36
|
+
expect(view.card.createdBy).toBe(PROFILE)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
test('another entity\'s card answers WorkcardNotFound — to a read and to a write', async () => {
|
|
40
|
+
const { context, facade } = await makeTestPlanning()
|
|
41
|
+
const project = await createProject(facade())
|
|
42
|
+
const task = (await createTask(facade(), project.id!, 'Mine', { labels: ['urgent'] })).card!
|
|
43
|
+
|
|
44
|
+
await expect(invoke(getCard(tree.card.get), context, { ...session(OTHER_ENTITY), params: { id: task.id } }))
|
|
45
|
+
.rejects.toBeInstanceOf(WorkcardNotFound)
|
|
46
|
+
await expect(invoke(executePlanning(tree.execute), context, {
|
|
47
|
+
...session(OTHER_ENTITY), body: { card: task.id, action: TransitionAction.Update, changes: { title: 'Theirs' } },
|
|
48
|
+
})).rejects.toBeInstanceOf(WorkcardNotFound)
|
|
49
|
+
|
|
50
|
+
const listed = await invoke(listCards(tree.card.list), context, { ...session(), query: { type: TASK, labels: 'urgent,later' } })
|
|
51
|
+
expect(listed.items.map((card: { id: string }) => card.id)).toEqual([task.id])
|
|
52
|
+
expect((await invoke(listCards(tree.card.list), context, { ...session(OTHER_ENTITY), query: { type: TASK } })).total).toBe(0)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
test('commit.get holds until the commit lands, and never past maxPoll', async () => {
|
|
56
|
+
const { context, facade, store } = await makeTestPlanning({}, { sync: false })
|
|
57
|
+
const receipt = await facade().execute({ card: { kind: WorkcardKind.Project, type: PROJECT, title: 'Polled' }, action: TransitionAction.Create })
|
|
58
|
+
const request = { ...session(), params: { transition: receipt.transition.id }, query: { wait: 3600 } }
|
|
59
|
+
|
|
60
|
+
const clamped = Date.now()
|
|
61
|
+
const pending = await invoke(getCommit(tree.commit.get, { maxPoll: 0.05 }), context, request)
|
|
62
|
+
expect(pending.state).toBe(CommitState.Pending)
|
|
63
|
+
expect(Date.now() - clamped).toBeLessThan(1000)
|
|
64
|
+
|
|
65
|
+
const held = invoke(getCommit(tree.commit.get), context, request)
|
|
66
|
+
await new Promise(resolve => setTimeout(resolve, 20))
|
|
67
|
+
await store.flush()
|
|
68
|
+
const landed = await held
|
|
69
|
+
expect(landed.state).toBe(CommitState.Committed)
|
|
70
|
+
expect(landed.card.title).toBe('Polled')
|
|
71
|
+
})
|
|
72
|
+
})
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { CommitState, TransitionAction, WorkcardConflict, WorkcardKind } from '@owlmeans/planning'
|
|
3
|
+
import { createProject, createTask, ENTITY, makeTestPlanning, PROFILE, TASK } from './context.js'
|
|
4
|
+
|
|
5
|
+
describe('@owlmeans/server-planning — the executor', () => {
|
|
6
|
+
test('a create mints the id, the code and seq 1, and answers a committed receipt', async () => {
|
|
7
|
+
const { facade } = await makeTestPlanning()
|
|
8
|
+
const planning = facade()
|
|
9
|
+
const project = await createProject(planning)
|
|
10
|
+
|
|
11
|
+
const receipt = await createTask(planning, project.id!)
|
|
12
|
+
|
|
13
|
+
expect(project.code).toBe('test-project')
|
|
14
|
+
expect(receipt.transition.seq).toBe(1)
|
|
15
|
+
expect(receipt.transition.commit.state).toBe(CommitState.Committed)
|
|
16
|
+
expect(receipt.transition.project).toBe(project.id)
|
|
17
|
+
expect(receipt.transition.actor).toEqual({ profileId: PROFILE, channel: 'test' })
|
|
18
|
+
expect(receipt.card!.id).toBe(receipt.transition.card)
|
|
19
|
+
expect(receipt.card!.code).toMatch(/^T-[0-9A-Z]{5}$/)
|
|
20
|
+
expect(receipt.card!.flows).toEqual({ 'test:task': 'todo', 'test:review': 'waiting' })
|
|
21
|
+
expect(receipt.card!.entityId).toBe(ENTITY)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
test('the same idempotency key appends once and answers the first receipt', async () => {
|
|
25
|
+
const { facade } = await makeTestPlanning()
|
|
26
|
+
const planning = facade()
|
|
27
|
+
const project = await createProject(planning)
|
|
28
|
+
const exec = {
|
|
29
|
+
card: { kind: WorkcardKind.Card, type: TASK, parent: project.id!, title: 'Once' },
|
|
30
|
+
action: TransitionAction.Create,
|
|
31
|
+
key: 'import:1',
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const first = await planning.execute(exec, { wait: true })
|
|
35
|
+
const second = await planning.execute(exec, { wait: true })
|
|
36
|
+
|
|
37
|
+
expect(second.transition.id).toBe(first.transition.id!)
|
|
38
|
+
expect((await planning.cards.list({ parent: project.id, type: TASK })).total).toBe(1)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
test('a stale expectSeq is a WorkcardConflict and appends nothing', async () => {
|
|
42
|
+
const { facade } = await makeTestPlanning()
|
|
43
|
+
const planning = facade()
|
|
44
|
+
const project = await createProject(planning)
|
|
45
|
+
const task = (await createTask(planning, project.id!)).card!
|
|
46
|
+
|
|
47
|
+
await planning.execute({ card: task.id!, action: TransitionAction.Update, changes: { title: 'Renamed' }, expectSeq: 1 }, { wait: true })
|
|
48
|
+
|
|
49
|
+
await expect(planning.execute({
|
|
50
|
+
card: task.id!, action: TransitionAction.Update, changes: { title: 'Again' }, expectSeq: 1,
|
|
51
|
+
})).rejects.toBeInstanceOf(WorkcardConflict)
|
|
52
|
+
expect((await planning.transitions.list({ card: task.id })).total).toBe(2)
|
|
53
|
+
expect((await planning.cards.get(task.id!)).title).toBe('Renamed')
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
test('an update with the values the card already holds appends nothing', async () => {
|
|
57
|
+
const { facade } = await makeTestPlanning()
|
|
58
|
+
const planning = facade()
|
|
59
|
+
const project = await createProject(planning)
|
|
60
|
+
const task = (await createTask(planning, project.id!, 'Same', { fields: { area: 'user' } })).card!
|
|
61
|
+
|
|
62
|
+
const receipt = await planning.execute({
|
|
63
|
+
card: task.id!, action: TransitionAction.Update, changes: { title: 'Same', fields: { area: 'user' } },
|
|
64
|
+
}, { wait: true })
|
|
65
|
+
|
|
66
|
+
expect(receipt.transition.seq).toBe(1)
|
|
67
|
+
expect((await planning.transitions.list({ card: task.id })).total).toBe(1)
|
|
68
|
+
})
|
|
69
|
+
})
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { CommitState, IntrinsicStatus, SpecificationFormat, TransitionAction, WorkcardKind } from '@owlmeans/planning'
|
|
3
|
+
import type { CommitEvent } from '@owlmeans/planning'
|
|
4
|
+
import { createProject, createTask, ENTITY, makeTestPlanning, OTHER_ENTITY, SPEC } from './context.js'
|
|
5
|
+
|
|
6
|
+
describe('@owlmeans/server-planning — the memory store', () => {
|
|
7
|
+
test('folds in seq order and publishes one commit per transition', async () => {
|
|
8
|
+
const { facade, store } = await makeTestPlanning()
|
|
9
|
+
const planning = facade()
|
|
10
|
+
const events: CommitEvent[] = []
|
|
11
|
+
store.commits.subscribe(event => { events.push(event) })
|
|
12
|
+
|
|
13
|
+
const project = await createProject(planning)
|
|
14
|
+
const task = (await createTask(planning, project.id!)).card!
|
|
15
|
+
await planning.execute({ card: task.id!, action: TransitionAction.Update, changes: { title: 'Renamed' } })
|
|
16
|
+
await planning.execute({ card: task.id!, action: TransitionAction.Transit, transition: 'start' })
|
|
17
|
+
|
|
18
|
+
const card = await planning.cards.get(task.id!)
|
|
19
|
+
expect(card.seq).toBe(3)
|
|
20
|
+
expect(card.head).toBe(3)
|
|
21
|
+
expect(card.title).toBe('Renamed')
|
|
22
|
+
expect(card.status).toBe('doing')
|
|
23
|
+
expect(card.intrinsic).toBe(IntrinsicStatus.InProgress)
|
|
24
|
+
expect(events.filter(event => event.card === task.id).map(event => [event.seq, event.state]))
|
|
25
|
+
.toEqual([[1, CommitState.Committed], [2, CommitState.Committed], [3, CommitState.Committed]])
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
test('deleting a project purges its cards, specifications, links and log — a sibling survives', async () => {
|
|
29
|
+
const { facade, store } = await makeTestPlanning()
|
|
30
|
+
const planning = facade()
|
|
31
|
+
const doomed = await createProject(planning, 'Doomed')
|
|
32
|
+
const kept = await createProject(planning, 'Kept')
|
|
33
|
+
const first = (await createTask(planning, doomed.id!, 'First')).card!
|
|
34
|
+
const second = (await createTask(planning, doomed.id!, 'Second', {})).card!
|
|
35
|
+
await planning.execute({ card: second.id!, action: TransitionAction.Link, link: { type: 'follows', to: first.id! } }, { wait: true })
|
|
36
|
+
const spec = (await planning.execute({
|
|
37
|
+
card: { kind: WorkcardKind.Specification, type: SPEC, parent: first.id!, title: 'design', category: 'design', format: SpecificationFormat.Json, body: '{}' },
|
|
38
|
+
action: TransitionAction.Create,
|
|
39
|
+
}, { wait: true })).card!
|
|
40
|
+
const survivor = (await createTask(planning, kept.id!, 'Survivor')).card!
|
|
41
|
+
|
|
42
|
+
const deleted = await planning.execute({ card: doomed.id!, action: TransitionAction.Delete }, { wait: true })
|
|
43
|
+
|
|
44
|
+
for (const id of [doomed.id!, first.id!, second.id!, spec.id!]) {
|
|
45
|
+
expect(await planning.cards.load(id)).toBeNull()
|
|
46
|
+
}
|
|
47
|
+
expect((await planning.relationships.list({ from: second.id })).total).toBe(0)
|
|
48
|
+
expect((await planning.transitions.list({ project: doomed.id })).total).toBe(0)
|
|
49
|
+
expect((await planning.commits.status(deleted.transition.id!)).state).toBe(CommitState.Committed)
|
|
50
|
+
expect(await planning.cards.load(survivor.id!)).not.toBeNull()
|
|
51
|
+
// The kept project, its task and their two create transitions.
|
|
52
|
+
expect(await store.cards.purge(kept.id!, ENTITY)).toBe(4)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
test('byKey answers the first transition under that key, within its entity only', async () => {
|
|
56
|
+
const { facade, store } = await makeTestPlanning()
|
|
57
|
+
const planning = facade()
|
|
58
|
+
const receipt = await planning.execute({
|
|
59
|
+
card: { kind: WorkcardKind.Project, type: 'test:project', title: 'Keyed' }, action: TransitionAction.Create, key: 'first',
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
expect((await store.transitions.byKey(ENTITY, 'first'))!.id).toBe(receipt.transition.id!)
|
|
63
|
+
expect(await store.transitions.byKey(OTHER_ENTITY, 'first')).toBeNull()
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
test('a list honours parents, labels, a flow status and a field', async () => {
|
|
67
|
+
const { facade } = await makeTestPlanning()
|
|
68
|
+
const planning = facade()
|
|
69
|
+
const project = await createProject(planning, 'Main')
|
|
70
|
+
const other = await createProject(planning, 'Other')
|
|
71
|
+
const urgent = (await createTask(planning, project.id!, 'Urgent', { labels: ['urgent'], fields: { area: 'guest' } })).card!
|
|
72
|
+
const shared = (await createTask(planning, project.id!, 'Shared', { labels: ['later'], parents: [other.id!], fields: { area: 'user' } })).card!
|
|
73
|
+
await planning.execute({ card: shared.id!, action: TransitionAction.Transit, flow: 'test:review', transition: 'approve' })
|
|
74
|
+
|
|
75
|
+
const ids = async (query: Parameters<typeof planning.cards.list>[0]) =>
|
|
76
|
+
(await planning.cards.list(query)).items.map(card => card.id)
|
|
77
|
+
|
|
78
|
+
expect(await ids({ labels: ['urgent'] })).toEqual([urgent.id])
|
|
79
|
+
expect(await ids({ within: other.id })).toEqual([shared.id])
|
|
80
|
+
expect(await ids({ flow: { id: 'test:review', status: 'approved' } })).toEqual([shared.id])
|
|
81
|
+
expect(await ids({ fields: { area: 'guest' } })).toEqual([urgent.id])
|
|
82
|
+
expect((await facade(OTHER_ENTITY).cards.list({ labels: ['urgent'] })).total).toBe(0)
|
|
83
|
+
})
|
|
84
|
+
})
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { CommitState, PlanningRefused, TransitionAction, WorkcardKind } from '@owlmeans/planning'
|
|
3
|
+
import { makeMemoryPlanningStore } from '../src/store/memory.js'
|
|
4
|
+
import { BUG, createProject, createTask, ENTITY, makeTestPlanning, PROJECT, TASK } from './context.js'
|
|
5
|
+
|
|
6
|
+
describe('@owlmeans/server-planning — plugins', () => {
|
|
7
|
+
test('before middlewares run in order, and a throw refuses with nothing appended', async () => {
|
|
8
|
+
const calls: string[] = []
|
|
9
|
+
const { facade, service } = await makeTestPlanning({
|
|
10
|
+
plugins: [
|
|
11
|
+
{ name: 'second', order: 20, before: async () => { calls.push('second') } },
|
|
12
|
+
{ name: 'first', order: 10, before: async exec => { calls.push('first'); return { ...exec, cause: 'first' } } },
|
|
13
|
+
],
|
|
14
|
+
})
|
|
15
|
+
const planning = facade()
|
|
16
|
+
|
|
17
|
+
const project = await planning.execute({ card: { kind: WorkcardKind.Project, type: PROJECT, title: 'P' }, action: TransitionAction.Create }, { wait: true })
|
|
18
|
+
expect(calls).toEqual(['first', 'second'])
|
|
19
|
+
expect(project.transition.cause).toBe('first')
|
|
20
|
+
|
|
21
|
+
service.use({ name: 'refuser', order: 30, before: async () => { throw new Error('not today') } })
|
|
22
|
+
const refusal = await createTask(planning, project.card!.id!).catch(error => error)
|
|
23
|
+
expect(refusal).toBeInstanceOf(PlanningRefused)
|
|
24
|
+
expect(refusal.message).toContain('refuser:not today')
|
|
25
|
+
expect((await planning.cards.list({ parent: project.card!.id, type: TASK })).total).toBe(0)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
test('the first plugin that mints a code wins', async () => {
|
|
29
|
+
const { facade } = await makeTestPlanning({
|
|
30
|
+
plugins: [
|
|
31
|
+
{ name: 'late', order: 20, mintCode: async () => 'T-LATE' },
|
|
32
|
+
{ name: 'early', order: 10, mintCode: async () => 'T-EARLY' },
|
|
33
|
+
{ name: 'silent', order: 5, mintCode: async () => undefined },
|
|
34
|
+
],
|
|
35
|
+
})
|
|
36
|
+
const planning = facade()
|
|
37
|
+
const project = await createProject(planning)
|
|
38
|
+
|
|
39
|
+
expect((await createTask(planning, project.id!)).card!.code).toBe('T-EARLY')
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
test('a plugin that owns a type receives its writes and answers its reads', async () => {
|
|
43
|
+
const bugs = makeMemoryPlanningStore()
|
|
44
|
+
const { facade, store } = await makeTestPlanning({ plugins: [{ name: 'bugs', owns: type => type === BUG, store: bugs }] })
|
|
45
|
+
const planning = facade()
|
|
46
|
+
const project = await createProject(planning)
|
|
47
|
+
|
|
48
|
+
const bug = (await planning.execute({
|
|
49
|
+
card: { kind: WorkcardKind.Card, type: BUG, parent: project.id!, title: 'Crash' }, action: TransitionAction.Create,
|
|
50
|
+
}, { wait: true })).card!
|
|
51
|
+
|
|
52
|
+
expect(await bugs.cards.get(bug.id!, ENTITY)).not.toBeNull()
|
|
53
|
+
expect(await store.cards.get(bug.id!, ENTITY)).toBeNull()
|
|
54
|
+
expect((await planning.cards.list({ type: BUG })).total).toBe(1)
|
|
55
|
+
expect((await planning.cards.load(bug.id!))!.title).toBe('Crash')
|
|
56
|
+
// A query naming no type is the default store's alone.
|
|
57
|
+
expect((await planning.cards.list({ parent: project.id })).total).toBe(0)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
test('after hooks run once per committed transition, in the folding process, and never fail a write', async () => {
|
|
61
|
+
const seen: string[] = []
|
|
62
|
+
const { facade, store } = await makeTestPlanning({
|
|
63
|
+
plugins: [
|
|
64
|
+
{ name: 'boom', order: 1, after: async () => { throw new Error('boom') } },
|
|
65
|
+
{ name: 'counter', after: async (event, ctx) => { seen.push(`${event.card}:${event.state}:${ctx.transition?.actor.channel}`) } },
|
|
66
|
+
],
|
|
67
|
+
}, { sync: false })
|
|
68
|
+
const planning = facade()
|
|
69
|
+
const errors = console.error
|
|
70
|
+
console.error = () => undefined
|
|
71
|
+
try {
|
|
72
|
+
const receipt = await planning.execute({ card: { kind: WorkcardKind.Project, type: PROJECT, title: 'Later' }, action: TransitionAction.Create })
|
|
73
|
+
expect(receipt.transition.commit.state).toBe(CommitState.Pending)
|
|
74
|
+
expect(seen).toEqual([])
|
|
75
|
+
|
|
76
|
+
await store.flush()
|
|
77
|
+
await store.flush()
|
|
78
|
+
await store.flush(receipt.transition.card)
|
|
79
|
+
|
|
80
|
+
expect(seen).toEqual([`${receipt.transition.card}:committed:test`])
|
|
81
|
+
expect((await receipt.committed({ timeout: 100 }))!.title).toBe('Later')
|
|
82
|
+
} finally {
|
|
83
|
+
console.error = errors
|
|
84
|
+
}
|
|
85
|
+
})
|
|
86
|
+
})
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import {
|
|
3
|
+
FieldsInvalid, SpecificationFormat, SpecificationRevisionConflict, SpecificationSlotUnknown, TransitionAction, WorkcardKind,
|
|
4
|
+
} from '@owlmeans/planning'
|
|
5
|
+
import type { Specification } from '@owlmeans/planning'
|
|
6
|
+
import { createProject, makeTestPlanning, SPEC } from './context.js'
|
|
7
|
+
|
|
8
|
+
describe('@owlmeans/server-planning — specifications', () => {
|
|
9
|
+
test('a revisioned slot written twice is revision 1 then 2 on one record', async () => {
|
|
10
|
+
const { facade } = await makeTestPlanning()
|
|
11
|
+
const planning = facade()
|
|
12
|
+
const project = await planning.model(await createProject(planning))
|
|
13
|
+
|
|
14
|
+
const first = (await project.write('plan', JSON.stringify({ steps: [1] }), { wait: true })).card as Specification
|
|
15
|
+
const second = (await (await project.reload()).write('plan', JSON.stringify({ steps: [1, 2] }), { wait: true })).card as Specification
|
|
16
|
+
|
|
17
|
+
expect(first.revision).toBe(1)
|
|
18
|
+
expect(second.id).toBe(first.id!)
|
|
19
|
+
expect(second.revision).toBe(2)
|
|
20
|
+
expect((await planning.specifications.list(project.id)).total).toBe(1)
|
|
21
|
+
expect((await planning.specifications.current(project.id, 'plan'))!.body).toBe(JSON.stringify({ steps: [1, 2] }))
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
test('revisions() replays every body from the log, newest first', async () => {
|
|
25
|
+
const { facade } = await makeTestPlanning()
|
|
26
|
+
const planning = facade()
|
|
27
|
+
const project = await planning.model(await createProject(planning))
|
|
28
|
+
const spec = (await project.write('plan', '{"steps":["a"]}', { wait: true })).card!
|
|
29
|
+
await (await project.reload()).write('plan', '{"steps":["a","b"]}', { wait: true })
|
|
30
|
+
|
|
31
|
+
const revisions = await planning.specifications.revisions(spec.id!)
|
|
32
|
+
|
|
33
|
+
expect(revisions.map(entry => [entry.revision, entry.body])).toEqual([[2, '{"steps":["a","b"]}'], [1, '{"steps":["a"]}']])
|
|
34
|
+
expect(revisions[0].by?.channel).toBe('test')
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test('a second document for a slot that holds one is SpecificationRevisionConflict', async () => {
|
|
38
|
+
const { facade } = await makeTestPlanning()
|
|
39
|
+
const planning = facade()
|
|
40
|
+
const project = await createProject(planning)
|
|
41
|
+
const create = (body: string) => planning.execute({
|
|
42
|
+
card: { kind: WorkcardKind.Specification, type: SPEC, parent: project.id!, title: 'Brief', category: 'brief', body },
|
|
43
|
+
action: TransitionAction.Create,
|
|
44
|
+
}, { wait: true })
|
|
45
|
+
|
|
46
|
+
await create('# one')
|
|
47
|
+
|
|
48
|
+
await expect(create('# two')).rejects.toBeInstanceOf(SpecificationRevisionConflict)
|
|
49
|
+
expect((await planning.specifications.current(project.id!, 'brief'))!.body).toBe('# one')
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
test('an undeclared slot and a body outside the slot schema are refused', async () => {
|
|
53
|
+
const { facade } = await makeTestPlanning()
|
|
54
|
+
const planning = facade()
|
|
55
|
+
const project = await planning.model(await createProject(planning))
|
|
56
|
+
|
|
57
|
+
await expect(project.write('roadmap', 'text')).rejects.toBeInstanceOf(SpecificationSlotUnknown)
|
|
58
|
+
await expect(project.write('plan', '{"steps":1}')).rejects.toBeInstanceOf(FieldsInvalid)
|
|
59
|
+
await expect(project.write('plan', 'not json', { format: SpecificationFormat.Json })).rejects.toBeInstanceOf(FieldsInvalid)
|
|
60
|
+
})
|
|
61
|
+
})
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": [
|
|
3
|
+
"@owlmeans/dep-config/tsconfig.base.json",
|
|
4
|
+
"@owlmeans/dep-config/tsconfig.node.json"
|
|
5
|
+
],
|
|
6
|
+
"compilerOptions": {
|
|
7
|
+
"types": [
|
|
8
|
+
"bun"
|
|
9
|
+
],
|
|
10
|
+
"rootDir": "../",
|
|
11
|
+
"noEmit": true
|
|
12
|
+
},
|
|
13
|
+
"include": [
|
|
14
|
+
"./**/*",
|
|
15
|
+
"../src/**/*"
|
|
16
|
+
]
|
|
17
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import {
|
|
3
|
+
CardTypeNotAllowed, FieldsInvalid, IllegalTransition, LabelNotAllowed, PlanningError, TransitionAction, WorkcardKind,
|
|
4
|
+
} from '@owlmeans/planning'
|
|
5
|
+
import { createProject, createTask, makeTestPlanning, NOTE, TASK } from './context.js'
|
|
6
|
+
|
|
7
|
+
describe('@owlmeans/server-planning — validation', () => {
|
|
8
|
+
test('a transition the flow does not offer from the current status is IllegalTransition', async () => {
|
|
9
|
+
const { facade } = await makeTestPlanning()
|
|
10
|
+
const planning = facade()
|
|
11
|
+
const project = await createProject(planning)
|
|
12
|
+
const task = (await createTask(planning, project.id!)).card!
|
|
13
|
+
|
|
14
|
+
await expect(planning.execute({ card: task.id!, action: TransitionAction.Transit, transition: 'finish' }))
|
|
15
|
+
.rejects.toBeInstanceOf(IllegalTransition)
|
|
16
|
+
await expect(planning.execute({ card: task.id!, action: TransitionAction.Transit, transition: 'approve' }))
|
|
17
|
+
.rejects.toBeInstanceOf(IllegalTransition)
|
|
18
|
+
expect((await planning.transitions.list({ card: task.id })).total).toBe(1)
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
test('fields outside the type schema are FieldsInvalid, labels outside the list LabelNotAllowed', async () => {
|
|
22
|
+
const { facade } = await makeTestPlanning()
|
|
23
|
+
const planning = facade()
|
|
24
|
+
const project = await createProject(planning)
|
|
25
|
+
|
|
26
|
+
await expect(createTask(planning, project.id!, 'Bad', { fields: { area: 'moon' } })).rejects.toBeInstanceOf(FieldsInvalid)
|
|
27
|
+
await expect(createTask(planning, project.id!, 'Bad', { labels: ['someday'] })).rejects.toBeInstanceOf(LabelNotAllowed)
|
|
28
|
+
|
|
29
|
+
const task = (await createTask(planning, project.id!)).card!
|
|
30
|
+
await expect(planning.execute({ card: task.id!, action: TransitionAction.Update, changes: { fields: { extra: 1 } } }))
|
|
31
|
+
.rejects.toBeInstanceOf(FieldsInvalid)
|
|
32
|
+
expect((await planning.cards.list({ parent: project.id })).total).toBe(1)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
test('a card type the parent does not accept is CardTypeNotAllowed', async () => {
|
|
36
|
+
const { facade } = await makeTestPlanning()
|
|
37
|
+
const planning = facade()
|
|
38
|
+
const project = await createProject(planning)
|
|
39
|
+
const task = (await createTask(planning, project.id!)).card!
|
|
40
|
+
|
|
41
|
+
await expect(planning.execute({
|
|
42
|
+
card: { kind: WorkcardKind.Card, type: NOTE, parent: project.id!, title: 'Note' }, action: TransitionAction.Create,
|
|
43
|
+
})).rejects.toBeInstanceOf(CardTypeNotAllowed)
|
|
44
|
+
await expect(planning.execute({
|
|
45
|
+
card: { kind: WorkcardKind.Card, type: TASK, parent: task.id!, title: 'Sub' }, action: TransitionAction.Create,
|
|
46
|
+
})).rejects.toBeInstanceOf(CardTypeNotAllowed)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test('a status written by an update is refused as immutable', async () => {
|
|
50
|
+
const { facade } = await makeTestPlanning()
|
|
51
|
+
const planning = facade()
|
|
52
|
+
const project = await createProject(planning)
|
|
53
|
+
const task = (await createTask(planning, project.id!)).card!
|
|
54
|
+
|
|
55
|
+
const refusal = await planning.execute({ card: task.id!, action: TransitionAction.Update, changes: { status: 'done' } })
|
|
56
|
+
.catch(error => error)
|
|
57
|
+
|
|
58
|
+
expect(refusal).toBeInstanceOf(PlanningError)
|
|
59
|
+
expect(refusal.message).toContain('immutable:status')
|
|
60
|
+
expect((await planning.cards.get(task.id!)).status).toBe('todo')
|
|
61
|
+
})
|
|
62
|
+
})
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": [
|
|
3
|
+
"@owlmeans/dep-config/tsconfig.base.json",
|
|
4
|
+
"@owlmeans/dep-config/tsconfig.node.json"
|
|
5
|
+
],
|
|
6
|
+
"compilerOptions": {
|
|
7
|
+
"rootDir": "./src/",
|
|
8
|
+
"outDir": "./build/"
|
|
9
|
+
},
|
|
10
|
+
"exclude": [
|
|
11
|
+
"./dist/**/*",
|
|
12
|
+
"./build/**/*",
|
|
13
|
+
"./tests/**/*",
|
|
14
|
+
"./*.ts"
|
|
15
|
+
]
|
|
16
|
+
}
|