Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adds command listitem attachment add #5345

Closed
Closed
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
110 changes: 110 additions & 0 deletions docs/docs/cmd/spo/listitem/listitem-attachment-add.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import Global from '/docs/cmd/_global.mdx';
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';

# spo listitem attachment add

Adds an attachment to a list item

## Usage

```sh
m365 spo listitem attachment add [options]
```

## Options

```md definition-list
`-u, --webUrl <webUrl>`
: URL of the site where the list item is located.

`--listId [listId]`
: ID of the list. Specify either `listTitle`, `listId` or `listUrl`.

`--listTitle [listTitle]`
: Title of the list. Specify either `listTitle`, `listId` or `listUrl`.

`--listUrl [listUrl]`
: Server- or site-relative URL of the list. Specify either `listTitle`, `listId` or `listUrl`.

`--listItemId <listItemId>`
: The ID of the list item.

`-p, --filePath <filePath>`
: Local path to the file that will be added as an attachment to the list item.

`-n, --fileName [fileName]`
: Name for the file. If no name is provided, the name from `filePath` will be utilized.
```

<Global />

## Examples

Add a new attachment to a list item from a local file by using list title

```sh
m365 spo listitem attachment add --webUrl https://contoso.sharepoint.com/sites/project-x --listTitle "DemoList" --listItemId 147 --filePath "C:/Reports/File1.jpg"
```

Add a new attachment to a list item from a local file by using list URL with a different filename

```sh
m365 spo listitem attachment add --webUrl https://contoso.sharepoint.com/sites/project-x --listUrl "/sites/project-x/Lists/DemoList" --listItemId 147 --filePath "C:/Reports/File1.jpg" --fileName "File2.jpg"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For fileName, we don't need to define the extension as this is done in code.

Suggested change
m365 spo listitem attachment add --webUrl https://contoso.sharepoint.com/sites/project-x --listUrl "/sites/project-x/Lists/DemoList" --listItemId 147 --filePath "C:/Reports/File1.jpg" --fileName "File2.jpg"
m365 spo listitem attachment add --webUrl https://contoso.sharepoint.com/sites/project-x --listUrl "/sites/project-x/Lists/DemoList" --listItemId 147 --filePath "C:/Reports/File1.jpg" --fileName "File2"

```

## Response

<Tabs>
<TabItem value="JSON">

```json
[
{
"FileName": "File1.jpg",
"FileNameAsPath": {
"DecodedUrl": "File1.jpg"
},
"ServerRelativePath": {
"DecodedUrl": "/Lists/DemoList/Attachments/147/File1.jpg"
},
"ServerRelativeUrl": "/Lists/Test/Attachments/147/File1.jpg"
}
]
```

</TabItem>
<TabItem value="Text">

```text
FileName : File1.jpg
FileNameAsPath : {"DecodedUrl":"File1jpg"}
ServerRelativePath: {"DecodedUrl":"/Lists/DemoList/Attachments/743/File1.jpg"}
ServerRelativeUrl : /Lists/DemoList/Attachments/147/File1.jpg
```

</TabItem>
<TabItem value="CSV">

```csv
FileName,ServerRelativeUrl
File1.jpg,/Lists/DemoList/Attachments/147/File1.jpg
```

</TabItem>
<TabItem value="Markdown">

```md
# spo listitem attachment add --webUrl https://contoso.sharepoint.com/sites/project-x --listTitle "DemoList" --listItemId 147 --filePath "C:/Reports/File1.jpg"

Date: 25/07/2023

Property | Value
---------|-------
FileName | File1.jpg
ServerRelativeUrl | /Lists/DemoList/Attachments/147/File1.jpg
```

</TabItem>
</Tabs>

