Add Postmark integration (inbound emails for task creation)

This commit is contained in:
Frederic Guillot 2015-04-19 14:48:12 -04:00
parent 370b5a0fd7
commit 1891e87d03
37 changed files with 478 additions and 6 deletions

View File

@ -108,6 +108,7 @@ Documentation
- [Gitlab webhooks](docs/gitlab-webhooks.markdown)
- [Hipchat](docs/hipchat.markdown)
- [Slack](docs/slack.markdown)
- [Postmark](docs/postmark.markdown)
#### More

View File

@ -100,4 +100,20 @@ class Webhook extends Base
echo $result ? 'PARSED' : 'IGNORED';
}
/**
* Handle Postmark webhooks
*
* @access public
*/
public function postmark()
{
if ($this->config->get('webhook_token') !== $this->request->getStringParam('token')) {
$this->response->text('Not Authorized', 401);
}
$result = $this->postmarkWebhook->parsePayload($this->request->getJson() ?: array());
echo $result ? 'PARSED' : 'IGNORED';
}
}

View File

@ -0,0 +1,67 @@
<?php
namespace Integration;
use HTML_To_Markdown;
/**
* Postmark Webhook
*
* @package integration
* @author Frederic Guillot
*/
class PostmarkWebhook extends Base
{
/**
* Parse incoming email
*
* @access public
* @param array $payload Incoming email
* @return boolean
*/
public function parsePayload(array $payload)
{
if (empty($payload['From']) || empty($payload['Subject']) || empty($payload['MailboxHash']) || empty($payload['TextBody'])) {
return false;
}
// The user must exists in Kanboard
$user = $this->user->getByEmail($payload['From']);
if (empty($user)) {
$this->container['logger']->debug('PostmarkWebhook: ignored => user not found');
return false;
}
// The project must have a short name
$project = $this->project->getByIdentifier($payload['MailboxHash']);
if (empty($project)) {
$this->container['logger']->debug('PostmarkWebhook: ignored => project not found');
return false;
}
// The user must be member of the project
if (! $this->projectPermission->isMember($project['id'], $user['id'])) {
$this->container['logger']->debug('PostmarkWebhook: ignored => user is not member of the project');
return false;
}
// Get the Markdown contents
if (empty($payload['HtmlBody'])) {
$description = $payload['TextBody'];
}
else {
$markdown = new HTML_To_Markdown($payload['HtmlBody'], array('strip_tags' => true));
$description = $markdown->output();
}
// Finally, we create the task
return (bool) $this->taskCreation->create(array(
'project_id' => $project['id'],
'title' => $payload['Subject'],
'description' => $description,
'creator_id' => $user['id'],
));
}
}

View File

@ -858,4 +858,8 @@ return array(
// 'Take a screenshot and press CTRL+V or ⌘+V to paste here.' => '',
// 'Screenshot uploaded successfully.' => '',
// 'SEK - Swedish Krona' => '',
// 'The project identifier is an optional alphanumeric code used to identify your project.' => '',
// 'Identifier' => '',
// 'Postmark (incoming emails)' => '',
// 'Help on Postmark integration' => '',
);

View File

@ -858,4 +858,8 @@ return array(
// 'Take a screenshot and press CTRL+V or ⌘+V to paste here.' => '',
// 'Screenshot uploaded successfully.' => '',
// 'SEK - Swedish Krona' => '',
// 'The project identifier is an optional alphanumeric code used to identify your project.' => '',
// 'Identifier' => '',
// 'Postmark (incoming emails)' => '',
// 'Help on Postmark integration' => '',
);

View File

@ -858,4 +858,8 @@ return array(
// 'Take a screenshot and press CTRL+V or ⌘+V to paste here.' => '',
// 'Screenshot uploaded successfully.' => '',
// 'SEK - Swedish Krona' => '',
// 'The project identifier is an optional alphanumeric code used to identify your project.' => '',
// 'Identifier' => '',
// 'Postmark (incoming emails)' => '',
// 'Help on Postmark integration' => '',
);

