@sqaoss/flowy 1.9.0 → 1.11.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 +3 -1
- package/package.json +5 -2
- package/server/src/contract.test.ts +53 -7
- package/server/src/index.errors.test.ts +6 -3
- package/server/src/index.ts +10 -1
- package/server/src/migrations.test.ts +61 -0
- package/server/src/migrations.ts +27 -0
- package/server/src/resolvers.test.ts +355 -16
- package/server/src/resolvers.ts +315 -38
- package/server/src/schema.ts +32 -1
- package/skills/using-flowy/SKILL.md +3 -0
- package/src/commands/history.test.ts +99 -0
- package/src/commands/history.ts +20 -0
- package/src/commands/search.test.ts +90 -0
- package/src/commands/search.ts +19 -3
- package/src/commands/status.test.ts +71 -0
- package/src/commands/status.ts +18 -4
- package/src/index.test.ts +16 -0
- package/src/index.ts +2 -0
- package/src/util/operations.test.ts +1 -0
- package/src/util/operations.ts +26 -3
|
@@ -773,8 +773,10 @@ describe('createResolvers', () => {
|
|
|
773
773
|
})
|
|
774
774
|
|
|
775
775
|
const results = resolvers.Query.search(null, { query: 'Auth' })
|
|
776
|
-
expect(results).toHaveLength(1)
|
|
777
|
-
expect(results[0].title).toBe('Authentication')
|
|
776
|
+
expect(results.nodes).toHaveLength(1)
|
|
777
|
+
expect(results.nodes[0].title).toBe('Authentication')
|
|
778
|
+
expect(results.truncated).toBe(false)
|
|
779
|
+
expect(results.total).toBe(1)
|
|
778
780
|
})
|
|
779
781
|
|
|
780
782
|
it('finds nodes by description', () => {
|
|
@@ -785,8 +787,8 @@ describe('createResolvers', () => {
|
|
|
785
787
|
})
|
|
786
788
|
|
|
787
789
|
const results = resolvers.Query.search(null, { query: 'OAuth' })
|
|
788
|
-
expect(results).toHaveLength(1)
|
|
789
|
-
expect(results[0].title).toBe('Login')
|
|
790
|
+
expect(results.nodes).toHaveLength(1)
|
|
791
|
+
expect(results.nodes[0].title).toBe('Login')
|
|
790
792
|
})
|
|
791
793
|
|
|
792
794
|
it('filters by type', () => {
|
|
@@ -803,8 +805,8 @@ describe('createResolvers', () => {
|
|
|
803
805
|
query: 'Auth',
|
|
804
806
|
type: 'project',
|
|
805
807
|
})
|
|
806
|
-
expect(results).toHaveLength(1)
|
|
807
|
-
expect(results[0].type).toBe('project')
|
|
808
|
+
expect(results.nodes).toHaveLength(1)
|
|
809
|
+
expect(results.nodes[0].type).toBe('project')
|
|
808
810
|
})
|
|
809
811
|
|
|
810
812
|
it('filters by status', () => {
|
|
@@ -825,8 +827,8 @@ describe('createResolvers', () => {
|
|
|
825
827
|
query: 'Auth',
|
|
826
828
|
status: 'in_progress',
|
|
827
829
|
})
|
|
828
|
-
expect(results).toHaveLength(1)
|
|
829
|
-
expect(results[0].title).toBe('Auth')
|
|
830
|
+
expect(results.nodes).toHaveLength(1)
|
|
831
|
+
expect(results.nodes[0].title).toBe('Auth')
|
|
830
832
|
})
|
|
831
833
|
|
|
832
834
|
it('respects limit', () => {
|
|
@@ -841,7 +843,58 @@ describe('createResolvers', () => {
|
|
|
841
843
|
query: 'Task',
|
|
842
844
|
limit: 2,
|
|
843
845
|
})
|
|
844
|
-
expect(results).toHaveLength(2)
|
|
846
|
+
expect(results.nodes).toHaveLength(2)
|
|
847
|
+
})
|
|
848
|
+
|
|
849
|
+
it('signals truncation when results are capped at the limit', () => {
|
|
850
|
+
for (let i = 0; i < 5; i++) {
|
|
851
|
+
resolvers.Mutation.createNode(null, {
|
|
852
|
+
type: 'task',
|
|
853
|
+
title: `Task ${i}`,
|
|
854
|
+
})
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
const results = resolvers.Query.search(null, {
|
|
858
|
+
query: 'Task',
|
|
859
|
+
limit: 2,
|
|
860
|
+
})
|
|
861
|
+
expect(results.nodes).toHaveLength(2)
|
|
862
|
+
expect(results.truncated).toBe(true)
|
|
863
|
+
expect(results.total).toBe(5)
|
|
864
|
+
})
|
|
865
|
+
|
|
866
|
+
it('does not signal truncation when all results fit under the limit', () => {
|
|
867
|
+
for (let i = 0; i < 3; i++) {
|
|
868
|
+
resolvers.Mutation.createNode(null, {
|
|
869
|
+
type: 'task',
|
|
870
|
+
title: `Task ${i}`,
|
|
871
|
+
})
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
const results = resolvers.Query.search(null, {
|
|
875
|
+
query: 'Task',
|
|
876
|
+
limit: 50,
|
|
877
|
+
})
|
|
878
|
+
expect(results.nodes).toHaveLength(3)
|
|
879
|
+
expect(results.truncated).toBe(false)
|
|
880
|
+
expect(results.total).toBe(3)
|
|
881
|
+
})
|
|
882
|
+
|
|
883
|
+
it('does not signal truncation when results exactly equal the limit', () => {
|
|
884
|
+
for (let i = 0; i < 2; i++) {
|
|
885
|
+
resolvers.Mutation.createNode(null, {
|
|
886
|
+
type: 'task',
|
|
887
|
+
title: `Task ${i}`,
|
|
888
|
+
})
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
const results = resolvers.Query.search(null, {
|
|
892
|
+
query: 'Task',
|
|
893
|
+
limit: 2,
|
|
894
|
+
})
|
|
895
|
+
expect(results.nodes).toHaveLength(2)
|
|
896
|
+
expect(results.truncated).toBe(false)
|
|
897
|
+
expect(results.total).toBe(2)
|
|
845
898
|
})
|
|
846
899
|
})
|
|
847
900
|
|
|
@@ -958,7 +1011,9 @@ describe('createResolvers', () => {
|
|
|
958
1011
|
const results = resolvers.Query.search(null, {
|
|
959
1012
|
query: 'zzz_no_match',
|
|
960
1013
|
})
|
|
961
|
-
expect(results).toEqual([])
|
|
1014
|
+
expect(results.nodes).toEqual([])
|
|
1015
|
+
expect(results.truncated).toBe(false)
|
|
1016
|
+
expect(results.total).toBe(0)
|
|
962
1017
|
})
|
|
963
1018
|
|
|
964
1019
|
it('throws when query is shorter than 3 characters', () => {
|
|
@@ -976,23 +1031,23 @@ describe('createResolvers', () => {
|
|
|
976
1031
|
it('succeeds with 3-character query', () => {
|
|
977
1032
|
create(resolvers, { type: 'project', title: 'abc match' })
|
|
978
1033
|
const results = resolvers.Query.search(null, { query: 'abc' })
|
|
979
|
-
expect(results).toHaveLength(1)
|
|
1034
|
+
expect(results.nodes).toHaveLength(1)
|
|
980
1035
|
})
|
|
981
1036
|
|
|
982
1037
|
it('does not treat % as LIKE wildcard', () => {
|
|
983
1038
|
create(resolvers, { type: 'project', title: '100% done' })
|
|
984
1039
|
create(resolvers, { type: 'project', title: '100 things' })
|
|
985
1040
|
const results = resolvers.Query.search(null, { query: '100%' })
|
|
986
|
-
expect(results).toHaveLength(1)
|
|
987
|
-
expect(results[0].title).toBe('100% done')
|
|
1041
|
+
expect(results.nodes).toHaveLength(1)
|
|
1042
|
+
expect(results.nodes[0].title).toBe('100% done')
|
|
988
1043
|
})
|
|
989
1044
|
|
|
990
1045
|
it('does not treat _ as LIKE wildcard', () => {
|
|
991
1046
|
create(resolvers, { type: 'project', title: '_est something' })
|
|
992
1047
|
create(resolvers, { type: 'project', title: 'Test something' })
|
|
993
1048
|
const results = resolvers.Query.search(null, { query: '_est' })
|
|
994
|
-
expect(results).toHaveLength(1)
|
|
995
|
-
expect(results[0].title).toBe('_est something')
|
|
1049
|
+
expect(results.nodes).toHaveLength(1)
|
|
1050
|
+
expect(results.nodes[0].title).toBe('_est something')
|
|
996
1051
|
})
|
|
997
1052
|
})
|
|
998
1053
|
|
|
@@ -1071,6 +1126,150 @@ describe('createResolvers', () => {
|
|
|
1071
1126
|
})
|
|
1072
1127
|
})
|
|
1073
1128
|
|
|
1129
|
+
describe('status lifecycle enforcement (opt-in)', () => {
|
|
1130
|
+
let strict: ReturnType<typeof createResolvers>
|
|
1131
|
+
|
|
1132
|
+
beforeEach(() => {
|
|
1133
|
+
strict = createResolvers(db, { enforceStatusLifecycle: true })
|
|
1134
|
+
})
|
|
1135
|
+
|
|
1136
|
+
function strictCreate(title: string): NodeGql {
|
|
1137
|
+
return strict.Mutation.createNode(null, { type: 'task', title })
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
it('allows the canonical forward flow draft -> ... -> done', () => {
|
|
1141
|
+
const node = strictCreate('Flow')
|
|
1142
|
+
expect(node.status).toBe('draft')
|
|
1143
|
+
let cur = strict.Mutation.updateNode(null, {
|
|
1144
|
+
id: node.id,
|
|
1145
|
+
status: 'pending_review',
|
|
1146
|
+
})
|
|
1147
|
+
expect(cur.status).toBe('pending_review')
|
|
1148
|
+
cur = strict.Mutation.updateNode(null, {
|
|
1149
|
+
id: node.id,
|
|
1150
|
+
status: 'approved',
|
|
1151
|
+
})
|
|
1152
|
+
expect(cur.status).toBe('approved')
|
|
1153
|
+
cur = strict.Mutation.updateNode(null, {
|
|
1154
|
+
id: node.id,
|
|
1155
|
+
status: 'in_progress',
|
|
1156
|
+
})
|
|
1157
|
+
expect(cur.status).toBe('in_progress')
|
|
1158
|
+
cur = strict.Mutation.updateNode(null, { id: node.id, status: 'done' })
|
|
1159
|
+
expect(cur.status).toBe('done')
|
|
1160
|
+
})
|
|
1161
|
+
|
|
1162
|
+
it('rejects an illegal skip (draft -> done) with VALIDATION_ERROR', () => {
|
|
1163
|
+
const node = strictCreate('Skip')
|
|
1164
|
+
expect(() =>
|
|
1165
|
+
strict.Mutation.updateNode(null, { id: node.id, status: 'done' }),
|
|
1166
|
+
).toThrow(/transition/i)
|
|
1167
|
+
try {
|
|
1168
|
+
strict.Mutation.updateNode(null, { id: node.id, status: 'done' })
|
|
1169
|
+
} catch (e) {
|
|
1170
|
+
expect((e as { extensions?: { code?: string } }).extensions?.code).toBe(
|
|
1171
|
+
'VALIDATION_ERROR',
|
|
1172
|
+
)
|
|
1173
|
+
}
|
|
1174
|
+
})
|
|
1175
|
+
|
|
1176
|
+
it('rejects skipping pending_review -> in_progress', () => {
|
|
1177
|
+
const node = strictCreate('Skip2')
|
|
1178
|
+
strict.Mutation.updateNode(null, {
|
|
1179
|
+
id: node.id,
|
|
1180
|
+
status: 'pending_review',
|
|
1181
|
+
})
|
|
1182
|
+
expect(() =>
|
|
1183
|
+
strict.Mutation.updateNode(null, {
|
|
1184
|
+
id: node.id,
|
|
1185
|
+
status: 'in_progress',
|
|
1186
|
+
}),
|
|
1187
|
+
).toThrow(/transition/i)
|
|
1188
|
+
})
|
|
1189
|
+
|
|
1190
|
+
it('allows cancelling from an active state', () => {
|
|
1191
|
+
const node = strictCreate('Cancel')
|
|
1192
|
+
strict.Mutation.updateNode(null, {
|
|
1193
|
+
id: node.id,
|
|
1194
|
+
status: 'pending_review',
|
|
1195
|
+
})
|
|
1196
|
+
const cur = strict.Mutation.updateNode(null, {
|
|
1197
|
+
id: node.id,
|
|
1198
|
+
status: 'cancelled',
|
|
1199
|
+
})
|
|
1200
|
+
expect(cur.status).toBe('cancelled')
|
|
1201
|
+
})
|
|
1202
|
+
|
|
1203
|
+
it('allows blocking from in_progress and resuming', () => {
|
|
1204
|
+
const node = strictCreate('Block')
|
|
1205
|
+
strict.Mutation.updateNode(null, {
|
|
1206
|
+
id: node.id,
|
|
1207
|
+
status: 'pending_review',
|
|
1208
|
+
})
|
|
1209
|
+
strict.Mutation.updateNode(null, { id: node.id, status: 'approved' })
|
|
1210
|
+
strict.Mutation.updateNode(null, { id: node.id, status: 'in_progress' })
|
|
1211
|
+
let cur = strict.Mutation.updateNode(null, {
|
|
1212
|
+
id: node.id,
|
|
1213
|
+
status: 'blocked',
|
|
1214
|
+
})
|
|
1215
|
+
expect(cur.status).toBe('blocked')
|
|
1216
|
+
cur = strict.Mutation.updateNode(null, {
|
|
1217
|
+
id: node.id,
|
|
1218
|
+
status: 'in_progress',
|
|
1219
|
+
})
|
|
1220
|
+
expect(cur.status).toBe('in_progress')
|
|
1221
|
+
})
|
|
1222
|
+
|
|
1223
|
+
it('rejects blocking directly from draft', () => {
|
|
1224
|
+
const node = strictCreate('BlockEarly')
|
|
1225
|
+
expect(() =>
|
|
1226
|
+
strict.Mutation.updateNode(null, { id: node.id, status: 'blocked' }),
|
|
1227
|
+
).toThrow(/transition/i)
|
|
1228
|
+
})
|
|
1229
|
+
|
|
1230
|
+
it('allows reopening done -> in_progress', () => {
|
|
1231
|
+
const node = strictCreate('Reopen')
|
|
1232
|
+
strict.Mutation.updateNode(null, {
|
|
1233
|
+
id: node.id,
|
|
1234
|
+
status: 'pending_review',
|
|
1235
|
+
})
|
|
1236
|
+
strict.Mutation.updateNode(null, { id: node.id, status: 'approved' })
|
|
1237
|
+
strict.Mutation.updateNode(null, { id: node.id, status: 'in_progress' })
|
|
1238
|
+
strict.Mutation.updateNode(null, { id: node.id, status: 'done' })
|
|
1239
|
+
const cur = strict.Mutation.updateNode(null, {
|
|
1240
|
+
id: node.id,
|
|
1241
|
+
status: 'in_progress',
|
|
1242
|
+
})
|
|
1243
|
+
expect(cur.status).toBe('in_progress')
|
|
1244
|
+
})
|
|
1245
|
+
|
|
1246
|
+
it('allows a same-status no-op even under enforcement', () => {
|
|
1247
|
+
const node = strictCreate('NoOp')
|
|
1248
|
+
const cur = strict.Mutation.updateNode(null, {
|
|
1249
|
+
id: node.id,
|
|
1250
|
+
status: 'draft',
|
|
1251
|
+
})
|
|
1252
|
+
expect(cur.status).toBe('draft')
|
|
1253
|
+
})
|
|
1254
|
+
|
|
1255
|
+
it('still validates the status vocabulary', () => {
|
|
1256
|
+
const node = strictCreate('Vocab')
|
|
1257
|
+
expect(() =>
|
|
1258
|
+
strict.Mutation.updateNode(null, { id: node.id, status: 'bogus' }),
|
|
1259
|
+
).toThrow(/Invalid status/)
|
|
1260
|
+
})
|
|
1261
|
+
|
|
1262
|
+
it('does NOT enforce transitions when the flag is off (default)', () => {
|
|
1263
|
+
// default `resolvers` (no enforcement) permits the illegal skip
|
|
1264
|
+
const node = create(resolvers, { type: 'task', title: 'Default' })
|
|
1265
|
+
const updated = resolvers.Mutation.updateNode(null, {
|
|
1266
|
+
id: node.id,
|
|
1267
|
+
status: 'done',
|
|
1268
|
+
})
|
|
1269
|
+
expect(updated.status).toBe('done')
|
|
1270
|
+
})
|
|
1271
|
+
})
|
|
1272
|
+
|
|
1074
1273
|
describe('Query.descendants — edge cases', () => {
|
|
1075
1274
|
it('returns empty array for leaf node', () => {
|
|
1076
1275
|
const leaf = create(resolvers, { type: 'task', title: 'Leaf' })
|
|
@@ -1216,7 +1415,7 @@ describe('createResolvers', () => {
|
|
|
1216
1415
|
query: 'Test',
|
|
1217
1416
|
limit: 0,
|
|
1218
1417
|
})
|
|
1219
|
-
expect(results).toEqual([])
|
|
1418
|
+
expect(results.nodes).toEqual([])
|
|
1220
1419
|
})
|
|
1221
1420
|
})
|
|
1222
1421
|
|
|
@@ -1415,4 +1614,144 @@ describe('createResolvers', () => {
|
|
|
1415
1614
|
).toEqual([])
|
|
1416
1615
|
})
|
|
1417
1616
|
})
|
|
1617
|
+
|
|
1618
|
+
describe('audit_log — recording + Query.auditLog', () => {
|
|
1619
|
+
it('records a create audit entry on createNode', () => {
|
|
1620
|
+
const node = create(resolvers, { type: 'task', title: 'Audited' })
|
|
1621
|
+
const log = resolvers.Query.auditLog(null, { nodeId: node.id })
|
|
1622
|
+
expect(log).toHaveLength(1)
|
|
1623
|
+
expect(log[0]).toMatchObject({ nodeId: node.id, action: 'create' })
|
|
1624
|
+
// snapshot is a JSON string mirroring the created node
|
|
1625
|
+
expect(log[0]?.snapshot).toBeTypeOf('string')
|
|
1626
|
+
expect(JSON.parse(log[0]?.snapshot as string)).toMatchObject({
|
|
1627
|
+
id: node.id,
|
|
1628
|
+
title: 'Audited',
|
|
1629
|
+
})
|
|
1630
|
+
})
|
|
1631
|
+
|
|
1632
|
+
it('shapes entries like the SaaS auditLog field', () => {
|
|
1633
|
+
const node = create(resolvers, { type: 'task', title: 'Shape' })
|
|
1634
|
+
const [entry] = resolvers.Query.auditLog(null, { nodeId: node.id })
|
|
1635
|
+
expect(entry).toHaveProperty('id')
|
|
1636
|
+
expect(entry).toHaveProperty('nodeId')
|
|
1637
|
+
expect(entry).toHaveProperty('action')
|
|
1638
|
+
expect(entry).toHaveProperty('field')
|
|
1639
|
+
expect(entry).toHaveProperty('oldValue')
|
|
1640
|
+
expect(entry).toHaveProperty('newValue')
|
|
1641
|
+
expect(entry).toHaveProperty('snapshot')
|
|
1642
|
+
expect(entry).toHaveProperty('changedBy')
|
|
1643
|
+
expect(entry).toHaveProperty('createdAt')
|
|
1644
|
+
expect(entry?.changedBy).toBe('local')
|
|
1645
|
+
expect(entry?.createdAt).toBeTypeOf('string')
|
|
1646
|
+
})
|
|
1647
|
+
|
|
1648
|
+
it('records field-level diffs on updateNode (status_change vs update)', () => {
|
|
1649
|
+
const node = create(resolvers, { type: 'task', title: 'Orig' })
|
|
1650
|
+
resolvers.Mutation.updateNode(null, {
|
|
1651
|
+
id: node.id,
|
|
1652
|
+
title: 'Renamed',
|
|
1653
|
+
status: 'in_progress',
|
|
1654
|
+
})
|
|
1655
|
+
const log = resolvers.Query.auditLog(null, { nodeId: node.id })
|
|
1656
|
+
const actions = log.map((e) => e.action)
|
|
1657
|
+
// create + a title update + a status_change
|
|
1658
|
+
expect(actions).toContain('create')
|
|
1659
|
+
expect(actions).toContain('update')
|
|
1660
|
+
expect(actions).toContain('status_change')
|
|
1661
|
+
|
|
1662
|
+
const titleEntry = log.find((e) => e.field === 'title')
|
|
1663
|
+
expect(titleEntry).toMatchObject({
|
|
1664
|
+
action: 'update',
|
|
1665
|
+
oldValue: 'Orig',
|
|
1666
|
+
newValue: 'Renamed',
|
|
1667
|
+
})
|
|
1668
|
+
const statusEntry = log.find((e) => e.field === 'status')
|
|
1669
|
+
expect(statusEntry).toMatchObject({
|
|
1670
|
+
action: 'status_change',
|
|
1671
|
+
oldValue: 'draft',
|
|
1672
|
+
newValue: 'in_progress',
|
|
1673
|
+
})
|
|
1674
|
+
})
|
|
1675
|
+
|
|
1676
|
+
it('records an approve entry on approveNode', () => {
|
|
1677
|
+
const node = create(resolvers, { type: 'task', title: 'Appr' })
|
|
1678
|
+
resolvers.Mutation.updateNode(null, {
|
|
1679
|
+
id: node.id,
|
|
1680
|
+
status: 'pending_review',
|
|
1681
|
+
})
|
|
1682
|
+
resolvers.Mutation.approveNode(null, { id: node.id })
|
|
1683
|
+
const log = resolvers.Query.auditLog(null, { nodeId: node.id })
|
|
1684
|
+
const approve = log.find((e) => e.action === 'approve')
|
|
1685
|
+
expect(approve).toMatchObject({
|
|
1686
|
+
field: 'status',
|
|
1687
|
+
oldValue: 'pending_review',
|
|
1688
|
+
newValue: 'approved',
|
|
1689
|
+
})
|
|
1690
|
+
})
|
|
1691
|
+
|
|
1692
|
+
it('records create_edge / remove_edge against the source node', () => {
|
|
1693
|
+
const blocker = create(resolvers, { type: 'task', title: 'Blocker' })
|
|
1694
|
+
const blocked = create(resolvers, { type: 'task', title: 'Blocked' })
|
|
1695
|
+
resolvers.Mutation.createEdge(null, {
|
|
1696
|
+
sourceId: blocker.id,
|
|
1697
|
+
targetId: blocked.id,
|
|
1698
|
+
relation: 'blocks',
|
|
1699
|
+
})
|
|
1700
|
+
let log = resolvers.Query.auditLog(null, { nodeId: blocker.id })
|
|
1701
|
+
const created = log.find((e) => e.action === 'create_edge')
|
|
1702
|
+
expect(created).toMatchObject({
|
|
1703
|
+
field: 'blocks',
|
|
1704
|
+
newValue: blocked.id,
|
|
1705
|
+
})
|
|
1706
|
+
|
|
1707
|
+
resolvers.Mutation.removeEdge(null, {
|
|
1708
|
+
sourceId: blocker.id,
|
|
1709
|
+
targetId: blocked.id,
|
|
1710
|
+
relation: 'blocks',
|
|
1711
|
+
})
|
|
1712
|
+
log = resolvers.Query.auditLog(null, { nodeId: blocker.id })
|
|
1713
|
+
const removed = log.find((e) => e.action === 'remove_edge')
|
|
1714
|
+
expect(removed).toMatchObject({
|
|
1715
|
+
field: 'blocks',
|
|
1716
|
+
oldValue: blocked.id,
|
|
1717
|
+
})
|
|
1718
|
+
})
|
|
1719
|
+
|
|
1720
|
+
it('returns entries newest-first and respects limit', () => {
|
|
1721
|
+
const node = create(resolvers, { type: 'task', title: 'Limit' })
|
|
1722
|
+
resolvers.Mutation.updateNode(null, { id: node.id, title: 'A' })
|
|
1723
|
+
resolvers.Mutation.updateNode(null, { id: node.id, title: 'B' })
|
|
1724
|
+
const all = resolvers.Query.auditLog(null, { nodeId: node.id })
|
|
1725
|
+
// create, then two title updates => 3 entries, newest first
|
|
1726
|
+
expect(all.length).toBeGreaterThanOrEqual(3)
|
|
1727
|
+
expect(all[0]?.action).not.toBe('create')
|
|
1728
|
+
const limited = resolvers.Query.auditLog(null, {
|
|
1729
|
+
nodeId: node.id,
|
|
1730
|
+
limit: 1,
|
|
1731
|
+
})
|
|
1732
|
+
expect(limited).toHaveLength(1)
|
|
1733
|
+
})
|
|
1734
|
+
|
|
1735
|
+
it('records a delete entry (node_id nulled, snapshot retained)', () => {
|
|
1736
|
+
const node = create(resolvers, { type: 'task', title: 'Doomed' })
|
|
1737
|
+
resolvers.Mutation.deleteNode(null, { id: node.id })
|
|
1738
|
+
// node_id is set to null on delete (matching SaaS), so the row is no
|
|
1739
|
+
// longer returned by auditLog(nodeId), but a delete row exists with the
|
|
1740
|
+
// pre-delete snapshot.
|
|
1741
|
+
const direct = db.raw
|
|
1742
|
+
.query("SELECT action, snapshot FROM audit_log WHERE action = 'delete'")
|
|
1743
|
+
.all() as Array<{ action: string; snapshot: string | null }>
|
|
1744
|
+
expect(direct).toHaveLength(1)
|
|
1745
|
+
expect(JSON.parse(direct[0]?.snapshot as string)).toMatchObject({
|
|
1746
|
+
id: node.id,
|
|
1747
|
+
title: 'Doomed',
|
|
1748
|
+
})
|
|
1749
|
+
})
|
|
1750
|
+
|
|
1751
|
+
it('returns [] for a node with no history', () => {
|
|
1752
|
+
expect(
|
|
1753
|
+
resolvers.Query.auditLog(null, { nodeId: 'task_nonexistent' }),
|
|
1754
|
+
).toEqual([])
|
|
1755
|
+
})
|
|
1756
|
+
})
|
|
1418
1757
|
})
|