Commit 521c1d37 authored by Karamel's avatar Karamel
Browse files

Add record equality, input error and accept writing a ticket/session twice

Added DoctrineModel->equals()
Added error catching on ticket/session routes
Accept a ticket or session if it was already regisered with the same content instead of rejecting it
parent dcd426b2
<?php
use \Pasteque\Server\Exception\InvalidFieldException;
use \Pasteque\Server\Exception\RecordNotFoundException;
use \Pasteque\Server\Model\CashRegister;
use \Pasteque\Server\Model\CashSession;
......@@ -51,6 +52,8 @@ $app->POST('/api/cash',
$jsonSess = $data['session'];
$structSess = json_decode($jsonSess, true);
if ($structSess === null) {
APICaller::run($ptApp, 'cashSession', 'registerGeneralInputFailure',
[$jsonSess, 'Unable to parse input data']);
return $response->withStatus(400, 'Unable to parse input data');
}
$structSess['openDate'] = DateUtils::readDate($structSess['openDate']);
......@@ -59,7 +62,13 @@ $app->POST('/api/cash',
if ($session == null) {
$session = new CashSession();
}
$session->merge($structSess, $ptApp->getDao());
try {
$session->merge($structSess, $ptApp->getDao());
} catch (InvalidFieldException $e) {
APICaller::run($ptApp, 'cashSession', 'registerGeneralInputFailure',
[$structSess, $e]);
return $response->reject($e);
}
return $response->withAPIResult(APICaller::run($ptApp, 'cashSession',
'write', $session));
});
......
......@@ -188,6 +188,8 @@ $app->POST('/api/ticket', function ($request, $response, $args) {
$jsonTkts = $data['tickets'];
$structTkts = json_decode($jsonTkts, true);
if ($structTkts === null) {
APICaller::run($ptApp, 'ticket', 'registerGeneralInputFailure',
[$jsonTkts, 'Unable to parse input data']);
return $response->withStatus(400, 'Unable to parse input data');
}
$result = ['successes' => [], 'failures' => [], 'errors' => []];
......@@ -201,11 +203,20 @@ $app->POST('/api/ticket', function ($request, $response, $args) {
if ($ticket === null) {
$ticket = new Ticket();
}
$ticket->merge($structTkt, $ptApp->getDao());
$apiRes = APICaller::run($ptApp, 'ticket', 'write', $ticket);
$tktResp = ['cashRegister' => $structTkt['cashRegister'],
'sequence' => $structTkt['sequence'],
'number' => $structTkt['number']];
try {
$ticket->merge($structTkt, $ptApp->getDao());
} catch (InvalidFieldException $e) {
APICaller::run($ptApp, 'ticket', 'registerGeneralInputFailure',
[$structTkt, $e]);
$err = ['input' => $structTkt, 'error' => $e->toStruct()];
$tktResp['message'] = json_encode($err);
$result['failures'][] = $tktResp;
continue;
}
$apiRes = APICaller::run($ptApp, 'ticket', 'write', $ticket);
switch ($apiRes->getStatus()) {
case APIResult::STATUS_CALL_OK:
$result['successes'][] = $tktResp;
......
......@@ -23,6 +23,7 @@
namespace Pasteque\Server\API;
use \Pasteque\Server\API\FiscalAPI;
use \Pasteque\Server\Exception\PastequeException;
use \Pasteque\Server\Model\CashRegister;
use \Pasteque\Server\Model\CashSession;
use \Pasteque\Server\Model\FiscalTicket;
......@@ -75,6 +76,39 @@ class CashsessionAPI extends APIHelper implements API
}
}
/**
* Register an unexpected error while trying to write a cash session
* before the session object is readable.
* @param $input The faulty data.
* @param $reason The error reason. String message or PastequException.
*/
public function registerGeneralInputFailure($input, $reason) {
$sequence = FiscalTicket::getGeneralFailureSequence();
$fiscalAPI = new FiscalAPI($this->dao);
$previousFTicket = $fiscalAPI->getLastFiscalTicket(FiscalTicket::TYPE_ZTICKET, $sequence);
// Create the failure ticket
$fiscalTicket = new FiscalTicket();
$fiscalTicket->setType(FiscalTicket::TYPE_ZTICKET);
$fiscalTicket->setSequence($sequence);
if ($previousFTicket !== null) {
$fiscalTicket->setNumber($previousFTicket->getNumber() + 1);
} else {
$fiscalTicket->setNumber(1);
}
$fiscalTicket->setDate(new \DateTime());
$stone = ['input' => $data, 'failure' => $reason];
if ($reason instanceof PastequeException) {
$stone['failure'] = $reason->toStruct();
}
$fiscalTicket->setContent(json_encode($stone));
// Sign
$fiscalTicket->sign($previousFTicket);
// Write
$this->dao->write($fiscalTicket);
$this->updateEOSTicket($fiscalTicket);
$this->dao->commit();
}
/** Write a FiscalTicket about the failed zticket, so that there is a trace
* and the ticket can be purged client-side. It does commit.
* @param $session The faulty session.
......@@ -128,6 +162,10 @@ class CashsessionAPI extends APIHelper implements API
$currentData = $this->dao->readSnapshot(static::MODEL_NAME, $session->getId());
// Overwriting a closed session
if ($currentData !== null && $currentData->isClosed()) {
if ($currentData->equals($session)) {
// Nothing new, consider it is ok
return $currentData;
}
$rejectReason = 'Closed sessions are read-only.';
$this->registerFailure($session, $rejectReason);
throw new \BadMethodCallException($rejectReason);
......
......@@ -24,6 +24,7 @@ namespace Pasteque\Server\API;
use \Pasteque\Server\Exception\InvalidRecordException;
use \Pasteque\Server\Exception\InvalidFieldException;
use \Pasteque\Server\Exception\PastequeException;
use \Pasteque\Server\Exception\UnicityException;
use \Pasteque\Server\Model\CashRegister;
use \Pasteque\Server\Model\CashSession;
......@@ -38,18 +39,38 @@ class TicketAPI extends APIHelper implements API
const DEFAULT_ORDER = '-number';
/**
* Check for duplicated ticket in database. This is required because
* Doctrine will crash everything if a DB exception is thrown.
* @param \Pasteque\Server\Model\Ticket $tkt
* Register an unexpected error while trying to write a ticket
* before the ticket object is readable.
* @param $input The faulty data.
* @param $reason The error reason. String message or PastequException.
*/
private function canInsert($tkt) {
$search = $this->dao->search(static::MODEL_NAME,
[new DAOCondition('cashRegister', '=', $tkt->getCashRegister()),
new DAOCondition('number', '=', $tkt->getNumber())]);
return count($search) == 0;
public function registerGeneralInputFailure($input, $reason) {
$sequence = FiscalTicket::getGeneralFailureSequence();
$fiscalAPI = new FiscalAPI($this->dao);
$previousFTicket = $fiscalAPI->getLastFiscalTicket(FiscalTicket::TYPE_TICKET, $sequence);
// Create the failure ticket
$fiscalTicket = new FiscalTicket();
$fiscalTicket->setType(FiscalTicket::TYPE_TICKET);
$fiscalTicket->setSequence($sequence);
if ($previousFTicket !== null) {
$fiscalTicket->setNumber($previousFTicket->getNumber() + 1);
} else {
$fiscalTicket->setNumber(1);
}
$fiscalTicket->setDate(new \DateTime());
$stone = ['input' => $data, 'failure' => $reason];
if ($reason instanceof PastequeException) {
$stone['failure'] = $reason->toStruct();
}
$fiscalTicket->setContent(json_encode($stone));
// Sign
$fiscalTicket->sign($previousFTicket);
// Write
$this->dao->write($fiscalTicket);
$this->updateEOSTicket($fiscalTicket);
$this->dao->commit();
}
/** Write a FiscalTicket about the failed ticket, so that there is a trace
* and the ticket can be purged client-side. It does commit.
* @param $ticket The faulty ticket.
......@@ -106,12 +127,24 @@ class TicketAPI extends APIHelper implements API
throw new InvalidRecordException(InvalidRecordException::CSTR_GENERATED,
static::MODEL_NAME, $ticket->getDictId());
}
// Overwriting an existing ticket
if (!$this->canInsert($ticket)) {
$rejectReason = 'Tickets are read only.';
$this->registerFailure($ticket, $rejectReason);
throw new InvalidRecordException(InvalidRecordException::CSTR_READ_ONLY,
static::MODEL_NAME, $ticket->getDictId());
// Check for an existing ticket
$search = $this->dao->search(static::MODEL_NAME,
[new DAOCondition('cashRegister', '=', $ticket->getCashRegister()),
new DAOCondition('number', '=', $ticket->getNumber())]);
if (count($search) > 0) {
$oldTkt = $this->dao->readSnapshot(static::MODEL_NAME,
$search[0]->getId());
if ($oldTkt->equals($ticket)) {
// Nothing new, consider it is ok
return $oldTkt;
} else {
// Overriding an existing ticket
$rejectReason = 'Tickets are read only.';
$this->registerFailure($ticket, $rejectReason);
throw new InvalidRecordException(
InvalidRecordException::CSTR_READ_ONLY,
static::MODEL_NAME, $ticket->getDictId());
}
}
// Cash session must be opened
$sessSearch = $this->dao->search(CashSession::class,
......
......@@ -192,6 +192,14 @@ class FiscalTicket extends DoctrineMainModel
return sprintf('failure-%s', static::getZTicketSequence($cashSession));
}
/**
* Get the sequence for general errors before a regular FiscalTicket
* could be produced.
*/
public static function getGeneralFailureSequence() {
return 'failure-general';
}
public function toStruct() {
$struct = parent::toStruct();
$struct['date'] = DateUtils::toTimestamp($this->getDate());
......
......@@ -138,4 +138,42 @@ abstract class DoctrineEmbeddedModel extends DoctrineModel
$struct[$parentField] = $parent->getId();
return $struct;
}
/**
* @Override from DoctrineModel->equals
*/
public function equals($o) {
if ($o === null) {
return false;
}
if (!($o instanceof static) && (!($this instanceof $o))) {
// Double check because Doctrine proxies are subclasses
return false;
}
// Check that each direct field ignoring id shares the same values
foreach (static::getDirectFieldNames() as $field) {
$fieldName = $field;
if ($field instanceof \Pasteque\Server\Model\Field\Field) {
$fieldName = $field->getName();
}
if ($fieldName == 'id') {
continue;
}
if (!$this->directFieldEquals($o, $field)) {
return false;
}
}
// Check that each association fields ignoring internal fields
// and parent shares the same values
foreach (static::getAssociationFields() as $field) {
if (!empty($field['internal'])
|| $field['name'] == static::getParentFieldName()) {
continue;
}
if (!$this->associationEquals($o, $field)) {
return false;
}
}
return true;
}
}
......@@ -143,4 +143,41 @@ abstract class DoctrineMainModel extends DoctrineModel
throw $e;
}
}
/**
* @Override from DoctrineModel->equals
*/
public function equals($o) {
if ($o === null) {
return false;
}
if (!($o instanceof static) && (!($this instanceof $o))) {
// Double check because Doctrine proxies are subclasses
return false;
}
// Check that each direct field ignoring id shares the same values
foreach (static::getDirectFieldNames() as $field) {
$fieldName = $field;
if ($field instanceof \Pasteque\Server\Model\Field\Field) {
$fieldName = $field->getName();
}
if ($fieldName == 'id') {
continue;
}
if (!$this->directFieldEquals($o, $field)) {
return false;
}
}
// Check that each association fields ignoring internal fields shares
// the same values
foreach (static::getAssociationFields() as $field) {
if (!empty($field['internal'])) {
continue;
}
if (!$this->associationEquals($o, $field)) {
return false;
}
}
return true;
}
}
......@@ -23,6 +23,7 @@ namespace Pasteque\Server\System\DAO;
use \Pasteque\Server\Exception\InvalidFieldException;
use \Pasteque\Server\Model\Fields\Field;
use \Pasteque\Server\System\DateUtils;
/** Parent class of all models to use with Doctrine. */
abstract class DoctrineModel
......@@ -250,13 +251,14 @@ abstract class DoctrineModel
// Check for required (not null) and type
if (empty($field['null'])
&& $struct[$fieldName] === null) {
throw new InvalidFieldException(InvalidFieldException::NOT_NULL,
throw new InvalidFieldException(
InvalidFieldException::CSTR_NOT_NULL,
static::class, $field['name'],
$struct[], null);
$this->getId(), null);
}
if (!empty($field['array']) && !is_array($struct[$fieldName])) {
$id = $this->getId();
throw new InvalidFieldException(InvalidFieldException::ARRAY,
throw new InvalidFieldException(InvalidFieldException::CSTR_ARRAY,
static::class, $field['name'],
$id, null);
}
......@@ -407,4 +409,68 @@ abstract class DoctrineModel
return $subrecord;
}
}
/**
* Assuming $o is of the same class, check if $this and $o shares the
* same value for field $field.
*/
protected function directFieldEquals($o, $field) {
$fieldName = $field;
if ($field instanceof \Pasteque\Server\Model\Field\Field) {
$fieldName = $field->getName();
}
$getter = $this->findMethodNameOrThrow('get', $fieldName);
$thisVal = call_user_func([$this, $getter]);
$oVal = call_user_func([$o, $getter]);
if ($thisVal === null || $oVal === null) {
return $thisVal == $oVal;
}
if ($thisVal instanceof \DateTime) {
return DateUtils::equals($thisVal, $oVal);
}
return $thisVal == $oVal;
}
/**
* Assuming $o is of the same class, check if $this and $o shares the
* same association values for field $field. The values of the associated
* records are compared.
*/
protected function associationEquals($o, $field) {
$isArray = !empty($field['array']);
$getter = $this->findMethodNameOrThrow('get', $field['name']);
$thisVal = call_user_func([$this, $getter]);
$oVal = call_user_func([$o, $getter]);
if ($thisVal === null || $oVal === null) {
return $thisVal === $oVal;
}
if (!$isArray) {
return ($thisVal->equals($oVal));
} else {
if (count($thisVal) != count($oVal)) {
return false;
}
for ($i = 0; $i < count($thisVal); $i++) {
$thisSubVal = $thisVal[$i];
$oSubVal = $oVal[$i];
if ($thisSubVal === null || $oSubVal === null) {
if ($thisSubVal != $oSubVal) {
return false;
}
} else {
if (!$thisSubVal->equals($oSubVal)) {
return false;
}
}
}
}
return true;
}
/**
* Check if this record has the same values as the other.
* It should compare that $this and $o are of the same class and that
* every relevant fields share the same values.
*/
public abstract function equals($o);
}
......@@ -443,6 +443,31 @@ class CashsessionAPITest extends TestCase
}
}
/** @depends testWrite */
public function testCloseTwice() {
$session = new CashSession();
$session->setCashRegister($this->cashRegister);
$session->setSequence(1);
$openDate = new \DateTime();
$openDate->setDate(2017, 1, 2);
$openDate->setTime(14, 0);
$this->api->write($session);
$closeDate = new \DateTime();
$closeDate->setDate(2017, 1, 2);
$closeDate->setTime(22, 0);
$session->setCloseDate($closeDate);
$this->api->write($session);
$fTktCount = count($this->dao->search(FiscalTicket::class));
$sessCount = count($this->dao->search(CashSession::class));
$this->api->write($session);
// Check that there is no new cash session
$this->assertEquals($sessCount,
count($this->dao->search(CashSession::class)));
// Check that there is no new fiscal ticket
$this->assertEquals($fTktCount,
count($this->dao->search(FiscalTicket::class)));
}
/** @depends testWrite */
public function testOverwriteClosed() {
$this->expectException(\BadMethodCallException::class);
......
......@@ -323,63 +323,35 @@ class TicketAPITest extends TestCase
/** @depends testSaveTicket
* Test writing two identical tickets (without sharing references). */
public function testSaveDoubleTicket() {
public function testSaveTicketTwice() {
$tkt1 = new Ticket();
$tkt1->merge($this->sampleTkt(), $this->dao);
$this->api->write($tkt1);
$fTktCount = count($this->dao->search(FiscalTicket::class));
$exceptionThrown = false;
$tkt2 = new Ticket();
$tkt2->merge($this->sampleTkt(), $this->dao);
$exceptionThrown = false;
try {
$this->api->write($tkt2);
} catch (InvalidRecordException $e) {
$exceptionThrown = true;
$now = new \DateTime();
// Check the exception
$this->assertEquals(InvalidRecordException::CSTR_READ_ONLY,
$e->getConstraint());
$this->assertEquals(Ticket::class, $e->getClass());
$tktId = $e->getId();
$this->assertEquals($this->session->getCashRegister()->getId(),
$tktId['cashRegister']);
$this->assertEquals($this->session->getSequence(),
$tktId['sequence']);
$this->assertEquals($tkt2->getNumber(), $tktId['number']);
// Check that the ticket is untouched.
$searchTkt = $this->dao->search(Ticket::class);
$this->assertEquals(1, count($searchTkt));
// Check that the failure fiscal ticket is there.
$readfTkt = $this->dao->read(FiscalTicket::class, [
'type' => FiscalTicket::TYPE_TICKET,
'sequence' => FiscalTicket::getFailureTicketSequence($tkt1),
'number' => 1]);
$readEOSTkt = $this->dao->read(FiscalTicket::class, [
'type' => FiscalTicket::TYPE_TICKET,
'sequence' => FiscalTicket::getFailureTicketSequence($tkt1),
'number' => 0]);
$this->assertNotNull($readfTkt);
$this->assertNotNull($readEOSTkt);
$this->assertLessThan(3,
abs($now->getTimestamp() - $readfTkt->getDate()->getTimestamp()));
$fdata = json_decode($readfTkt->getContent(), true);
$this->assertNotNull($fdata);
$this->assertEquals($fdata['failure'], 'Tickets are read only.');
$this->assertTrue($readfTkt->checkSignature(null));
$this->assertTrue($readEOSTkt->checkSignature($readfTkt));
}
$this->assertTrue($exceptionThrown,
'Expecting InvalidRecordException.');
$this->api->write($tkt2);
// Check that there is no new ticket
$searchTkt = $this->dao->search(Ticket::class);
$this->assertEquals(1, count($searchTkt));
// Check that there is no new fiscal ticket
$this->assertEquals($fTktCount,
count($this->dao->search(FiscalTicket::class)));
}
/** @depends testSaveTicket
* Test writing the same ticket twice (shared references). */
* Test writing 2 tickets with the same number. */
public function testSaveTicketOverwrite() {
$tkt = new Ticket();
$tkt->merge($this->sampleTkt(), $this->dao);
$this->api->write($tkt);
$tkt2 = new Ticket();
$tkt2->merge($this->sampleTkt(), $this->dao);
$tkt2->getLines()->get(0)->setProductLabel('test2');
$exceptionThrown = false;
try {
$this->api->write($tkt);
$this->api->write($tkt2);
} catch (InvalidRecordException $e) {
$exceptionThrown = true;
$now = new \DateTime();
......@@ -423,14 +395,26 @@ class TicketAPITest extends TestCase
public function testFailTwice() {
$tkt = new Ticket();
$tkt->merge($this->sampleTkt(), $this->dao);
$this->api->write($tkt);
$tkt2 = new Ticket();
$tkt2->merge($this->sampleTkt(), $this->dao);
$line2 = new TicketLine();
$line2->setDispOrder(1);
$line2->setTaxedUnitPrice(22.0);
$line2->setQuantity(1);
$line2->setTax($this->tax);
$line2->setProduct($this->prd);
$line2->setTaxRate(0.1);
$line2->setTaxedPrice(22.0);
$line2->setFinalTaxedPrice(22.0);
$tkt2->addLine($line2);
$exceptionThrown = false;
try {
$this->api->write($tkt);
$this->api->write($tkt);
$this->api->write($tkt2);
} catch (InvalidRecordException $e) {
// this is expected, refail
try {
$this->api->write($tkt);
$this->api->write($tkt2);
} catch (InvalidRecordException $e) {
$exceptionThrown = true;
$now = new \DateTime();
......
......@@ -155,4 +155,92 @@ class CategoryTest extends TestCase
$this->assertEquals($cat->getLabel(), $parent->getLabel());
$this->assertEquals($cat->getDispOrder(), $parent->getDispOrder());
}
public function testEqualsSimple() {
$cat = new Category();
$cat->setReference('Ref');
$cat->setLabel('label');
$cat2 = new Category();
$cat2->setReference('Ref');
$cat2->setLabel('label');
$this->assertTrue($cat->equals($cat2));
}
public function testEqualsAssociation() {
$parent = new Category();
$parent->setReference('Parent');
$parent->setLabel('Parent');
$this->dao->write($parent);
$this->dao->commit();
$cat = new Category();
$cat->setReference('Ref');
$cat->setLabel('label');
$cat->setParent($parent);
$cat2 = new Category();
$cat2->setReference('Ref');
$cat2->setLabel('label');
$cat2->setParent($parent);
$this->assertTrue($cat->equals($cat2));
}
public function testUnequalsNull() {
$cat = new Category();
$cat->setReference('Ref');
$cat->setLabel('label');
$this->assertFalse($cat->equals(null));
}
public function testUnequalsModel() {
$cat = new Category();
$prd = new \Pasteque\Server\Model\Product();
$this->assertFalse($cat->equals($prd));
}
public function testUnequalsSimple() {
$cat = new Category();
$cat->setReference('Ref');
$cat->setLabel('label');