View File

@ -858,4 +858,8 @@ return array(
// 'Take a screenshot and press CTRL+V or ⌘+V to paste here.' => '',
// 'Screenshot uploaded successfully.' => '',
// 'SEK - Swedish Krona' => '',
// 'The project identifier is an optional alphanumeric code used to identify your project.' => '',
// 'Identifier' => '',
// 'Postmark (incoming emails)' => '',
// 'Help on Postmark integration' => '',
);

View File

@ -860,4 +860,8 @@ return array(
'Take a screenshot and press CTRL+V or ⌘+V to paste here.' => 'Prenez une capture d\'écran et appuyez sur CTRL+V ou ⌘+V pour coller ici.',
'Screenshot uploaded successfully.' => 'Capture d\'écran téléchargée avec succès.',
'SEK - Swedish Krona' => 'SEK - Couronne suédoise',
'The project identifier is an optional alphanumeric code used to identify your project.' => 'L\'identificateur du projet est un code alpha-numérique optionnel pour identifier votre projet.',
'Identifier' => 'Identificateur',
'Postmark (incoming emails)' => 'Postmark (emails entrants)',
'Help on Postmark integration' => 'Aide sur l\'intégration avec Postmark',
);

View File

@ -858,4 +858,8 @@ return array(
// 'Take a screenshot and press CTRL+V or ⌘+V to paste here.' => '',
// 'Screenshot uploaded successfully.' => '',
// 'SEK - Swedish Krona' => '',
// 'The project identifier is an optional alphanumeric code used to identify your project.' => '',
// 'Identifier' => '',
// 'Postmark (incoming emails)' => '',
// 'Help on Postmark integration' => '',
);

View File

@ -858,4 +858,8 @@ return array(
// 'Take a screenshot and press CTRL+V or ⌘+V to paste here.' => '',
// 'Screenshot uploaded successfully.' => '',
// 'SEK - Swedish Krona' => '',
// 'The project identifier is an optional alphanumeric code used to identify your project.' => '',
// 'Identifier' => '',
// 'Postmark (incoming emails)' => '',
// 'Help on Postmark integration' => '',
);

View File

@ -858,4 +858,8 @@ return array(
// 'Take a screenshot and press CTRL+V or ⌘+V to paste here.' => '',
// 'Screenshot uploaded successfully.' => '',
// 'SEK - Swedish Krona' => '',
// 'The project identifier is an optional alphanumeric code used to identify your project.' => '',
// 'Identifier' => '',
// 'Postmark (incoming emails)' => '',
// 'Help on Postmark integration' => '',
);

View File

@ -858,4 +858,8 @@ return array(
// 'Take a screenshot and press CTRL+V or ⌘+V to paste here.' => '',
// 'Screenshot uploaded successfully.' => '',
// 'SEK - Swedish Krona' => '',
// 'The project identifier is an optional alphanumeric code used to identify your project.' => '',
// 'Identifier' => '',
// 'Postmark (incoming emails)' => '',
// 'Help on Postmark integration' => '',
);

View File

@ -858,4 +858,8 @@ return array(
// 'Take a screenshot and press CTRL+V or ⌘+V to paste here.' => '',
// 'Screenshot uploaded successfully.' => '',
// 'SEK - Swedish Krona' => '',
// 'The project identifier is an optional alphanumeric code used to identify your project.' => '',
// 'Identifier' => '',
// 'Postmark (incoming emails)' => '',
// 'Help on Postmark integration' => '',
);

View File

@ -858,4 +858,8 @@ return array(
// 'Take a screenshot and press CTRL+V or ⌘+V to paste here.' => '',
// 'Screenshot uploaded successfully.' => '',
// 'SEK - Swedish Krona' => '',
// 'The project identifier is an optional alphanumeric code used to identify your project.' => '',
// 'Identifier' => '',
// 'Postmark (incoming emails)' => '',
// 'Help on Postmark integration' => '',
);