5 changes: 5 additions & 0 deletions docs/src/config/sidebars.js
Original file line number Diff line number Diff line change
Expand Up @@ -2629,6 +2629,11 @@ const sidebars = {
label: 'listitem remove',
id: 'cmd/spo/listitem/listitem-remove'
},
{
type: 'doc',
label: 'listitem attachment add',
id: 'cmd/spo/listitem/listitem-attachment-add'
},
{
type: 'doc',
label: 'listitem attachment list',
Expand Down
1 change: 1 addition & 0 deletions src/m365/spo/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ export default {
LIST_WEBHOOK_REMOVE: `${prefix} list webhook remove`,
LIST_WEBHOOK_SET: `${prefix} list webhook set`,
LISTITEM_ADD: `${prefix} listitem add`,
LISTITEM_ATTACHMENT_ADD: `${prefix} listitem attachment add`,
LISTITEM_ATTACHMENT_LIST: `${prefix} listitem attachment list`,
LISTITEM_BATCH_ADD: `${prefix} listitem batch add`,
LISTITEM_BATCH_SET: `${prefix} listitem batch set`,
Expand Down
220 changes: 220 additions & 0 deletions src/m365/spo/commands/listitem/listitem-attachment-add.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
import assert from 'assert';
import sinon from 'sinon';
import { telemetry } from '../../../../telemetry.js';
import auth from '../../../../Auth.js';
import { Cli } from '../../../../cli/Cli.js';
import { CommandInfo } from '../../../../cli/CommandInfo.js';
import { Logger } from '../../../../cli/Logger.js';
import { CommandError } from '../../../../Command.js';
import { formatting } from '../../../../utils/formatting.js';
import { pid } from '../../../../utils/pid.js';
import { session } from '../../../../utils/session.js';
import { sinonUtil } from '../../../../utils/sinonUtil.js';
import { urlUtil } from '../../../../utils/urlUtil.js';
import commands from '../../commands.js';
import fs from 'fs';
import request from '../../../../request.js';
import command from './listitem-attachment-add.js';

describe(commands.LISTITEM_ATTACHMENT_ADD, () => {
const webUrl = 'https://contoso.sharepoint.com/sites/project-x';
const listId = '236a0f92482d475bba8fd0e4f78555e4';
const listTitle = 'Test list';
const listUrl = 'sites/project-x/lists/testlist';
const listServerRelativeUrl: string = urlUtil.getServerRelativePath(webUrl, listUrl);
const listItemId = 1;
const filePath = 'C:\\Temp\\Test.pdf';
const fileName = 'CLIRocks.pdf';

const response = { 'FileName': 'CLIRocks.pdf', 'FileNameAsPath': { 'DecodedUrl': 'Testje.pdf' }, 'ServerRelativePath': { 'DecodedUrl': '/Lists/aaaaaa/Attachments/743/Testje.pdf' }, 'ServerRelativeUrl': '/Lists/aaaaaa/Attachments/743/Testje.pdf' };

let cli: Cli;
let log: any[];
let logger: Logger;
let loggerLogSpy: sinon.SinonSpy;
let commandInfo: CommandInfo;

before(() => {
cli = Cli.getInstance();
sinon.stub(auth, 'restoreAuth').resolves();
sinon.stub(telemetry, 'trackEvent').returns();
sinon.stub(pid, 'getProcessName').returns('');
sinon.stub(session, 'getId').returns('');
auth.service.connected = true;
commandInfo = Cli.getCommandInfo(command);
});

beforeEach(() => {
log = [];
logger = {
log: async (msg: string) => {
log.push(msg);
},
logRaw: async (msg: string) => {
log.push(msg);
},
logToStderr: async (msg: string) => {
log.push(msg);
}
};
loggerLogSpy = sinon.spy(logger, 'log');
sinon.stub(cli, 'getSettingWithDefaultValue').callsFake(((_, defaultValue) => defaultValue));
});

afterEach(() => {
sinonUtil.restore([,
fs.existsSync,
fs.readFileSync,
request.post,
cli.getSettingWithDefaultValue
]);
});

after(() => {
sinon.restore();
auth.service.connected = false;
});

it('has correct name', () => {
assert.strictEqual(command.name, commands.LISTITEM_ATTACHMENT_ADD);
});

it('has a description', () => {
assert.notStrictEqual(command.description, null);
});

it('fails validation if the webUrl option is not a valid SharePoint site URL', async () => {
sinon.stub(fs, 'existsSync').returns(true);
const actual = await command.validate({ options: { webUrl: 'invalid', listTitle: listTitle, listItemId: listItemId, filePath: filePath } }, commandInfo);
assert.notStrictEqual(actual, true);
});

it('passes validation if the webUrl option is a valid SharePoint site URL and filePath exists', async () => {
sinon.stub(fs, 'existsSync').returns(true);
const actual = await command.validate({ options: { webUrl: webUrl, listTitle: listTitle, listItemId: listItemId, filePath: filePath } }, commandInfo);
assert(actual);
});

it('fails validation if the listId option is not a valid GUID', async () => {
sinon.stub(fs, 'existsSync').returns(true);
const actual = await command.validate({ options: { webUrl: webUrl, listId: 'invalid', listItemId: listItemId, filePath: filePath } }, commandInfo);
assert.notStrictEqual(actual, true);
});

it('fails validation if the listItemId option is not a valid number', async () => {
sinon.stub(fs, 'existsSync').returns(true);
const actual = await command.validate({ options: { webUrl: webUrl, listId: listId, listItemId: 'invalid', filePath: filePath } }, commandInfo);
assert.notStrictEqual(actual, true);
});

it('passes validation if the listId option is a valid GUID', async () => {
sinon.stub(fs, 'existsSync').returns(true);
const actual = await command.validate({ options: { webUrl: webUrl, listId: listId, listItemId: listItemId, filePath: filePath } }, commandInfo);
assert(actual);
});

it('fails validation if filePath does not exist', async () => {
sinon.stub(fs, 'existsSync').returns(false);
const actual = await command.validate({ options: { webUrl: webUrl, listTitle: listTitle, listItemId: listItemId, filePath: filePath } }, commandInfo);
assert.notStrictEqual(actual, true);
});

it('adds attachment to listitem in list retrieved by id while specifying fileName', async () => {
sinon.stub(fs, 'existsSync').returns(true);
sinon.stub(fs, 'readFileSync').returns('content read');
sinon.stub(request, 'post').callsFake(async (args) => {
if (args.url === `${webUrl}/_api/web/lists(guid'${listId}')/items(${listItemId})/AttachmentFiles/add(FileName='${fileName}')`) {
return response;
}

throw 'Invalid request';
});

await command.action(logger, { options: { verbose: true, webUrl: webUrl, listId: listId, listItemId: listItemId, filePath: filePath, fileName: fileName } });
assert(loggerLogSpy.calledOnceWith(response));
});

it('adds attachment to listitem in list retrieved by url while not specifying fileName', async () => {
sinon.stub(fs, 'existsSync').returns(true);
sinon.stub(fs, 'readFileSync').returns('content read');
sinon.stub(request, 'post').callsFake(async (args) => {
if (args.url === `${webUrl}/_api/web/GetList('${formatting.encodeQueryParameter(listServerRelativeUrl)}')/items(${listItemId})/AttachmentFiles/add(FileName='${filePath.replace(/^.*[\\\/]/, '')}')`) {
return response;
}

throw 'Invalid request';
});

await command.action(logger, { options: { verbose: true, webUrl: webUrl, listUrl: listUrl, listItemId: listItemId, filePath: filePath } });
assert(loggerLogSpy.calledOnceWith(response));
});

it('adds attachment to listitem in list retrieved by url while specifying fileName without extension', async () => {
sinon.stub(fs, 'existsSync').returns(true);
sinon.stub(fs, 'readFileSync').returns('content read');
const fileNameWithoutExtension = fileName.split('.')[0];
const fileNameWithExtension = `${fileNameWithoutExtension}.${filePath.split('.').pop()}`;
sinon.stub(request, 'post').callsFake(async (args) => {
if (args.url === `${webUrl}/_api/web/lists/getByTitle('${formatting.encodeQueryParameter(listTitle)}')/items(${listItemId})/AttachmentFiles/add(FileName='${fileNameWithExtension}')`) {
return response;
}

throw 'Invalid request';
});

await command.action(logger, { options: { verbose: true, webUrl: webUrl, listTitle: listTitle, listItemId: listItemId, filePath: filePath, fileName: fileNameWithoutExtension } });
assert(loggerLogSpy.calledOnceWith(response));
});

it('handles error when file with specific name already exists', async () => {
sinon.stub(fs, 'existsSync').returns(true);
sinon.stub(fs, 'readFileSync').returns('content read');
const error = {
error: {
'odata.error': {
code: '-2130575257, Microsoft.SharePoint.SPException',
message: {
lang: 'en-US',
value: 'The specified name is already in use.\n\nThe document or folder name was not changed. To change the name to a different value, close this dialog and edit the properties of the document or folder.'
}
}
}
};
sinon.stub(request, 'post').callsFake(async (args) => {
if (args.url === `${webUrl}/_api/web/lists(guid'${listId}')/items(${listItemId})/AttachmentFiles/add(FileName='${fileName}')`) {
throw error;
}

throw 'Invalid request';
});

await assert.rejects(command.action(logger, { options: { verbose: true, webUrl: webUrl, listId: listId, listItemId: listItemId, filePath: filePath, fileName: fileName } }),
new CommandError(error.error['odata.error'].message.value.split('\n')[0]));
});

it('handles API error', async () => {
sinon.stub(fs, 'existsSync').returns(true);
sinon.stub(fs, 'readFileSync').returns('content read');
const error = {
error: {
'odata.error': {
code: '-2130575257, Microsoft.SharePoint.SPException',
message: {
lang: 'en-US',
value: 'An error has occured.'
}
}
}
};
sinon.stub(request, 'post').callsFake(async (args) => {
if (args.url === `${webUrl}/_api/web/lists(guid'${listId}')/items(${listItemId})/AttachmentFiles/add(FileName='${fileName}')`) {
throw error;
}

throw 'Invalid request';
});

await assert.rejects(command.action(logger, { options: { verbose: true, webUrl: webUrl, listId: listId, listItemId: listItemId, filePath: filePath, fileName: fileName } }),
new CommandError(error.error['odata.error'].message.value));
});
});
Loading