@things-factory/notification 10.1.250 → 10.1.254

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/notification",
3
- "version": "10.1.250",
3
+ "version": "10.1.254",
4
4
  "main": "dist-server/index.js",
5
5
  "browser": "dist-client/index.js",
6
6
  "things-factory": true,
@@ -30,17 +30,17 @@
30
30
  "@material/web": "^2.0.0",
31
31
  "@operato/layout": "^10.0.0",
32
32
  "@operato/styles": "^10.0.0",
33
- "@things-factory/auth-base": "^10.1.250",
34
- "@things-factory/email-base": "^10.1.250",
35
- "@things-factory/i18n-base": "^10.1.250",
36
- "@things-factory/organization": "^10.1.250",
37
- "@things-factory/setting-base": "^10.1.250",
38
- "@things-factory/shell": "^10.1.247",
33
+ "@things-factory/auth-base": "^10.1.254",
34
+ "@things-factory/email-base": "^10.1.254",
35
+ "@things-factory/i18n-base": "^10.1.254",
36
+ "@things-factory/organization": "^10.1.254",
37
+ "@things-factory/setting-base": "^10.1.254",
38
+ "@things-factory/shell": "^10.1.254",
39
39
  "clipboard": "^2.0.6",
40
40
  "firebase": "^9.14.0",
41
41
  "firebase-admin": "^11.3.0",
42
42
  "google": "^2.1.0",
43
43
  "marked": "^15.0.0"
44
44
  },
45
- "gitHead": "4fdb6d24ef24f43d9e158d4c6d6396ad3097bb9e"
45
+ "gitHead": "793e2fd617db5bb917ec892e64956fb690f7e7b0"
46
46
  }
