ts-patch-mongoose 3.0.0 → 3.1.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.
@@ -1,6 +1,7 @@
1
1
  import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
2
2
 
3
3
  import mongoose, { model, Schema } from 'mongoose'
4
+ import em from '../src/em'
4
5
  import { patchHistoryPlugin } from '../src/index'
5
6
  import { HistoryModel } from '../src/model'
6
7
  import server from './mongo/server'
@@ -554,6 +555,10 @@ describe('plugin — complex data structures', () => {
554
555
 
555
556
  // --- All mongoose schema types ---
556
557
 
558
+ const hasDouble = 'Double' in Schema.Types
559
+ const hasInt32 = 'Int32' in Schema.Types
560
+ const hasBigInt = 'BigInt' in Schema.Types
561
+
557
562
  const AllTypesSchema = new Schema(
558
563
  {
559
564
  str: String,
@@ -575,6 +580,141 @@ const AllTypesSchema = new Schema(
575
580
  { timestamps: true },
576
581
  )
577
582
 
583
+ if (hasDouble) AllTypesSchema.add({ dbl: (Schema.Types as Record<string, unknown>).Double })
584
+ if (hasInt32) AllTypesSchema.add({ int32: (Schema.Types as Record<string, unknown>).Int32 })
585
+ if (hasBigInt) AllTypesSchema.add({ bigint: (Schema.Types as Record<string, unknown>).BigInt })
586
+
587
+ // --- Realistic SaaS Organization model (e2e) ---
588
+
589
+ const ContactSchema = new Schema(
590
+ {
591
+ email: { type: String, required: true },
592
+ phone: String,
593
+ website: String,
594
+ },
595
+ { _id: false },
596
+ )
597
+
598
+ const BillingSchema = new Schema(
599
+ {
600
+ plan: { type: String, enum: ['free', 'starter', 'pro', 'enterprise'], default: 'free' },
601
+ mrr: Schema.Types.Decimal128,
602
+ currency: { type: String, default: 'USD' },
603
+ cardLast4: String,
604
+ nextBillingDate: Date,
605
+ },
606
+ { _id: false },
607
+ )
608
+
609
+ const TeamMemberSchema = new Schema(
610
+ {
611
+ userId: { type: Schema.Types.ObjectId, required: true },
612
+ role: { type: String, enum: ['owner', 'admin', 'member', 'viewer'], required: true },
613
+ joinedAt: { type: Date, default: Date.now },
614
+ },
615
+ { _id: true },
616
+ )
617
+
618
+ const HeadquartersSchema = new Schema(
619
+ {
620
+ street: String,
621
+ city: String,
622
+ state: String,
623
+ zip: String,
624
+ country: { type: String, default: 'US' },
625
+ coords: { lat: Number, lng: Number },
626
+ timezone: String,
627
+ },
628
+ { _id: false },
629
+ )
630
+
631
+ interface Contact {
632
+ email: string
633
+ phone?: string
634
+ website?: string
635
+ }
636
+
637
+ interface Billing {
638
+ plan: string
639
+ mrr?: mongoose.Types.Decimal128
640
+ currency: string
641
+ cardLast4?: string
642
+ nextBillingDate?: Date
643
+ }
644
+
645
+ interface TeamMember {
646
+ userId: mongoose.Types.ObjectId
647
+ role: string
648
+ joinedAt?: Date
649
+ }
650
+
651
+ interface Headquarters {
652
+ street?: string
653
+ city?: string
654
+ state?: string
655
+ zip?: string
656
+ country?: string
657
+ coords?: { lat: number; lng: number }
658
+ timezone?: string
659
+ }
660
+
661
+ interface Organization {
662
+ name: string
663
+ slug: string
664
+ apiKey: mongoose.Types.UUID
665
+ active: boolean
666
+ contact: Contact
667
+ billing: Billing
668
+ headquarters: Headquarters
669
+ team: TeamMember[]
670
+ tags: string[]
671
+ domains: string[]
672
+ settings: Map<string, string>
673
+ featureFlags: Record<string, unknown>
674
+ logo?: Buffer
675
+ notes: string
676
+ seatCount: number
677
+ createdAt?: Date
678
+ updatedAt?: Date
679
+ }
680
+
681
+ const OrganizationSchema = new Schema<Organization>(
682
+ {
683
+ name: { type: String, required: true },
684
+ slug: { type: String, required: true, unique: true },
685
+ apiKey: { type: Schema.Types.UUID, required: true },
686
+ active: { type: Boolean, default: true },
687
+ contact: { type: ContactSchema, required: true },
688
+ billing: { type: BillingSchema, default: () => ({}) },
689
+ headquarters: HeadquartersSchema,
690
+ team: [TeamMemberSchema],
691
+ tags: [String],
692
+ domains: [String],
693
+ settings: { type: Map, of: String },
694
+ featureFlags: { type: Schema.Types.Mixed, default: {} },
695
+ logo: Buffer,
696
+ notes: String,
697
+ seatCount: { type: Number, default: 1 },
698
+ },
699
+ { timestamps: true },
700
+ )
701
+
702
+ const ORG_CREATED = 'org-created'
703
+ const ORG_UPDATED = 'org-updated'
704
+ const ORG_DELETED = 'org-deleted'
705
+
706
+ OrganizationSchema.plugin(patchHistoryPlugin, {
707
+ eventCreated: ORG_CREATED,
708
+ eventUpdated: ORG_UPDATED,
709
+ eventDeleted: ORG_DELETED,
710
+ omit: ['__v', 'createdAt', 'updatedAt', 'notes'],
711
+ getUser: () => ({ userId: 'system', role: 'service-account' }),
712
+ getReason: () => 'api-call',
713
+ getMetadata: () => ({ service: 'org-service', requestId: 'req-123' }),
714
+ })
715
+
716
+ const OrganizationModel = model<Organization>('Organization', OrganizationSchema)
717
+
578
718
  AllTypesSchema.plugin(patchHistoryPlugin, {
579
719
  omit: ['__v', 'createdAt', 'updatedAt'],
580
720
  })
@@ -793,6 +933,31 @@ describe('plugin — all mongoose schema types', () => {
793
933
  expect(paths).toContain('/str')
794
934
  expect(paths).toContain('/num')
795
935
  })
936
+
937
+ it.runIf(hasDouble)('should track Double update', async () => {
938
+ const doc = await AllTypesModel.create({ dbl: 3.14 })
939
+ await AllTypesModel.updateOne({ _id: doc._id }, { dbl: 2.71 }).exec()
940
+
941
+ const [update] = await HistoryModel.find({ op: 'updateOne', collectionId: doc._id })
942
+ expect(getPatch(update, '/dbl')?.value).toBe(2.71)
943
+ })
944
+
945
+ it.runIf(hasInt32)('should track Int32 update', async () => {
946
+ const doc = await AllTypesModel.create({ int32: 100 })
947
+ await AllTypesModel.updateOne({ _id: doc._id }, { int32: 200 }).exec()
948
+
949
+ const [update] = await HistoryModel.find({ op: 'updateOne', collectionId: doc._id })
950
+ expect(getPatch(update, '/int32')?.value).toBe(200)
951
+ })
952
+
953
+ it.runIf(hasBigInt)('should track BigInt update', async () => {
954
+ const doc = await AllTypesModel.create({ bigint: BigInt(1000) })
955
+ await AllTypesModel.updateOne({ _id: doc._id }, { bigint: BigInt(9999) }).exec()
956
+
957
+ const [update] = await HistoryModel.find({ op: 'updateOne', collectionId: doc._id })
958
+ const paths = update?.patch?.map((p) => p.path) ?? []
959
+ expect(paths.some((p) => p?.startsWith('/bigint'))).toBe(true)
960
+ })
796
961
  })
797
962
 
798
963
  // --- Populated documents ---
@@ -1330,3 +1495,1153 @@ describe('plugin — replaceOne', () => {
1330
1495
  expect(paths.some((p) => p?.includes('/shippingAddress'))).toBe(true)
1331
1496
  })
1332
1497
  })
