@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.
@@ -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
  })