From 2dd6070161dd43b7b6cabf7e6bbdfa4b95194007 Mon Sep 17 00:00:00 2001 From: SebastianKrupinski Date: Sat, 13 Jun 2026 14:15:17 -0400 Subject: [PATCH] fix: import Signed-off-by: SebastianKrupinski --- .../AppNavigation/ContactsSettings.vue | 13 +- .../AppNavigation/Settings/ImportScreen.vue | 168 ++++++ .../Settings/ImportScreenRow.vue | 192 +++++++ .../Settings/SettingsImportContacts.vue | 506 +++++++++--------- src/services/importService.ts | 118 ++++ src/services/parseVcf.js | 47 -- src/store/addressbooks.js | 51 -- src/store/import.ts | 289 ++++++++++ src/store/importState.js | 135 ----- src/store/index.js | 2 - src/types/import.ts | 115 ++++ src/views/Contacts.vue | 42 -- src/views/Processing/ImportView.vue | 107 ---- .../component/SettingsImportContacts.test.js | 80 +++ tests/javascript/store/import.test.js | 121 +++++ tests/javascript/store/importService.test.js | 88 +++ tests/setup.js | 5 + 17 files changed, 1416 insertions(+), 663 deletions(-) create mode 100644 src/components/AppNavigation/Settings/ImportScreen.vue create mode 100644 src/components/AppNavigation/Settings/ImportScreenRow.vue create mode 100644 src/services/importService.ts delete mode 100644 src/services/parseVcf.js create mode 100644 src/store/import.ts delete mode 100644 src/store/importState.js create mode 100644 src/types/import.ts delete mode 100644 src/views/Processing/ImportView.vue create mode 100644 tests/javascript/component/SettingsImportContacts.test.js create mode 100644 tests/javascript/store/import.test.js create mode 100644 tests/javascript/store/importService.test.js diff --git a/src/components/AppNavigation/ContactsSettings.vue b/src/components/AppNavigation/ContactsSettings.vue index 5e2099613f..90885eb9cf 100644 --- a/src/components/AppNavigation/ContactsSettings.vue +++ b/src/components/AppNavigation/ContactsSettings.vue @@ -22,10 +22,7 @@ @update:model-value="toggleSocialSync" /> - + @@ -105,10 +102,6 @@ export default { }, methods: { - onClickImport(event) { - this.$emit('clicked', event) - }, - async toggleSocialSync(value) { this.enableSocialSyncLoading = true @@ -126,10 +119,6 @@ export default { } }, - onLoad() { - this.$emit('file-loaded', false) - }, - async onOpen() { this.showSettings = true }, diff --git a/src/components/AppNavigation/Settings/ImportScreen.vue b/src/components/AppNavigation/Settings/ImportScreen.vue new file mode 100644 index 0000000000..8a71686bca --- /dev/null +++ b/src/components/AppNavigation/Settings/ImportScreen.vue @@ -0,0 +1,168 @@ + + + + + + + diff --git a/src/components/AppNavigation/Settings/ImportScreenRow.vue b/src/components/AppNavigation/Settings/ImportScreenRow.vue new file mode 100644 index 0000000000..bdd8fdd812 --- /dev/null +++ b/src/components/AppNavigation/Settings/ImportScreenRow.vue @@ -0,0 +1,192 @@ + + + + + + + diff --git a/src/components/AppNavigation/Settings/SettingsImportContacts.vue b/src/components/AppNavigation/Settings/SettingsImportContacts.vue index fd6f440d46..cc47a798cb 100644 --- a/src/components/AppNavigation/Settings/SettingsImportContacts.vue +++ b/src/components/AppNavigation/Settings/SettingsImportContacts.vue @@ -5,205 +5,132 @@ diff --git a/src/views/Processing/ImportView.vue b/src/views/Processing/ImportView.vue deleted file mode 100644 index b74f1f1052..0000000000 --- a/src/views/Processing/ImportView.vue +++ /dev/null @@ -1,107 +0,0 @@ - - - - - - - diff --git a/tests/javascript/component/SettingsImportContacts.test.js b/tests/javascript/component/SettingsImportContacts.test.js new file mode 100644 index 0000000000..51db65f5aa --- /dev/null +++ b/tests/javascript/component/SettingsImportContacts.test.js @@ -0,0 +1,80 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +jest.mock('@nextcloud/vue', () => ({ NcButton: {} }), { virtual: true }) +jest.mock('@nextcloud/paths', () => ({ encodePath: (path) => path }), { virtual: true }) +jest.mock('@nextcloud/router', () => ({ + generateRemoteUrl: (path) => `/remote/${path}`, + generateOcsUrl: (path) => `/ocs${path}`, +}), { virtual: true }) +jest.mock('@nextcloud/dialogs', () => ({ showError: jest.fn(), showSuccess: jest.fn(), showWarning: jest.fn() }), { virtual: true }) +jest.mock('@nextcloud/auth', () => ({ getCurrentUser: () => ({ uid: 'admin' }) }), { virtual: true }) +jest.mock('../../../src/components/AppNavigation/Settings/ImportScreen.vue', () => ({})) +jest.mock('vue-material-design-icons/AlertCircleOutline.vue', () => ({})) +jest.mock('vue-material-design-icons/UploadOutline.vue', () => ({})) + +// eslint-disable-next-line import/first +import SettingsImportContacts from '../../../src/components/AppNavigation/Settings/SettingsImportContacts.vue' + +const { resolveTarget } = SettingsImportContacts.methods + +/** + * Build a queued import entry. + * + * @param {string|null} addressbookId selected destination id + * @return {object} + */ +function entry(addressbookId) { + return { + file: { id: 1, name: 'contacts.vcf', parser: { getName: () => null } }, + addressbookId, + options: { format: 'ical', supersede: false }, + } +} + +describe('SettingsImportContacts.resolveTarget', () => { + test('returns an existing address book by id', async () => { + const ctx = { + $store: { getters: { getAddressbooks: [{ id: 'ab-1', displayName: 'Work' }] }, dispatch: jest.fn() }, + importStore: { setAddressbookForFile: jest.fn() }, + } + + const target = await resolveTarget.call(ctx, entry('ab-1')) + + expect(target).toEqual({ id: 'ab-1', displayName: 'Work' }) + expect(ctx.$store.dispatch).not.toHaveBeenCalled() + }) + + test('throws when the selected address book no longer exists', async () => { + const ctx = { + $store: { getters: { getAddressbooks: [] }, dispatch: jest.fn() }, + importStore: { setAddressbookForFile: jest.fn() }, + } + + await expect(resolveTarget.call(ctx, entry('missing'))).rejects.toThrow() + }) + + test('creates a new address book when "new" is selected', async () => { + const addressbooks = [{ id: 'ab-1', displayName: 'Work' }] + const ctx = { + $store: { + getters: { getAddressbooks: addressbooks }, + dispatch: jest.fn().mockImplementation((action) => { + if (action === 'appendAddressbook') { + addressbooks.push({ id: 'ab-new', displayName: 'Imported' }) + } + return Promise.resolve() + }), + }, + importStore: { setAddressbookForFile: jest.fn() }, + } + + const target = await resolveTarget.call(ctx, entry('new')) + + expect(ctx.$store.dispatch).toHaveBeenCalledWith('appendAddressbook', { displayName: expect.any(String) }) + expect(target.id).toBe('ab-new') + expect(ctx.importStore.setAddressbookForFile).toHaveBeenCalledWith({ fileId: 1, addressbookId: 'ab-new' }) + }) +}) diff --git a/tests/javascript/store/import.test.js b/tests/javascript/store/import.test.js new file mode 100644 index 0000000000..e37cdfb871 --- /dev/null +++ b/tests/javascript/store/import.test.js @@ -0,0 +1,121 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { createPinia, setActivePinia } from 'pinia' + +jest.mock('../../../src/services/importService', () => ({ + __esModule: true, + importService: { import: jest.fn() }, +})) + +// eslint-disable-next-line import/first +import useImportStore from '../../../src/store/import.ts' +// eslint-disable-next-line import/first +import { importService } from '../../../src/services/importService' + +const mockImport = importService.import + +/** + * Build a minimal file payload for addFile(). + * + * @param {object} overrides values to override on the payload + * @return {object} + */ +function filepayload(overrides = {}) { + return { + contents: 'BEGIN:VCARD\nVERSION:3.0\nFN:Jane\nEND:VCARD', + lastModified: 1, + name: 'contacts.vcf', + size: 42, + type: 'text/vcard', + parser: { getName: () => null }, + ...overrides, + } +} + +describe('import store', () => { + let store + + beforeEach(() => { + setActivePinia(createPinia()) + store = useImportStore() + mockImport.mockReset() + }) + + test('addFile queues an entry with a format derived from the MIME type', () => { + store.addFile(filepayload()) + store.addFile(filepayload({ name: 'c.json', type: 'application/json' })) + store.addFile(filepayload({ name: 'c.xml', type: 'text/xml' })) + + expect(store.files).toHaveLength(3) + expect(store.files[0].options.format).toBe('ical') + expect(store.files[1].options.format).toBe('jcal') + expect(store.files[2].options.format).toBe('xcal') + expect(store.files[0].addressbookId).toBeNull() + expect(store.files[0].options.supersede).toBe(false) + }) + + test('setOptionsForFile and setAddressbookForFile mutate the queued entry', () => { + store.addFile(filepayload()) + const { id } = store.files[0].file + + store.setOptionsForFile({ fileId: id, options: { supersede: true } }) + store.setAddressbookForFile({ fileId: id, addressbookId: 'ab-1' }) + + expect(store.files[0].options.supersede).toBe(true) + expect(store.files[0].options.format).toBe('ical') + expect(store.files[0].addressbookId).toBe('ab-1') + }) + + test('reset clears queued sessions and state', () => { + store.addFile(filepayload()) + store.stage = 'selecting' + store.removeAllFiles() + store.reset() + + expect(store.files).toHaveLength(0) + expect(store.stage).toBe('idle') + expect(store.running).toBe(false) + }) + + test('startImport resolves targets via the injected resolver and aggregates counters', async () => { + const resolveTarget = jest.fn().mockResolvedValue({ id: 'ab-1', displayName: 'Work' }) + mockImport.mockImplementation(async (request, onData) => { + expect(request.target).toBe('ab-1') + onData({ type: 'count', vcard: 3 }) + onData({ type: 'object', disposition: 'created', identifier: 'a', errors: [] }) + onData({ type: 'object', disposition: 'updated', identifier: 'b', errors: [] }) + onData({ type: 'object', disposition: 'exists', identifier: 'c', errors: [] }) + }) + + store.addFile(filepayload()) + store.setAddressbookForFile({ fileId: store.files[0].file.id, addressbookId: 'ab-1' }) + + const totals = await store.startImport(resolveTarget) + + expect(resolveTarget).toHaveBeenCalledWith(store.files[0]) + expect(totals).toMatchObject({ + discovered: 3, + processed: 3, + created: 1, + updated: 1, + exists: 1, + error: 0, + }) + expect(store.stage).toBe('completed') + expect(store.sessions[store.files[0].file.id].targetId).toBe('ab-1') + expect(store.sessions[store.files[0].file.id].targetDisplayName).toBe('Work') + }) + + test('startImport marks the session and stage as error when the resolver throws', async () => { + const resolveTarget = jest.fn().mockRejectedValue(new Error('boom')) + + store.addFile(filepayload()) + + await expect(store.startImport(resolveTarget)).rejects.toThrow('boom') + expect(store.stage).toBe('error') + expect(mockImport).not.toHaveBeenCalled() + }) +}) diff --git a/tests/javascript/store/importService.test.js b/tests/javascript/store/importService.test.js new file mode 100644 index 0000000000..4fca46aeba --- /dev/null +++ b/tests/javascript/store/importService.test.js @@ -0,0 +1,88 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +jest.mock('@nextcloud/auth', () => ({ + getRequestToken: () => 'token', +})) + +jest.mock('@nextcloud/router', () => ({ + generateOcsUrl: (path) => `/ocs/v2.php/apps/dav/api/v1${path}`, +})) + +// eslint-disable-next-line import/first +import { importService } from '../../../src/services/importService.ts' + +/** + * Build a Response-like object whose body streams the given NDJSON lines. + * + * @param {string[]} lines NDJSON lines (without trailing newline) + * @param {object} init response init overrides + * @return {object} + */ +function streamResponse(lines, init = {}) { + const encoder = new TextEncoder() + const chunks = lines.map((line) => encoder.encode(line + '\n')) + let index = 0 + + return { + ok: true, + status: 200, + ...init, + body: { + getReader() { + return { + read() { + if (index < chunks.length) { + return Promise.resolve({ done: false, value: chunks[index++] }) + } + return Promise.resolve({ done: true, value: undefined }) + }, + releaseLock() {}, + } + }, + }, + } +} + +describe('importService', () => { + afterEach(() => { + delete global.fetch + }) + + test('forwards count and object events but ignores control messages', async () => { + global.fetch = jest.fn().mockResolvedValue(streamResponse([ + JSON.stringify({ type: 'control', transaction: 't', disposition: 'start' }), + JSON.stringify({ type: 'count', transaction: 't', vcard: 2 }), + JSON.stringify({ type: 'object', transaction: 't', identifier: 'a', disposition: 'created', errors: [] }), + JSON.stringify({ type: 'control', transaction: 't', disposition: 'end' }), + ])) + + const received = [] + await importService.import({ + target: 'ab-1', + options: { format: 'ical', validation: 1, errors: 0, supersede: false }, + data: 'BEGIN:VCARD\nEND:VCARD', + }, (event) => received.push(event)) + + expect(received).toHaveLength(2) + expect(received[0].type).toBe('count') + expect(received[1].type).toBe('object') + + const [, requestInit] = global.fetch.mock.calls[0] + const body = JSON.parse(requestInit.body) + expect(body.transaction).toEqual(expect.any(String)) + expect(body.target).toBe('ab-1') + }) + + test('throws when the response is not ok', async () => { + global.fetch = jest.fn().mockResolvedValue(streamResponse([], { ok: false, status: 400 })) + + await expect(importService.import({ + target: 'ab-1', + options: { format: 'ical', validation: 1, errors: 0, supersede: false }, + data: '', + }, () => {})).rejects.toThrow('status 400') + }) +}) diff --git a/tests/setup.js b/tests/setup.js index 905b407fdd..a93fedf98d 100644 --- a/tests/setup.js +++ b/tests/setup.js @@ -29,6 +29,11 @@ jest.mock('@nextcloud/axios', () => ({ }, }), { virtual: true }) +// jsdom does not provide these globals, but Node does +const { TextDecoder, TextEncoder } = require('util') +global.TextEncoder = global.TextEncoder ?? TextEncoder +global.TextDecoder = global.TextDecoder ?? TextDecoder + global.appName = 'contacts' global.OC = {