@things-factory/worklist 10.1.42 → 10.1.44
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/dist-client/components/decision-slots.d.ts +14 -1
- package/dist-client/components/decision-slots.js +33 -1
- package/dist-client/components/decision-slots.js.map +1 -1
- package/dist-client/pages/activity-approval/activity-approval-page.js +3 -3
- package/dist-client/pages/activity-approval/activity-approval-page.js.map +1 -1
- package/dist-client/pages/activity-approval/approval-gate.d.ts +14 -6
- package/dist-client/pages/activity-approval/approval-gate.js +17 -5
- package/dist-client/pages/activity-approval/approval-gate.js.map +1 -1
- package/dist-client/route.d.ts +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/dist-server/controllers/activity-instance/approval-line-input.d.ts +29 -0
- package/dist-server/controllers/activity-instance/approval-line-input.js +44 -0
- package/dist-server/controllers/activity-instance/approval-line-input.js.map +1 -0
- package/dist-server/controllers/activity-instance/assign.js +12 -2
- package/dist-server/controllers/activity-instance/assign.js.map +1 -1
- package/dist-server/controllers/activity-instance/claim-draft.d.ts +17 -0
- package/dist-server/controllers/activity-instance/claim-draft.js +30 -0
- package/dist-server/controllers/activity-instance/claim-draft.js.map +1 -0
- package/dist-server/controllers/activity-instance/discard-draft.d.ts +5 -5
- package/dist-server/controllers/activity-instance/discard-draft.js +5 -5
- package/dist-server/controllers/activity-instance/discard-draft.js.map +1 -1
- package/dist-server/controllers/activity-instance/draft.js +11 -0
- package/dist-server/controllers/activity-instance/draft.js.map +1 -1
- package/dist-server/controllers/activity-instance/issue.js +20 -0
- package/dist-server/controllers/activity-instance/issue.js.map +1 -1
- package/dist-server/controllers/activity-instance/pick.js +16 -0
- package/dist-server/controllers/activity-instance/pick.js.map +1 -1
- package/dist-server/controllers/common.js +4 -3
- package/dist-server/controllers/common.js.map +1 -1
- package/dist-server/service/index.d.ts +3 -3
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +10 -10
- package/tests/approval-line-input.test.ts +112 -0
- package/tests/delegate-thread.test.ts +101 -0
- package/tests/discard-draft.test.ts +53 -15
- package/tests/discard-versus-issue.pg.test.ts +127 -0
- package/tests/org-member-domain.test.ts +87 -0
- package/tests/pick-race.pg.test.ts +128 -0
- package/translations/en.json +3 -0
- package/translations/ja.json +3 -0
- package/translations/ko.json +3 -0
- package/translations/ms.json +3 -0
- package/translations/zh.json +3 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@things-factory/worklist",
|
|
3
|
-
"version": "10.1.
|
|
3
|
+
"version": "10.1.44",
|
|
4
4
|
"main": "dist-server/index.js",
|
|
5
5
|
"browser": "dist-client/index.js",
|
|
6
6
|
"things-factory": true,
|
|
@@ -37,14 +37,14 @@
|
|
|
37
37
|
"@operato/moment-timezone-es": "^10.0.0",
|
|
38
38
|
"@operato/p13n": "^10.0.0",
|
|
39
39
|
"@operato/popup": "^10.15.0",
|
|
40
|
-
"@things-factory/attachment-base": "^10.1.
|
|
41
|
-
"@things-factory/auth-base": "^10.1.
|
|
42
|
-
"@things-factory/board-service": "^10.1.
|
|
43
|
-
"@things-factory/context-ui": "^10.1.
|
|
44
|
-
"@things-factory/organization": "^10.1.
|
|
45
|
-
"@things-factory/personalization": "^10.1.
|
|
46
|
-
"@things-factory/scheduler-client": "^10.1.
|
|
47
|
-
"@things-factory/shell": "^10.1.
|
|
40
|
+
"@things-factory/attachment-base": "^10.1.44",
|
|
41
|
+
"@things-factory/auth-base": "^10.1.44",
|
|
42
|
+
"@things-factory/board-service": "^10.1.44",
|
|
43
|
+
"@things-factory/context-ui": "^10.1.44",
|
|
44
|
+
"@things-factory/organization": "^10.1.44",
|
|
45
|
+
"@things-factory/personalization": "^10.1.44",
|
|
46
|
+
"@things-factory/scheduler-client": "^10.1.44",
|
|
47
|
+
"@things-factory/shell": "^10.1.44"
|
|
48
48
|
},
|
|
49
|
-
"gitHead": "
|
|
49
|
+
"gitHead": "7c95c4e75e155e51c714409e865d96f317232c23"
|
|
50
50
|
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { join } from 'path'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The approval line a case carries, through the real draft and issue controllers on sqlite
|
|
5
|
+
* (ADR-0091 decision 7).
|
|
6
|
+
*
|
|
7
|
+
* The comparison itself is held in node (test/approval-line-input.test.ts). What only the
|
|
8
|
+
* controllers can show: the refusal is asked with the activity's line as it was loaded, before
|
|
9
|
+
* anything is saved; the same line handed back - as plant's server doors do - still passes; and an
|
|
10
|
+
* activity with no line takes the one handed in.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const { createSqliteTestDataSource } = require(join(__dirname, '..', '..', 'ops-master', 'test', 'support', 'database.cjs'))
|
|
14
|
+
const DIST = join(__dirname, '..', 'dist-server')
|
|
15
|
+
const { Activity } = require(join(DIST, 'service', 'activity', 'activity.js'))
|
|
16
|
+
const { ActivityInstance } = require(join(DIST, 'service', 'activity-instance', 'activity-instance.js'))
|
|
17
|
+
const { ActivityThread } = require(join(DIST, 'service', 'activity-thread', 'activity-thread.js'))
|
|
18
|
+
const { ActivityApproval } = require(join(DIST, 'service', 'activity-approval', 'activity-approval.js'))
|
|
19
|
+
const { draft } = require(join(DIST, 'controllers', 'activity-instance', 'draft.js'))
|
|
20
|
+
const { issue } = require(join(DIST, 'controllers', 'activity-instance', 'issue.js'))
|
|
21
|
+
const { Employee } = require('@things-factory/organization')
|
|
22
|
+
const { Domain } = require('@things-factory/shell')
|
|
23
|
+
const { User } = require('@things-factory/auth-base')
|
|
24
|
+
|
|
25
|
+
let db: any
|
|
26
|
+
let domain: any
|
|
27
|
+
let raiser: any
|
|
28
|
+
let approver: any
|
|
29
|
+
let other: any
|
|
30
|
+
|
|
31
|
+
beforeAll(async () => {
|
|
32
|
+
db = createSqliteTestDataSource([Activity, ActivityInstance, ActivityThread, ActivityApproval, Employee])
|
|
33
|
+
await db.initialize()
|
|
34
|
+
|
|
35
|
+
domain = await db.getRepository(Domain).save({ name: 'd', subdomain: 'd' })
|
|
36
|
+
|
|
37
|
+
const person = async (name: string) => {
|
|
38
|
+
const user = await db.getRepository(User).save({ name, email: `${name}@example.com`, password: 'x', status: 'activated', domains: [domain] })
|
|
39
|
+
return db.getRepository(Employee).save({ domain, name, controlNo: name, user })
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
raiser = await db.getRepository(User).save({ name: 'raiser', email: 'raiser@example.com', password: 'x', status: 'activated', domains: [domain] })
|
|
43
|
+
approver = await person('approver')
|
|
44
|
+
other = await person('other')
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
afterAll(async () => db?.destroy())
|
|
48
|
+
|
|
49
|
+
const lineOf = (employee: any, participation?: string) => [
|
|
50
|
+
{ type: 'Employee', value: employee.id, ...(participation ? { participation } : {}) }
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
async function anActivity(approvalLine: any[] | null) {
|
|
54
|
+
return db.getRepository(Activity).save({
|
|
55
|
+
domain,
|
|
56
|
+
name: `a-${Math.random().toString(36).slice(2, 8)}`,
|
|
57
|
+
activityType: 'standard',
|
|
58
|
+
startingType: 'issue',
|
|
59
|
+
approvalLine
|
|
60
|
+
})
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const as = (tx: any) => ({ state: { domain, user: raiser, tx }, t: (key: string) => key }) as any
|
|
64
|
+
|
|
65
|
+
const drafting = (activity: any, approvalLine?: any[]) =>
|
|
66
|
+
db.transaction((tx: any) => draft({ activityId: activity.id, name: 'case', ...(approvalLine ? { approvalLine } : {}) }, as(tx)))
|
|
67
|
+
|
|
68
|
+
const issuing = (activity: any, approvalLine?: any[]) =>
|
|
69
|
+
db.transaction((tx: any) => issue({ activityId: activity.id, name: 'case', ...(approvalLine ? { approvalLine } : {}) }, as(tx)))
|
|
70
|
+
|
|
71
|
+
const saved = () => db.getRepository(ActivityInstance).count()
|
|
72
|
+
|
|
73
|
+
describe.each([
|
|
74
|
+
['draft', drafting],
|
|
75
|
+
['issue', issuing]
|
|
76
|
+
])('%s', (_, run) => {
|
|
77
|
+
it('a line other than the activity\'s is refused by name, and nothing is saved', async () => {
|
|
78
|
+
const activity = await anActivity(lineOf(approver))
|
|
79
|
+
const before = await saved()
|
|
80
|
+
|
|
81
|
+
await expect(run(activity, lineOf(other))).rejects.toThrow('error.activity-instance approval line is set')
|
|
82
|
+
expect(await saved()).toBe(before)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('an approver turned into a reference is refused - the line would end with nobody judging', async () => {
|
|
86
|
+
const activity = await anActivity(lineOf(approver))
|
|
87
|
+
|
|
88
|
+
await expect(run(activity, lineOf(approver, 'reference'))).rejects.toThrow('error.activity-instance approval line is set')
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('the same line handed back passes, written with its keys in another order', async () => {
|
|
92
|
+
const activity = await anActivity(lineOf(approver))
|
|
93
|
+
const handedBack = [{ value: approver.id, participation: 'approval', type: 'Employee' }]
|
|
94
|
+
|
|
95
|
+
const result = await run(activity, handedBack)
|
|
96
|
+
expect(result).toBeTruthy()
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('no line handed in takes the activity\'s', async () => {
|
|
100
|
+
const activity = await anActivity(lineOf(approver))
|
|
101
|
+
|
|
102
|
+
const result = await run(activity)
|
|
103
|
+
expect(result.approvalLine?.map((one: any) => one.value)).toEqual([approver.id])
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('an activity with no line takes the one handed in (ad hoc)', async () => {
|
|
107
|
+
const activity = await anActivity(null)
|
|
108
|
+
|
|
109
|
+
const result = await run(activity, lineOf(other))
|
|
110
|
+
expect(result.approvalLine?.map((one: any) => one.value)).toEqual([other.id])
|
|
111
|
+
})
|
|
112
|
+
})
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { join } from 'path'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Handing a thread on, through the real controller on sqlite (ADR-0091 decision 4).
|
|
5
|
+
*
|
|
6
|
+
* The thread controller used to save the assignee object as the client sent it - any id, any tenant,
|
|
7
|
+
* active or not. Now it looks the receiver up: active, in this domain, not the thread's own holder.
|
|
8
|
+
* Unlike an approval seat, a thread may go to whoever raised the case - doing the work is not judging.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const { createSqliteTestDataSource } = require(join(__dirname, '..', '..', 'ops-master', 'test', 'support', 'database.cjs'))
|
|
12
|
+
const DIST = join(__dirname, '..', 'dist-server')
|
|
13
|
+
const { Activity } = require(join(DIST, 'service', 'activity', 'activity.js'))
|
|
14
|
+
const { ActivityInstance } = require(join(DIST, 'service', 'activity-instance', 'activity-instance.js'))
|
|
15
|
+
const { ActivityThread } = require(join(DIST, 'service', 'activity-thread', 'activity-thread.js'))
|
|
16
|
+
const { ActivityApproval } = require(join(DIST, 'service', 'activity-approval', 'activity-approval.js'))
|
|
17
|
+
const { delegate } = require(join(DIST, 'controllers', 'activity-thread', 'delegate.js'))
|
|
18
|
+
const { Domain } = require('@things-factory/shell')
|
|
19
|
+
const { User } = require('@things-factory/auth-base')
|
|
20
|
+
|
|
21
|
+
let db: any
|
|
22
|
+
let here: any
|
|
23
|
+
let holder: any
|
|
24
|
+
let colleague: any
|
|
25
|
+
let issuer: any
|
|
26
|
+
let stranger: any
|
|
27
|
+
let retired: any
|
|
28
|
+
|
|
29
|
+
beforeAll(async () => {
|
|
30
|
+
db = createSqliteTestDataSource([Activity, ActivityInstance, ActivityThread, ActivityApproval])
|
|
31
|
+
await db.initialize()
|
|
32
|
+
|
|
33
|
+
here = await db.getRepository(Domain).save({ name: 'here', subdomain: 'here' })
|
|
34
|
+
const elsewhere = await db.getRepository(Domain).save({ name: 'elsewhere', subdomain: 'elsewhere' })
|
|
35
|
+
|
|
36
|
+
const person = (name: string, domains: any[], status = 'activated') =>
|
|
37
|
+
db.getRepository(User).save({ name, email: `${name}@example.com`, password: 'x', status, domains })
|
|
38
|
+
|
|
39
|
+
holder = await person('holder', [here])
|
|
40
|
+
colleague = await person('colleague', [here])
|
|
41
|
+
issuer = await person('issuer', [here])
|
|
42
|
+
stranger = await person('stranger', [elsewhere])
|
|
43
|
+
retired = await person('retired', [here], 'inactive')
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
afterAll(async () => db?.destroy())
|
|
47
|
+
|
|
48
|
+
async function aThread() {
|
|
49
|
+
const activity = await db.getRepository(Activity).save({ domain: here, name: `a-${Math.random().toString(36).slice(2, 8)}`, activityType: 'standard' })
|
|
50
|
+
const instance = await db.getRepository(ActivityInstance).save({ domain: here, name: 'i', state: 'assigned', issuer, activity })
|
|
51
|
+
|
|
52
|
+
return db.getRepository(ActivityThread).save({ domain: here, name: 't', round: 1, state: 'assigned', assignee: holder, activityInstance: instance })
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const handOn = (thread: any, to: any) =>
|
|
56
|
+
db.transaction((tx: any) =>
|
|
57
|
+
delegate({ id: thread.id, assignee: { id: to.id }, reason: 'away' }, { state: { domain: here, user: holder, tx }, t: (key: string) => key } as any)
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
const assigneeOf = async (thread: any) => (await db.getRepository(ActivityThread).findOne({ where: { id: thread.id } })).assigneeId
|
|
61
|
+
|
|
62
|
+
it('a colleague in this domain receives the thread', async () => {
|
|
63
|
+
const thread = await aThread()
|
|
64
|
+
|
|
65
|
+
await handOn(thread, colleague)
|
|
66
|
+
expect(await assigneeOf(thread)).toBe(colleague.id)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('whoever raised the case may receive it - doing the work is not judging', async () => {
|
|
70
|
+
const thread = await aThread()
|
|
71
|
+
|
|
72
|
+
await handOn(thread, issuer)
|
|
73
|
+
expect(await assigneeOf(thread)).toBe(issuer.id)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('someone of another tenant is refused, and the thread stays with its holder', async () => {
|
|
77
|
+
const thread = await aThread()
|
|
78
|
+
|
|
79
|
+
await expect(handOn(thread, stranger)).rejects.toThrow('error.activity-approval delegate outside domain')
|
|
80
|
+
expect(await assigneeOf(thread)).toBe(holder.id)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('someone no longer active is nobody to receive it', async () => {
|
|
84
|
+
const thread = await aThread()
|
|
85
|
+
|
|
86
|
+
await expect(handOn(thread, retired)).rejects.toThrow('error.user not found')
|
|
87
|
+
expect(await assigneeOf(thread)).toBe(holder.id)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('an id that is nobody at all is refused, not saved', async () => {
|
|
91
|
+
const thread = await aThread()
|
|
92
|
+
|
|
93
|
+
await expect(handOn(thread, { id: '00000000-0000-0000-0000-000000000000' })).rejects.toThrow('error.user not found')
|
|
94
|
+
expect(await assigneeOf(thread)).toBe(holder.id)
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('to its own holder is refused', async () => {
|
|
98
|
+
const thread = await aThread()
|
|
99
|
+
|
|
100
|
+
await expect(handOn(thread, holder)).rejects.toThrow('error.activity-approval delegate to self')
|
|
101
|
+
})
|
|
@@ -104,42 +104,80 @@ it('an id that is not here is refused by name', async () => {
|
|
|
104
104
|
|
|
105
105
|
describe('attachment files and the commit', () => {
|
|
106
106
|
/*
|
|
107
|
-
* deleteAttachmentsByRef
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
* test.failing: this is red in intent - it passes while the hole is open, and turns red the day files
|
|
113
|
-
* wait for the commit, which is the signal to make it a plain test.
|
|
107
|
+
* deleteAttachmentsByRef used to remove the files from storage while the transaction was still open,
|
|
108
|
+
* so a commit that then failed brought the rows back without their files. Now @transaction hands the
|
|
109
|
+
* request an after-commit list and attachment-base puts the file deletes on it (ADR-0091 decision 8).
|
|
110
|
+
* These run the discard the way the directive does, through shell's runTransaction.
|
|
114
111
|
*/
|
|
115
112
|
const attachmentConst = require(join(__dirname, '..', '..', 'attachment-base', 'dist-server', 'attachment-const.js'))
|
|
113
|
+
const { runTransaction } = require('@things-factory/shell')
|
|
114
|
+
const { ActivityInstanceMutation } = require(join(DIST, 'service', 'activity-instance', 'activity-instance-mutation.js'))
|
|
116
115
|
const deletedFiles: string[] = []
|
|
117
116
|
|
|
118
117
|
beforeAll(() => {
|
|
119
118
|
attachmentConst.STORAGE = { ...(attachmentConst.STORAGE || {}), deleteFile: async (path: string) => deletedFiles.push(path) }
|
|
120
119
|
})
|
|
121
120
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
121
|
+
beforeEach(() => (deletedFiles.length = 0))
|
|
122
|
+
|
|
123
|
+
const attachTo = (instance: any) =>
|
|
124
|
+
db.getRepository(Attachment).save({
|
|
125
125
|
domain,
|
|
126
126
|
name: 'a.txt',
|
|
127
|
-
refBy:
|
|
127
|
+
refBy: instance.id,
|
|
128
128
|
refType: 'ActivityInstance',
|
|
129
|
-
path: `files/${
|
|
129
|
+
path: `files/${instance.id}.txt`,
|
|
130
130
|
mimetype: 'text/plain',
|
|
131
131
|
encoding: '7bit',
|
|
132
132
|
size: '1'
|
|
133
133
|
})
|
|
134
134
|
|
|
135
|
+
const asDirective = (user: any, work: (context: any) => Promise<any>) =>
|
|
136
|
+
runTransaction(
|
|
137
|
+
(body: any) => db.transaction(body),
|
|
138
|
+
(tx: any, afterCommit: any) => work({ state: { domain, user, tx, afterCommit }, t: (key: string) => key }),
|
|
139
|
+
() => {}
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
it('a discard that commits removes the file, after the commit', async () => {
|
|
143
|
+
const draft = await aCase('draft')
|
|
144
|
+
await attachTo(draft)
|
|
145
|
+
|
|
146
|
+
await asDirective(writer, context => discardDraft(draft.id, context))
|
|
147
|
+
|
|
148
|
+
expect(deletedFiles).toEqual([`files/${draft.id}.txt`])
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it('a transaction that rolls back after the discard leaves the file in storage', async () => {
|
|
152
|
+
const draft = await aCase('draft')
|
|
153
|
+
await attachTo(draft)
|
|
154
|
+
|
|
135
155
|
await expect(
|
|
136
|
-
|
|
137
|
-
await discardDraft(draft.id,
|
|
156
|
+
asDirective(writer, async context => {
|
|
157
|
+
await discardDraft(draft.id, context)
|
|
138
158
|
throw new Error('the commit fails')
|
|
139
159
|
})
|
|
140
160
|
).rejects.toThrow('the commit fails')
|
|
141
161
|
|
|
142
162
|
expect(await left(draft.id)).toEqual({ instance: true, threads: 1, approvals: 1 })
|
|
143
|
-
expect(deletedFiles).
|
|
163
|
+
expect(deletedFiles).toEqual([])
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it('discarding several where the last has gone out keeps every file - the first two roll back with it', async () => {
|
|
167
|
+
mockManages = true
|
|
168
|
+
const first = await aCase('draft')
|
|
169
|
+
const second = await aCase('draft')
|
|
170
|
+
const issued = await aCase('issued')
|
|
171
|
+
for (const one of [first, second, issued]) await attachTo(one)
|
|
172
|
+
|
|
173
|
+
await expect(
|
|
174
|
+
asDirective(someoneElse, context =>
|
|
175
|
+
new ActivityInstanceMutation().deleteDraftActivityInstances([first.id, second.id, issued.id], context)
|
|
176
|
+
)
|
|
177
|
+
).rejects.toThrow('error.activity-instance not a draft any more')
|
|
178
|
+
|
|
179
|
+
expect(await left(first.id)).toMatchObject({ instance: true })
|
|
180
|
+
expect(await left(second.id)).toMatchObject({ instance: true })
|
|
181
|
+
expect(deletedFiles).toEqual([])
|
|
144
182
|
})
|
|
145
183
|
})
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { join } from 'path'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Issuing and discarding the same draft at the same moment, on postgres (ADR-0091 decision 3).
|
|
5
|
+
*
|
|
6
|
+
* sqlite holds one connection and cannot run two transactions side by side, so what it says about this
|
|
7
|
+
* is no evidence either way. This needs a real server, and runs only when WORKLIST_PG_URL names one:
|
|
8
|
+
*
|
|
9
|
+
* docker run -d --name wl-ux-pg -e POSTGRES_PASSWORD=wlux -e POSTGRES_USER=wlux -e POSTGRES_DB=wlux \
|
|
10
|
+
* -p 55433:5432 postgres:14-alpine
|
|
11
|
+
* WORKLIST_PG_URL=postgres://wlux:wlux@localhost:55433/wlux npx jest packages/worklist/tests/discard-versus-issue.pg.test.ts
|
|
12
|
+
*
|
|
13
|
+
* Each round must end one way or the other - the discard won (the draft is gone, the issue failed) or
|
|
14
|
+
* the issue won (the case is out, the discard was refused by name). Both succeeding, or a case that
|
|
15
|
+
* comes back after it was discarded, is the defect.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const url = process.env.WORKLIST_PG_URL
|
|
19
|
+
const maybe = url ? describe : describe.skip
|
|
20
|
+
|
|
21
|
+
jest.mock('@things-factory/auth-base', () => {
|
|
22
|
+
const actual = jest.requireActual('@things-factory/auth-base')
|
|
23
|
+
return { ...actual, checkPermission: async () => false }
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
/* Attachments are not what this measures, and their entity declares a sqlite-only blob column. */
|
|
27
|
+
jest.mock('@things-factory/attachment-base', () => {
|
|
28
|
+
const actual = jest.requireActual('@things-factory/attachment-base')
|
|
29
|
+
return { ...actual, deleteAttachmentsByRef: async () => false }
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
maybe('discard and issue at once, on postgres', () => {
|
|
33
|
+
const { DataSource } = require('typeorm')
|
|
34
|
+
const { entityClosure } = require(join(__dirname, '..', '..', 'ops-master', 'test', 'support', 'database.cjs'))
|
|
35
|
+
const { NamingStrategy } = require('@things-factory/shell/dist-server/initializers/naming-strategy.js')
|
|
36
|
+
const DIST = join(__dirname, '..', 'dist-server')
|
|
37
|
+
const { Activity } = require(join(DIST, 'service', 'activity', 'activity.js'))
|
|
38
|
+
const { ActivityInstance } = require(join(DIST, 'service', 'activity-instance', 'activity-instance.js'))
|
|
39
|
+
const { ActivityThread } = require(join(DIST, 'service', 'activity-thread', 'activity-thread.js'))
|
|
40
|
+
const { ActivityApproval } = require(join(DIST, 'service', 'activity-approval', 'activity-approval.js'))
|
|
41
|
+
const { discardDraft } = require(join(DIST, 'controllers', 'activity-instance', 'discard-draft.js'))
|
|
42
|
+
const { issue } = require(join(DIST, 'controllers', 'activity-instance', 'issue.js'))
|
|
43
|
+
const { draft: saveDraft } = require(join(DIST, 'controllers', 'activity-instance', 'draft.js'))
|
|
44
|
+
const { Employee } = require('@things-factory/organization')
|
|
45
|
+
const { Domain } = require('@things-factory/shell')
|
|
46
|
+
const { User } = require('@things-factory/auth-base')
|
|
47
|
+
|
|
48
|
+
let db: any
|
|
49
|
+
let domain: any
|
|
50
|
+
let writer: any
|
|
51
|
+
let activity: any
|
|
52
|
+
|
|
53
|
+
beforeAll(async () => {
|
|
54
|
+
db = new DataSource({
|
|
55
|
+
type: 'postgres',
|
|
56
|
+
url,
|
|
57
|
+
synchronize: true,
|
|
58
|
+
dropSchema: true,
|
|
59
|
+
entities: entityClosure([Activity, ActivityInstance, ActivityThread, ActivityApproval, Employee]),
|
|
60
|
+
namingStrategy: new NamingStrategy()
|
|
61
|
+
})
|
|
62
|
+
await db.initialize()
|
|
63
|
+
|
|
64
|
+
domain = await db.getRepository(Domain).save({ name: 'd', subdomain: 'd' })
|
|
65
|
+
writer = await db.getRepository(User).save({ name: 'w', email: 'w@example.com', password: 'x', status: 'activated', domains: [domain] })
|
|
66
|
+
activity = await db.getRepository(Activity).save({ domain, name: 'race', activityType: 'standard', startingType: 'issue' })
|
|
67
|
+
}, 60000)
|
|
68
|
+
|
|
69
|
+
afterAll(async () => db?.destroy())
|
|
70
|
+
|
|
71
|
+
const as = (tx: any) => ({ state: { domain, user: writer, tx }, t: (key: string) => key }) as any
|
|
72
|
+
|
|
73
|
+
it('each round ends with exactly one of them having happened', async () => {
|
|
74
|
+
const outcomes: string[] = []
|
|
75
|
+
|
|
76
|
+
for (let round = 0; round < 25; round++) {
|
|
77
|
+
const draft = await db.getRepository(ActivityInstance).save({ domain, name: `r${round}`, state: 'draft', creator: writer, activity })
|
|
78
|
+
|
|
79
|
+
const [discarded, issued] = await Promise.allSettled([
|
|
80
|
+
db.transaction((tx: any) => discardDraft(draft.id, as(tx))),
|
|
81
|
+
db.transaction((tx: any) => issue({ id: draft.id, activityId: activity.id, name: `r${round}` }, as(tx)))
|
|
82
|
+
])
|
|
83
|
+
|
|
84
|
+
const row = await db.getRepository(ActivityInstance).findOne({ where: { id: draft.id } })
|
|
85
|
+
const verdict = `${discarded.status}/${issued.status}/${row ? row.state : 'gone'}`
|
|
86
|
+
outcomes.push(verdict)
|
|
87
|
+
|
|
88
|
+
/* The only two endings that are allowed. */
|
|
89
|
+
expect(['fulfilled/rejected/gone', 'rejected/fulfilled/' + (row?.state ?? 'gone')]).toContain(verdict)
|
|
90
|
+
|
|
91
|
+
if (discarded.status === 'fulfilled') {
|
|
92
|
+
expect(row).toBeNull()
|
|
93
|
+
} else {
|
|
94
|
+
expect(row?.state).not.toBe('draft')
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/* Say which endings were seen - a run where only one side ever wins proves less. */
|
|
99
|
+
console.log('endings', [...new Set(outcomes)].join(' · '))
|
|
100
|
+
}, 120000)
|
|
101
|
+
|
|
102
|
+
it('saving a draft while it is discarded never brings it back', async () => {
|
|
103
|
+
const outcomes: string[] = []
|
|
104
|
+
|
|
105
|
+
for (let round = 0; round < 25; round++) {
|
|
106
|
+
const draft = await db.getRepository(ActivityInstance).save({ domain, name: `s${round}`, state: 'draft', creator: writer, activity })
|
|
107
|
+
|
|
108
|
+
const [discarded, saved] = await Promise.allSettled([
|
|
109
|
+
db.transaction((tx: any) => discardDraft(draft.id, as(tx))),
|
|
110
|
+
db.transaction((tx: any) => saveDraft({ id: draft.id, name: `s${round}-edited` }, as(tx)))
|
|
111
|
+
])
|
|
112
|
+
|
|
113
|
+
const row = await db.getRepository(ActivityInstance).findOne({ where: { id: draft.id } })
|
|
114
|
+
const verdict = `${discarded.status}/${saved.status}/${row ? row.state : 'gone'}`
|
|
115
|
+
outcomes.push(verdict)
|
|
116
|
+
|
|
117
|
+
/*
|
|
118
|
+
* Saving then discarding is a fine order, and ends gone. What must never happen is a draft that was
|
|
119
|
+
* reported thrown away and is still there - the save writing back what it had read.
|
|
120
|
+
*/
|
|
121
|
+
if (discarded.status === 'fulfilled') expect(row).toBeNull()
|
|
122
|
+
else expect(row?.state).toBe('draft')
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
console.log('endings', [...new Set(outcomes)].join(' · '))
|
|
126
|
+
}, 120000)
|
|
127
|
+
})
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { join } from 'path'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A person named on an approval line or as an assignee is found in the case's domain only.
|
|
5
|
+
*
|
|
6
|
+
* getSystemUserFromOrgMemberItem turns a line entry - an employee, a department (its manager) or a
|
|
7
|
+
* role - into the person who holds the seat. It looked each up by id alone, so an entry naming
|
|
8
|
+
* another tenant's employee made that tenant's person an approver here. assign found users by
|
|
9
|
+
* email alone, with the same effect for assignees. Real entities on sqlite.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const { createSqliteTestDataSource } = require(join(__dirname, '..', '..', 'ops-master', 'test', 'support', 'database.cjs'))
|
|
13
|
+
const DIST = join(__dirname, '..', 'dist-server')
|
|
14
|
+
const { ActivityInstance } = require(join(DIST, 'service', 'activity-instance', 'activity-instance.js'))
|
|
15
|
+
const { getSystemUserFromOrgMemberItem } = require(join(DIST, 'controllers', 'common.js'))
|
|
16
|
+
const { assign } = require(join(DIST, 'controllers', 'activity-instance', 'assign.js'))
|
|
17
|
+
const { ActivityThread } = require(join(DIST, 'service', 'activity-thread', 'activity-thread.js'))
|
|
18
|
+
const { Employee, Department } = require('@things-factory/organization')
|
|
19
|
+
const { Domain } = require('@things-factory/shell')
|
|
20
|
+
const { Role, User } = require('@things-factory/auth-base')
|
|
21
|
+
|
|
22
|
+
let db: any
|
|
23
|
+
let here: any
|
|
24
|
+
let elsewhere: any
|
|
25
|
+
let mine: any
|
|
26
|
+
let theirs: any
|
|
27
|
+
|
|
28
|
+
beforeAll(async () => {
|
|
29
|
+
db = createSqliteTestDataSource([ActivityInstance, ActivityThread, Employee, Department])
|
|
30
|
+
await db.initialize()
|
|
31
|
+
|
|
32
|
+
here = await db.getRepository(Domain).save({ name: 'here', subdomain: 'here' })
|
|
33
|
+
elsewhere = await db.getRepository(Domain).save({ name: 'elsewhere', subdomain: 'elsewhere' })
|
|
34
|
+
|
|
35
|
+
const person = async (name: string, domain: any) => {
|
|
36
|
+
const user = await db.getRepository(User).save({ name, email: `${name}@example.com`, password: 'x', status: 'activated', domains: [domain] })
|
|
37
|
+
const employee = await db.getRepository(Employee).save({ domain, name, controlNo: name, user })
|
|
38
|
+
const department = await db.getRepository(Department).save({ domain, name: `${name}-dept`, controlNo: `${name}-dept`, manager: employee })
|
|
39
|
+
const role = await db.getRepository(Role).save({ domain, name: `${name}-role`, users: [user] })
|
|
40
|
+
|
|
41
|
+
return { user, employee, department, role }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
mine = await person('mine', here)
|
|
45
|
+
theirs = await person('theirs', elsewhere)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
afterAll(async () => db?.destroy())
|
|
49
|
+
|
|
50
|
+
const resolve = (item: any) =>
|
|
51
|
+
db.transaction((tx: any) => getSystemUserFromOrgMemberItem(item, { state: { domain: here, user: mine.user, tx }, t: (k: string) => k } as any))
|
|
52
|
+
|
|
53
|
+
it.each([
|
|
54
|
+
['Employee', 'employee'],
|
|
55
|
+
['Department', 'department'],
|
|
56
|
+
['Role', 'role']
|
|
57
|
+
])('an entry of type %s names the person of this domain', async (type, key) => {
|
|
58
|
+
expect(await resolve({ type, value: mine[key].id })).toMatchObject({ id: mine.user.id })
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it.each([
|
|
62
|
+
['Employee', 'employee'],
|
|
63
|
+
['Department', 'department'],
|
|
64
|
+
['Role', 'role']
|
|
65
|
+
])("an entry of type %s naming another tenant's is nobody here", async (type, key) => {
|
|
66
|
+
expect(await resolve({ type, value: theirs[key].id })).toBeFalsy()
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
describe('assign', () => {
|
|
70
|
+
const assignTo = (instance: any, emails: string[]) =>
|
|
71
|
+
db.transaction((tx: any) =>
|
|
72
|
+
assign({ id: instance.id, assignees: emails }, { state: { domain: here, user: mine.user, tx }, t: (k: string) => k } as any)
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
it("another tenant's address is refused by name, and no thread is made", async () => {
|
|
76
|
+
const instance = await db.getRepository(ActivityInstance).save({ domain: here, name: 'i', state: 'issued' })
|
|
77
|
+
|
|
78
|
+
await expect(assignTo(instance, [theirs.user.email])).rejects.toThrow('error.activity-instance assignee not in this domain')
|
|
79
|
+
expect(await db.getRepository(ActivityThread).count({ where: { activityInstance: { id: instance.id } } })).toBe(0)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('an address that is nobody at all is refused by name too, not dropped', async () => {
|
|
83
|
+
const instance = await db.getRepository(ActivityInstance).save({ domain: here, name: 'i', state: 'issued' })
|
|
84
|
+
|
|
85
|
+
await expect(assignTo(instance, ['nobody@example.com'])).rejects.toThrow('error.activity-instance assignee not in this domain')
|
|
86
|
+
})
|
|
87
|
+
})
|