View File

@ -858,4 +858,8 @@ return array(
// 'Take a screenshot and press CTRL+V or ⌘+V to paste here.' => '',
// 'Screenshot uploaded successfully.' => '',
// 'SEK - Swedish Krona' => '',
// 'The project identifier is an optional alphanumeric code used to identify your project.' => '',
// 'Identifier' => '',
// 'Postmark (incoming emails)' => '',
// 'Help on Postmark integration' => '',
);

View File

@ -858,4 +858,8 @@ return array(
// 'Take a screenshot and press CTRL+V or ⌘+V to paste here.' => '',
// 'Screenshot uploaded successfully.' => '',
// 'SEK - Swedish Krona' => '',
// 'The project identifier is an optional alphanumeric code used to identify your project.' => '',
// 'Identifier' => '',
// 'Postmark (incoming emails)' => '',
// 'Help on Postmark integration' => '',
);

View File

@ -858,4 +858,8 @@ return array(
// 'Take a screenshot and press CTRL+V or ⌘+V to paste here.' => '',
// 'Screenshot uploaded successfully.' => '',
'SEK - Swedish Krona' => 'SEK - Svensk Krona',
// 'The project identifier is an optional alphanumeric code used to identify your project.' => '',
// 'Identifier' => '',
// 'Postmark (incoming emails)' => '',
// 'Help on Postmark integration' => '',
);

View File

@ -858,4 +858,8 @@ return array(
// 'Take a screenshot and press CTRL+V or ⌘+V to paste here.' => '',
// 'Screenshot uploaded successfully.' => '',
// 'SEK - Swedish Krona' => '',
// 'The project identifier is an optional alphanumeric code used to identify your project.' => '',
// 'Identifier' => '',
// 'Postmark (incoming emails)' => '',
// 'Help on Postmark integration' => '',
);

View File

@ -858,4 +858,8 @@ return array(
// 'Take a screenshot and press CTRL+V or ⌘+V to paste here.' => '',
// 'Screenshot uploaded successfully.' => '',
// 'SEK - Swedish Krona' => '',
// 'The project identifier is an optional alphanumeric code used to identify your project.' => '',
// 'Identifier' => '',
// 'Postmark (incoming emails)' => '',
// 'Help on Postmark integration' => '',
);

View File

@ -858,4 +858,8 @@ return array(
// 'Take a screenshot and press CTRL+V or ⌘+V to paste here.' => '',
// 'Screenshot uploaded successfully.' => '',
// 'SEK - Swedish Krona' => '',
// 'The project identifier is an optional alphanumeric code used to identify your project.' => '',
// 'Identifier' => '',
// 'Postmark (incoming emails)' => '',
// 'Help on Postmark integration' => '',
);

View File

