|
| 1 | +import database from '../database'; |
| 2 | +import { DEFAULT_EMOJIS } from '../constants/emojis'; |
| 3 | +import migrations from '../database/model/migrations'; |
| 4 | +import appSchema from '../database/schema/app'; |
| 5 | +import { addFrequentlyUsed, getFrequentlyUsedEmojis } from './emojis'; |
| 6 | + |
| 7 | +jest.mock('../database', () => ({ |
| 8 | + __esModule: true, |
| 9 | + default: { |
| 10 | + active: { |
| 11 | + get: jest.fn(), |
| 12 | + write: jest.fn() |
| 13 | + } |
| 14 | + } |
| 15 | +})); |
| 16 | +jest.mock('./helpers/log', () => ({ __esModule: true, default: jest.fn() })); |
| 17 | + |
| 18 | +const mockGet = database.active.get as jest.Mock; |
| 19 | +const mockWrite = database.active.write as jest.Mock; |
| 20 | + |
| 21 | +let mockFetch: jest.Mock; |
| 22 | +let mockCreate: jest.Mock; |
| 23 | +let mockQuery: jest.Mock; |
| 24 | + |
| 25 | +beforeEach(() => { |
| 26 | + jest.clearAllMocks(); |
| 27 | + mockFetch = jest.fn().mockResolvedValue([]); |
| 28 | + mockCreate = jest.fn(); |
| 29 | + mockQuery = jest.fn(() => ({ fetch: mockFetch })); |
| 30 | + mockGet.mockReturnValue({ query: mockQuery, create: mockCreate }); |
| 31 | + mockWrite.mockImplementation((cb: () => Promise<void>) => cb()); |
| 32 | +}); |
| 33 | + |
| 34 | +describe('addFrequentlyUsed', () => { |
| 35 | + it('creates a new row by querying content + is_custom and never sets the emoji as the id', async () => { |
| 36 | + mockFetch.mockResolvedValue([]); |
| 37 | + |
| 38 | + await addFrequentlyUsed({ name: '大丈夫', extension: 'png' }); |
| 39 | + |
| 40 | + expect(mockQuery).toHaveBeenCalledTimes(1); |
| 41 | + expect(mockQuery.mock.calls[0]).toHaveLength(2); |
| 42 | + expect(mockCreate).toHaveBeenCalledTimes(1); |
| 43 | + |
| 44 | + const record: any = {}; |
| 45 | + mockCreate.mock.calls[0][0](record); |
| 46 | + expect(record.content).toBe('大丈夫'); |
| 47 | + expect(record.isCustom).toBe(true); |
| 48 | + expect(record.extension).toBe('png'); |
| 49 | + expect(record.count).toBe(1); |
| 50 | + expect(record.id).toBeUndefined(); |
| 51 | + expect(record._raw).toBeUndefined(); |
| 52 | + }); |
| 53 | + |
| 54 | + it('does not set a custom extension or id for a standard emoji', async () => { |
| 55 | + mockFetch.mockResolvedValue([]); |
| 56 | + |
| 57 | + await addFrequentlyUsed('grinning'); |
| 58 | + |
| 59 | + const record: any = {}; |
| 60 | + mockCreate.mock.calls[0][0](record); |
| 61 | + expect(record.content).toBe('grinning'); |
| 62 | + expect(record.isCustom).toBe(false); |
| 63 | + expect(record.extension).toBeUndefined(); |
| 64 | + expect(record.id).toBeUndefined(); |
| 65 | + }); |
| 66 | + |
| 67 | + it('increments count for an existing emoji instead of creating a duplicate', async () => { |
| 68 | + const existing: any = { count: 3 }; |
| 69 | + existing.update = jest.fn((fn: (f: any) => void) => fn(existing)); |
| 70 | + mockFetch.mockResolvedValue([existing]); |
| 71 | + |
| 72 | + await addFrequentlyUsed('grinning'); |
| 73 | + |
| 74 | + expect(mockCreate).not.toHaveBeenCalled(); |
| 75 | + expect(existing.update).toHaveBeenCalledTimes(1); |
| 76 | + expect(existing.count).toBe(4); |
| 77 | + }); |
| 78 | + |
| 79 | + it('runs the existing-row lookup inside the serialized write so concurrent calls cannot both create', async () => { |
| 80 | + mockWrite.mockImplementation(() => Promise.resolve()); |
| 81 | + |
| 82 | + await addFrequentlyUsed('grinning'); |
| 83 | + |
| 84 | + expect(mockWrite).toHaveBeenCalledTimes(1); |
| 85 | + expect(mockQuery).not.toHaveBeenCalled(); |
| 86 | + expect(mockFetch).not.toHaveBeenCalled(); |
| 87 | + }); |
| 88 | +}); |
| 89 | + |
| 90 | +describe('getFrequentlyUsedEmojis', () => { |
| 91 | + it('maps standard and custom rows to the shapes the UI expects', async () => { |
| 92 | + mockFetch.mockResolvedValue([ |
| 93 | + { isCustom: false, content: 'grinning' }, |
| 94 | + { isCustom: true, content: 'rocketchat', extension: 'png' } |
| 95 | + ]); |
| 96 | + |
| 97 | + const result = await getFrequentlyUsedEmojis(); |
| 98 | + |
| 99 | + expect(result).toEqual(['grinning', { name: 'rocketchat', extension: 'png' }]); |
| 100 | + }); |
| 101 | + |
| 102 | + it('returns an empty list instead of throwing when the native bridge desyncs', async () => { |
| 103 | + mockFetch.mockRejectedValue( |
| 104 | + new Error("Record ID frequently_used_emojis#大丈夫 was sent over the bridge, but it's not cached") |
| 105 | + ); |
| 106 | + |
| 107 | + await expect(getFrequentlyUsedEmojis()).resolves.toEqual([]); |
| 108 | + }); |
| 109 | + |
| 110 | + it('falls back to the default emojis on a bridge error when defaults are requested', async () => { |
| 111 | + mockFetch.mockRejectedValue( |
| 112 | + new Error("Record ID frequently_used_emojis#大丈夫 was sent over the bridge, but it's not cached") |
| 113 | + ); |
| 114 | + |
| 115 | + await expect(getFrequentlyUsedEmojis(true)).resolves.toEqual(DEFAULT_EMOJIS); |
| 116 | + }); |
| 117 | +}); |
| 118 | + |
| 119 | +describe('frequently_used_emojis migration', () => { |
| 120 | + it('bumps the schema to v29 with a matching migration', () => { |
| 121 | + expect(appSchema.version).toBe(29); |
| 122 | + expect((migrations as any).maxVersion).toBe(29); |
| 123 | + }); |
| 124 | + |
| 125 | + it('v29 deletes only legacy rows whose id contains a non-printable-ASCII character', () => { |
| 126 | + const v29 = (migrations as any).sortedMigrations.find((m: any) => m.toVersion === 29); |
| 127 | + expect(v29).toBeDefined(); |
| 128 | + const sqls = (v29.steps as any[]).filter(s => s.type === 'sql').map(s => s.sql); |
| 129 | + expect(sqls.some(sql => /DELETE FROM frequently_used_emojis/i.test(sql) && /\[\^ -~\]/.test(sql))).toBe(true); |
| 130 | + }); |
| 131 | +}); |
0 commit comments