Skip to content

Commit 455f2cd

Browse files
committed
test: Add CSV import integration and unit tests
Signed-off-by: Anna Larch <anna@nextcloud.com> AI-Assisted-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent e2aa9a6 commit 455f2cd

File tree

7 files changed

+622
-3
lines changed

7 files changed

+622
-3
lines changed

cypress/e2e/boardFeatures.js

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -266,19 +266,25 @@ describe('Board import', function() {
266266
})
267267

268268
it('Imports a board from JSON', function() {
269-
cy.get('#app-navigation-vue .app-navigation__list .app-navigation-entry:contains("Import board")')
270-
.should('be.visible')
269+
// Open settings dialog
270+
cy.get('#app-navigation-vue').contains('Deck settings').click()
271+
272+
// Click "Import from device" in the Import section
273+
cy.get('.app-settings-section').contains('button', 'Import from device')
271274
.click()
272275

273276
// Upload a JSON file
274-
cy.get('input[type="file"]')
277+
cy.get('.app-settings-section input[type="file"]')
275278
.selectFile([
276279
{
277280
contents: 'cypress/fixtures/import-board.json',
278281
fileName: 'import-board.json',
279282
},
280283
], { force: true })
281284

285+
// Close settings dialog by pressing Escape
286+
cy.get('body').type('{esc}')
287+
282288
cy.get('.app-navigation__list .app-navigation-entry:contains("Imported board")')
283289
.should('be.visible')
284290
})

tests/bootstrap-standalone.php

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
<?php
2+
3+
/**
4+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
5+
* SPDX-License-Identifier: AGPL-3.0-or-later
6+
*
7+
* Bootstrap for running unit tests that have no Nextcloud server dependencies.
8+
* Usage: vendor/bin/phpunit --bootstrap tests/bootstrap-standalone.php tests/unit/Service/Importer/CsvParserTest.php
9+
*/
10+
11+
require_once __DIR__ . '/../vendor/autoload.php';
12+
13+
spl_autoload_register(function (string $class) {
14+
$prefix = 'OCA\\Deck\\';
15+
if (str_starts_with($class, $prefix)) {
16+
$relativeClass = substr($class, strlen($prefix));
17+
$file = __DIR__ . '/../lib/' . str_replace('\\', '/', $relativeClass) . '.php';
18+
if (file_exists($file)) {
19+
require_once $file;
20+
}
21+
}
22+
});

tests/integration/features/bootstrap/BoardContext.php

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
<?php
22

33
use Behat\Behat\Context\Context;
4+
use Behat\Behat\Hook\Scope\AfterScenarioScope;
45
use Behat\Behat\Hook\Scope\BeforeScenarioScope;
56
use Behat\Gherkin\Node\TableNode;
67
use PHPUnit\Framework\Assert;
@@ -20,6 +21,9 @@ class BoardContext implements Context {
2021
private array $storedStacks = [];
2122
private ?array $activities = null;
2223

24+
/** @var int[] Board IDs created during the current scenario */
25+
private array $createdBoardIds = [];
26+
2327
private ServerContext $serverContext;
2428

2529
/** @BeforeScenario */
@@ -29,6 +33,24 @@ public function gatherContexts(BeforeScenarioScope $scope) {
2933
$this->serverContext = $environment->getContext('ServerContext');
3034
}
3135

36+
/** @AfterScenario */
37+
public function cleanupBoards(AfterScenarioScope $scope): void {
38+
foreach ($this->createdBoardIds as $boardId) {
39+
try {
40+
$this->requestContext->sendJSONrequest('DELETE', '/index.php/apps/deck/boards/' . $boardId);
41+
} catch (\Exception $e) {
42+
// Ignore cleanup errors
43+
}
44+
}
45+
$this->createdBoardIds = [];
46+
}
47+
48+
private function trackBoard(?array $board): void {
49+
if (is_array($board) && isset($board['id'])) {
50+
$this->createdBoardIds[] = $board['id'];
51+
}
52+
}
53+
3254
public function getLastUsedCard() {
3355
return $this->card;
3456
}
@@ -56,6 +78,7 @@ public function createsABoardNamedWithColor($title, $color) {
5678
]);
5779
$this->getResponse()->getBody()->seek(0);
5880
$this->board = json_decode((string)$this->getResponse()->getBody(), true);
81+
$this->trackBoard($this->board);
5982
}
6083