@@ -0,0 +1,190 @@
1
+ /*
2
+ * Web push reaches a person at every browser that registered an FCM token for them, sent one token at a time over
3
+ * FCM HTTP v1. A token FCM says is gone is forgotten.
4
+ *
5
+ * Until 2026-11-05 a person was reached through a legacy FCM device group, keyed by a server key no config set and
6
+ * that Google shut down in 2024 - register made no group and notify found none, so web push reached no one.
7
+ *
8
+ * FCM is a fake: what it was handed is what is checked. Reads dist-server (entities need tsc's decorator metadata) -
9
+ * run the package's build:server first.
10
+ */
11
+ import { DataSource } from 'typeorm'
12
+
13
+ import { NamingStrategy, addDataSource, entities as shellEntities } from '@things-factory/shell'
14
+ import { User, entities as authEntities } from '@things-factory/auth-base'
15
+ import { config } from '@things-factory/env'
16
+
17
+ const mockMessaging = {
18
+ multicasts: [] as any[],
19
+ answer: (tokens: string[]) => tokens.map(() => ({ success: true }) as any),
20
+ async sendEachForMulticast(message: any) {
21
+ this.multicasts.push(message)
22
+ const responses = this.answer(message.tokens)
23
+ return { responses, successCount: responses.filter(r => r.success).length, failureCount: responses.filter(r => !r.success).length }
24
+ },
25
+ async send() {
26
+ return 'sent'
27
+ }
28
+ }
29
+
30
+ jest.mock('firebase-admin/app', () => ({ initializeApp: () => ({}), cert: (account: any) => account }))
31
+ jest.mock('firebase-admin/messaging', () => ({ getMessaging: () => mockMessaging }))
32
+
33
+ let dataSource: DataSource
34
+ let fcm: any
35
+ let notifierOf: any
36
+ let deliverNotice: any
37
+ let NotificationToken: any
38
+ let kim: User
39
+ let lee: User
40
+
41
+ const tokensOf = async (user: User) =>
42
+ (await dataSource.getRepository(NotificationToken).find({ where: { user: { id: user.id } } })).map((one: any) => one.token).sort()
43
+
44
+ beforeAll(async () => {
45
+ config.set('notification', { fcm: { serviceAccount: { project_id: 'test' } } })
46
+ fcm = require('../dist-server/controllers/fcm.js')
47
+ NotificationToken = require('../dist-server/service/notification-token/notification-token.js').NotificationToken
48
+ notifierOf = require('../dist-server/middlewares/notification-middleware.js').notifierOf
49
+ deliverNotice = require('../dist-server/controllers/notice/deliver-notice.js').deliverNotice
50
+
51
+ dataSource = new DataSource({
52
+ type: 'sqlite',
53
+ database: ':memory:',
54
+ synchronize: true,
55
+ dropSchema: true,
56
+ namingStrategy: new NamingStrategy(),
57
+ entities: [...shellEntities, ...authEntities, NotificationToken],
58
+ logging: false
59
+ })
60
+ await dataSource.initialize()
61
+ addDataSource('default', dataSource)
62
+
63
+ const users = dataSource.getRepository(User)
64
+ kim = await users.save({ name: 'kim', username: 'kim', email: 'kim@test.com' } as any)
65
+ lee = await users.save({ name: 'lee', username: 'lee', email: 'lee@test.com' } as any)
66
+ })
67
+
68
+ afterAll(async () => {
69
+ await dataSource?.destroy()
70
+ })
71
+
72
+ beforeEach(async () => {
73
+ await dataSource.getRepository(NotificationToken).clear()
74
+ mockMessaging.multicasts = []
75
+ mockMessaging.answer = tokens => tokens.map(() => ({ success: true }))
76
+ })
77
+
78
+ describe('registering a browser', () => {
79
+ test('keeps each browser of a person, once', async () => {
80
+ expect(await fcm.register(kim, { subscription: 'kim-phone' })).toBe(true)
81
+ await fcm.register(kim, { subscription: 'kim-laptop' })
82
+ await fcm.register(kim, { subscription: 'kim-phone' })
83
+
84
+ expect(await tokensOf(kim)).toEqual(['kim-laptop', 'kim-phone'])
85
+ })
86
+
87
+ test('a browser someone else signs in on and turns notices on for moves to them', async () => {
88
+ await fcm.register(kim, { subscription: 'shared-tablet' })
89
+ await fcm.register(lee, { subscription: 'shared-tablet' })
90
+
91
+ expect(await tokensOf(kim)).toEqual([])
92
+ expect(await tokensOf(lee)).toEqual(['shared-tablet'])
93
+ })
94
+
95
+ test('turning notices off forgets the browser - only for the person holding it', async () => {
96
+ await fcm.register(kim, { subscription: 'kim-phone' })
97
+
98
+ await fcm.unregister(lee, { subscription: 'kim-phone' })
99
+ expect(await tokensOf(kim)).toEqual(['kim-phone'])
100
+
101
+ await fcm.unregister(kim, { subscription: 'kim-phone' })
102
+ expect(await tokensOf(kim)).toEqual([])
103
+ })
104
+ })
105
+
106
+ describe('pushing to people', () => {
107
+ test('goes to every browser of each receiver over HTTP v1, with data as strings', async () => {
108
+ await fcm.register(kim, { subscription: 'kim-phone' })
109
+ await fcm.register(kim, { subscription: 'kim-laptop' })
110
+ await fcm.register(lee, { subscription: 'lee-phone' })
111
+
112
+ await fcm.notify({
113
+ receivers: [kim.id],
114
+ title: '결재 차례',
115
+ body: '지출결의 1건',
116
+ data: { type: 'approval', url: 'https://office.example.com/domain/hatiolab/activity-approval/a-1', timestamp: 1, missing: undefined }
117
+ })
118
+
119
+ expect(mockMessaging.multicasts).toHaveLength(1)
120
+ const [sent] = mockMessaging.multicasts
121
+ expect([...sent.tokens].sort()).toEqual(['kim-laptop', 'kim-phone'])
122
+ expect(sent.notification).toEqual({ title: '결재 차례', body: '지출결의 1건' })
123
+ expect(sent.data).toEqual({ type: 'approval', url: 'https://office.example.com/domain/hatiolab/activity-approval/a-1', timestamp: '1' })
124
+ })
125
+
126
+ test('a person with no browser registered is not pushed to', async () => {
127
+ await fcm.notify({ receivers: [lee.id], title: 't', body: 'b', data: {} })
128
+
129
+ expect(mockMessaging.multicasts).toEqual([])
130
+ })
131
+
132
+ test('a token FCM says is gone is forgotten; one that failed for another reason is kept', async () => {
133
+ await fcm.register(kim, { subscription: 'kim-old-phone' })
134
+ await fcm.register(kim, { subscription: 'kim-phone' })
135
+ mockMessaging.answer = tokens =>
136
+ tokens.map(token =>
137
+ token === 'kim-old-phone'
138
+ ? { success: false, error: { code: 'messaging/registration-token-not-registered', message: 'gone' } }
139
+ : { success: false, error: { code: 'messaging/internal-error', message: 'try later' } }
140
+ )
141
+
142
+ await fcm.notify({ receivers: [kim.id], title: 't', body: 'b', data: {} })
143
+
144
+ expect(await tokensOf(kim)).toEqual(['kim-phone'])
145
+ })
146
+ })
147
+
148
+ /*
149
+ * The service worker that opens a web push knows no domain: a page relative to the domain resolved against it loses
150
+ * /domain/<x>/ and a tap lands nowhere. A notice sent with no request (a scheduled reminder) has no full link either.
151
+ */
152
+ describe('the page a web push opens', () => {
153
+ const domain = { subdomain: 'hatiolab', extType: 'domain' }
154
+
155
+ test('a notice sent with no request opens its page within the domain, from the origin', async () => {
156
+ await fcm.register(kim, { subscription: 'kim-phone' })
157
+
158
+ await deliverNotice(
159
+ {
160
+ type: 'approval-overdue',
161
+ receivers: [{ id: kim.id }],
162
+ words: () => ({ subject: '늦은 결재', body: '1건' }),
163
+ address: 'activity-approval/a-1',
164
+ label: 'overdue reminder'
165
+ },
166
+ { attributes: async () => ({}), push: notifierOf(domain), mail: async () => undefined, teams: async () => ({ ok: true }), report: () => {} }
167
+ )
168
+
169
+ expect(mockMessaging.multicasts.map(sent => sent.data.url)).toEqual(['/domain/hatiolab/activity-approval/a-1'])
170
+ })
171
+
172
+ test('the open screen is told the page within its domain, as it navigates', async () => {
173
+ const { pubsub } = require('@things-factory/shell')
174
+ const published = jest.spyOn(pubsub, 'publish').mockImplementation(async () => undefined)
175
+
176
+ await notifierOf(domain)({ receivers: [kim.id], title: 't', url: 'activity-approval/a-1', mode: 'foreground' })
177
+
178
+ expect((published.mock.calls[0][1] as any).notification.url).toBe('activity-approval/a-1')
179
+ published.mockRestore()
180
+ })
181
+
182
+ test('an address with a scheme or from the origin is handed on as it is', async () => {
183
+ await fcm.register(kim, { subscription: 'kim-phone' })
184
+
185
+ await notifierOf(domain)({ receivers: [kim.id], title: 't', url: 'https://example.com/x' })
186
+ await notifierOf(domain)({ receivers: [kim.id], title: 't', url: '/domain/other/y' })
187
+
188
+ expect(mockMessaging.multicasts.map(sent => sent.data.url)).toEqual(['https://example.com/x', '/domain/other/y'])
189
+ })
190
+ })