Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/__mocks__/capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@
attachments: {
allowed: true,
folder: '/Talk',
'conversation-subfolders': true,

Check failure on line 137 in src/__mocks__/capabilities.ts

View workflow job for this annotation

GitHub Actions / test

Object literal may only specify known properties, and ''conversation-subfolders'' does not exist in type '{ allowed: boolean; folder?: string | undefined; }'.
},
call: {
enabled: true,
Expand Down Expand Up @@ -211,6 +212,7 @@
attachments: [
'allowed',
'folder',
'conversation-subfolders',
],
call: [
'predefined-backgrounds',
Expand Down
55 changes: 54 additions & 1 deletion src/services/__tests__/filesSharingServices.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import axios from '@nextcloud/axios'
import { generateOcsUrl } from '@nextcloud/router'
import { afterEach, describe, expect, test, vi } from 'vitest'
import { shareFile } from '../filesSharingServices.ts'
import { postAttachment, shareFile } from '../filesSharingServices.ts'

vi.mock('@nextcloud/axios', () => ({
default: {
Expand Down Expand Up @@ -37,4 +37,57 @@ describe('filesSharingServices', () => {
},
)
})

test('postAttachment calls the Talk chat attachment API endpoint', async () => {
axios.post.mockResolvedValue({ data: { ocs: { data: { renames: [{ 'test.txt': 'test.txt' }] } } } })

const renames = await postAttachment({
token: 'XXTOKENXX',
filePath: 'Talk/My Room-XXTOKENXX/Current User-current-user/upload-id1-0-test.txt',
fileName: 'test.txt',
referenceId: 'the-reference-id',
talkMetaData: '{"caption":"hello"}',
})

expect(axios.post).toHaveBeenCalledWith(
generateOcsUrl('apps/spreed/api/v1/chat/{token}/attachment', { token: 'XXTOKENXX' }),
{
filePath: 'Talk/My Room-XXTOKENXX/Current User-current-user/upload-id1-0-test.txt',
fileName: 'test.txt',
referenceId: 'the-reference-id',
talkMetaData: '{"caption":"hello"}',
},
)
expect(renames).toEqual([{ 'test.txt': 'test.txt' }])
})

test('postAttachment returns conflict-resolved renames when backend renames the file', async () => {
axios.post.mockResolvedValue({
data: { ocs: { data: { renames: [{ 'photo.jpg': 'photo (1).jpg' }] } } },
})

const renames = await postAttachment({
token: 'XXTOKENXX',
filePath: 'Talk/Room-XXTOKENXX/Alice-alice/upload-id1-0-photo.jpg',
fileName: 'photo.jpg',
referenceId: 'ref-1',
talkMetaData: '{}',
})

expect(renames).toEqual([{ 'photo.jpg': 'photo (1).jpg' }])
})

test('postAttachment returns empty array when response has no renames field', async () => {
axios.post.mockResolvedValue({})

const renames = await postAttachment({
token: 'XXTOKENXX',
filePath: 'Talk/Room-XXTOKENXX/Alice-alice/upload-id1-0-doc.pdf',
fileName: 'doc.pdf',
referenceId: 'ref-2',
talkMetaData: '{}',
})

expect(renames).toEqual([])
})
})
39 changes: 39 additions & 0 deletions src/services/filesSharingServices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,14 @@ import axios from '@nextcloud/axios'
import { generateOcsUrl } from '@nextcloud/router'
import { SHARE } from '../constants.ts'

type PostAttachmentParams = {
token: string
filePath: string
fileName: string
referenceId: string
talkMetaData: string
}

/**
* Appends a file as a message to the messages list
*
Expand Down Expand Up @@ -56,8 +64,39 @@ async function createNewFile({ filePath, templatePath, templateType }: createFil
} as createFileFromTemplateParams)
}

/**
* Post a file from a conversation attachment subfolder as a chat message.
*
* Unlike shareFile(), this does not create a per-file TYPE_ROOM share.
* Access is controlled by the folder-level share that was automatically
* created when the conversation subfolder was first created via WebDAV MKCOL.
*
* @param payload The function payload
* @param payload.token The conversation token
* @param payload.filePath File path relative to the user's home root
* @param payload.fileName Desired final file name (used for server-side rename-on-conflict)
* @param payload.referenceId Client reference ID for the chat message
* @param payload.talkMetaData JSON-encoded metadata (caption, messageType, silent, …)
* @return An array of `{ originalName: finalName }` entries — one per posted
* file. When the backend had to rename due to a conflict the two
* names differ; otherwise they are identical.
*/
async function postAttachment({ token, filePath, fileName, referenceId, talkMetaData }: PostAttachmentParams): Promise<Record<string, string>[]> {
const response = await axios.post<{ ocs: { data: { renames: Record<string, string>[] } } }>(
generateOcsUrl('apps/spreed/api/v1/chat/{token}/attachment', { token }),
{
filePath,
fileName,
referenceId,
talkMetaData,
},
)
return response.data?.ocs?.data?.renames ?? []
}

export {
createNewFile,
getFileTemplates,
postAttachment,
shareFile,
}
144 changes: 142 additions & 2 deletions src/stores/__tests__/upload.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,21 @@ import { showError } from '@nextcloud/dialogs'
import { getUploader } from '@nextcloud/upload'
import { createPinia, setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
import { getTalkConfig } from '../../services/CapabilitiesManager.ts'
import { getDavClient } from '../../services/DavClient.ts'
import { shareFile } from '../../services/filesSharingServices.ts'
import { postAttachment, shareFile } from '../../services/filesSharingServices.ts'
import { findUniquePath } from '../../utils/fileUpload.ts'
import { useActorStore } from '../actor.ts'
import { useSettingsStore } from '../settings.ts'
import { useUploadStore } from '../upload.ts'

// conversationGetter must be defined before vi.mock so the factory can close over it.
// Vitest evaluates the factory lazily (after module-level init), so this works.
const conversationGetter = vi.fn().mockReturnValue(null)
const vuexStoreDispatch = vi.fn()
vi.mock('vuex', () => ({
useStore: vi.fn(() => ({
getters: {},
getters: { conversation: conversationGetter },
dispatch: vuexStoreDispatch,
})),
}))
Expand All @@ -33,8 +37,16 @@ vi.mock('../../utils/fileUpload.ts', async () => {
}
})
vi.mock('../../services/filesSharingServices.ts', () => ({
postAttachment: vi.fn(),
shareFile: vi.fn(),
}))
vi.mock('../../services/CapabilitiesManager.ts', async (importOriginal) => {
const actual = await importOriginal()
return {
...actual,
getTalkConfig: vi.fn().mockReturnValue(true),
}
})

describe('fileUploadStore', () => {
let actorStore
Expand Down Expand Up @@ -63,13 +75,16 @@ describe('fileUploadStore', () => {
describe('uploading', () => {
const uploadMock = vi.fn()
const client = {
createDirectory: vi.fn().mockResolvedValue(undefined),
exists: vi.fn(),
}

beforeEach(() => {
getDavClient.mockReturnValue(client)
getUploader.mockReturnValue({ upload: uploadMock })
console.error = vi.fn()
// Default: ONE_TO_ONE room — no conversation folder used
conversationGetter.mockReturnValue({ type: 1, displayName: 'Direct message' })
})

afterEach(() => {
Expand Down Expand Up @@ -369,6 +384,131 @@ describe('fileUploadStore', () => {
expect(uploadStore.currentUploadId).not.toBeDefined()
})

describe('conversation folder (group/public rooms)', () => {
const TOKEN = 'XXTOKENXX'

beforeEach(() => {
uploadMock.mockResolvedValue()
postAttachment.mockResolvedValue()
})

test('creates conversation subfolder and posts via postAttachment for a group room', async () => {
conversationGetter.mockReturnValue({ type: 2, displayName: 'My Room' })

const file = {
name: 'pngimage.png',
type: 'image/png',
size: 123,
lastModified: Date.UTC(2021, 3, 27, 15, 30, 0),
}

uploadStore.initialiseUpload({ uploadId: 'upload-id1', token: TOKEN, files: [file] })

// uid = 'current-user' (len 12), prefixLen = min(16, 63-12) = 16
// subPrefix = 'Current User' (12 chars < 16), subfolderName = 'Current User-current-user'
const convFolderName = 'My Room-' + TOKEN
const subfolderName = 'Current User-current-user'
const subfolderPath = 'Talk/' + convFolderName + '/' + subfolderName

await uploadStore.uploadFiles({ token: TOKEN, uploadId: 'upload-id1', options: { silent: false } })

// Conversation subfolders must be created via DAV MKCOL
expect(client.createDirectory).toHaveBeenCalledWith('/files/current-user/Talk')
expect(client.createDirectory).toHaveBeenCalledWith('/files/current-user/Talk/' + convFolderName)
expect(client.createDirectory).toHaveBeenCalledWith('/files/current-user/Talk/' + convFolderName + '/' + subfolderName)

// No PROPFIND round-trip — backend handles conflict resolution
expect(findUniquePath).not.toHaveBeenCalled()

// File is uploaded to a temp name inside the conversation subfolder
expect(uploadMock).toHaveBeenCalledTimes(1)
const uploadedPath = uploadMock.mock.calls[0][0]
expect(uploadedPath).toMatch(new RegExp('^/' + subfolderPath + '/upload-id1-.*-' + file.name + '$'))

// File is posted via Talk attachment endpoint with original name for rename-on-conflict
expect(postAttachment).toHaveBeenCalledTimes(1)
expect(postAttachment).toHaveBeenCalledWith(expect.objectContaining({
token: TOKEN,
fileName: file.name,
filePath: expect.stringMatching(new RegExp('^' + subfolderPath + '/upload-id1-.*-' + file.name + '$')),
}))
expect(shareFile).not.toHaveBeenCalled()
})

test('passes original file name to postAttachment for each file in a multi-file upload', async () => {
conversationGetter.mockReturnValue({ type: 2, displayName: 'Room' })

const file1 = { name: 'photo.jpg', type: 'image/jpeg', size: 100, lastModified: 0 }
const file2 = { name: 'doc.pdf', type: 'application/pdf', size: 200, lastModified: 0 }

uploadStore.initialiseUpload({ uploadId: 'upload-id1', token: TOKEN, files: [file1, file2] })

await uploadStore.uploadFiles({ token: TOKEN, uploadId: 'upload-id1', options: null })

expect(findUniquePath).not.toHaveBeenCalled()
expect(postAttachment).toHaveBeenCalledTimes(2)
expect(postAttachment).toHaveBeenCalledWith(expect.objectContaining({ fileName: 'photo.jpg' }))
expect(postAttachment).toHaveBeenCalledWith(expect.objectContaining({ fileName: 'doc.pdf' }))
})

test('sanitizes slash in room name to space for conversation folder', async () => {
conversationGetter.mockReturnValue({ type: 2, displayName: 'Team/Chat' })

const file = { name: 'test.txt', type: 'text/plain', size: 10, lastModified: 0 }
uploadStore.initialiseUpload({ uploadId: 'upload-id1', token: TOKEN, files: [file] })

// '/' is replaced by ' ', trimmed → 'Team Chat'
const convFolderName = 'Team Chat-' + TOKEN

await uploadStore.uploadFiles({ token: TOKEN, uploadId: 'upload-id1', options: { silent: false } })

expect(client.createDirectory).toHaveBeenCalledWith('/files/current-user/Talk/' + convFolderName)
})

test('hyphen in room name does not confuse token extraction in folder name', async () => {
conversationGetter.mockReturnValue({ type: 3, displayName: 'My-Group' })

const file = { name: 'test.txt', type: 'text/plain', size: 10, lastModified: 0 }
uploadStore.initialiseUpload({ uploadId: 'upload-id1', token: TOKEN, files: [file] })

// Hyphen in room name is preserved; the token is simply appended with '-'
const convFolderName = 'My-Group-' + TOKEN

await uploadStore.uploadFiles({ token: TOKEN, uploadId: 'upload-id1', options: { silent: false } })

expect(client.createDirectory).toHaveBeenCalledWith('/files/current-user/Talk/' + convFolderName)
})

test('truncates long room name to 64 Unicode code points in folder name', async () => {
const longName = 'A'.repeat(70)
conversationGetter.mockReturnValue({ type: 2, displayName: longName })

const file = { name: 'test.txt', type: 'text/plain', size: 10, lastModified: 0 }
uploadStore.initialiseUpload({ uploadId: 'upload-id1', token: TOKEN, files: [file] })

const convFolderName = 'A'.repeat(64) + '-' + TOKEN

await uploadStore.uploadFiles({ token: TOKEN, uploadId: 'upload-id1', options: { silent: false } })

expect(client.createDirectory).toHaveBeenCalledWith('/files/current-user/Talk/' + convFolderName)
})

test('falls back to shareFile when conversation-subfolders capability is false', async () => {
getTalkConfig.mockReturnValueOnce(false)
conversationGetter.mockReturnValue({ type: 2, displayName: 'My Room' })
findUniquePath.mockResolvedValueOnce({ path: '/Talk/photo.jpg', name: 'photo.jpg' })

const file = { name: 'photo.jpg', type: 'image/jpeg', size: 100, lastModified: 0 }
uploadStore.initialiseUpload({ uploadId: 'upload-id1', token: TOKEN, files: [file] })

await uploadStore.uploadFiles({ token: TOKEN, uploadId: 'upload-id1', options: null })

expect(client.createDirectory).not.toHaveBeenCalled()
expect(postAttachment).not.toHaveBeenCalled()
expect(shareFile).toHaveBeenCalledTimes(1)
})
})

test('autorenames files using timestamps when requested', () => {
const files = [
{
Expand Down
Loading
Loading