6184
/**
@@ -470,4 +493,167 @@ private function getStack(): mixed {
470493
return $found;
471494
}
472495

496+
/**
497+
* @When /^importing a board from CSV file with content$/
498+
*/
499+
public function importingABoardFromCsvFileWithContent(\Behat\Gherkin\Node\PyStringNode $content) {
500+
$csvContent = $content->getRaw();
501+
$this->requestContext->sendPlainRequest('POST', '/index.php/apps/deck/boards/import', [
502+
'multipart' => [
503+
[
504+
'name' => 'file',
505+
'contents' => $csvContent,
506+
'filename' => 'import.csv',
507+
'headers' => ['Content-Type' => 'text/csv'],
508+
],
509+
],
510+
]);
511+
$this->getResponse()->getBody()->seek(0);
512+
$body = json_decode((string)$this->getResponse()->getBody(), true);
513+
if ($this->getResponse()->getStatusCode() === 200 && is_array($body) && isset($body['board'])) {
514+
$this->board = $body['board'];
515+
$this->trackBoard($this->board);
516+
}
517+
}
518+
519+
/**
520+
* @When /^importing CSV cards into the current board with content$/
521+
*/
522+
public function importingCsvCardsIntoTheCurrentBoardWithContent(\Behat\Gherkin\Node\PyStringNode $content) {
523+
Assert::assertNotNull($this->board, 'Board must be created first');
524+
$csvContent = $content->getRaw();
525+
// Allow tests to reference the last created card's ID via {lastCardId}
526+
if ($this->card !== null && isset($this->card['id'])) {
527+
$csvContent = str_replace('{lastCardId}', (string)$this->card['id'], $csvContent);
528+
}
529+
$this->requestContext->sendPlainRequest('POST', '/index.php/apps/deck/boards/' . $this->board['id'] . '/importCsv', [
530+
'multipart' => [
531+
[
532+
'name' => 'file',
533+
'contents' => $csvContent,
534+
'filename' => 'cards.csv',
535+
'headers' => ['Content-Type' => 'text/csv'],
536+
],
537+
],
538+
]);
539+
$this->getResponse()->getBody()->seek(0);
540+
$body = json_decode((string)$this->getResponse()->getBody(), true);
541+
if ($this->getResponse()->getStatusCode() === 200 && is_array($body) && isset($body['board'])) {
542+
$this->board = $body['board'];
543+
}
544+
}
545+
546+
/**
547+
* @When /^importing a non-CSV file into the current board$/
548+
*/
549+
public function importingANonCsvFileIntoTheCurrentBoard() {
550+
Assert::assertNotNull($this->board, 'Board must be created first');
551+
$this->requestContext->sendPlainRequest('POST', '/index.php/apps/deck/boards/' . $this->board['id'] . '/importCsv', [
552+
'multipart' => [
553+
[
554+
'name' => 'file',
555+
'contents' => '{"not": "csv"}',
556+
'filename' => 'data.json',
557+
'headers' => ['Content-Type' => 'application/json'],
558+
],
559+
],
560+
]);
561+
}
562+
563+
/**
564+
* @Then /^the board should have (\d+) stacks$/
565+
*/
566+
public function theBoardShouldHaveStacks($count) {
567+
$this->requestContext->sendJSONrequest('GET', '/index.php/apps/deck/stacks/' . $this->board['id']);
568+
$this->requestContext->getResponse()->getBody()->seek(0);
569+
$stacks = json_decode((string)$this->getResponse()->getBody(), true);
570+
Assert::assertCount((int)$count, $stacks, 'Expected ' . $count . ' stacks, got ' . count($stacks));
571+
}
572+
573+
/**
574+
* @Then /^the board should have a stack named "([^"]*)"$/
575+
*/
576+
public function theBoardShouldHaveAStackNamed($name) {
577+
$this->requestContext->sendJSONrequest('GET', '/index.php/apps/deck/stacks/' . $this->board['id']);
578+
$this->requestContext->getResponse()->getBody()->seek(0);
579+
$stacks = json_decode((string)$this->getResponse()->getBody(), true);
580+
$found = array_filter($stacks, fn ($s) => $s['title'] === $name);
581+
Assert::assertNotEmpty($found, 'Stack "' . $name . '" not found on board');
582+
}
583+
584+
/**
585+
* @Then /^the stack "([^"]*)" should have (\d+) cards$/
586+
*/
587+
public function theStackShouldHaveCards($stackName, $count) {
588+
$this->requestContext->sendJSONrequest('GET', '/index.php/apps/deck/stacks/' . $this->board['id']);
589+
$this->requestContext->getResponse()->getBody()->seek(0);
590+
$stacks = json_decode((string)$this->getResponse()->getBody(), true);
591+
$found = null;
592+
foreach ($stacks as $stack) {
593+
if ($stack['title'] === $stackName) {
594+
$found = $stack;
595+
break;
596+
}
597+
}
598+
Assert::assertNotNull($found, 'Stack "' . $stackName . '" not found');
599+
$cardCount = count($found['cards'] ?? []);
600+
Assert::assertEquals((int)$count, $cardCount, 'Expected ' . $count . ' cards in stack "' . $stackName . '", got ' . $cardCount);
601+
}
602+
603+
/**
604+
* @Then /^the board should have labels "([^"]*)"$/
605+
*/
606+
public function theBoardShouldHaveLabels($labelList) {
607+
$expectedLabels = array_map('trim', explode(',', $labelList));
608+
$this->requestContext->sendJSONrequest('GET', '/index.php/apps/deck/boards/' . $this->board['id']);
609+
$this->requestContext->getResponse()->getBody()->seek(0);
610+
$board = json_decode((string)$this->getResponse()->getBody(), true);
611+
$actualLabels = array_map(fn ($l) => $l['title'], $board['labels'] ?? []);
612+
foreach ($expectedLabels as $expected) {
613+
Assert::assertContains($expected, $actualLabels, 'Label "' . $expected . '" not found on board. Existing: ' . implode(', ', $actualLabels));
614+
}
615+
}
616+
617+
/**
618+
* @Then /^the card "([^"]*)" should have duedate "([^"]*)"$/
619+
*/
620+
public function theCardShouldHaveDuedate($cardTitle, $expectedDuedate) {
621+
$card = $this->findCardOnBoard($cardTitle);
622+
Assert::assertNotNull($card, 'Card "' . $cardTitle . '" not found on board');
623+
Assert::assertNotEmpty($card['duedate'], 'Card "' . $cardTitle . '" has no duedate set');
624+
Assert::assertEquals($expectedDuedate, $card['duedate'], 'Duedate mismatch for card "' . $cardTitle . '"');
625+
}
626+
627+
/**
628+
* @Then /^the card "([^"]*)" should not have a duedate$/
629+
*/
630+
public function theCardShouldNotHaveADuedate($cardTitle) {
631+
$card = $this->findCardOnBoard($cardTitle);
632+
Assert::assertNotNull($card, 'Card "' . $cardTitle . '" not found on board');
633+
Assert::assertEmpty($card['duedate'], 'Expected no duedate for card "' . $cardTitle . '", got "' . ($card['duedate'] ?? '') . '"');
634+
}
635+
636+
/**
637+
* @Then /^the card "([^"]*)" should have description "([^"]*)"$/
638+
*/
639+
public function theCardShouldHaveDescription($cardTitle, $expectedDescription) {
640+
$card = $this->findCardOnBoard($cardTitle);
641+
Assert::assertNotNull($card, 'Card "' . $cardTitle . '" not found on board');
642+
Assert::assertEquals($expectedDescription, $card['description'], 'Card description mismatch');
643+
}
644+
645+
private function findCardOnBoard(string $cardTitle): ?array {
646+
$this->requestContext->sendJSONrequest('GET', '/index.php/apps/deck/stacks/' . $this->board['id']);
647+
$this->requestContext->getResponse()->getBody()->seek(0);
648+
$stacks = json_decode((string)$this->getResponse()->getBody(), true);
649+
foreach ($stacks as $stack) {
650+
foreach ($stack['cards'] ?? [] as $card) {
651+
if ($card['title'] === $cardTitle) {
652+
return $card;
653+
}
654+
}
655+
}
656+
return null;
657+
}
658+
473659
}
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
Feature: CSV Import
2+
3+
Background:
4+
Given user "admin" exists
5+
Given user "user0" exists
6+
7+
Scenario: Import a new board from CSV
8+
Given Logging in using web as "admin"
9+
When importing a board from CSV file with content
10+
"""
11+
"Card title" "Description" "List name" "Tags" "Due date" "Created" "Modified"
12+
"First card" "A description" "To Do" "Feature, Bug," "null" "01/02/2026" "15/03/2026"
13+
"Second card" "" "Done" "Feature," "2026-03-20T19:00:00+00:00" "10/02/2026" "20/03/2026"
14+
"Third card" "Line 1
15+
Line 2" "To Do" "" "null" "05/03/2026" "05/03/2026"
16+
"""
17+
Then the response should have a status code "200"
18+
When fetches the board named "import"
19+
Then the board should have 2 stacks
20+
And the board should have a stack named "To Do"
21+
And the board should have a stack named "Done"
22+
And the stack "To Do" should have 2 cards
23+
And the stack "Done" should have 1 cards
24+
25+
Scenario: Import cards from CSV into an existing board
26+
Given Logging in using web as "admin"
27+
And creates a board named "MyBoard" with color "000000"
28+
And create a stack named "To Do"
29+
And create a card named "Existing card"
30+
When importing CSV cards into the current board with content
31+
"""
32+
"Card title" "Description" "List name" "Tags" "Due date" "Created" "Modified"
33+
"Imported card 1" "Some description" "To Do" "Urgent," "null" "01/01/2026" "01/01/2026"
34+
"Imported card 2" "" "New Stack" "" "null" "02/01/2026" "02/01/2026"
35+
"""
36+
Then the response should have a status code "200"
37+
When fetches the board named "MyBoard"
38+
Then the board should have 2 stacks
39+
And the board should have a stack named "To Do"
40+
And the board should have a stack named "New Stack"
41+
And the stack "To Do" should have 2 cards
42+
And the stack "New Stack" should have 1 cards
43+
44+
Scenario: Import CSV with labels creates labels on the board
45+
Given Logging in using web as "admin"
46+
And creates a board named "LabelBoard" with color "000000"
47+
And create a stack named "To Do"
48+
When importing CSV cards into the current board with content
49+
"""
50+
"Card title" "Description" "List name" "Tags" "Due date" "Created" "Modified"
51+
"Card A" "" "To Do" "Feature, Bug," "null" "01/01/2026" "01/01/2026"
52+
"Card B" "" "To Do" "Feature," "null" "01/01/2026" "01/01/2026"
53+
"""
54+
Then the response should have a status code "200"
55+
When fetches the board named "LabelBoard"
56+
Then the board should have labels "Feature, Bug"
57+
58+
Scenario: Import CSV with various date formats
59+
Given Logging in using web as "admin"
60+
And creates a board named "DateBoard" with color "000000"
61+
And create a stack named "To Do"
62+
When importing CSV cards into the current board with content
63+
"""
64+
"Card title" "Description" "List name" "Tags" "Due date" "Created" "Modified"
65+
"ISO 8601 due" "" "To Do" "" "2026-03-20T19:00:00+00:00" "23/02/2026" "28/03/2026"
66+
"ISO date due" "" "To Do" "" "2026-06-15" "2026-01-10" "2026-06-15"
67+
"Dot format dates" "" "To Do" "" "null" "15.03.2026" "20.3.2026"
68+
"Slash Y/m/d dates" "" "To Do" "" "null" "2026/3/15" "2026/03/20"
69+
"Null due date" "" "To Do" "" "null" "01/02/2026" "01/02/2026"
70+
"Empty due date" "" "To Do" "" "" "01/02/2026" "01/02/2026"
71+
"""
72+
Then the response should have a status code "200"
73+
When fetches the board named "DateBoard"
74+
And the stack "To Do" should have 6 cards
75+
Then the card "ISO 8601 due" should have duedate "2026-03-20T19:00:00+00:00"
76+
And the card "ISO date due" should have duedate "2026-06-15T00:00:00+00:00"
77+
And the card "Null due date" should not have a duedate
78+
And the card "Empty due date" should not have a duedate
79+
80+
Scenario: Re-import CSV with card ID updates existing card
81+
Given Logging in using web as "admin"
82+
And creates a board named "UpdateBoard" with color "000000"
83+
And create a stack named "To Do"
84+
And create a card named "Original title"
85+
When importing CSV cards into the current board with content
86+
"""
87+
"ID" "Card title" "Description" "List name" "Tags" "Due date" "Created" "Modified"
88+
"{lastCardId}" "Updated title" "New description" "To Do" "" "null" "01/01/2026" "01/01/2026"
89+
"" "Brand new card" "" "To Do" "" "null" "01/01/2026" "01/01/2026"
90+
"""
91+
Then the response should have a status code "200"
92+
When fetches the board named "UpdateBoard"
93+
Then the board should have a stack named "To Do"
94+
And the stack "To Do" should have 2 cards
95+
And the card "Updated title" should have description "New description"
96+
97+
Scenario: Reject non-CSV file for card import
98+
Given Logging in using web as "admin"
99+
And creates a board named "MyBoard" with color "000000"
100+
When importing a non-CSV file into the current board
101+
Then the response should have a status code "400"

tests/unit/Middleware/ExceptionMiddlewareTest.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
use OCA\Deck\NoPermissionException;
3030
use OCA\Deck\NotFoundException;
3131
use OCA\Deck\Service\BoardService;
32+
use OCA\Deck\Service\CsvImportService;
3233
use OCA\Deck\Service\ExternalBoardService;
3334
use OCA\Deck\Service\Importer\BoardImportService;
3435
use OCA\Deck\Service\PermissionService;
@@ -95,6 +96,7 @@ public function testAfterExceptionFail() {
9596
$this->createMock(ExternalBoardService::class),
9697
$this->createMock(PermissionService::class),
9798
$this->createMock(BoardImportService::class),
99+
$this->createMock(CsvImportService::class),
98100
$this->createMock(\OCP\IL10N::class),
99101
'admin'
100102
);

0 commit comments

Comments
 (0)