diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8f7c2db05..bed7e4030 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -261,7 +261,7 @@ jobs: report_name: Coverage Frontend type: lcov result_path: ./coverage/lcov.info - min_coverage: 32 + min_coverage: 46 token: ${{ github.token }} ############################################################################## diff --git a/community_server/src/Controller/AppRequestsController.php b/community_server/src/Controller/AppRequestsController.php index ff3314e94..4b81b8ac9 100644 --- a/community_server/src/Controller/AppRequestsController.php +++ b/community_server/src/Controller/AppRequestsController.php @@ -25,7 +25,7 @@ class AppRequestsController extends AppController $this->loadComponent('GradidoNumber'); //$this->loadComponent('JsonRpcRequestClient'); //$this->Auth->allow(['add', 'edit']); - $this->Auth->allow(['index', 'sendCoins', 'createCoins', 'getBalance', 'listTransactions']); + $this->Auth->allow(['index', 'sendCoins', 'createCoins', 'getBalance', 'listTransactions', 'getDecayStartBlock']); } @@ -333,16 +333,37 @@ class AppRequestsController extends AppController $this->addAdminError('StateBalancesController', 'overview', $gdtEntries, $user['id'] ? $user['id'] : 0); } + $limit = $count; + $offset = 0; + $skip_first_transaction = false; + if($page == 1) { + $limit--; + } else { + $offset = (( $page - 1 ) * $count) - 1; + } + if($offset && $orderDirection == 'ASC') { + // move cursor one step backwards to able to load one transaction previous last which will be shown for decay calculation + $offset--; + $limit++; + $skip_first_transaction = true; + } else if($orderDirection == 'DESC') { + $limit++; + $skip_first_transaction = true; + } $stateUserTransactionsQuery = $stateUserTransactionsTable ->find() ->where(['state_user_id' => $user['id']]) ->order(['balance_date' => $orderDirection]) ->contain([]) - ->limit($count) - ->page($page) + ->limit($limit) + //->page($page) + ->offset($offset) ; $decay = true; + if($page > 1) { + $decay = false; + } $transactions = []; $transactions_from_db = $stateUserTransactionsQuery->toArray(); @@ -351,7 +372,7 @@ class AppRequestsController extends AppController $transactions_from_db = array_reverse($transactions_from_db); } - $transactions = $transactionsTable->listTransactionsHumanReadable($transactions_from_db, $user, $decay); + $transactions = $transactionsTable->listTransactionsHumanReadable($transactions_from_db, $user, $decay, $skip_first_transaction); if($orderDirection == 'DESC') { $transactions = array_reverse($transactions); @@ -382,6 +403,16 @@ class AppRequestsController extends AppController $this->set('body', $body); } + public function getDecayStartBlock() + { + $transactionsTable = TableRegistry::getTableLocator()->get('Transactions'); + $decayStartBlock = $transactionsTable->find()->where(['transaction_type_id' => 9]); + if(!$decayStartBlock->count()) { + return $this->returnJson(['state' => 'error', 'msg' => 'not found']); + } + return $this->returnJson(['state' => 'success', 'decay_start' => $decayStartBlock->first()->received]); + } + private function acquireAccessToken($session_id) { diff --git a/community_server/src/Model/Table/StateBalancesTable.php b/community_server/src/Model/Table/StateBalancesTable.php index 25d588f30..ff7a0aca2 100644 --- a/community_server/src/Model/Table/StateBalancesTable.php +++ b/community_server/src/Model/Table/StateBalancesTable.php @@ -98,7 +98,12 @@ class StateBalancesTable extends AppTable // if start date for decay is after enddate, we also just return input if($decayStartDate === null || $decayStartDate >= $endDate) { if($withInterval) { - return ['balance' => $startBalance, 'interval' => new \DateInterval('PT0S')]; + return [ + 'balance' => $startBalance, + 'interval' => new \DateInterval('PT0S'), + 'start_date' => $startDate->getTimestamp(), + 'end_date' => $startDate->getTimestamp() + ]; } else { return $startBalance; } @@ -118,7 +123,12 @@ class StateBalancesTable extends AppTable } $decay = $state_balance->partDecay($endDate); if($withInterval) { - return ['balance' => $decay, 'interval' => $interval]; + return [ + 'balance' => $decay, + 'interval' => $interval, + 'start_date' => $state_balance->record_date->getTimestamp(), + 'end_date' => $endDate->getTimestamp() + ]; } else { return $decay; } diff --git a/community_server/src/Model/Table/TransactionsTable.php b/community_server/src/Model/Table/TransactionsTable.php index 301e9b70b..925f6f31e 100644 --- a/community_server/src/Model/Table/TransactionsTable.php +++ b/community_server/src/Model/Table/TransactionsTable.php @@ -133,11 +133,12 @@ class TransactionsTable extends Table } - public function listTransactionsHumanReadable($stateUserTransactions, array $user, $decay = true) + public function listTransactionsHumanReadable($stateUserTransactions, array $user, $decay = true, $skip_first_transaction = false) { $stateUsersTable = TableRegistry::getTableLocator()->get('StateUsers'); $stateBalancesTable = TableRegistry::getTableLocator()->get('StateBalances'); + $transactionsTable = TableRegistry::getTableLocator()->get('Transactions'); $transaction_ids = []; $involved_user_ids = []; @@ -162,11 +163,15 @@ class TransactionsTable extends Table $state_balance = $stateBalancesTable->newEntity(); $final_transactions = []; + $decay_start_transaction = $transactionsTable->find()->where(['transaction_type_id' => 9]); + $decay_start_transaction_id = 0; + if($decay_start_transaction->count()) { + $decay_start_transaction_id = $decay_start_transaction->first()->id; + } + $decay_start_time = $stateBalancesTable->getDecayStartDateCached()->getTimestamp(); foreach($stateUserTransactions as $i => $su_transaction) { - - // sender or receiver when user has sended money // group name if creation // type: gesendet / empfangen / geschöpft @@ -185,19 +190,34 @@ class TransactionsTable extends Table if($i > 0 ) { $prev = $stateUserTransactions[$i-1]; } - if($prev && $decay == true) + if($prev) { - if($prev->balance > 0) { + if($prev->balance > 0) + { $current = $su_transaction; $calculated_decay = $stateBalancesTable->calculateDecay($prev->balance, $prev->balance_date, $current->balance_date, true); $balance = floatval($prev->balance - $calculated_decay['balance']); - if($balance > 100) + if($balance) { - $final_transactions['decay'] = [ + $final_transaction['decay'] = [ 'balance' => $balance, - 'decay_duration' => $calculated_decay['interval']->format('%a days, %H hours, %I minutes, %S seconds') - ]; + 'decay_duration' => $calculated_decay['interval']->format('%a days, %H hours, %I minutes, %S seconds'), + 'decay_start' => $calculated_decay['start_date'], + 'decay_end' => $calculated_decay['end_date'] + ]; + if($prev->transaction_id < $decay_start_transaction_id && + $current->transaction_id > $decay_start_transaction_id) { + $final_transaction['decay']['decay_start_block'] = $decay_start_time; + } + // hint: use transaction id + /*if($calculated_decay['start_date'] < $decay_start_time && $calculated_decay['end_date'] > $decay_start_time) { + $final_transaction['decay']['decay_start_block'] = $decay_start_time; + } else { + echo "start block: " . $decay_start_time . "
"; + echo "start date: " . $calculated_decay['start_date'] . "
"; + echo "end date: " . $calculated_decay['end_date']. "
"; + }*/ } } } @@ -247,24 +267,28 @@ class TransactionsTable extends Table $final_transaction['name'] = $otherUser->first_name . ' ' . $otherUser->last_name; $final_transaction['email'] = $otherUser->email; } - - $final_transactions[] = $final_transaction; + if($i > 0 || !$skip_first_transaction) { + $final_transactions[] = $final_transaction; + } - if($i == $stateUserTransactionsCount-1 && $decay == true) { + if($i == $stateUserTransactionsCount-1 && $decay) { + $now = new FrozenTime(); $calculated_decay = $stateBalancesTable->calculateDecay( $su_transaction->balance, - $su_transaction->balance_date, new FrozenTime(), true); + $su_transaction->balance_date, $now, true); $decay_start_date = $stateBalancesTable->getDecayStartDateCached(); $duration = $su_transaction->balance_date->timeAgoInWords(); if($decay_start_date > $su_transaction->balance_date) { $duration = $decay_start_date->timeAgoInWords(); } $balance = floatval($su_transaction->balance - $calculated_decay['balance']); - if($balance > 100) { + if($balance) { $final_transactions[] = [ 'type' => 'decay', 'balance' => $balance, 'decay_duration' => $duration, + 'decay_start' => $calculated_decay['start_date'], + 'decay_end' => $calculated_decay['end_date'], 'memo' => '' ]; } diff --git a/community_server/src/Template/AppRequests/list_transactions.ctp b/community_server/src/Template/AppRequests/list_transactions.ctp index f978e2e31..174092273 100644 --- a/community_server/src/Template/AppRequests/list_transactions.ctp +++ b/community_server/src/Template/AppRequests/list_transactions.ctp @@ -12,15 +12,19 @@ $body['gdtSum'] = $this->element('centToFloat', ['cent' => $body['gdtSum'], 'pre foreach($body['transactions'] as $i => $transaction) { $useCeil = false; - if($transaction['type'] == 'decay') { - $useCeil = true; - } - $body['transactions'][$i]['balance'] = $this->element('centToFloat', ['cent' => $transaction['balance'], 'precision' => 4, 'useCeil' => $useCeil]); - if(isset($transaction['creation_amount'])) { - $body['transactions'][$i]['creation_amount'] = $this->element('centToFloat', ['cent' => $transaction['creation_amount'], 'precision' => 4]); - } - if(isset($transaction['decay'])) { - $body['transactions'][$i]['decay']['balance'] = $this->element('centToFloat', ['cent' => $transaction['decay']['balance'], 'precision' => 4]); + if(!isset($transaction['type'])) { + $body = ['state' => 'error', 'msg' => 'transaction without type found', 'details' => $transaction]; + } else { + if($transaction['type'] == 'decay') { + $useCeil = true; + } + $body['transactions'][$i]['balance'] = $this->element('centToFloat', ['cent' => $transaction['balance'], 'precision' => 4, 'useCeil' => $useCeil]); + if(isset($transaction['creation_amount'])) { + $body['transactions'][$i]['creation_amount'] = $this->element('centToFloat', ['cent' => $transaction['creation_amount'], 'precision' => 4]); + } + if(isset($transaction['decay'])) { + $body['transactions'][$i]['decay']['balance'] = $this->element('centToFloat', ['cent' => $transaction['decay']['balance'], 'precision' => 4]); + } } } diff --git a/community_server/tests/Fixture/StateUserTransactionsFixture.php b/community_server/tests/Fixture/StateUserTransactionsFixture.php index 37a132eb5..605e10e5b 100644 --- a/community_server/tests/Fixture/StateUserTransactionsFixture.php +++ b/community_server/tests/Fixture/StateUserTransactionsFixture.php @@ -46,12 +46,12 @@ class StateUserTransactionsFixture extends BaseTestFixture [8, 4, 4, 2, 0, '2021-04-14 00:00:00'], [23, 1, 5, 2, 0, '2021-04-14 09:01:07'], [24, 4, 5, 2, 0, '2021-04-14 09:01:07'], - [25, 4, 6, 2, 0, '2021-04-14 09:02:28'], - [26, 1, 6, 2, 0, '2021-04-14 09:02:28'], - [27, 4, 7, 2, 0, '2021-04-14 09:28:46'], - [28, 1, 7, 2, 0, '2021-04-14 09:28:46'], - [29, 4, 8, 2, 0, '2021-04-14 09:31:28'], - [30, 1, 8, 2, 0, '2021-04-14 09:31:28'] + [25, 4, 7, 2, 0, '2021-04-14 09:02:28'], + [26, 1, 7, 2, 0, '2021-04-14 09:02:28'], + [27, 4, 8, 2, 0, '2021-04-14 09:28:46'], + [28, 1, 8, 2, 0, '2021-04-14 09:28:46'], + [29, 4, 9, 2, 0, '2021-04-14 09:31:28'], + [30, 1, 9, 2, 0, '2021-04-14 09:31:28'] ]; $this->records = $this->sqlEntrysToRecords($sql, $this->fields); parent::init(); diff --git a/community_server/tests/Fixture/TransactionSendCoinsFixture.php b/community_server/tests/Fixture/TransactionSendCoinsFixture.php index a109dfd7f..c186ccf8e 100644 --- a/community_server/tests/Fixture/TransactionSendCoinsFixture.php +++ b/community_server/tests/Fixture/TransactionSendCoinsFixture.php @@ -43,9 +43,9 @@ class TransactionSendCoinsFixture extends BaseTestFixture [2, 3, '0000000000000000000000000000000000000000000000000000000000000000', 1, 'e3369de3623ce8446d0424c4013e7a1d71a2671ae3d7bf1e798ebf0665d145f2', 4, 1000000, 6254699], [3, 4, '0000000000000000000000000000000000000000000000000000000000000000', 1, 'e3369de3623ce8446d0424c4013e7a1d71a2671ae3d7bf1e798ebf0665d145f2', 4, 100000, 7027197], [11, 5, '0000000000000000000000000000000000000000000000000000000000000000', 1, 'e3369de3623ce8446d0424c4013e7a1d71a2671ae3d7bf1e798ebf0665d145f2', 4, 100000, 6922113], - [12, 6, '0000000000000000000000000000000000000000000000000000000000000000', 4, 'f7f4a49a4ac10379f8b9ddcb731c4d9ec495e6edd16075f52672cd25e3179f0f', 1, 100000, 9212951], - [13, 7, '0000000000000000000000000000000000000000000000000000000000000000', 4, 'f7f4a49a4ac10379f8b9ddcb731c4d9ec495e6edd16075f52672cd25e3179f0f', 1, 100000, 9112627], - [14, 8, '0000000000000000000000000000000000000000000000000000000000000000', 4, 'f7f4a49a4ac10379f8b9ddcb731c4d9ec495e6edd16075f52672cd25e3179f0f', 1, 100000, 8912594] + [12, 7, '0000000000000000000000000000000000000000000000000000000000000000', 4, 'f7f4a49a4ac10379f8b9ddcb731c4d9ec495e6edd16075f52672cd25e3179f0f', 1, 100000, 9212951], + [13, 8, '0000000000000000000000000000000000000000000000000000000000000000', 4, 'f7f4a49a4ac10379f8b9ddcb731c4d9ec495e6edd16075f52672cd25e3179f0f', 1, 100000, 9112627], + [14, 9, '0000000000000000000000000000000000000000000000000000000000000000', 4, 'f7f4a49a4ac10379f8b9ddcb731c4d9ec495e6edd16075f52672cd25e3179f0f', 1, 100000, 8912594] ]; $this->records = $this->sqlEntrysToRecords($sql, $this->fields); diff --git a/community_server/tests/Fixture/TransactionSignaturesFixture.php b/community_server/tests/Fixture/TransactionSignaturesFixture.php index 710f055a0..9a5bbecee 100644 --- a/community_server/tests/Fixture/TransactionSignaturesFixture.php +++ b/community_server/tests/Fixture/TransactionSignaturesFixture.php @@ -41,9 +41,9 @@ class TransactionSignaturesFixture extends BaseTestFixture [3, 3, 'c70f124feaaea02194d22a5f597963ed3e430343122a0952877854766fe37a709f92b39510de2aae494ef11abe743cd59f08f971b1e0e36f4c333990453d8b0d', 'f7f4a49a4ac10379f8b9ddcb731c4d9ec495e6edd16075f52672cd25e3179f0f'], [4, 4, 'a65b39e51ab6191c51d5629bbcefd30f85f801efbb14e1c635c519e97abe217a248820fa1fc6aef56227c9d888c1919bc92471d5d7ae3522c9c50fba9f0d8402', 'f7f4a49a4ac10379f8b9ddcb731c4d9ec495e6edd16075f52672cd25e3179f0f'], [5, 5, 'a65b39e51ab6191c51d5629bbcefd30f85f801efbb14e1c635c519e97abe217a248820fa1fc6aef56227c9d888c1919bc92471d5d7ae3522c9c50fba9f0d8402', 'f7f4a49a4ac10379f8b9ddcb731c4d9ec495e6edd16075f52672cd25e3179f0f'], - [6, 6, 'c233726674bff9bfb8ccb98bf358c6bc701825d971ece915d3c3a3de98886d1d13ee2f773cd9fc4ccbe543ac17be0d780ebead23a0dbf4ec814f7bae2efb9c0e', 'e3369de3623ce8446d0424c4013e7a1d71a2671ae3d7bf1e798ebf0665d145f2'], - [7, 7, '83ab780535883ec53ee76d0f68db0e1596418c9e100c806a4d4655d4dedf589d54a6319a2795dabab301e212b52f0dafb2725b7583447f19e47cb417d188a107', 'e3369de3623ce8446d0424c4013e7a1d71a2671ae3d7bf1e798ebf0665d145f2'], - [8, 8, '83ab780535883ec53ee76d0f68db0e1596418c9e100c806a4d4655d4dedf589d54a6319a2795dabab301e212b52f0dafb2725b7583447f19e47cb417d188a107', 'e3369de3623ce8446d0424c4013e7a1d71a2671ae3d7bf1e798ebf0665d145f2'] + [6, 7, 'c233726674bff9bfb8ccb98bf358c6bc701825d971ece915d3c3a3de98886d1d13ee2f773cd9fc4ccbe543ac17be0d780ebead23a0dbf4ec814f7bae2efb9c0e', 'e3369de3623ce8446d0424c4013e7a1d71a2671ae3d7bf1e798ebf0665d145f2'], + [7, 8, '83ab780535883ec53ee76d0f68db0e1596418c9e100c806a4d4655d4dedf589d54a6319a2795dabab301e212b52f0dafb2725b7583447f19e47cb417d188a107', 'e3369de3623ce8446d0424c4013e7a1d71a2671ae3d7bf1e798ebf0665d145f2'], + [8, 9, '83ab780535883ec53ee76d0f68db0e1596418c9e100c806a4d4655d4dedf589d54a6319a2795dabab301e212b52f0dafb2725b7583447f19e47cb417d188a107', 'e3369de3623ce8446d0424c4013e7a1d71a2671ae3d7bf1e798ebf0665d145f2'] ]; $this->records = $this->sqlEntrysToRecords($sql, $this->fields); parent::init(); diff --git a/community_server/tests/Fixture/TransactionsFixture.php b/community_server/tests/Fixture/TransactionsFixture.php index befafc16b..8c38f0657 100644 --- a/community_server/tests/Fixture/TransactionsFixture.php +++ b/community_server/tests/Fixture/TransactionsFixture.php @@ -44,9 +44,10 @@ class TransactionsFixture extends BaseTestFixture [3, NULL, 2, '4e2235f208edaf5cbb285955732022a625cf1e100eb629c56896d2fbfb8b34e800000000000000000000000000000000', 'test', '2021-04-12 00:00:00', 1], [4, NULL, 2, 'fc6e69696beb7c56ad7c511fc3999f954411427bec810184b70c092911deae1900000000000000000000000000000000', 'test time', '2021-04-14 00:00:00', 1], [5, NULL, 2, 'a7149ebc0d6cd8c061906dafe05e13689b51642a41100d0ec7bb6cd2dcafdc1800000000000000000000000000000000', 'test time', '2021-04-14 09:01:07', 1], - [6, NULL, 2, '2e3c3ab3e42c06f2ecb12f61c970712467d8ad9ddfa16fa58dd76492e5924b7d00000000000000000000000000000000', 'test time 3', '2021-04-14 09:02:28', 1], - [7, NULL, 2, 'c2c6354d77ff371daeee25fce9c947748b53d3d6b8398a92bd681923cfd2057100000000000000000000000000000000', 'test login crash', '2021-04-14 09:28:46', 1], - [8, NULL, 2, '5a8cbf1aaac06b00b2951ff39983cb2ca9a1e6710d72c8e5067278dc679a823100000000000000000000000000000000', 'test login crash', '2021-04-14 09:31:28', 1] + [6, NULL, 9, '', '', '2021-04-14 09:02:00', 1], + [7, NULL, 2, '2e3c3ab3e42c06f2ecb12f61c970712467d8ad9ddfa16fa58dd76492e5924b7d00000000000000000000000000000000', 'test time 3', '2021-04-14 09:02:28', 1], + [8, NULL, 2, 'c2c6354d77ff371daeee25fce9c947748b53d3d6b8398a92bd681923cfd2057100000000000000000000000000000000', 'test login crash', '2021-04-14 09:28:46', 1], + [9, NULL, 2, '5a8cbf1aaac06b00b2951ff39983cb2ca9a1e6710d72c8e5067278dc679a823100000000000000000000000000000000', 'test login crash', '2021-04-14 09:31:28', 1] ]; $this->records = $this->sqlEntrysToRecords($sql, $this->fields); parent::init(); diff --git a/community_server/tests/TestCase/Controller/AppRequestControllerTest.php b/community_server/tests/TestCase/Controller/AppRequestControllerTest.php index a374ae878..5ee1f3c53 100644 --- a/community_server/tests/TestCase/Controller/AppRequestControllerTest.php +++ b/community_server/tests/TestCase/Controller/AppRequestControllerTest.php @@ -57,8 +57,8 @@ class AppRequestControllerTest extends TestCase $response = $this->getAndParseWithoutCompare('/api/get-balance/' . $session_id); $this->assertEquals('success', $response->state); - $this->assertEquals(9100000, $response->balance); - $this->assertLessThan(9100000, $response->decay); + $this->assertEquals(9099652, $response->balance); + $this->assertLessThan(9099652, $response->decay); } @@ -94,8 +94,8 @@ class AppRequestControllerTest extends TestCase $response = $this->getAndParseWithoutCompare('/api/get-balance/' . $session_id); $this->assertEquals('success', $response->state); - $this->assertEquals(10900000, $response->balance); - $this->assertLessThan(10900000, $response->decay); + $this->assertEquals(10899568, $response->balance); + $this->assertLessThan(10899568, $response->decay); } public function testGetBalanceInvalidSession() @@ -202,19 +202,16 @@ class AppRequestControllerTest extends TestCase "email": "test3.yahoo.com" }, { - "transaction_id": 6, + "transaction_id": 7, "date": "2021-04-14T09:02:28+00:00", "memo": "test time 3", - "balance": 100000, - "type": "receive", - "pubkey": "0000000000000000000000000000000000000000000000000000000000000000", - "name": "Samuel Schmied", - "email": "test3.yahoo.com" - }, - { - "transaction_id": 7, - "date": "2021-04-14T09:28:46+00:00", - "memo": "test login crash", + "decay": { + "balance": 6, + "decay_duration": "0 days, 00 hours, 00 minutes, 28 seconds", + "decay_start": 1618390920, + "decay_end": 1618390948, + "decay_start_block": 1618390920 + }, "balance": 100000, "type": "receive", "pubkey": "0000000000000000000000000000000000000000000000000000000000000000", @@ -223,22 +220,52 @@ class AppRequestControllerTest extends TestCase }, { "transaction_id": 8, - "date": "2021-04-14T09:31:28+00:00", + "date": "2021-04-14T09:28:46+00:00", "memo": "test login crash", + "decay": { + "balance": 309, + "decay_duration": "0 days, 00 hours, 26 minutes, 18 seconds", + "decay_start": 1618390948, + "decay_end": 1618392526 + }, "balance": 100000, "type": "receive", "pubkey": "0000000000000000000000000000000000000000000000000000000000000000", "name": "Samuel Schmied", "email": "test3.yahoo.com" + }, + { + "transaction_id": 9, + "date": "2021-04-14T09:31:28+00:00", + "memo": "test login crash", + "decay": { + "balance": 33, + "decay_duration": "0 days, 00 hours, 02 minutes, 42 seconds", + "decay_start": 1618392526, + "decay_end": 1618392688 + }, + "balance": 100000, + "type": "receive", + "pubkey": "0000000000000000000000000000000000000000000000000000000000000000", + "name": "Samuel Schmied", + "email": "test3.yahoo.com" + }, + { + "type": "decay", + "balance": 1345726, + "decay_duration": "on 14.04.21", + "decay_start": 1618392688, + "decay_end": 1625673853, + "memo": "" } ], "transactionExecutingCount": 0, "count": 7, "gdtSum": 180000, - "timeUsed": 0.7237420082092285, - "decay_date": "2021-06-22T08:54:43+00:00", - "balance": 9100000, - "decay": 9100000 + "timeUsed": 0.44154810905456545, + "decay_date": "2021-07-07T16:04:13+00:00", + "balance": 9099652, + "decay": 7753926 }'; $this->getAndParse('/api/list-transactions/', json_decode($expectedResult, true)); } @@ -276,6 +303,19 @@ class AppRequestControllerTest extends TestCase $expected[$field] = $json->$field; } } + // decay balance variy always + if(isset($expected['transactions'])) { + $dynamic_transaction_fields = ['decay_duration', 'balance', 'decay_end']; + foreach($expected['transactions'] as $i => $transaction) { + if(isset($transaction['type']) && $transaction['type'] == 'decay') { + foreach($dynamic_transaction_fields as $field) { + if(isset($transaction[$field])) { + $expected['transactions'][$i][$field] = $json->transactions[$i][$field]; + } + } + } + } + } $expected = json_encode($expected); } diff --git a/docu/community-server.api.md b/docu/community-server.api.md index 11556513b..64127dd81 100644 --- a/docu/community-server.api.md +++ b/docu/community-server.api.md @@ -63,6 +63,8 @@ Assuming: session is valid "type": "decay", "balance": "14.74", "decay_duration": "4 days, 2 hours ago", + "decay_start": 1618390948, + "decay_end": 1618392526, "memo": "" }, { @@ -73,7 +75,13 @@ Assuming: session is valid "date": "2021-02-19T13:25:36+00:00", "balance": 192.0, "memo": "a piece of cake :)", - "pubkey": "038a6f93270dc57b91d76bf110ad3863fcb7d1b08e7692e793fcdb4467e5b6a7" + "pubkey": "038a6f93270dc57b91d76bf110ad3863fcb7d1b08e7692e793fcdb4467e5b6a7", + "decay": { + "balance": 309, + "decay_duration": "0 days, 00 hours, 26 minutes, 18 seconds", + "decay_start": 1618390948, + "decay_end": 1618392526 + }, }, { "name": "Gradido Akademie", diff --git a/frontend/package.json b/frontend/package.json index 04893215d..0c406c696 100755 --- a/frontend/package.json +++ b/frontend/package.json @@ -59,7 +59,6 @@ "sweetalert2": "^9.5.4", "vee-validate": "^3.4.5", "vue": "^2.6.11", - "vue-bootstrap-toasts": "^1.0.7", "vue-bootstrap-typeahead": "^0.2.6", "vue-chartjs": "^3.5.0", "vue-cli-plugin-i18n": "^1.0.1", @@ -75,6 +74,7 @@ "vue-qrcode": "^0.3.5", "vue-qrcode-reader": "^2.3.16", "vue-router": "^3.0.6", + "vue-toasted": "^1.1.28", "vue2-transitions": "^0.2.3", "vuex": "^3.6.0", "vuex-persistedstate": "^4.0.0-beta.3" diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 500a7b2e1..a6613bec1 100755 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -3,7 +3,6 @@
-
diff --git a/frontend/src/apis/loginAPI.js b/frontend/src/apis/loginAPI.js index f71e7634f..9dd8ce98d 100644 --- a/frontend/src/apis/loginAPI.js +++ b/frontend/src/apis/loginAPI.js @@ -93,7 +93,7 @@ const loginAPI = { update: { 'User.first_name': data.firstName, 'User.last_name': data.lastName, - 'User.description': data.description, + // 'User.description': data.description, }, } return apiPost(CONFIG.LOGIN_API_URL + 'updateUserInfos', payload) diff --git a/frontend/src/components/DecayInformation.vue b/frontend/src/components/DecayInformation.vue new file mode 100644 index 000000000..c3f07c87b --- /dev/null +++ b/frontend/src/components/DecayInformation.vue @@ -0,0 +1,85 @@ + + diff --git a/frontend/src/components/Inputs/InputEmail.spec.js b/frontend/src/components/Inputs/InputEmail.spec.js new file mode 100644 index 000000000..f8e374654 --- /dev/null +++ b/frontend/src/components/Inputs/InputEmail.spec.js @@ -0,0 +1,71 @@ +import { mount } from '@vue/test-utils' + +import InputEmail from './InputEmail' + +const localVue = global.localVue + +describe('InputEmail', () => { + let wrapper + + const propsData = { + name: 'input-field-name', + label: 'input-field-label', + placeholder: 'input-field-placeholder', + value: '', + } + + const Wrapper = () => { + return mount(InputEmail, { localVue, propsData }) + } + + describe('mount', () => { + beforeEach(() => { + wrapper = Wrapper() + }) + + it('has an input field', () => { + expect(wrapper.find('input').exists()).toBeTruthy() + }) + + describe('properties', () => { + it('has the name "input-field-name"', () => { + expect(wrapper.find('input').attributes('name')).toEqual('input-field-name') + }) + + it('has the id "input-field-name-input-field"', () => { + expect(wrapper.find('input').attributes('id')).toEqual('input-field-name-input-field') + }) + + it('has the placeholder "input-field-placeholder"', () => { + expect(wrapper.find('input').attributes('placeholder')).toEqual('input-field-placeholder') + }) + + it('has the value ""', () => { + expect(wrapper.vm.currentValue).toEqual('') + }) + + it('has the label "input-field-label"', () => { + expect(wrapper.find('label').text()).toEqual('input-field-label') + }) + + it('has the label for "input-field-name-input-field"', () => { + expect(wrapper.find('label').attributes('for')).toEqual('input-field-name-input-field') + }) + }) + + describe('input value changes', () => { + it('emits input with new value', async () => { + await wrapper.find('input').setValue('12') + expect(wrapper.emitted('input')).toBeTruthy() + expect(wrapper.emitted('input')).toEqual([['12']]) + }) + }) + + describe('value property changes', () => { + it('updates data model', async () => { + await wrapper.setProps({ value: 'user@example.org' }) + expect(wrapper.vm.currentValue).toEqual('user@example.org') + }) + }) + }) +}) diff --git a/frontend/src/components/Inputs/InputEmail.vue b/frontend/src/components/Inputs/InputEmail.vue new file mode 100644 index 000000000..41338fd8c --- /dev/null +++ b/frontend/src/components/Inputs/InputEmail.vue @@ -0,0 +1,73 @@ + + + diff --git a/frontend/src/components/Inputs/InputPassword.spec.js b/frontend/src/components/Inputs/InputPassword.spec.js new file mode 100644 index 000000000..ec446536d --- /dev/null +++ b/frontend/src/components/Inputs/InputPassword.spec.js @@ -0,0 +1,98 @@ +import { mount } from '@vue/test-utils' + +import InputPassword from './InputPassword' + +const localVue = global.localVue + +describe('InputPassword', () => { + let wrapper + + const propsData = { + name: 'input-field-name', + label: 'input-field-label', + placeholder: 'input-field-placeholder', + value: '', + } + + const Wrapper = () => { + return mount(InputPassword, { localVue, propsData }) + } + + describe('mount', () => { + beforeEach(() => { + wrapper = Wrapper() + }) + + it('has an input field', () => { + expect(wrapper.find('input').exists()).toBeTruthy() + }) + + describe('properties', () => { + it('has the name "input-field-name"', () => { + expect(wrapper.find('input').attributes('name')).toEqual('input-field-name') + }) + + it('has the id "input-field-name-input-field"', () => { + expect(wrapper.find('input').attributes('id')).toEqual('input-field-name-input-field') + }) + + it('has the placeholder "input-field-placeholder"', () => { + expect(wrapper.find('input').attributes('placeholder')).toEqual('input-field-placeholder') + }) + + it('has the value ""', () => { + expect(wrapper.vm.currentValue).toEqual('') + }) + + it('has the label "input-field-label"', () => { + expect(wrapper.find('label').text()).toEqual('input-field-label') + }) + + it('has the label for "input-field-name-input-field"', () => { + expect(wrapper.find('label').attributes('for')).toEqual('input-field-name-input-field') + }) + }) + + describe('input value changes', () => { + it('emits input with new value', async () => { + await wrapper.find('input').setValue('12') + expect(wrapper.emitted('input')).toBeTruthy() + expect(wrapper.emitted('input')).toEqual([['12']]) + }) + }) + + describe('password visibilty', () => { + it('has type password by default', () => { + expect(wrapper.find('input').attributes('type')).toEqual('password') + }) + + it('changes to type text when icon is clicked', async () => { + await wrapper.find('button').trigger('click') + expect(wrapper.find('input').attributes('type')).toEqual('text') + }) + + it('changes back to type password when icon is clicked twice', async () => { + await wrapper.find('button').trigger('click') + await wrapper.find('button').trigger('click') + expect(wrapper.find('input').attributes('type')).toEqual('password') + }) + }) + + describe('password visibilty icon', () => { + it('is by default bi-eye-slash', () => { + expect(wrapper.find('svg').classes('bi-eye-slash')).toBe(true) + }) + + it('changes to bi-eye when clicked', async () => { + await wrapper.find('button').trigger('click') + expect(wrapper.find('svg').classes('bi-eye')).toBe(true) + }) + + it('changes back to bi-eye-slash when clicked twice', async () => { + await wrapper.find('button').trigger('click') + await wrapper.find('button').trigger('click') + expect(wrapper.find('svg').classes('bi-eye-slash')).toBe(true) + }) + }) + }) +}) diff --git a/frontend/src/components/Inputs/InputPassword.vue b/frontend/src/components/Inputs/InputPassword.vue new file mode 100644 index 000000000..6b72f0b01 --- /dev/null +++ b/frontend/src/components/Inputs/InputPassword.vue @@ -0,0 +1,81 @@ + + diff --git a/frontend/src/components/Inputs/InputPasswordConfirmation.spec.js b/frontend/src/components/Inputs/InputPasswordConfirmation.spec.js new file mode 100644 index 000000000..953d0b960 --- /dev/null +++ b/frontend/src/components/Inputs/InputPasswordConfirmation.spec.js @@ -0,0 +1,64 @@ +import { mount } from '@vue/test-utils' + +import InputPasswordConfirmation from './InputPasswordConfirmation' + +const localVue = global.localVue + +// validation is tested in src/views/Pages/UserProfile/UserCard_FormUserPasswort.spec.js + +describe('InputPasswordConfirmation', () => { + let wrapper + + const propsData = { + value: { + password: '', + passwordRepeat: '', + }, + } + + const mocks = { + $t: jest.fn((t) => t), + } + + const Wrapper = () => { + return mount(InputPasswordConfirmation, { localVue, propsData, mocks }) + } + + describe('mount', () => { + beforeEach(() => { + wrapper = Wrapper() + }) + + it('has two input fields', () => { + expect(wrapper.findAll('input')).toHaveLength(2) + }) + + describe('input values ', () => { + it('emits input with new value for first input field', async () => { + await wrapper.findAll('input').at(0).setValue('1234') + expect(wrapper.emitted('input')).toBeTruthy() + expect(wrapper.emitted('input')).toEqual([ + [ + { + password: '1234', + passwordRepeat: '', + }, + ], + ]) + }) + + it('emits input with new value for second input field', async () => { + await wrapper.findAll('input').at(1).setValue('1234') + expect(wrapper.emitted('input')).toBeTruthy() + expect(wrapper.emitted('input')).toEqual([ + [ + { + password: '', + passwordRepeat: '1234', + }, + ], + ]) + }) + }) + }) +}) diff --git a/frontend/src/components/Inputs/InputPasswordConfirmation.vue b/frontend/src/components/Inputs/InputPasswordConfirmation.vue new file mode 100644 index 000000000..08efaccfd --- /dev/null +++ b/frontend/src/components/Inputs/InputPasswordConfirmation.vue @@ -0,0 +1,68 @@ + + diff --git a/frontend/src/components/PaginationButtons.vue b/frontend/src/components/PaginationButtons.vue index ac7ff73c6..252301388 100644 --- a/frontend/src/components/PaginationButtons.vue +++ b/frontend/src/components/PaginationButtons.vue @@ -6,7 +6,7 @@ - +

{{ currentPage }} / {{ totalPages }}

diff --git a/frontend/src/locales/de.json b/frontend/src/locales/de.json index 4d85269c5..7de64ff6a 100644 --- a/frontend/src/locales/de.json +++ b/frontend/src/locales/de.json @@ -18,7 +18,25 @@ "de": "Deutsch", "en": "English" }, - "decay": "Vergänglichkeit", + "decay": { + "decay": "Vergänglichkeit", + "decay_since_last_transaction":"Vergänglichkeit seit der letzten Transaktion", + "calculation_decay":"Berechnung der Vergänglichkeit", + "Starting_block_decay":"Startblock Vergänglichkeit", + "decay_introduced":"Die Vergänglichkeit wurde Eingeführt am", + "last_transaction":"Letzte Transaktion", + "past_time":"Vergangene Zeit", + "since_introduction":"seit Einführung der Vergänglichkeit", + "year":"Jahre", + "months":"Monate", + "days":"Tage", + "hours":"Stunden", + "minutes":"Minuten", + "seconds":"Sekunden", + "received":"empfangen", + "sent":"gesendet", + "created":"geschöpft" + }, "form": { "cancel": "Abbrechen", "reset": "Zurücksetzen", @@ -66,6 +84,7 @@ }, "error": { "error":"Fehler", + "no-account": "Leider konnten wir keinen Account finden mit diesen Daten!", "change-password": "Fehler beim Ändern des Passworts" }, "transaction":{ @@ -140,6 +159,7 @@ }, "reset-password": { "title": "Passwort zurücksetzen", - "text": "Jetzt kannst du ein neues Passwort speichern, mit dem du dich zukünftig in der Gradido-App anmelden kannst." + "text": "Jetzt kannst du ein neues Passwort speichern, mit dem du dich zukünftig in der Gradido-App anmelden kannst.", + "not-authenticated": "Leider konnten wir dich nicht authentifizieren. Bitte wende dich an den Support." } } diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index 987605bd8..ac75c90f0 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -18,7 +18,25 @@ "de": "Deutsch", "en": "English" }, - "decay": "Decay", + "decay": { + "decay": "Decay", + "decay_since_last_transaction":"Decay since the last transaction", + "calculation_decay": "Calculation of Decay", + "Starting_block_decay": "Starting Block Decay", + "decay_introduced": "Decay was Introduced on", + "last_transaction": "Last transaction:", + "past_time": "Past time", + "since_introduction": "Since the introduction of Decay", + "year": "Years", + "months": "Months", + "days": "Days", + "hours": "Hours", + "minutes": "Minutes", + "seconds": "Seconds", + "received":"received", + "sent":"sent", + "created":"created" + }, "form": { "cancel":"Cancel", "reset": "Reset", @@ -66,6 +84,7 @@ }, "error": { "error":"Error", + "no-account": "Unfortunately we could not find an account to the given data!", "change-password": "Error while changing password" }, "transaction":{ @@ -141,6 +160,7 @@ }, "reset-password": { "title": "Reset Password", - "text": "Now you can save a new password to login to the Gradido-App in the future." + "text": "Now you can save a new password to login to the Gradido-App in the future.", + "not-authenticated": "Unfortunately we could not authenticate you. Please contact the support." } } diff --git a/frontend/src/main.js b/frontend/src/main.js index 1eda3eaca..9e1b4c06b 100755 --- a/frontend/src/main.js +++ b/frontend/src/main.js @@ -2,22 +2,18 @@ import Vue from 'vue' import DashboardPlugin from './plugins/dashboard-plugin' import App from './App.vue' import i18n from './i18n.js' -import { configure, extend } from 'vee-validate' -// eslint-disable-next-line camelcase -import { required, email, min, max, is_not } from 'vee-validate/dist/rules' +import { loadAllRules } from './validation-rules' -// store import { store } from './store/store' -import loginAPI from './apis/loginAPI' - -// router setup import router from './routes/router' // plugin setup Vue.use(DashboardPlugin) Vue.config.productionTip = false +loadAllRules(i18n) + router.beforeEach((to, from, next) => { if (to.meta.requiresAuth && !store.state.sessionId) { next({ path: '/login' }) @@ -26,68 +22,6 @@ router.beforeEach((to, from, next) => { } }) -configure({ - defaultMessage: (field, values) => { - values._field_ = i18n.t(`fields.${field}`) - return i18n.t(`validations.messages.${values._rule_}`, values) - }, -}) - -extend('email', { - ...email, - message: (_, values) => i18n.t('validations.messages.email', values), -}) - -extend('required', { - ...required, - message: (_, values) => i18n.t('validations.messages.required', values), -}) - -extend('min', { - ...min, - message: (_, values) => i18n.t('validations.messages.min', values), -}) - -extend('max', { - ...max, - message: (_, values) => i18n.t('validations.messages.max', values), -}) - -extend('gddSendAmount', { - validate(value, { min, max }) { - value = value.replace(',', '.') - return value.match(/^[0-9]+(\.[0-9]{0,2})?$/) && Number(value) >= min && Number(value) <= max - }, - params: ['min', 'max'], - message: (_, values) => { - values.min = i18n.n(values.min, 'ungroupedDecimal') - values.max = i18n.n(values.max, 'ungroupedDecimal') - return i18n.t('form.validation.gddSendAmount', values) - }, -}) - -extend('gddUsernameUnique', { - async validate(value) { - const result = await loginAPI.checkUsername(value) - return result.result.data.state === 'success' - }, - message: (_, values) => i18n.t('form.validation.usernmae-unique', values), -}) - -extend('gddUsernameRgex', { - validate(value) { - return !!value.match(/^[a-zA-Z][-_a-zA-Z0-9]{2,}$/) - }, - message: (_, values) => i18n.t('form.validation.usernmae-regex', values), -}) - -// eslint-disable-next-line camelcase -extend('is_not', { - // eslint-disable-next-line camelcase - ...is_not, - message: (_, values) => i18n.t('form.validation.is-not', values), -}) - /* eslint-disable no-new */ new Vue({ el: '#app', diff --git a/frontend/src/plugins/dashboard-plugin.js b/frontend/src/plugins/dashboard-plugin.js index 2edac0995..b32bb20cd 100755 --- a/frontend/src/plugins/dashboard-plugin.js +++ b/frontend/src/plugins/dashboard-plugin.js @@ -1,12 +1,11 @@ import '@/polyfills' -import { configure, extend } from 'vee-validate' import GlobalComponents from './globalComponents' import GlobalDirectives from './globalDirectives' import SideBar from '@/components/SidebarPlugin' import PortalVue from 'portal-vue' -import VueBootstrapToasts from 'vue-bootstrap-toasts' +import Toasted from 'vue-toasted' // vue-bootstrap import { BootstrapVue, IconsPlugin } from 'bootstrap-vue' @@ -14,8 +13,6 @@ import { BootstrapVue, IconsPlugin } from 'bootstrap-vue' // asset imports import '@/assets/scss/argon.scss' import '@/assets/vendor/nucleo/css/nucleo.css' -import * as rules from 'vee-validate/dist/rules' -import { messages } from 'vee-validate/dist/locale/en.json' import VueQrcodeReader from 'vue-qrcode-reader' import VueQrcode from 'vue-qrcode' @@ -28,13 +25,6 @@ import VueMoment from 'vue-moment' import Loading from 'vue-loading-overlay' import 'vue-loading-overlay/dist/vue-loading.css' -Object.keys(rules).forEach((rule) => { - extend(rule, { - ...rules[rule], // copies rule configuration - message: messages[rule], // assign message - }) -}) - export default { install(Vue) { Vue.use(GlobalComponents) @@ -43,17 +33,20 @@ export default { Vue.use(PortalVue) Vue.use(BootstrapVue) Vue.use(IconsPlugin) - Vue.use(VueBootstrapToasts) Vue.use(VueMoment) Vue.use(VueQrcodeReader) Vue.use(VueQrcode) Vue.use(FlatPickr) Vue.use(Loading) - configure({ - classes: { - valid: 'is-valid', - invalid: 'is-invalid', - dirty: ['is-dirty', 'is-dirty'], // multiple classes per flag! + Vue.use(Toasted, { + position: 'top-center', + duration: 5000, + fullWidth: true, + action: { + text: 'x', + onClick: (e, toastObject) => { + toastObject.goAway(0) + }, }, }) }, diff --git a/frontend/src/store/store.test.js b/frontend/src/store/store.test.js index 6bc004273..4f276feec 100644 --- a/frontend/src/store/store.test.js +++ b/frontend/src/store/store.test.js @@ -1,6 +1,6 @@ import { mutations, actions } from './store' -const { language, email, sessionId } = mutations +const { language, email, sessionId, username, firstName, lastName, description } = mutations const { login, logout } = actions describe('Vuex store', () => { @@ -28,51 +28,102 @@ describe('Vuex store', () => { expect(state.sessionId).toEqual('1234') }) }) + + describe('username', () => { + it('sets the state of username', () => { + const state = { username: null } + username(state, 'user') + expect(state.username).toEqual('user') + }) + }) + + describe('firstName', () => { + it('sets the state of firstName', () => { + const state = { firstName: null } + firstName(state, 'Peter') + expect(state.firstName).toEqual('Peter') + }) + }) + + describe('lastName', () => { + it('sets the state of lastName', () => { + const state = { lastName: null } + lastName(state, 'Lustig') + expect(state.lastName).toEqual('Lustig') + }) + }) + + describe('description', () => { + it('sets the state of description', () => { + const state = { description: null } + description(state, 'Nickelbrille') + expect(state.description).toEqual('Nickelbrille') + }) + }) }) describe('actions', () => { describe('login', () => { const commit = jest.fn() const state = {} + const commitedData = { + sessionId: 1234, + user: { + email: 'someone@there.is', + language: 'en', + username: 'user', + first_name: 'Peter', + last_name: 'Lustig', + description: 'Nickelbrille', + }, + } - it('calls three commits', () => { - login( - { commit, state }, - { sessionId: 1234, user: { email: 'someone@there.is', language: 'en' } }, - ) + it('calls seven commits', () => { + login({ commit, state }, commitedData) expect(commit).toHaveBeenCalledTimes(7) }) it('commits sessionId', () => { - login( - { commit, state }, - { sessionId: 1234, user: { email: 'someone@there.is', language: 'en' } }, - ) + login({ commit, state }, commitedData) expect(commit).toHaveBeenNthCalledWith(1, 'sessionId', 1234) }) it('commits email', () => { - login( - { commit, state }, - { sessionId: 1234, user: { email: 'someone@there.is', language: 'en' } }, - ) + login({ commit, state }, commitedData) expect(commit).toHaveBeenNthCalledWith(2, 'email', 'someone@there.is') }) it('commits language', () => { - login( - { commit, state }, - { sessionId: 1234, user: { email: 'someone@there.is', language: 'en' } }, - ) + login({ commit, state }, commitedData) expect(commit).toHaveBeenNthCalledWith(3, 'language', 'en') }) + + it('commits username', () => { + login({ commit, state }, commitedData) + expect(commit).toHaveBeenNthCalledWith(4, 'username', 'user') + }) + + it('commits firstName', () => { + login({ commit, state }, commitedData) + expect(commit).toHaveBeenNthCalledWith(5, 'firstName', 'Peter') + }) + + it('commits lastName', () => { + login({ commit, state }, commitedData) + expect(commit).toHaveBeenNthCalledWith(6, 'lastName', 'Lustig') + }) + + it('commits description', () => { + login({ commit, state }, commitedData) + expect(commit).toHaveBeenNthCalledWith(7, 'description', 'Nickelbrille') + }) }) describe('logout', () => { const commit = jest.fn() const state = {} - it('calls two commits', () => { + it('calls six commits', () => { logout({ commit, state }) expect(commit).toHaveBeenCalledTimes(6) }) @@ -87,11 +138,36 @@ describe('Vuex store', () => { expect(commit).toHaveBeenNthCalledWith(2, 'email', null) }) - // how can I get this working? - it.skip('calls sessionStorage.clear()', () => { + it('commits username', () => { logout({ commit, state }) - const spy = jest.spyOn(sessionStorage, 'clear') - expect(spy).toHaveBeenCalledTimes(1) + expect(commit).toHaveBeenNthCalledWith(3, 'username', '') + }) + + it('commits firstName', () => { + logout({ commit, state }) + expect(commit).toHaveBeenNthCalledWith(4, 'firstName', '') + }) + + it('commits lastName', () => { + logout({ commit, state }) + expect(commit).toHaveBeenNthCalledWith(5, 'lastName', '') + }) + + it('commits description', () => { + logout({ commit, state }) + expect(commit).toHaveBeenNthCalledWith(6, 'description', '') + }) + + // how to get this working? + it.skip('calls sessionStorage.clear()', () => { + const clearStorageMock = jest.fn() + global.sessionStorage = jest.fn(() => { + return { + clear: clearStorageMock, + } + }) + logout({ commit, state }) + expect(clearStorageMock).toBeCalled() }) }) }) diff --git a/frontend/src/validation-rules.js b/frontend/src/validation-rules.js new file mode 100644 index 000000000..59900b272 --- /dev/null +++ b/frontend/src/validation-rules.js @@ -0,0 +1,110 @@ +import { configure, extend } from 'vee-validate' +// eslint-disable-next-line camelcase +import { required, email, min, max, is_not } from 'vee-validate/dist/rules' +import loginAPI from './apis/loginAPI' + +export const loadAllRules = (i18nCallback) => { + configure({ + defaultMessage: (field, values) => { + values._field_ = i18nCallback.t(`fields.${field}`) + return i18nCallback.t(`validations.messages.${values._rule_}`, values) + }, + classes: { + valid: 'is-valid', + invalid: 'is-invalid', + dirty: ['is-dirty', 'is-dirty'], // multiple classes per flag! + }, + }) + + extend('email', { + ...email, + message: (_, values) => i18nCallback.t('validations.messages.email', values), + }) + + extend('required', { + ...required, + message: (_, values) => i18nCallback.t('validations.messages.required', values), + }) + + extend('min', { + ...min, + message: (_, values) => i18nCallback.t('validations.messages.min', values), + }) + + extend('max', { + ...max, + message: (_, values) => i18nCallback.t('validations.messages.max', values), + }) + + extend('gddSendAmount', { + validate(value, { min, max }) { + value = value.replace(',', '.') + return value.match(/^[0-9]+(\.[0-9]{0,2})?$/) && Number(value) >= min && Number(value) <= max + }, + params: ['min', 'max'], + message: (_, values) => { + values.min = i18nCallback.n(values.min, 'ungroupedDecimal') + values.max = i18nCallback.n(values.max, 'ungroupedDecimal') + return i18nCallback.t('form.validation.gddSendAmount', values) + }, + }) + + extend('gddUsernameUnique', { + async validate(value) { + const result = await loginAPI.checkUsername(value) + return result.result.data.state === 'success' + }, + message: (_, values) => i18nCallback.t('form.validation.usernmae-unique', values), + }) + + extend('gddUsernameRgex', { + validate(value) { + return !!value.match(/^[a-zA-Z][-_a-zA-Z0-9]{2,}$/) + }, + message: (_, values) => i18nCallback.t('form.validation.usernmae-regex', values), + }) + + // eslint-disable-next-line camelcase + extend('is_not', { + // eslint-disable-next-line camelcase + ...is_not, + message: (_, values) => i18nCallback.t('form.validation.is-not', values), + }) + + // Password validation + + extend('containsLowercaseCharacter', { + validate(value) { + return !!value.match(/[a-z]+/) + }, + message: (_, values) => i18nCallback.t('site.signup.lowercase', values), + }) + + extend('containsUppercaseCharacter', { + validate(value) { + return !!value.match(/[A-Z]+/) + }, + message: (_, values) => i18nCallback.t('site.signup.uppercase', values), + }) + + extend('containsNumericCharacter', { + validate(value) { + return !!value.match(/[0-9]+/) + }, + message: (_, values) => i18nCallback.t('site.signup.one_number', values), + }) + + extend('atLeastEightCharactera', { + validate(value) { + return !!value.match(/.{8,}/) + }, + message: (_, values) => i18nCallback.t('site.signup.minimum', values), + }) + + extend('samePassword', { + validate(value, [pwd]) { + return value === pwd + }, + message: (_, values) => i18nCallback.t('site.signup.dont_match', values), + }) +} diff --git a/frontend/src/views/Layout/DashboardLayout_gdd.vue b/frontend/src/views/Layout/DashboardLayout_gdd.vue index 9990b91f2..bf0385400 100755 --- a/frontend/src/views/Layout/DashboardLayout_gdd.vue +++ b/frontend/src/views/Layout/DashboardLayout_gdd.vue @@ -34,7 +34,11 @@ - + diff --git a/frontend/src/views/Pages/AccountOverview.spec.js b/frontend/src/views/Pages/AccountOverview.spec.js index 031828129..18bc7c036 100644 --- a/frontend/src/views/Pages/AccountOverview.spec.js +++ b/frontend/src/views/Pages/AccountOverview.spec.js @@ -1,34 +1,127 @@ -import { shallowMount } from '@vue/test-utils' +import { mount } from '@vue/test-utils' import AccountOverview from './AccountOverview' +import communityAPI from '../../apis/communityAPI.js' + +jest.mock('../../apis/communityAPI.js') + +const sendMock = jest.fn() +sendMock.mockReturnValue({ success: true }) + +communityAPI.send = sendMock const localVue = global.localVue describe('AccountOverview', () => { let wrapper + const propsData = { + balance: 123.45, + transactionCount: 1, + } + const mocks = { $t: jest.fn((t) => t), + $store: { + state: { + sessionId: 1, + }, + }, + $n: jest.fn((n) => String(n)), } const Wrapper = () => { - return shallowMount(AccountOverview, { localVue, mocks }) + return mount(AccountOverview, { localVue, mocks, propsData }) } - describe('shallow Mount', () => { + describe('mount', () => { beforeEach(() => { wrapper = Wrapper() }) it('has a status line', () => { - expect(wrapper.find('gdd-status-stub').exists()).toBeTruthy() + expect(wrapper.find('div.gdd-status').exists()).toBeTruthy() }) it('has a send field', () => { - expect(wrapper.find('gdd-send-stub').exists()).toBeTruthy() + expect(wrapper.find('div.gdd-send').exists()).toBeTruthy() }) it('has a transactions table', () => { - expect(wrapper.find('gdd-transaction-list-stub').exists()).toBeTruthy() + expect(wrapper.find('div.gdd-transaction-list').exists()).toBeTruthy() + }) + + describe('transaction form', () => { + it('steps forward in the dialog', async () => { + await wrapper.findComponent({ name: 'TransactionForm' }).vm.$emit('set-transaction', { + email: 'user@example.org', + amount: 23.45, + memo: 'Make the best of it!', + }) + expect(wrapper.findComponent({ name: 'TransactionConfirmation' }).exists()).toBeTruthy() + }) + }) + + describe('confirm transaction', () => { + beforeEach(() => { + wrapper.setData({ + currentTransactionStep: 1, + transactionData: { + email: 'user@example.org', + amount: 23.45, + memo: 'Make the best of it!', + }, + }) + }) + + it('resets the transaction process when on-reset is emitted', async () => { + await wrapper.findComponent({ name: 'TransactionConfirmation' }).vm.$emit('on-reset') + expect(wrapper.findComponent({ name: 'TransactionForm' }).exists()).toBeTruthy() + expect(wrapper.vm.transactionData).toEqual({ + email: '', + amount: 0, + memo: '', + }) + }) + + describe('transaction is confirmed and server response is success', () => { + beforeEach(async () => { + jest.clearAllMocks() + await wrapper + .findComponent({ name: 'TransactionConfirmation' }) + .vm.$emit('send-transaction') + }) + + it('calls the API when send-transaction is emitted', async () => { + expect(sendMock).toBeCalledWith(1, { + email: 'user@example.org', + amount: 23.45, + memo: 'Make the best of it!', + }) + }) + + it('emits update-balance', () => { + expect(wrapper.emitted('update-balance')).toBeTruthy() + expect(wrapper.emitted('update-balance')).toEqual([[23.45]]) + }) + + it('shows the succes page', () => { + expect(wrapper.find('div.card-body').text()).toContain('form.send_transaction_success') + }) + }) + + describe('transaction is confirmed and server response is error', () => { + beforeEach(async () => { + jest.clearAllMocks() + sendMock.mockReturnValue({ success: false }) + await wrapper + .findComponent({ name: 'TransactionConfirmation' }) + .vm.$emit('send-transaction') + }) + + it('shows the error page', () => { + expect(wrapper.find('div.card-body').text()).toContain('form.send_transaction_error') + }) + }) }) }) }) diff --git a/frontend/src/views/Pages/AccountOverview.vue b/frontend/src/views/Pages/AccountOverview.vue index 35490e093..08eb503ad 100644 --- a/frontend/src/views/Pages/AccountOverview.vue +++ b/frontend/src/views/Pages/AccountOverview.vue @@ -30,7 +30,7 @@ { }, } + const propsData = { + balance: 100.0, + } + const Wrapper = () => { - return mount(TransactionForm, { localVue, mocks }) + return mount(TransactionForm, { localVue, mocks, propsData }) } describe('mount', () => { @@ -53,6 +58,18 @@ describe('GddSend', () => { 'E-Mail', ) }) + + it('flushes an error message when no valid email is given', async () => { + await wrapper.find('#input-group-1').find('input').setValue('a') + await flushPromises() + expect(wrapper.find('span.errors').text()).toBe('validations.messages.email') + }) + + it('trims the email after blur', async () => { + await wrapper.find('#input-group-1').find('input').setValue(' valid@email.com ') + await flushPromises() + expect(wrapper.vm.form.email).toBe('valid@email.com') + }) }) describe('ammount field', () => { @@ -73,6 +90,24 @@ describe('GddSend', () => { '0.01', ) }) + + it('flushes an error message when no valid amount is given', async () => { + await wrapper.find('#input-group-2').find('input').setValue('a') + await flushPromises() + expect(wrapper.find('span.errors').text()).toBe('form.validation.gddSendAmount') + }) + + it('flushes an error message when amount is too high', async () => { + await wrapper.find('#input-group-2').find('input').setValue('123.34') + await flushPromises() + expect(wrapper.find('span.errors').text()).toBe('form.validation.gddSendAmount') + }) + + it('flushes no errors when amount is valid', async () => { + await wrapper.find('#input-group-2').find('input').setValue('87.34') + await flushPromises() + expect(wrapper.find('span.errors').exists()).toBeFalsy() + }) }) describe('message text box', () => { @@ -89,6 +124,18 @@ describe('GddSend', () => { it('has a label form.memo', () => { expect(wrapper.find('label.input-3').text()).toBe('form.memo') }) + + it('flushes an error message when memo is less than 5 characters', async () => { + await wrapper.find('#input-group-3').find('textarea').setValue('a') + await flushPromises() + expect(wrapper.find('span.errors').text()).toBe('validations.messages.min') + }) + + it('flushes no error message when memo is valid', async () => { + await wrapper.find('#input-group-3').find('textarea').setValue('Long enough') + await flushPromises() + expect(wrapper.find('span.errors').exists()).toBeFalsy() + }) }) describe('cancel button', () => { @@ -100,11 +147,42 @@ describe('GddSend', () => { expect(wrapper.find('button[type="reset"]').text()).toBe('form.reset') }) - it.skip('clears the email field on click', async () => { - wrapper.find('#input-group-1').find('input').setValue('someone@watches.tv') - wrapper.find('button[type="reset"]').trigger('click') - await wrapper.vm.$nextTick() - expect(wrapper.vm.form.email).toBeNull() + it('clears all fields on click', async () => { + await wrapper.find('#input-group-1').find('input').setValue('someone@watches.tv') + await wrapper.find('#input-group-2').find('input').setValue('87.23') + await wrapper.find('#input-group-3').find('textarea').setValue('Long enugh') + await flushPromises() + expect(wrapper.vm.form.email).toBe('someone@watches.tv') + expect(wrapper.vm.form.amount).toBe('87.23') + expect(wrapper.vm.form.memo).toBe('Long enugh') + await wrapper.find('button[type="reset"]').trigger('click') + await flushPromises() + expect(wrapper.vm.form.email).toBe('') + expect(wrapper.vm.form.amount).toBe('') + expect(wrapper.vm.form.memo).toBe('') + }) + }) + + describe('submit', () => { + beforeEach(async () => { + await wrapper.find('#input-group-1').find('input').setValue('someone@watches.tv') + await wrapper.find('#input-group-2').find('input').setValue('87.23') + await wrapper.find('#input-group-3').find('textarea').setValue('Long enugh') + await wrapper.find('form').trigger('submit') + await flushPromises() + }) + + it('emits set-transaction', async () => { + expect(wrapper.emitted('set-transaction')).toBeTruthy() + expect(wrapper.emitted('set-transaction')).toEqual([ + [ + { + email: 'someone@watches.tv', + amount: 87.23, + memo: 'Long enugh', + }, + ], + ]) }) }) }) diff --git a/frontend/src/views/Pages/AccountOverview/GddSend/TransactionForm.vue b/frontend/src/views/Pages/AccountOverview/GddSend/TransactionForm.vue index c9c9df7b3..74a4a8de1 100644 --- a/frontend/src/views/Pages/AccountOverview/GddSend/TransactionForm.vue +++ b/frontend/src/views/Pages/AccountOverview/GddSend/TransactionForm.vue @@ -168,7 +168,7 @@ export default { }, methods: { onSubmit() { - this.normalizeAmount() + this.normalizeAmount(true) this.$emit('set-transaction', { email: this.form.email, amount: this.form.amountValue, @@ -181,10 +181,11 @@ export default { this.form.amount = '' this.form.memo = '' }, - setTransaction(data) { - this.form.email = data.email - this.form.amount = data.amount - }, + /* + setTransaction(data) { + this.form.email = data.email + this.form.amount = data.amount + }, */ normalizeAmount(isValid) { this.amountFocused = false if (!isValid) return diff --git a/frontend/src/views/Pages/AccountOverview/GddStatus.vue b/frontend/src/views/Pages/AccountOverview/GddStatus.vue index c0c4869dd..4ab675485 100644 --- a/frontend/src/views/Pages/AccountOverview/GddStatus.vue +++ b/frontend/src/views/Pages/AccountOverview/GddStatus.vue @@ -1,5 +1,5 @@ - diff --git a/frontend/src/views/Pages/UserProfile/UserCard_FormUsername.spec.js b/frontend/src/views/Pages/UserProfile/UserCard_FormUsername.spec.js index b1d705952..3227895ad 100644 --- a/frontend/src/views/Pages/UserProfile/UserCard_FormUsername.spec.js +++ b/frontend/src/views/Pages/UserProfile/UserCard_FormUsername.spec.js @@ -1,29 +1,28 @@ import { mount } from '@vue/test-utils' -import { extend } from 'vee-validate' import UserCardFormUsername from './UserCard_FormUsername' import loginAPI from '../../../apis/loginAPI' import flushPromises from 'flush-promises' +import { extend } from 'vee-validate' jest.mock('../../../apis/loginAPI') -extend('gddUsernameRgex', { - validate(value) { - return true - }, -}) - -extend('gddUsernameUnique', { - validate(value) { - return true - }, -}) - const localVue = global.localVue const mockAPIcall = jest.fn((args) => { return { success: true } }) +// override this rule to avoid API call +extend('gddUsernameUnique', { + validate(value) { + return true + }, +}) + +const toastErrorMock = jest.fn() +const toastSuccessMock = jest.fn() +const storeCommitMock = jest.fn() + loginAPI.changeUsernameProfile = mockAPIcall describe('UserCard_FormUsername', () => { @@ -37,10 +36,11 @@ describe('UserCard_FormUsername', () => { email: 'user@example.org', username: '', }, - commit: jest.fn(), + commit: storeCommitMock, }, - $toast: { - success: jest.fn(), + $toasted: { + success: toastSuccessMock, + error: toastErrorMock, }, } @@ -111,10 +111,43 @@ describe('UserCard_FormUsername', () => { expect(wrapper.find('div.display-username').text()).toEqual('@username') }) + it('commits the username to the store', () => { + expect(storeCommitMock).toBeCalledWith('username', 'username') + }) + + it('toasts an success message', () => { + expect(toastSuccessMock).toBeCalledWith('site.profil.user-data.change-success') + }) + it('has no edit button anymore', () => { expect(wrapper.find('svg.bi-pencil').exists()).toBeFalsy() }) }) + + describe('submit retruns error', () => { + beforeEach(async () => { + jest.clearAllMocks() + mockAPIcall.mockReturnValue({ + success: false, + result: { message: 'Error' }, + }) + await wrapper.find('input[placeholder="Username"]').setValue('username') + await wrapper.find('form').trigger('submit') + await flushPromises() + }) + + it('calls the loginAPI', () => { + expect(mockAPIcall).toHaveBeenCalledWith(1, 'user@example.org', 'username') + }) + + it('toasts an error message', () => { + expect(toastErrorMock).toBeCalledWith('Error') + }) + + it('renders an empty username', () => { + expect(wrapper.find('div.display-username').text()).toEqual('@') + }) + }) }) }) }) diff --git a/frontend/src/views/Pages/UserProfile/UserCard_FormUsername.vue b/frontend/src/views/Pages/UserProfile/UserCard_FormUsername.vue index e908fd08c..dc971acba 100644 --- a/frontend/src/views/Pages/UserProfile/UserCard_FormUsername.vue +++ b/frontend/src/views/Pages/UserProfile/UserCard_FormUsername.vue @@ -95,9 +95,9 @@ export default { this.$store.commit('username', this.form.username) this.username = this.form.username this.showUsername = true - this.$toast.success(this.$t('site.profil.user-data.change-success')) + this.$toasted.success(this.$t('site.profil.user-data.change-success')) } else { - this.$toast.error(result.result.message) + this.$toasted.error(result.result.message) this.showUsername = true this.username = this.$store.state.username this.form.username = this.$store.state.username diff --git a/frontend/src/views/Pages/UserProfileTransactionList.spec.js b/frontend/src/views/Pages/UserProfileTransactionList.spec.js new file mode 100644 index 000000000..0761c5032 --- /dev/null +++ b/frontend/src/views/Pages/UserProfileTransactionList.spec.js @@ -0,0 +1,41 @@ +import { mount } from '@vue/test-utils' +import UserProfileTransactionList from './UserProfileTransactionList' + +const localVue = global.localVue + +describe('UserProfileTransactionList', () => { + let wrapper + + const mocks = { + $t: jest.fn((t) => t), + } + + const Wrapper = () => { + return mount(UserProfileTransactionList, { localVue, mocks }) + } + + describe('mount', () => { + beforeEach(() => { + wrapper = Wrapper() + }) + + it('renders the transaction table', () => { + expect(wrapper.findComponent({ name: 'GddTransactionList' }).exists()).toBeTruthy() + }) + + it('emits update-transactions after creation', () => { + expect(wrapper.emitted('update-transactions')).toEqual( + expect.arrayContaining([expect.arrayContaining([{ firstPage: 1, items: 25 }])]), + ) + }) + + it('emist update-transactions when update-transactions is called', () => { + wrapper + .findComponent({ name: 'GddTransactionList' }) + .vm.$emit('update-transactions', { firstPage: 2, items: 25 }) + expect(wrapper.emitted('update-transactions')).toEqual( + expect.arrayContaining([expect.arrayContaining([{ firstPage: 2, items: 25 }])]), + ) + }) + }) +}) diff --git a/frontend/src/views/Pages/UserProfileTransactionList.vue b/frontend/src/views/Pages/UserProfileTransactionList.vue index f1896acc7..e6a25b436 100644 --- a/frontend/src/views/Pages/UserProfileTransactionList.vue +++ b/frontend/src/views/Pages/UserProfileTransactionList.vue @@ -19,12 +19,13 @@ import GddTransactionList from './AccountOverview/GddTransactionList.vue' export default { + name: 'UserProfileTransactionList', components: { GddTransactionList, }, props: { transactions: { - default: [], + default: () => [], }, transactionCount: { type: Number, default: 0 }, }, diff --git a/frontend/test/testSetup.js b/frontend/test/testSetup.js index 26d311941..565ebc33f 100644 --- a/frontend/test/testSetup.js +++ b/frontend/test/testSetup.js @@ -1,10 +1,11 @@ import { createLocalVue } from '@vue/test-utils' import { BootstrapVue, IconsPlugin } from 'bootstrap-vue' import Vuex from 'vuex' + import { ValidationProvider, ValidationObserver, extend } from 'vee-validate' import * as rules from 'vee-validate/dist/rules' - import { messages } from 'vee-validate/dist/locale/en.json' + import RegeneratorRuntime from 'regenerator-runtime' import SideBar from '@/components/SidebarPlugin' import VueQrcode from 'vue-qrcode' @@ -14,7 +15,7 @@ import VueMoment from 'vue-moment' import clickOutside from '@/directives/click-ouside.js' import { focus } from 'vue-focus' -global.localVue = createLocalVue() +import { loadAllRules } from '../src/validation-rules' Object.keys(rules).forEach((rule) => { extend(rule, { @@ -23,6 +24,15 @@ Object.keys(rules).forEach((rule) => { }) }) +const i18nMock = { + t: (identifier, values) => identifier, + n: (value, format) => value, +} + +loadAllRules(i18nMock) + +global.localVue = createLocalVue() + global.localVue.use(BootstrapVue) global.localVue.use(Vuex) global.localVue.use(IconsPlugin) diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 65f3ff71d..7f689a72c 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -13268,11 +13268,6 @@ vm-browserify@^1.0.1: resolved "https://registry.yarnpkg.com/vm-browserify/-/vm-browserify-1.1.2.tgz#78641c488b8e6ca91a75f511e7a3b32a86e5dda0" integrity sha512-2ham8XPWTONajOR0ohOKOHXkm3+gaBmGut3SRuu75xLd/RRaY6vqgh8NBYYk7+RW3u5AtzPQZG8F10LHkl0lAQ== -vue-bootstrap-toasts@^1.0.7: - version "1.0.7" - resolved "https://registry.yarnpkg.com/vue-bootstrap-toasts/-/vue-bootstrap-toasts-1.0.7.tgz#111c38855941e8eb0538e21f41c173e2af67dd53" - integrity sha512-JhurJOAwdNcINQ/QlT701sx0r447YTGpvtxtmZNC9pwDvEqp2I0Pyv15jS4neWwYHkA1gXB42nBsDRcWcj1hlg== - vue-bootstrap-typeahead@^0.2.6: version "0.2.6" resolved "https://registry.yarnpkg.com/vue-bootstrap-typeahead/-/vue-bootstrap-typeahead-0.2.6.tgz#8c1999a00bf4bf9fc906bae3a462482482cbc297" @@ -13475,6 +13470,11 @@ vue-template-es2015-compiler@^1.6.0, vue-template-es2015-compiler@^1.9.0: resolved "https://registry.yarnpkg.com/vue-template-es2015-compiler/-/vue-template-es2015-compiler-1.9.1.tgz#1ee3bc9a16ecbf5118be334bb15f9c46f82f5825" integrity sha512-4gDntzrifFnCEvyoO8PqyJDmguXgVPxKiIxrBKjIowvL9l+N66196+72XVYR8BBf1Uv1Fgt3bGevJ+sEmxfZzw== +vue-toasted@^1.1.28: + version "1.1.28" + resolved "https://registry.yarnpkg.com/vue-toasted/-/vue-toasted-1.1.28.tgz#dbabb83acc89f7a9e8765815e491d79f0dc65c26" + integrity sha512-UUzr5LX51UbbiROSGZ49GOgSzFxaMHK6L00JV8fir/CYNJCpIIvNZ5YmS4Qc8Y2+Z/4VVYRpeQL2UO0G800Raw== + vue2-transitions@^0.2.3: version "0.2.3" resolved "https://registry.yarnpkg.com/vue2-transitions/-/vue2-transitions-0.2.3.tgz#69c9d75b1db05f231b80980c03459d68490ba27d" diff --git a/login_server/src/cpp/JSONInterface/JsonUpdateUserInfos.cpp b/login_server/src/cpp/JSONInterface/JsonUpdateUserInfos.cpp index a99881756..eb18bf8f7 100644 --- a/login_server/src/cpp/JSONInterface/JsonUpdateUserInfos.cpp +++ b/login_server/src/cpp/JSONInterface/JsonUpdateUserInfos.cpp @@ -123,13 +123,18 @@ Poco::JSON::Object* JsonUpdateUserInfos::handle(Poco::Dynamic::Var params) } } else if ("User.description" == name) { - std::string str_val = validateString(value, "User.description", jsonErrorsArray); + std::string errorMessage = "User.description"; - if (str_val.size() > 0 && str_val != user_model->getDescription()) { + if (!value.isString()) { + errorMessage += " isn't a string"; + jsonErrorsArray.add(errorMessage); + } + std::string str_val = value.toString(); + + if (str_val != user_model->getDescription()) { user_model->setDescription(str_val); extractet_values++; } - } else if ("User.disabled" == name) { bool disabled;