1498
+
1499
+ // --- Organization e2e lifecycle ---
1500
+
1501
+ describe('plugin — organization e2e lifecycle', () => {
1502
+ const instance = server('plugin-org-e2e')
1503
+
1504
+ beforeAll(async () => {
1505
+ await instance.create()
1506
+ })
1507
+
1508
+ afterAll(async () => {
1509
+ await instance.destroy()
1510
+ })
1511
+
1512
+ beforeEach(async () => {
1513
+ await mongoose.connection.collection('organizations').deleteMany({})
1514
+ await mongoose.connection.collection('history').deleteMany({})
1515
+ })
1516
+
1517
+ afterEach(() => {
1518
+ vi.resetAllMocks()
1519
+ })
1520
+
1521
+ const makeOrg = () => ({
1522
+ name: 'Acme Corp',
1523
+ slug: `acme-${Date.now()}`,
1524
+ apiKey: '550e8400-e29b-41d4-a716-446655440000',
1525
+ active: true,
1526
+ contact: { email: 'admin@acme.com', phone: '+1-555-0100', website: 'https://acme.com' },
1527
+ billing: {
1528
+ plan: 'pro' as const,
1529
+ mrr: mongoose.Types.Decimal128.fromString('499.00'),
1530
+ currency: 'USD',
1531
+ cardLast4: '4242',
1532
+ nextBillingDate: new Date('2026-05-01'),
1533
+ },
1534
+ headquarters: {
1535
+ street: '100 Innovation Dr',
1536
+ city: 'San Francisco',
1537
+ state: 'CA',
1538
+ zip: '94105',
1539
+ country: 'US',
1540
+ coords: { lat: 37.7749, lng: -122.4194 },
1541
+ timezone: 'America/Los_Angeles',
1542
+ },
1543
+ team: [
1544
+ { userId: new mongoose.Types.ObjectId(), role: 'owner' },
1545
+ { userId: new mongoose.Types.ObjectId(), role: 'admin' },
1546
+ { userId: new mongoose.Types.ObjectId(), role: 'member' },
1547
+ ],
1548
+ tags: ['saas', 'enterprise', 'active'],
1549
+ domains: ['acme.com', 'acme.io'],
1550
+ settings: new Map([
1551
+ ['theme', 'dark'],
1552
+ ['locale', 'en-US'],
1553
+ ['notifications', 'enabled'],
1554
+ ]),
1555
+ featureFlags: { betaDashboard: true, newBilling: false, aiAssistant: { enabled: true, model: 'claude' } },
1556
+ logo: Buffer.from('fake-png-data'),
1557
+ notes: 'Internal: VIP customer, handle with care',
1558
+ seatCount: 25,
1559
+ })
1560
+
1561
+ it('should create organization and record full history', async () => {
1562
+ const org = await OrganizationModel.create(makeOrg())
1563
+
1564
+ const history = await HistoryModel.find({ collectionId: org._id })
1565
+ expect(history).toHaveLength(1)
1566
+
1567
+ const [entry] = history
1568
+ expect(entry?.op).toBe('create')
1569
+ expect(entry?.version).toBe(0)
1570
+
1571
+ const doc = entry?.doc as Record<string, unknown>
1572
+ expect(doc.name).toBe('Acme Corp')
1573
+ expect(doc.active).toBe(true)
1574
+ expect(doc.contact).toHaveProperty('email', 'admin@acme.com')
1575
+ expect(doc.billing).toHaveProperty('plan', 'pro')
1576
+ expect(doc.billing).toHaveProperty('cardLast4', '4242')
1577
+ expect(doc.headquarters).toHaveProperty('city', 'San Francisco')
1578
+ expect((doc.headquarters as Record<string, unknown>).coords).toHaveProperty('lat', 37.7749)
1579
+ expect((doc.team as unknown[]).length).toBe(3)
1580
+ expect(doc.tags).toEqual(['saas', 'enterprise', 'active'])
1581
+ expect(doc.domains).toEqual(['acme.com', 'acme.io'])
1582
+ expect(doc.settings).toBeDefined()
1583
+ expect(doc.featureFlags).toHaveProperty('betaDashboard', true)
1584
+ expect(doc.logo).toBeDefined()
1585
+ expect(doc.seatCount).toBe(25)
1586
+ expect(doc).not.toHaveProperty('notes')
1587
+ expect(doc).not.toHaveProperty('__v')
1588
+ expect(doc).not.toHaveProperty('createdAt')
1589
+ })
1590
+
1591
+ it('should track billing plan upgrade via save', async () => {
1592
+ const org = await OrganizationModel.create(makeOrg())
1593
+
1594
+ org.billing.plan = 'enterprise'
1595
+ org.billing.mrr = mongoose.Types.Decimal128.fromString('1299.00')
1596
+ org.seatCount = 100
1597
+ await org.save()
1598
+
1599
+ const updates = await HistoryModel.find({ op: 'update', collectionId: org._id })
1600
+ expect(updates).toHaveLength(1)
1601
+
1602
+ const paths = updates[0]?.patch?.map((p) => p.path) ?? []
1603
+ expect(paths.some((p) => p?.includes('/billing'))).toBe(true)
1604
+ expect(paths).toContain('/seatCount')
1605
+ })
1606
+
1607
+ it('should track team member changes via updateOne', async () => {
1608
+ const org = await OrganizationModel.create(makeOrg())
1609
+ const newMember = { userId: new mongoose.Types.ObjectId(), role: 'viewer' }
1610
+
1611
+ await OrganizationModel.updateOne({ _id: org._id }, { $push: { team: newMember } }).exec()
1612
+
1613
+ const updates = await HistoryModel.find({ op: 'updateOne', collectionId: org._id })
1614
+ expect(updates).toHaveLength(1)
1615
+
1616
+ const paths = updates[0]?.patch?.map((p) => p.path) ?? []
1617
+ expect(paths.some((p) => p?.startsWith('/team'))).toBe(true)
1618
+ })
1619
+
1620
+ it('should track featureFlags Mixed and settings Map changes', async () => {
1621
+ const org = await OrganizationModel.create(makeOrg())
1622
+
1623
+ org.featureFlags = { betaDashboard: true, newBilling: true, aiAssistant: { enabled: true, model: 'opus' } }
1624
+ org.settings.set('theme', 'light')
1625
+ org.settings.set('newKey', 'newVal')
1626
+ await org.save()
1627
+
1628
+ const updates = await HistoryModel.find({ op: 'update', collectionId: org._id })
1629
+ expect(updates).toHaveLength(1)
1630
+
1631
+ const paths = (updates[0]?.patch ?? []).map((p) => p.path ?? '')
1632
+ expect(paths.some((p) => p.includes('featureFlags'))).toBe(true)
1633
+ expect(paths.some((p) => p.includes('settings'))).toBe(true)
1634
+ })
1635
+
1636
+ it('should track headquarters relocation via findOneAndUpdate', async () => {
1637
+ const org = await OrganizationModel.create(makeOrg())
1638
+
1639
+ await OrganizationModel.findOneAndUpdate(
1640
+ { _id: org._id },
1641
+ {
1642
+ headquarters: {
1643
+ street: '1 Austin Blvd',
1644
+ city: 'Austin',
1645
+ state: 'TX',
1646
+ zip: '73301',
1647
+ country: 'US',
1648
+ coords: { lat: 30.2672, lng: -97.7431 },
1649
+ timezone: 'America/Chicago',
1650
+ },
1651
+ },
1652
+ ).exec()
1653
+
1654
+ const updates = await HistoryModel.find({ op: 'findOneAndUpdate', collectionId: org._id })
1655
+ expect(updates).toHaveLength(1)
1656
+
1657
+ const paths = updates[0]?.patch?.map((p) => p.path) ?? []
1658
+ expect(paths.some((p) => p?.includes('/headquarters'))).toBe(true)
1659
+ })
1660
+
1661
+ it('should track domain and tag array changes', async () => {
1662
+ const org = await OrganizationModel.create(makeOrg())
1663
+
1664
+ await OrganizationModel.updateOne({ _id: org._id }, { $addToSet: { domains: 'acme.dev' }, $pull: { tags: 'active' } }).exec()
1665
+
1666
+ const updates = await HistoryModel.find({ op: 'updateOne', collectionId: org._id })
1667
+ expect(updates).toHaveLength(1)
1668
+
1669
+ const paths = updates[0]?.patch?.map((p) => p.path) ?? []
1670
+ expect(paths.some((p) => p?.startsWith('/domains'))).toBe(true)
1671
+ expect(paths.some((p) => p?.startsWith('/tags'))).toBe(true)
1672
+ })
1673
+
1674
+ it('should track deactivation and record deletion', async () => {
1675
+ const org = await OrganizationModel.create(makeOrg())
1676
+
1677
+ await OrganizationModel.updateOne({ _id: org._id }, { active: false }).exec()
1678
+
1679
+ const updates = await HistoryModel.find({ op: 'updateOne', collectionId: org._id })
1680
+ expect(updates).toHaveLength(1)
1681
+ expect(getPatch(updates[0], '/active')?.value).toBe(false)
1682
+
1683
+ await OrganizationModel.deleteOne({ _id: org._id }).exec()
1684
+
1685
+ const allHistory = await HistoryModel.find({ collectionId: org._id }).sort('createdAt')
1686
+ expect(allHistory.length).toBe(3)
1687
+ expect(allHistory[0]?.op).toBe('create')
1688
+ expect(allHistory[1]?.op).toBe('updateOne')
1689
+ expect(allHistory[2]?.op).toBe('deleteOne')
1690
+ expect(allHistory[2]?.doc).toHaveProperty('name', 'Acme Corp')
1691
+ expect(allHistory[2]?.doc).not.toHaveProperty('notes')
1692
+ })
1693
+
1694
+ it('should handle full lifecycle: create → multiple updates → delete with version tracking', async () => {
1695
+ const org = await OrganizationModel.create(makeOrg())
1696
+
1697
+ org.name = 'Acme Inc'
1698
+ org.contact.email = 'hello@acme.io'
1699
+ await org.save()
1700
+
1701
+ org.billing.plan = 'enterprise'
1702
+ org.seatCount = 200
1703
+ await org.save()
1704
+
1705
+ await OrganizationModel.updateOne({ _id: org._id }, { $addToSet: { tags: 'flagship' } }).exec()
1706
+
1707
+ await OrganizationModel.deleteOne({ _id: org._id }).exec()
1708
+
1709
+ const history = await HistoryModel.find({ collectionId: org._id }).sort('createdAt')
1710
+ expect(history.length).toBe(5)
1711
+
1712
+ expect(history[0]?.op).toBe('create')
1713
+ expect(history[0]?.version).toBe(0)
1714
+
1715
+ expect(history[1]?.op).toBe('update')
1716
+ expect(history[1]?.version).toBe(1)
1717
+
1718
+ expect(history[2]?.op).toBe('update')
1719
+ expect(history[2]?.version).toBe(2)
1720
+
1721
+ expect(history[3]?.op).toBe('updateOne')
1722
+ expect(history[3]?.version).toBe(3)
1723
+
1724
+ expect(history[4]?.op).toBe('deleteOne')
1725
+ })
1726
+
1727
+ it('should track insertMany with history for each document', async () => {
1728
+ await OrganizationModel.insertMany([
1729
+ { ...makeOrg(), slug: `bulk-a-${Date.now()}`, name: 'Bulk A' },
1730
+ { ...makeOrg(), slug: `bulk-b-${Date.now()}`, name: 'Bulk B' },
1731
+ { ...makeOrg(), slug: `bulk-c-${Date.now()}`, name: 'Bulk C' },
1732
+ ])
1733
+
1734
+ const history = await HistoryModel.find({ op: 'create' }).sort('doc.name')
1735
+ expect(history).toHaveLength(3)
1736
+
1737
+ for (const entry of history) {
1738
+ expect(entry.version).toBe(0)
1739
+ expect(entry.doc).toHaveProperty('name')
1740
+ expect(entry.doc).toHaveProperty('apiKey')
1741
+ expect(entry.doc).not.toHaveProperty('notes')
1742
+ expect(entry.doc).not.toHaveProperty('__v')
1743
+ }
1744
+
1745
+ const names = history.map((h) => (h.doc as Record<string, unknown>).name)
1746
+ expect(names).toContain('Bulk A')
1747
+ expect(names).toContain('Bulk B')
1748
+ expect(names).toContain('Bulk C')
1749
+ })
1750
+
1751
+ it('should track updateMany across multiple organizations', async () => {
1752
+ await OrganizationModel.create({ ...makeOrg(), slug: `many-a-${Date.now()}`, tags: ['region-eu'] })
1753
+ await OrganizationModel.create({ ...makeOrg(), slug: `many-b-${Date.now()}`, tags: ['region-eu'] })
1754
+ await OrganizationModel.create({ ...makeOrg(), slug: `many-c-${Date.now()}`, tags: ['region-us'] })
1755
+
1756
+ await OrganizationModel.updateMany({ tags: 'region-eu' }, { $set: { active: false } }).exec()
1757
+
1758
+ const updates = await HistoryModel.find({ op: 'updateMany' })
1759
+ expect(updates).toHaveLength(2)
1760
+
1761
+ for (const entry of updates) {
1762
+ expect(entry.patch?.length).toBeGreaterThan(0)
1763
+ const paths = entry.patch?.map((p) => p.path) ?? []
1764
+ expect(paths).toContain('/active')
1765
+ }
1766
+ })
1767
+
1768
+ it('should track findByIdAndUpdate (mongoose normalizes op to findOneAndUpdate)', async () => {
1769
+ const org = await OrganizationModel.create(makeOrg())
1770
+
1771
+ await OrganizationModel.findByIdAndUpdate(org._id, { name: 'Acme Renamed' }).exec()
1772
+
1773
+ const updates = await HistoryModel.find({ collectionId: org._id, version: { $gt: 0 } })
1774
+ expect(updates).toHaveLength(1)
1775
+ expect(updates[0]?.op).toBe('findOneAndUpdate')
1776
+ expect(updates[0]?.patch?.find((p) => p.path === '/name' && p.op === 'replace')?.value).toBe('Acme Renamed')
1777
+ })
1778
+
1779
+ it('should track findOneAndReplace with full document swap', async () => {
1780
+ const org = await OrganizationModel.create(makeOrg())
1781
+ const replacement = makeOrg()
1782
+ replacement.name = 'Replaced Corp'
1783
+ replacement.slug = org.slug
1784
+ replacement.billing.plan = 'enterprise'
1785
+ replacement.seatCount = 500
1786
+
1787
+ await OrganizationModel.findOneAndReplace({ _id: org._id }, replacement).exec()
1788
+
1789
+ const updates = await HistoryModel.find({ op: 'findOneAndReplace', collectionId: org._id })
1790
+ expect(updates).toHaveLength(1)
1791
+ expect(updates[0]?.patch?.length).toBeGreaterThan(0)
1792
+
1793
+ const paths = updates[0]?.patch?.map((p) => p.path) ?? []
1794
+ expect(paths).toContain('/name')
1795
+ expect(paths.some((p) => p?.includes('/billing'))).toBe(true)
1796
+ expect(paths).toContain('/seatCount')
1797
+ })
1798
+
1799
+ it('should track replaceOne', async () => {
1800
+ const org = await OrganizationModel.create(makeOrg())
1801
+ const replacement = makeOrg()
1802
+ replacement.name = 'ReplaceOne Corp'
1803
+ replacement.slug = org.slug
1804
+ replacement.active = false
1805
+
1806
+ await OrganizationModel.replaceOne({ _id: org._id }, replacement).exec()
1807
+
1808
+ const updates = await HistoryModel.find({ op: 'replaceOne', collectionId: org._id })
1809
+ expect(updates).toHaveLength(1)
1810
+ expect(updates[0]?.patch?.length).toBeGreaterThan(0)
1811
+
1812
+ const paths = updates[0]?.patch?.map((p) => p.path) ?? []
1813
+ expect(paths).toContain('/name')
1814
+ expect(paths).toContain('/active')
1815
+ })
1816
+
1817
+ it('should track findOneAndDelete with full document snapshot', async () => {
1818
+ const org = await OrganizationModel.create(makeOrg())
1819
+
1820
+ await OrganizationModel.findOneAndDelete({ _id: org._id }).exec()
1821
+
1822
+ const history = await HistoryModel.find({ collectionId: org._id }).sort('createdAt')
1823
+ expect(history).toHaveLength(2)
1824
+
1825
+ const deletion = history[1]
1826
+ expect(deletion?.op).toBe('findOneAndDelete')
1827
+ expect(deletion?.doc).toHaveProperty('name', 'Acme Corp')
1828
+ expect(deletion?.doc).toHaveProperty('billing')
1829
+ expect(deletion?.doc).toHaveProperty('team')
1830
+ expect(deletion?.doc).not.toHaveProperty('notes')
1831
+ })
1832
+
1833
+ it('should track findByIdAndDelete (mongoose normalizes op to findOneAndDelete)', async () => {
1834
+ const org = await OrganizationModel.create(makeOrg())
1835
+
1836
+ await OrganizationModel.findByIdAndDelete(org._id).exec()
1837
+
1838
+ const history = await HistoryModel.find({ collectionId: org._id }).sort('createdAt')
1839
+ expect(history).toHaveLength(2)
1840
+
1841
+ expect(history[1]?.op).toBe('findOneAndDelete')
1842
+ expect(history[1]?.doc).toHaveProperty('name', 'Acme Corp')
1843
+ expect(history[1]?.doc).toHaveProperty('contact')
1844
+ expect(history[1]?.doc).not.toHaveProperty('notes')
1845
+ })
1846
+
1847
+ it('should track deleteMany with snapshots for all deleted documents', async () => {
1848
+ await OrganizationModel.create({ ...makeOrg(), slug: `del-a-${Date.now()}`, name: 'Del A', tags: ['sunset'] })
1849
+ await OrganizationModel.create({ ...makeOrg(), slug: `del-b-${Date.now()}`, name: 'Del B', tags: ['sunset'] })
1850
+ await OrganizationModel.create({ ...makeOrg(), slug: `del-c-${Date.now()}`, name: 'Del C', tags: ['keep'] })
1851
+
1852
+ await OrganizationModel.deleteMany({ tags: 'sunset' }).exec()
1853
+
1854
+ const deletions = await HistoryModel.find({ op: 'deleteMany' })
1855
+ expect(deletions).toHaveLength(2)
1856
+
1857
+ const names = deletions.map((d) => (d.doc as Record<string, unknown>).name)
1858
+ expect(names).toContain('Del A')
1859
+ expect(names).toContain('Del B')
1860
+
1861
+ for (const entry of deletions) {
1862
+ expect(entry.doc).toHaveProperty('contact')
1863
+ expect(entry.doc).toHaveProperty('billing')
1864
+ expect(entry.doc).not.toHaveProperty('notes')
1865
+ }
1866
+ })
1867
+
1868
+ it('should track upsert creating a new organization', async () => {
1869
+ const slug = `upsert-new-${Date.now()}`
1870
+
1871
+ await OrganizationModel.findOneAndUpdate(
1872
+ { slug },
1873
+ {
1874
+ name: 'Upserted Corp',
1875
+ slug,
1876
+ apiKey: '660e8400-e29b-41d4-a716-446655440000',
1877
+ contact: { email: 'upsert@test.com' },
1878
+ seatCount: 5,
1879
+ },
1880
+ { upsert: true },
1881
+ ).exec()
1882
+
1883
+ const docs = await OrganizationModel.find({ slug })
1884
+ expect(docs).toHaveLength(1)
1885
+
1886
+ const history = await HistoryModel.find({})
1887
+ expect(history).toHaveLength(1)
1888
+ expect(history[0]?.op).toBe('findOneAndUpdate')
1889
+ expect(history[0]?.doc).toHaveProperty('name', 'Upserted Corp')
1890
+ })
1891
+
1892
+ it('should track upsert updating an existing organization', async () => {
1893
+ const org = await OrganizationModel.create(makeOrg())
1894
+
1895
+ await OrganizationModel.findOneAndUpdate({ slug: org.slug }, { $set: { seatCount: 999 } }, { upsert: true }).exec()
1896
+
1897
+ const updates = await HistoryModel.find({ op: 'findOneAndUpdate', collectionId: org._id })
1898
+ expect(updates).toHaveLength(1)
1899
+
1900
+ const paths = updates[0]?.patch?.map((p) => p.path) ?? []
1901
+ expect(paths).toContain('/seatCount')
1902
+ })
1903
+
1904
+ // --- $ modifier operators ---
1905
+
1906
+ it('$set — should track field replacement', async () => {
1907
+ const org = await OrganizationModel.create(makeOrg())
1908
+
1909
+ await OrganizationModel.updateOne({ _id: org._id }, { $set: { name: 'Set Corp', 'contact.phone': '+1-555-9999' } }).exec()
1910
+
1911
+ const [update] = await HistoryModel.find({ op: 'updateOne', collectionId: org._id })
1912
+ const paths = update?.patch?.map((p) => p.path) ?? []
1913
+ expect(paths).toContain('/name')
1914
+ expect(paths.some((p) => p?.includes('/contact'))).toBe(true)
1915
+ })
1916
+
1917
+ it('$unset — should track field removal', async () => {
1918
+ const org = await OrganizationModel.create(makeOrg())
1919
+
1920
+ await OrganizationModel.updateOne({ _id: org._id }, { $unset: { logo: '' } }).exec()
1921
+
1922
+ const current = await OrganizationModel.findById(org._id).lean().exec()
1923
+ expect(current?.logo).toBeUndefined()
1924
+
1925
+ const [update] = await HistoryModel.find({ op: 'updateOne', collectionId: org._id })
1926
+ expect(update).toBeDefined()
1927
+ const paths = update?.patch?.map((p) => p.path) ?? []
1928
+ expect(paths.some((p) => p?.includes('/logo'))).toBe(true)
1929
+ })
1930
+
1931
+ it('$inc — should track numeric increment with correct post-update value', async () => {
1932
+ const org = await OrganizationModel.create(makeOrg())
1933
+ expect(org.seatCount).toBe(25)
1934
+
1935
+ await OrganizationModel.updateOne({ _id: org._id }, { $inc: { seatCount: 10 } }).exec()
1936
+
1937
+ const current = await OrganizationModel.findById(org._id).lean().exec()
1938
+ expect(current?.seatCount).toBe(35)
1939
+
1940
+ const [update] = await HistoryModel.find({ op: 'updateOne', collectionId: org._id })
1941
+ expect(update).toBeDefined()
1942
+ const seatPatch = update?.patch?.find((p) => p.path === '/seatCount' && p.op === 'replace')
1943
+ expect(seatPatch?.value).toBe(35)
1944
+ })
1945
+
1946
+ it('$mul — should track numeric multiply with correct post-update value', async () => {
1947
+ const org = await OrganizationModel.create(makeOrg())
1948
+
1949
+ await OrganizationModel.updateOne({ _id: org._id }, { $mul: { seatCount: 2 } }).exec()
1950
+
1951
+ const current = await OrganizationModel.findById(org._id).lean().exec()
1952
+ expect(current?.seatCount).toBe(50)
1953
+
1954
+ const [update] = await HistoryModel.find({ op: 'updateOne', collectionId: org._id })
1955
+ expect(update).toBeDefined()
1956
+ const seatPatch = update?.patch?.find((p) => p.path === '/seatCount' && p.op === 'replace')
1957
+ expect(seatPatch?.value).toBe(50)
1958
+ })
1959
+
1960
+ it('$min — should track conditional numeric update', async () => {
1961
+ const org = await OrganizationModel.create(makeOrg())
1962
+
1963
+ await OrganizationModel.updateOne({ _id: org._id }, { $min: { seatCount: 5 } }).exec()
1964
+
1965
+ const current = await OrganizationModel.findById(org._id).lean().exec()
1966
+ expect(current?.seatCount).toBe(5)
1967
+
1968
+ const [update] = await HistoryModel.find({ op: 'updateOne', collectionId: org._id })
1969
+ expect(update).toBeDefined()
1970
+ const seatPatch = update?.patch?.find((p) => p.path === '/seatCount' && p.op === 'replace')
1971
+ expect(seatPatch?.value).toBe(5)
1972
+ })
1973
+
1974
+ it('$pullAll — should track multiple array element removals', async () => {
1975
+ const org = await OrganizationModel.create(makeOrg())
1976
+
1977
+ await OrganizationModel.updateOne({ _id: org._id }, { $pullAll: { tags: ['saas', 'enterprise'] } }).exec()
1978
+
1979
+ const current = await OrganizationModel.findById(org._id).lean().exec()
1980
+ expect(current?.tags).toEqual(['active'])
1981
+
1982
+ const [update] = await HistoryModel.find({ op: 'updateOne', collectionId: org._id })
1983
+ expect(update).toBeDefined()
1984
+ const paths = update?.patch?.map((p) => p.path) ?? []
1985
+ expect(paths.some((p) => p?.startsWith('/tags'))).toBe(true)
1986
+ })
1987
+
1988
+ it('$push — should track array element addition', async () => {
1989
+ const org = await OrganizationModel.create(makeOrg())
1990
+
1991
+ await OrganizationModel.updateOne({ _id: org._id }, { $push: { tags: 'new-tag' } }).exec()
1992
+
1993
+ const [update] = await HistoryModel.find({ op: 'updateOne', collectionId: org._id })
1994
+ const paths = update?.patch?.map((p) => p.path) ?? []
1995
+ expect(paths.some((p) => p?.startsWith('/tags'))).toBe(true)
1996
+ })
1997
+
1998
+ it('$push with $each — should track multiple array additions', async () => {
1999
+ const org = await OrganizationModel.create(makeOrg())
2000
+
2001
+ await OrganizationModel.updateOne({ _id: org._id }, { $push: { domains: { $each: ['acme.dev', 'acme.ai'] } } }).exec()
2002
+
2003
+ const [update] = await HistoryModel.find({ op: 'updateOne', collectionId: org._id })
2004
+ const paths = update?.patch?.map((p) => p.path) ?? []
2005
+ expect(paths.some((p) => p?.startsWith('/domains'))).toBe(true)
2006
+ })
2007
+
2008
+ it('$addToSet — should track unique array addition', async () => {
2009
+ const org = await OrganizationModel.create(makeOrg())
2010
+
2011
+ await OrganizationModel.updateOne({ _id: org._id }, { $addToSet: { tags: 'unique-tag' } }).exec()
2012
+
2013
+ const [update] = await HistoryModel.find({ op: 'updateOne', collectionId: org._id })
2014
+ const paths = update?.patch?.map((p) => p.path) ?? []
2015
+ expect(paths.some((p) => p?.startsWith('/tags'))).toBe(true)
2016
+ })
2017
+
2018
+ it('$pull — should track array element removal', async () => {
2019
+ const org = await OrganizationModel.create(makeOrg())
2020
+
2021
+ await OrganizationModel.updateOne({ _id: org._id }, { $pull: { tags: 'enterprise' } }).exec()
2022
+
2023
+ const [update] = await HistoryModel.find({ op: 'updateOne', collectionId: org._id })
2024
+ const paths = update?.patch?.map((p) => p.path) ?? []
2025
+ expect(paths.some((p) => p?.startsWith('/tags'))).toBe(true)
2026
+ })
2027
+
2028
+ it('$pop — should track removal of last array element', async () => {
2029
+ const org = await OrganizationModel.create(makeOrg())
2030
+
2031
+ await OrganizationModel.updateOne({ _id: org._id }, { $pop: { tags: 1 } }).exec()
2032
+
2033
+ const [update] = await HistoryModel.find({ op: 'updateOne', collectionId: org._id })
2034
+ const paths = update?.patch?.map((p) => p.path) ?? []
2035
+ expect(paths.some((p) => p?.startsWith('/tags'))).toBe(true)
2036
+ })
2037
+
2038
+ it('$rename — should track field rename', async () => {
2039
+ const org = await OrganizationModel.create(makeOrg())
2040
+
2041
+ await OrganizationModel.updateOne({ _id: org._id }, { $rename: { 'contact.phone': 'contact.mobile' } }).exec()
2042
+
2043
+ const [update] = await HistoryModel.find({ op: 'updateOne', collectionId: org._id })
2044
+ const paths = update?.patch?.map((p) => p.path) ?? []
2045
+ expect(paths.some((p) => p?.includes('/contact'))).toBe(true)
2046
+ })
2047
+
2048
+ it('$currentDate — should track date set to now', async () => {
2049
+ const org = await OrganizationModel.create(makeOrg())
2050
+
2051
+ await OrganizationModel.updateOne({ _id: org._id }, { $currentDate: { 'billing.nextBillingDate': true } }).exec()
2052
+
2053
+ const [update] = await HistoryModel.find({ op: 'updateOne', collectionId: org._id })
2054
+ const paths = update?.patch?.map((p) => p.path) ?? []
2055
+ expect(paths.some((p) => p?.includes('/billing'))).toBe(true)
2056
+ })
2057
+
2058
+ it('combined $set + $inc + $push — should track all operators with correct values', async () => {
2059
+ const org = await OrganizationModel.create(makeOrg())
2060
+
2061
+ await OrganizationModel.updateOne(
2062
+ { _id: org._id },
2063
+ {
2064
+ $set: { name: 'Combined Corp', active: false },
2065
+ $inc: { seatCount: 50 },
2066
+ $push: { tags: 'combined' },
2067
+ },
2068
+ ).exec()
2069
+
2070
+ const current = await OrganizationModel.findById(org._id).lean().exec()
2071
+ expect(current?.name).toBe('Combined Corp')
2072
+ expect(current?.active).toBe(false)
2073
+ expect(current?.seatCount).toBe(75)
2074
+ expect(current?.tags).toContain('combined')
2075
+
2076
+ const [update] = await HistoryModel.find({ op: 'updateOne', collectionId: org._id })
2077
+ const paths = update?.patch?.map((p) => p.path) ?? []
2078
+ expect(paths).toContain('/name')
2079
+ expect(paths).toContain('/active')
2080
+ expect(paths).toContain('/seatCount')
2081
+ expect(paths.some((p) => p?.startsWith('/tags'))).toBe(true)
2082
+
2083
+ expect(update?.patch?.find((p) => p.path === '/name' && p.op === 'replace')?.value).toBe('Combined Corp')
2084
+ expect(update?.patch?.find((p) => p.path === '/active' && p.op === 'replace')?.value).toBe(false)
2085
+ expect(update?.patch?.find((p) => p.path === '/seatCount' && p.op === 'replace')?.value).toBe(75)
2086
+ })
2087
+
2088
+ it('$push subdocument into team array — should track nested array addition', async () => {
2089
+ const org = await OrganizationModel.create(makeOrg())
2090
+ const newMemberId = new mongoose.Types.ObjectId()
2091
+
2092
+ await OrganizationModel.updateOne({ _id: org._id }, { $push: { team: { userId: newMemberId, role: 'viewer' } } }).exec()
2093
+
2094
+ const [update] = await HistoryModel.find({ op: 'updateOne', collectionId: org._id })
2095
+ const paths = update?.patch?.map((p) => p.path) ?? []
2096
+ expect(paths.some((p) => p?.startsWith('/team'))).toBe(true)
2097
+ })
2098
+
2099
+ // --- getUser / getReason / getMetadata ---
2100
+
2101
+ it('create history should contain user, reason, and metadata', async () => {
2102
+ const org = await OrganizationModel.create(makeOrg())
2103
+
2104
+ const [entry] = await HistoryModel.find({ collectionId: org._id })
2105
+ expect(entry?.user).toEqual({ userId: 'system', role: 'service-account' })
2106
+ expect(entry?.reason).toBe('api-call')
2107
+ expect(entry?.metadata).toEqual({ service: 'org-service', requestId: 'req-123' })
2108
+ })
2109
+
2110
+ it('update history should contain user, reason, and metadata', async () => {
2111
+ const org = await OrganizationModel.create(makeOrg())
2112
+
2113
+ org.name = 'Updated Corp'
2114
+ await org.save()
2115
+
2116
+ const update = await HistoryModel.findOne({ op: 'update', collectionId: org._id })
2117
+ expect(update?.user).toEqual({ userId: 'system', role: 'service-account' })
2118
+ expect(update?.reason).toBe('api-call')
2119
+ expect(update?.metadata).toEqual({ service: 'org-service', requestId: 'req-123' })
2120
+ })
2121
+
2122
+ it('delete history should contain user, reason, and metadata', async () => {
2123
+ const org = await OrganizationModel.create(makeOrg())
2124
+
2125
+ await OrganizationModel.deleteOne({ _id: org._id }).exec()
2126
+
2127
+ const deletion = await HistoryModel.findOne({ op: 'deleteOne', collectionId: org._id })
2128
+ expect(deletion?.user).toEqual({ userId: 'system', role: 'service-account' })
2129
+ expect(deletion?.reason).toBe('api-call')
2130
+ expect(deletion?.metadata).toEqual({ service: 'org-service', requestId: 'req-123' })
2131
+ })
2132
+
2133
+ it('updateOne history should contain user, reason, and metadata', async () => {
2134
+ const org = await OrganizationModel.create(makeOrg())
2135
+
2136
+ await OrganizationModel.updateOne({ _id: org._id }, { name: 'Query Updated' }).exec()
2137
+
2138
+ const update = await HistoryModel.findOne({ op: 'updateOne', collectionId: org._id })
2139
+ expect(update?.user).toEqual({ userId: 'system', role: 'service-account' })
2140
+ expect(update?.reason).toBe('api-call')
2141
+ expect(update?.metadata).toEqual({ service: 'org-service', requestId: 'req-123' })
2142
+ })
2143
+
2144
+ // --- History record structure ---
2145
+
2146
+ it('create history should contain doc snapshot, no patch array', async () => {
2147
+ const org = await OrganizationModel.create(makeOrg())
2148
+
2149
+ const [entry] = await HistoryModel.find({ collectionId: org._id })
2150
+ expect(entry?.op).toBe('create')
2151
+ expect(entry?.modelName).toBe('Organization')
2152
+ expect(entry?.collectionName).toBe('organizations')
2153
+ expect(entry?.collectionId).toEqual(org._id)
2154
+ expect(entry?.version).toBe(0)
2155
+ expect(entry?.doc).toBeDefined()
2156
+ expect(entry?.patch).toEqual([])
2157
+ expect(entry?.createdAt).toBeDefined()
2158
+ expect(entry?.updatedAt).toBeDefined()
2159
+ })
2160
+
2161
+ it('update history should contain JSON patch, no doc snapshot', async () => {
2162
+ const org = await OrganizationModel.create(makeOrg())
2163
+
2164
+ org.name = 'Changed'
2165
+ await org.save()
2166
+
2167
+ const update = await HistoryModel.findOne({ op: 'update', collectionId: org._id })
2168
+ expect(update?.doc).toBeUndefined()
2169
+ expect(update?.patch).toBeDefined()
2170
+ expect(update?.patch?.length).toBeGreaterThan(0)
2171
+
2172
+ const nameOp = update?.patch?.find((p) => p.path === '/name' && p.op === 'replace')
2173
+ expect(nameOp).toBeDefined()
2174
+ expect(nameOp?.value).toBe('Changed')
2175
+ })
2176
+
2177
+ it('delete history should contain doc snapshot, no patch array', async () => {
2178
+ const org = await OrganizationModel.create(makeOrg())
2179
+
2180
+ await OrganizationModel.deleteOne({ _id: org._id }).exec()
2181
+
2182
+ const deletion = await HistoryModel.findOne({ op: 'deleteOne', collectionId: org._id })
2183
+ expect(deletion?.doc).toBeDefined()
2184
+ expect(deletion?.doc).toHaveProperty('name', 'Acme Corp')
2185
+ expect(deletion?.patch).toEqual([])
2186
+ })
2187
+
2188
+ // --- Event payloads ---
2189
+
2190
+ it('create event should emit with { doc } payload', async () => {
2191
+ await OrganizationModel.create(makeOrg())
2192
+
2193
+ expect(em.emit).toHaveBeenCalledWith(ORG_CREATED, expect.objectContaining({ doc: expect.objectContaining({ name: 'Acme Corp' }) }))
2194
+ })
2195
+
2196
+ it('update event should emit with { doc, oldDoc, patch } payload', async () => {
2197
+ const org = await OrganizationModel.create(makeOrg())
2198
+ vi.mocked(em.emit).mockClear()
2199
+
2200
+ org.name = 'New Name'
2201
+ await org.save()
2202
+
2203
+ expect(em.emit).toHaveBeenCalledWith(
2204
+ ORG_UPDATED,
2205
+ expect.objectContaining({
2206
+ oldDoc: expect.objectContaining({ name: 'Acme Corp' }),
2207
+ doc: expect.objectContaining({ name: 'New Name' }),
2208
+ patch: expect.arrayContaining([expect.objectContaining({ path: '/name', op: 'replace', value: 'New Name' })]),
2209
+ }),
2210
+ )
2211
+ })
2212
+
2213
+ it('delete event should emit with { oldDoc } payload', async () => {
2214
+ const org = await OrganizationModel.create(makeOrg())
2215
+ vi.mocked(em.emit).mockClear()
2216
+
2217
+ await OrganizationModel.deleteOne({ _id: org._id }).exec()
2218
+
2219
+ expect(em.emit).toHaveBeenCalledWith(ORG_DELETED, expect.objectContaining({ oldDoc: expect.objectContaining({ name: 'Acme Corp' }) }))
2220
+ })
2221
+
2222
+ // --- Omit behavior ---
2223
+
2224
+ it('omitted fields should never appear in create history doc', async () => {
2225
+ const org = await OrganizationModel.create(makeOrg())
2226
+
2227
+ const [entry] = await HistoryModel.find({ collectionId: org._id })
2228
+ expect(entry?.doc).not.toHaveProperty('notes')
2229
+ expect(entry?.doc).not.toHaveProperty('__v')
2230
+ expect(entry?.doc).not.toHaveProperty('createdAt')
2231
+ expect(entry?.doc).not.toHaveProperty('updatedAt')
2232
+ })
2233
+
2234
+ it('omitted fields should never appear in update patch paths', async () => {
2235
+ const org = await OrganizationModel.create(makeOrg())
2236
+
2237
+ org.notes = 'changed internal note'
2238
+ org.name = 'Trigger Real Change'
2239
+ await org.save()
2240
+
2241
+ const update = await HistoryModel.findOne({ op: 'update', collectionId: org._id })
2242
+ const paths = update?.patch?.map((p) => p.path) ?? []
2243
+ expect(paths).toContain('/name')
2244
+ expect(paths.every((p) => !p?.includes('notes'))).toBe(true)
2245
+ expect(paths.every((p) => !p?.includes('__v'))).toBe(true)
2246
+ expect(paths.every((p) => !p?.includes('createdAt'))).toBe(true)
2247
+ expect(paths.every((p) => !p?.includes('updatedAt'))).toBe(true)
2248
+ })
2249
+
2250
+ it('omitted fields should never appear in delete history doc', async () => {
2251
+ const org = await OrganizationModel.create(makeOrg())
2252
+
2253
+ await OrganizationModel.deleteOne({ _id: org._id }).exec()
2254
+
2255
+ const deletion = await HistoryModel.findOne({ op: 'deleteOne', collectionId: org._id })
2256
+ expect(deletion?.doc).not.toHaveProperty('notes')
2257
+ expect(deletion?.doc).not.toHaveProperty('__v')
2258
+ expect(deletion?.doc).not.toHaveProperty('createdAt')
2259
+ expect(deletion?.doc).not.toHaveProperty('updatedAt')
2260
+ })
2261
+
2262
+ // --- Version tracking ---
2263
+
2264
+ it('versions should increment sequentially per document', async () => {
2265
+ const org = await OrganizationModel.create(makeOrg())
2266
+
2267
+ for (let i = 1; i <= 5; i++) {
2268
+ org.seatCount = i * 10
2269
+ await org.save()
2270
+ }
2271
+
2272
+ const history = await HistoryModel.find({ collectionId: org._id }).sort('version')
2273
+ expect(history).toHaveLength(6)
2274
+
2275
+ for (let i = 0; i < 6; i++) {
2276
+ expect(history[i]?.version).toBe(i)
2277
+ }
2278
+ })
2279
+
2280
+ it('versions should be independent per document', async () => {
2281
+ const orgA = await OrganizationModel.create({ ...makeOrg(), slug: `a-${Date.now()}` })
2282
+ const orgB = await OrganizationModel.create({ ...makeOrg(), slug: `b-${Date.now()}` })
2283
+
2284
+ orgA.name = 'A changed'
2285
+ await orgA.save()
2286
+
2287
+ orgB.name = 'B changed'
2288
+ await orgB.save()
2289
+
2290
+ const historyA = await HistoryModel.find({ collectionId: orgA._id }).sort('version')
2291
+ const historyB = await HistoryModel.find({ collectionId: orgB._id }).sort('version')
2292
+
2293
+ expect(historyA[0]?.version).toBe(0)
2294
+ expect(historyA[1]?.version).toBe(1)
2295
+ expect(historyB[0]?.version).toBe(0)
2296
+ expect(historyB[1]?.version).toBe(1)
2297
+ })
2298
+
2299
+ // --- ignoreHook / ignoreEvent / ignorePatchHistory ---
2300
+
2301
+ it('ignoreHook should skip both history and events', async () => {
2302
+ const org = await OrganizationModel.create(makeOrg())
2303
+ vi.mocked(em.emit).mockClear()
2304
+
2305
+ await OrganizationModel.updateOne({ _id: org._id }, { name: 'Ignored' }).setOptions({ ignoreHook: true }).exec()
2306
+
2307
+ const updates = await HistoryModel.find({ op: 'updateOne', collectionId: org._id })
2308
+ expect(updates).toHaveLength(0)
2309
+ expect(em.emit).not.toHaveBeenCalledWith(ORG_UPDATED, expect.anything())
2310
+ })
2311
+
2312
+ it('ignoreEvent should keep history but skip events', async () => {
2313
+ const org = await OrganizationModel.create(makeOrg())
2314
+ vi.mocked(em.emit).mockClear()
2315
+
2316
+ await OrganizationModel.updateOne({ _id: org._id }, { name: 'EventSkipped' }).setOptions({ ignoreEvent: true }).exec()
2317
+
2318
+ const updates = await HistoryModel.find({ op: 'updateOne', collectionId: org._id })
2319
+ expect(updates).toHaveLength(1)
2320
+ expect(em.emit).not.toHaveBeenCalledWith(ORG_UPDATED, expect.anything())
2321
+ })
2322
+
2323
+ it('ignorePatchHistory should skip history but keep events', async () => {
2324
+ const org = await OrganizationModel.create(makeOrg())
2325
+ vi.mocked(em.emit).mockClear()
2326
+
2327
+ await OrganizationModel.updateOne({ _id: org._id }, { name: 'HistorySkipped' }).setOptions({ ignorePatchHistory: true }).exec()
2328
+
2329
+ const updates = await HistoryModel.find({ op: 'updateOne', collectionId: org._id })
2330
+ expect(updates).toHaveLength(0)
2331
+ expect(em.emit).toHaveBeenCalledWith(ORG_UPDATED, expect.anything())
2332
+ })
2333
+
2334
+ // --- No-op safety ---
2335
+
2336
+ it('update with no actual changes should not produce history', async () => {
2337
+ const org = await OrganizationModel.create(makeOrg())
2338
+
2339
+ await OrganizationModel.updateOne({ _id: org._id }, { name: 'Acme Corp' }).exec()
2340
+
2341
+ const updates = await HistoryModel.find({ op: 'updateOne', collectionId: org._id })
2342
+ expect(updates).toHaveLength(0)
2343
+ })
2344
+
2345
+ it('update targeting non-existent document should not crash or produce history', async () => {
2346
+ const fakeId = new mongoose.Types.ObjectId()
2347
+
2348
+ await OrganizationModel.updateOne({ _id: fakeId }, { name: 'Ghost' }).exec()
2349
+
2350
+ const history = await HistoryModel.find({})
2351
+ expect(history).toHaveLength(0)
2352
+ })
2353
+
2354
+ it('delete targeting non-existent document should not crash or produce history', async () => {
2355
+ const fakeId = new mongoose.Types.ObjectId()
2356
+
2357
+ await OrganizationModel.deleteOne({ _id: fakeId }).exec()
2358
+
2359
+ const history = await HistoryModel.find({})
2360
+ expect(history).toHaveLength(0)
2361
+ })
2362
+
2363
+ // --- insertMany with user/reason/metadata ---
2364
+
2365
+ it('insertMany should attach user, reason, and metadata to each history record', async () => {
2366
+ await OrganizationModel.insertMany([
2367
+ { ...makeOrg(), slug: `ins-a-${Date.now()}`, name: 'Ins A' },
2368
+ { ...makeOrg(), slug: `ins-b-${Date.now()}`, name: 'Ins B' },
2369
+ ])
2370
+
2371
+ const history = await HistoryModel.find({ op: 'create' }).sort('doc.name')
2372
+ expect(history).toHaveLength(2)
2373
+
2374
+ for (const entry of history) {
2375
+ expect(entry.user).toEqual({ userId: 'system', role: 'service-account' })
2376
+ expect(entry.reason).toBe('api-call')
2377
+ expect(entry.metadata).toEqual({ service: 'org-service', requestId: 'req-123' })
2378
+ }
2379
+ })
2380
+
2381
+ // --- preDelete callback ---
2382
+
2383
+ it('preDelete should receive cloned documents before deletion', async () => {
2384
+ const preDeleteDocs: unknown[][] = []
2385
+
2386
+ const PreDeleteSchema = new Schema<Organization>(
2387
+ {
2388
+ name: { type: String, required: true },
2389
+ slug: { type: String, required: true },
2390
+ apiKey: { type: Schema.Types.UUID, required: true },
2391
+ active: { type: Boolean, default: true },
2392
+ contact: { type: ContactSchema, required: true },
2393
+ seatCount: { type: Number, default: 1 },
2394
+ },
2395
+ { timestamps: true },
2396
+ )
2397
+
2398
+ PreDeleteSchema.plugin(patchHistoryPlugin, {
2399
+ omit: ['__v', 'createdAt', 'updatedAt'],
2400
+ preDelete: async (docs) => {
2401
+ preDeleteDocs.push(docs)
2402
+ },
2403
+ })
2404
+
2405
+ if (mongoose.models.PreDeleteOrg) mongoose.deleteModel('PreDeleteOrg')
2406
+ const PreDeleteModel = model<Organization>('PreDeleteOrg', PreDeleteSchema)
2407
+
2408
+ const org = await PreDeleteModel.create({
2409
+ name: 'Doomed Corp',
2410
+ slug: `doomed-${Date.now()}`,
2411
+ apiKey: '550e8400-e29b-41d4-a716-446655440000',
2412
+ contact: { email: 'bye@doomed.com' },
2413
+ })
2414
+
2415
+ await PreDeleteModel.deleteOne({ _id: org._id }).exec()
2416
+
2417
+ expect(preDeleteDocs).toHaveLength(1)
2418
+ expect(preDeleteDocs[0]).toHaveLength(1)
2419
+ expect(preDeleteDocs[0]?.[0]).toHaveProperty('name', 'Doomed Corp')
2420
+ })
2421
+ })
2422
+
2423
+ // --- patchHistoryDisabled mode ---
2424
+
2425
+ const EventOnlySchema = new Schema<Organization>(
2426
+ {
2427
+ name: { type: String, required: true },
2428
+ slug: { type: String, required: true },
2429
+ apiKey: { type: Schema.Types.UUID, required: true },
2430
+ active: { type: Boolean, default: true },
2431
+ contact: { type: ContactSchema, required: true },
2432
+ seatCount: { type: Number, default: 1 },
2433
+ },
2434
+ { timestamps: true },
2435
+ )
2436
+
2437
+ const EVENT_ONLY_CREATED = 'event-only-created'
2438
+ const EVENT_ONLY_UPDATED = 'event-only-updated'
2439
+ const EVENT_ONLY_DELETED = 'event-only-deleted'
2440
+
2441
+ EventOnlySchema.plugin(patchHistoryPlugin, {
2442
+ eventCreated: EVENT_ONLY_CREATED,
2443
+ eventUpdated: EVENT_ONLY_UPDATED,
2444
+ eventDeleted: EVENT_ONLY_DELETED,
2445
+ patchHistoryDisabled: true,
2446
+ omit: ['__v', 'createdAt', 'updatedAt'],
2447
+ })
2448
+
2449
+ const EventOnlyModel = model<Organization>('EventOnlyOrg', EventOnlySchema)
2450
+
2451
+ describe('plugin — patchHistoryDisabled (events only, no history)', () => {
2452
+ const instance = server('plugin-events-only')
2453
+
2454
+ beforeAll(async () => {
2455
+ await instance.create()
2456
+ })
2457
+
2458
+ afterAll(async () => {
2459
+ await instance.destroy()
2460
+ })
2461
+
2462
+ beforeEach(async () => {
2463
+ await mongoose.connection.collection('eventonlyorgs').deleteMany({})
2464
+ await mongoose.connection.collection('history').deleteMany({})
2465
+ })
2466
+
2467
+ afterEach(() => {
2468
+ vi.resetAllMocks()
2469
+ })
2470
+
2471
+ it('create should emit event but not write history', async () => {
2472
+ await EventOnlyModel.create({
2473
+ name: 'EventOnly Corp',
2474
+ slug: `eo-${Date.now()}`,
2475
+ apiKey: '550e8400-e29b-41d4-a716-446655440000',
2476
+ contact: { email: 'eo@test.com' },
2477
+ })
2478
+
2479
+ expect(em.emit).toHaveBeenCalledWith(EVENT_ONLY_CREATED, expect.objectContaining({ doc: expect.objectContaining({ name: 'EventOnly Corp' }) }))
2480
+
2481
+ const history = await HistoryModel.find({})
2482
+ expect(history).toHaveLength(0)
2483
+ })
2484
+
2485
+ it('update should emit event but not write history', async () => {
2486
+ const org = await EventOnlyModel.create({
2487
+ name: 'EventOnly Corp',
2488
+ slug: `eo-${Date.now()}`,
2489
+ apiKey: '550e8400-e29b-41d4-a716-446655440000',
2490
+ contact: { email: 'eo@test.com' },
2491
+ })
2492
+
2493
+ vi.mocked(em.emit).mockClear()
2494
+
2495
+ org.name = 'Updated EventOnly'
2496
+ await org.save()
2497
+
2498
+ expect(em.emit).toHaveBeenCalledWith(
2499
+ EVENT_ONLY_UPDATED,
2500
+ expect.objectContaining({
2501
+ oldDoc: expect.objectContaining({ name: 'EventOnly Corp' }),
2502
+ doc: expect.objectContaining({ name: 'Updated EventOnly' }),
2503
+ patch: expect.any(Array),
2504
+ }),
2505
+ )
2506
+
2507
+ const history = await HistoryModel.find({})
2508
+ expect(history).toHaveLength(0)
2509
+ })
2510
+
2511
+ it('delete should emit event but not write history', async () => {
2512
+ const org = await EventOnlyModel.create({
2513
+ name: 'EventOnly Corp',
2514
+ slug: `eo-${Date.now()}`,
2515
+ apiKey: '550e8400-e29b-41d4-a716-446655440000',
2516
+ contact: { email: 'eo@test.com' },
2517
+ })
2518
+
2519
+ vi.mocked(em.emit).mockClear()
2520
+
2521
+ await EventOnlyModel.deleteOne({ _id: org._id }).exec()
2522
+
2523
+ expect(em.emit).toHaveBeenCalledWith(EVENT_ONLY_DELETED, expect.objectContaining({ oldDoc: expect.objectContaining({ name: 'EventOnly Corp' }) }))
2524
+
2525
+ const history = await HistoryModel.find({})
2526
+ expect(history).toHaveLength(0)
2527
+ })
2528
+
2529
+ it('updateOne should emit event but not write history', async () => {
2530
+ const org = await EventOnlyModel.create({
2531
+ name: 'EventOnly Corp',
2532
+ slug: `eo-${Date.now()}`,
2533
+ apiKey: '550e8400-e29b-41d4-a716-446655440000',
2534
+ contact: { email: 'eo@test.com' },
2535
+ })
2536
+
2537
+ vi.mocked(em.emit).mockClear()
2538
+
2539
+ await EventOnlyModel.updateOne({ _id: org._id }, { name: 'Query Updated' }).exec()
2540
+
2541
+ expect(em.emit).toHaveBeenCalledWith(EVENT_ONLY_UPDATED, expect.anything())
2542
+
2543
+ const history = await HistoryModel.find({})
2544
+ expect(history).toHaveLength(0)
2545
+ })
2546
+ })
2547
+
2548
+ // --- Async getUser/getReason/getMetadata ---
2549
+
2550
+ const AsyncCallbackSchema = new Schema<Organization>(
2551
+ {
2552
+ name: { type: String, required: true },
2553
+ slug: { type: String, required: true },
2554
+ apiKey: { type: Schema.Types.UUID, required: true },
2555
+ active: { type: Boolean, default: true },
2556
+ contact: { type: ContactSchema, required: true },
2557
+ seatCount: { type: Number, default: 1 },
2558
+ },
2559
+ { timestamps: true },
2560
+ )
2561
+
2562
+ AsyncCallbackSchema.plugin(patchHistoryPlugin, {
2563
+ omit: ['__v', 'createdAt', 'updatedAt'],
2564
+ getUser: async () => {
2565
+ await new Promise((resolve) => setTimeout(resolve, 10))
2566
+ return { userId: 'async-user', source: 'http-context' }
2567
+ },
2568
+ getReason: async () => {
2569
+ await new Promise((resolve) => setTimeout(resolve, 10))
2570
+ return 'async-reason'
2571
+ },
2572
+ getMetadata: async () => {
2573
+ await new Promise((resolve) => setTimeout(resolve, 10))
2574
+ return { async: true, timestamp: Date.now() }
2575
+ },
2576
+ })
2577
+
2578
+ const AsyncCallbackModel = model<Organization>('AsyncCallbackOrg', AsyncCallbackSchema)
2579
+
2580
+ describe('plugin — async getUser/getReason/getMetadata', () => {
2581
+ const instance = server('plugin-async-callbacks')
2582
+
2583
+ beforeAll(async () => {
2584
+ await instance.create()
2585
+ })
2586
+
2587
+ afterAll(async () => {
2588
+ await instance.destroy()
2589
+ })
2590
+
2591
+ beforeEach(async () => {
2592
+ await mongoose.connection.collection('asynccallbackorgs').deleteMany({})
2593
+ await mongoose.connection.collection('history').deleteMany({})
2594
+ })
2595
+
2596
+ afterEach(() => {
2597
+ vi.resetAllMocks()
2598
+ })
2599
+
2600
+ it('create should resolve async getUser/getReason/getMetadata', async () => {
2601
+ const org = await AsyncCallbackModel.create({
2602
+ name: 'Async Corp',
2603
+ slug: `async-${Date.now()}`,
2604
+ apiKey: '550e8400-e29b-41d4-a716-446655440000',
2605
+ contact: { email: 'async@test.com' },
2606
+ })
2607
+
2608
+ const [entry] = await HistoryModel.find({ collectionId: org._id })
2609
+ expect(entry?.user).toEqual({ userId: 'async-user', source: 'http-context' })
2610
+ expect(entry?.reason).toBe('async-reason')
2611
+ expect(entry?.metadata).toHaveProperty('async', true)
2612
+ expect(entry?.metadata).toHaveProperty('timestamp')
2613
+ })
2614
+
2615
+ it('update should resolve async callbacks', async () => {
2616
+ const org = await AsyncCallbackModel.create({
2617
+ name: 'Async Corp',
2618
+ slug: `async-${Date.now()}`,
2619
+ apiKey: '550e8400-e29b-41d4-a716-446655440000',
2620
+ contact: { email: 'async@test.com' },
2621
+ })
2622
+
2623
+ org.name = 'Async Updated'
2624
+ await org.save()
2625
+
2626
+ const update = await HistoryModel.findOne({ op: 'update', collectionId: org._id })
2627
+ expect(update?.user).toEqual({ userId: 'async-user', source: 'http-context' })
2628
+ expect(update?.reason).toBe('async-reason')
2629
+ expect(update?.metadata).toHaveProperty('async', true)
2630
+ })
2631
+
2632
+ it('delete should resolve async callbacks', async () => {
2633
+ const org = await AsyncCallbackModel.create({
2634
+ name: 'Async Corp',
2635
+ slug: `async-${Date.now()}`,
2636
+ apiKey: '550e8400-e29b-41d4-a716-446655440000',
2637
+ contact: { email: 'async@test.com' },
2638
+ })
2639
+
2640
+ await AsyncCallbackModel.deleteOne({ _id: org._id }).exec()
2641
+
2642
+ const deletion = await HistoryModel.findOne({ op: 'deleteOne', collectionId: org._id })
2643
+ expect(deletion?.user).toEqual({ userId: 'async-user', source: 'http-context' })
2644
+ expect(deletion?.reason).toBe('async-reason')
2645
+ expect(deletion?.metadata).toHaveProperty('async', true)
2646
+ })
2647
+ })