@ -59,6 +59,18 @@ class Project extends Base
return $this->db->table(self::TABLE)->eq('name', $name)->findOne();
}
/**
* Get a project by the identifier (code)
*
* @access public
* @param string $identifier
* @return array
*/
public function getByIdentifier($identifier)
{
return $this->db->table(self::TABLE)->eq('identifier', strtoupper($identifier))->findOne();
}
/**
* Fetch project data by using the token
*
@ -276,6 +288,10 @@ class Project extends Base
$values['last_modified'] = time();
$values['is_private'] = empty($values['is_private']) ? 0 : 1;
if (! empty($values['identifier'])) {
$values['identifier'] = strtoupper($values['identifier']);
}
if (! $this->db->table(self::TABLE)->save($values)) {
$this->db->cancelTransaction();
return false;
@ -338,6 +354,10 @@ class Project extends Base
*/
public function update(array $values)
{
if (! empty($values['identifier'])) {
$values['identifier'] = strtoupper($values['identifier']);
}
return $this->exists($values['id']) &&
$this->db->table(self::TABLE)->eq('id', $values['id'])->save($values);
}
@ -443,7 +463,10 @@ class Project extends Base
new Validators\Integer('is_active', t('This value must be an integer')),
new Validators\Required('name', t('The project name is required')),
new Validators\MaxLength('name', t('The maximum length is %d characters', 50), 50),
new Validators\MaxLength('identifier', t('The maximum length is %d characters', 50), 50),
new Validators\AlphaNumeric('identifier', t('This value must be alphanumeric')) ,
new Validators\Unique('name', t('This project must be unique'), $this->db->getConnection(), self::TABLE),
new Validators\Unique('identifier', t('The identifier must be unique'), $this->db->getConnection(), self::TABLE),
);
}
@ -456,6 +479,10 @@ class Project extends Base
*/
public function validateCreation(array $values)
{
if (! empty($values['identifier'])) {
$values['identifier'] = strtoupper($values['identifier']);
}
$v = new Validator($values, $this->commonValidationRules());
return array(
@ -473,6 +500,10 @@ class Project extends Base
*/
public function validateModification(array $values)
{
if (! empty($values['identifier'])) {
$values['identifier'] = strtoupper($values['identifier']);
}
$rules = array(
new Validators\Required('id', t('This value is required')),
);

View File

@ -141,6 +141,18 @@ class User extends Base
return $this->db->table(self::TABLE)->eq('username', $username)->findOne();
}
/**
* Get a specific user by the email address
*
* @access public
* @param string $email Email
* @return array
*/
public function getByEmail($email)
{
return $this->db->table(self::TABLE)->eq('email', $email)->findOne();
}
/**
* Get all users
*

View File

@ -6,7 +6,12 @@ use PDO;
use Core\Security;
use Model\Link;
const VERSION = 65;
const VERSION = 66;
function version_66($pdo)
{
$pdo->exec("ALTER TABLE projects ADD COLUMN identifier VARCHAR(50) DEFAULT ''");
}
function version_65($pdo)
{

View File

@ -6,7 +6,12 @@ use PDO;
use Core\Security;
use Model\Link;
const VERSION = 46;
const VERSION = 47;
function version_47($pdo)
{
$pdo->exec("ALTER TABLE projects ADD COLUMN identifier VARCHAR(50) DEFAULT ''");
}
function version_46($pdo)
{

View File

@ -6,7 +6,12 @@ use Core\Security;
use PDO;
use Model\Link;
const VERSION = 64;
const VERSION = 65;
function version_65($pdo)
{
$pdo->exec("ALTER TABLE projects ADD COLUMN identifier TEXT DEFAULT ''");
}
function version_64($pdo)
{

View File

@ -78,6 +78,7 @@ class ClassProvider implements ServiceProviderInterface
'BitbucketWebhook',
'Hipchat',
'SlackWebhook',
'PostmarkWebhook',
)
);

View File

@ -6,7 +6,13 @@
<?= $this->formCsrf() ?>
<h3><?= t('Gravatar') ?></h3>
<h3><img src="assets/img/postmark-icon.png"/>&nbsp;<?= t('Postmark (incoming emails)') ?></h3>
<div class="listing">
<input type="text" class="auto-select" readonly="readonly" value="<?= $this->getCurrentBaseUrl().$this->u('webhook', 'postmark', array('token' => $values['webhook_token'])) ?>"/><br/>
<p class="form-help"><a href="http://kanboard.net/documentation/postmark" target="_blank"><?= t('Help on Postmark integration') ?></a></p>
</div>
<h3><img src="assets/img/gravatar-icon.png"/>&nbsp;<?= t('Gravatar') ?></h3>
<div class="listing">
<?= $this->formCheckbox('integration_gravatar', t('Enable Gravatar images'), 1, $values['integration_gravatar'] == 1) ?>
</div>

View File

@ -9,6 +9,10 @@
<?= $this->formLabel(t('Name'), 'name') ?>
<?= $this->formText('name', $values, $errors, array('required', 'maxlength="50"')) ?>
<?= $this->formLabel(t('Identifier'), 'identifier') ?>
<?= $this->formText('identifier', $values, $errors, array('maxlength="50"')) ?>
<p class="form-help"><?= t('The project identifier is an optional alphanumeric code used to identify your project.') ?></p>
<?= $this->formLabel(t('Description'), 'description') ?>
<div class="form-tabs">

View File

@ -15,6 +15,7 @@
<tr>
<th class="column-8"><?= $paginator->order(t('Id'), 'id') ?></th>
<th class="column-8"><?= $paginator->order(t('Status'), 'is_active') ?></th>
<th class="column-8"><?= $paginator->order(t('Identifier'), 'identifier') ?></th>
<th class="column-20"><?= $paginator->order(t('Project'), 'name') ?></th>
<th><?= t('Columns') ?></th>
</tr>
@ -30,6 +31,9 @@
<?= t('Inactive') ?>
<?php endif ?>
</td>
<td>
<?= $this->e($project['identifier']) ?>
</td>
<td>
<?= $this->a('<i class="fa fa-table"></i>', 'board', 'show', array('project_id' => $project['id']), false, 'dashboard-table-link', t('Board')) ?>&nbsp;

Binary file not shown.

After

Width:  |  Height:  |  Size: 517 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 474 B

View File

@ -13,7 +13,8 @@
"symfony/console" : "@stable",
"symfony/event-dispatcher" : "~2.6",
"fguillot/simpleLogger" : "0.0.1",
"christian-riesen/otp": "1.4"
"christian-riesen/otp": "1.4",
"nickcernis/html-to-markdown": "2.2.1"
},
"autoload" : {
"psr-0" : {

48
composer.lock generated
View File

@ -4,7 +4,7 @@
"Read more about it at http://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file",
"This file is @generated automatically"
],
"hash": "01ebe465ed3a59d8350670ebd4ef8793",
"hash": "1799891b06d5a8a516a48fefd429a3ed",
"packages": [
{
"name": "christian-riesen/base32",
@ -356,6 +356,52 @@
],
"time": "2014-09-05 15:19:58"
},
{
"name": "nickcernis/html-to-markdown",
"version": "2.2.1",
"source": {
"type": "git",
"url": "https://github.com/nickcernis/html-to-markdown.git",
"reference": "7263d2ce65011b050fa7ecda0cbe09b23e84271d"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/nickcernis/html-to-markdown/zipball/7263d2ce65011b050fa7ecda0cbe09b23e84271d",
"reference": "7263d2ce65011b050fa7ecda0cbe09b23e84271d",
"shasum": ""
},
"require": {
"php": ">=5.3"
},
"require-dev": {
"php": ">=5.3.3",
"phpunit/phpunit": "4.*"
},
"type": "library",
"autoload": {
"classmap": [
"HTML_To_Markdown.php"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Nick Cernis",
"email": "nick@cern.is",
"homepage": "http://modernnerd.net"
}
],
"description": "An HTML-to-markdown conversion helper for PHP",
"homepage": "https://github.com/nickcernis/html-to-markdown",
"keywords": [
"html",
"markdown"
],
"time": "2015-02-22 12:59:02"
},
{
"name": "pimple/pimple",
"version": "v3.0.0",

59
docs/postmark.markdown Normal file
View File

@ -0,0 +1,59 @@
Postmark
========
You can use the service [Postmark](https://postmarkapp.com/) to create tasks directly by email.
This integration works with the inbound email service of Postmark.
Kanboard use a webhook to handle incoming emails.
Incoming emails workflow
------------------------
1. You send an email to a specific address, by example **something+myproject@inbound.mydomain.tld**
2. Your email is forwarded to Postmark SMTP servers
3. Postmark call the Kanboard webhook with the email in JSON format
4. Kanboard parse the received email and create the task to the right project
Note: New tasks are automatically created in the first column.
Email format
------------
- The local part of the email address must use the plus separator, by example **kanboard+project123**
- The string defined after the plus sign must match a project identifier, by example **project123** is the identifier of the project **Project 123**
Email format
------------
- The email subject becomes the task subject
- The email body becomes the task description (Markdown format)
Incoming emails can be written in text or HTML formats.
**Kanboard is able to convert simple HTML emails to Markdown**.
Security and requirements
-------------------------
- The Kanboard webhook is protected by a random token
- The sender email address (From header) must match a Kanboard user
- The Kanboard project must have a unique identifier, by example **MYPROJECT**
- The Kanboard user must be member of the project
Postmark configuration
----------------------
- Follow the [official documentation about inbound email processing](http://developer.postmarkapp.com/developer-process-configure.html)
- The Kanboard webhook url is displayed in **Settings > Integrations > Postmark**
Kanboard configuration
----------------------
1. Be sure that your users have an email address in their profiles
2. Assign a project identifier to the desired projects: **Project settings > Edit**
3. Try to send an email to your project
Troubleshootings
----------------
- Test the webhook url from the Postmark console, you should have a status code `200 OK`
- Double-check requirements mentioned above

View File

@ -6,6 +6,8 @@ require __DIR__.'/../../app/constants.php';
use Symfony\Component\EventDispatcher\EventDispatcher;
use Symfony\Component\EventDispatcher\Debug\TraceableEventDispatcher;
use Symfony\Component\Stopwatch\Stopwatch;
use SimpleLogger\Logger;
use SimpleLogger\File;
date_default_timezone_set('UTC');
@ -38,6 +40,9 @@ abstract class Base extends PHPUnit_Framework_TestCase
);
$this->container['db']->log_queries = true;
$this->container['logger'] = new Logger;
$this->container['logger']->setLogger(new File('/dev/null'));
}
public function tearDown()

View File

@ -0,0 +1,83 @@
<?php
require_once __DIR__.'/Base.php';
use Integration\PostmarkWebhook;
use Model\TaskCreation;
use Model\TaskFinder;
use Model\Project;
use Model\ProjectPermission;
use Model\User;
class PostmarkWebhookTest extends Base
{
public function testHandlePayload()
{
$w = new PostmarkWebhook($this->container);
$p = new Project($this->container);
$pp = new ProjectPermission($this->container);
$u = new User($this->container);
$tc = new TaskCreation($this->container);
$tf = new TaskFinder($this->container);
$this->assertEquals(2, $u->create(array('name' => 'me', 'email' => 'me@localhost')));
$this->assertEquals(1, $p->create(array('name' => 'test1')));
$this->assertEquals(2, $p->create(array('name' => 'test2', 'identifier' => 'TEST1')));
// Empty payload
$this->assertFalse($w->parsePayload(array()));
// Unknown user
$this->assertFalse($w->parsePayload(array('From' => 'a@b.c', 'Subject' => 'Email task', 'MailboxHash' => 'foobar', 'TextBody' => 'boo')));
// Project not found
$this->assertFalse($w->parsePayload(array('From' => 'me@localhost', 'Subject' => 'Email task', 'MailboxHash' => 'test', 'TextBody' => 'boo')));
// User is not member
$this->assertFalse($w->parsePayload(array('From' => 'me@localhost', 'Subject' => 'Email task', 'MailboxHash' => 'test1', 'TextBody' => 'boo')));
$this->assertTrue($pp->addMember(2, 2));
// The task must be created
$this->assertTrue($w->parsePayload(array('From' => 'me@localhost', 'Subject' => 'Email task', 'MailboxHash' => 'test1', 'TextBody' => 'boo')));
$task = $tf->getById(1);
$this->assertNotEmpty($task);
$this->assertEquals(2, $task['project_id']);
$this->assertEquals('Email task', $task['title']);
$this->assertEquals('boo', $task['description']);
$this->assertEquals(2, $task['creator_id']);
}
public function testHtml2Markdown()
{
$w = new PostmarkWebhook($this->container);
$p = new Project($this->container);
$pp = new ProjectPermission($this->container);
$u = new User($this->container);
$tc = new TaskCreation($this->container);
$tf = new TaskFinder($this->container);
$this->assertEquals(2, $u->create(array('name' => 'me', 'email' => 'me@localhost')));
$this->assertEquals(1, $p->create(array('name' => 'test2', 'identifier' => 'TEST1')));
$this->assertTrue($pp->addMember(1, 2));
$this->assertTrue($w->parsePayload(array('From' => 'me@localhost', 'Subject' => 'Email task', 'MailboxHash' => 'test1', 'TextBody' => 'boo', 'HtmlBody' => '<p><strong>boo</strong></p>')));
$task = $tf->getById(1);
$this->assertNotEmpty($task);
$this->assertEquals(1, $task['project_id']);
$this->assertEquals('Email task', $task['title']);
$this->assertEquals('**boo**', $task['description']);
$this->assertEquals(2, $task['creator_id']);
$this->assertTrue($w->parsePayload(array('From' => 'me@localhost', 'Subject' => 'Email task', 'MailboxHash' => 'test1', 'TextBody' => '**boo**', 'HtmlBody' => '')));
$task = $tf->getById(2);
$this->assertNotEmpty($task);
$this->assertEquals(1, $task['project_id']);
$this->assertEquals('Email task', $task['title']);
$this->assertEquals('**boo**', $task['description']);
$this->assertEquals(2, $task['creator_id']);
}
}

View File

@ -203,4 +203,57 @@ class ProjectTest extends Base
$this->assertFalse($p->disablePublicAccess(123));
}
public function testIdentifier()
{
$p = new Project($this->container);
// Creation
$this->assertEquals(1, $p->create(array('name' => 'UnitTest1', 'identifier' => 'test1')));
$this->assertEquals(2, $p->create(array('name' => 'UnitTest2')));
$project = $p->getById(1);
$this->assertNotEmpty($project);
$this->assertEquals('TEST1', $project['identifier']);
$project = $p->getById(2);
$this->assertNotEmpty($project);
$this->assertEquals('', $project['identifier']);
// Update
$this->assertTrue($p->update(array('id' => '2', 'identifier' => 'test2')));
$project = $p->getById(2);
$this->assertNotEmpty($project);
$this->assertEquals('TEST2', $project['identifier']);
$project = $p->getByIdentifier('test1');
$this->assertNotEmpty($project);
$this->assertEquals('TEST1', $project['identifier']);
// Validation rules
$r = $p->validateCreation(array('name' => 'test', 'identifier' => 'TEST1'));
$this->assertFalse($r[0]);
$r = $p->validateCreation(array('name' => 'test', 'identifier' => 'test1'));
$this->assertFalse($r[0]);
$r = $p->validateModification(array('id' => 1, 'name' => 'test', 'identifier' => 'TEST1'));
$this->assertTrue($r[0]);
$r = $p->validateModification(array('id' => 1, 'name' => 'test', 'identifier' => 'test3'));
$this->assertTrue($r[0]);
$r = $p->validateModification(array('id' => 1, 'name' => 'test', 'identifier' => ''));
$this->assertTrue($r[0]);
$r = $p->validateModification(array('id' => 1, 'name' => 'test', 'identifier' => 'TEST2'));
$this->assertFalse($r[0]);
$r = $p->validateCreation(array('name' => 'test', 'identifier' => 'a-b-c'));
$this->assertFalse($r[0]);
$r = $p->validateCreation(array('name' => 'test', 'identifier' => 'test 123'));
$this->assertFalse($r[0]);
}
}