promote bonus shop items to first class objects

This commit is contained in:
Spine
2025-09-07 06:32:49 +00:00
parent de3883c7d2
commit 2db8d5f3c1
25 changed files with 1254 additions and 656 deletions

345
app/BonusItem.php Normal file
View File

@@ -0,0 +1,345 @@
<?php
namespace Gazelle;
class BonusItem extends \Gazelle\BaseObject {
final public const tableName = 'bonus_item';
final public const CACHE_KEY = 'bonus_%d';
public function flush(): static {
self::$cache->delete_value(sprintf(self::CACHE_KEY, $this->id));
unset($this->info);
return $this;
}
public function link(): string {
return '';
}
public function location(): string {
return 'bonus.php';
}
public function info(): array {
if (isset($this->info)) {
return $this->info;
}
$key = sprintf(self::CACHE_KEY, $this->id);
$info = self::$cache->get_value($key);
if ($info === false) {
$info = $this->pg()->rowAssoc('
select price,
amount,
userclass_min,
userclass_free,
prepare,
frequency,
label,
title
from bonus_item
where id_bonus_item = ?
', $this->id
);
self::$cache->cache_value($key, $info, 0);
}
$this->info = $info;
return $this->info;
}
public function amount(): int {
return $this->info()['amount'];
}
public function label(): string {
return $this->info()['label'];
}
public function needsPreparation(): bool {
return $this->info()['prepare'];
}
public function isRecurring(): bool {
return $this->info()['frequency'] === 'recurring';
}
/**
* Even after userclass levels are taken into account, there may exist other
* reasons to deny a user from buying an item from the shop.
*/
public function permitted(User $user): bool {
return match ($this->label()) {
'invite' => !$user->disableInvites(),
default => true,
};
}
public function price(): int {
return $this->info()['price'];
}
public function priceForUser(User $user): int {
return match ($this->label()) {
'collage-1' => $this->price() * 2 ** min(6, $user->paidPersonalCollages()),
default => $user->classLevel() >= $this->userclassFree()
? 0 : $this->price(),
};
}
public function title(): string {
return $this->info()['title'];
}
public function userclassFree(): int {
return $this->info()['userclass_free'];
}
public function userclassMin(): int {
return $this->info()['userclass_min'];
}
public function isPurchased(User $user): bool {
return (bool)$this->pg()->scalar('
select exists (
select 1
from relay.bonus_history bh
where bh."ItemID" = ?
and bh."UserID" = ?
)
', $this->id, $user->id
);
}
/* Object-orient orthodoxy would suggest creating classes for each type
* of item that can be purchased, and calling the purchase() method
* through polymorphism. That would be a lot of boilerplate code simply
* to provide one method. Rules are made to be broken, so we will cheat
* and use a humongous switch. New items will need their own effect
* added here. An unintended benefit is that determining the price and
* recording the purchase history happen in one place.
*
* Note: the price of the item is passed in as a parameter. This may
* seem a little weird, because the method call looks something like:
* $item->purchase($user, $item->price());
* ... but it allows the gifting of something to a user for free:
* $item->purchase($user, 0);
*/
public function purchase(User $user, int $price, array $extra = []): Enum\BonusItemPurchaseStatus {
if ($user->classLevel() < $this->userclassMin()) {
return Enum\BonusItemPurchaseStatus::forbidden;
}
if ($this->isPurchased($user) && !$this->isRecurring()) {
return Enum\BonusItemPurchaseStatus::alreadyPurchased;
}
if ($user->classLevel() >= $this->userclassFree()) {
$price = 0;
}
if (str_starts_with($this->label(), 'token-')) {
self::$db->prepared_query('
UPDATE user_bonus ub
INNER JOIN user_flt uf USING (user_id) SET
uf.tokens = uf.tokens + ?,
ub.points = ub.points - ?
WHERE ub.points >= ?
AND ub.user_id = ?
', $this->amount(), $price, $price, $user->id
);
if (self::$db->affected_rows() != 2) {
return Enum\BonusItemPurchaseStatus::insufficientFunds;
}
} elseif (str_starts_with($this->label(), 'other-')) {
if (!isset($extra['receiver'])) {
return Enum\BonusItemPurchaseStatus::incomplete;
}
$receiver = $extra['receiver'];
if ($user->id === $receiver->id || !$receiver->isEnabled()) {
return Enum\BonusItemPurchaseStatus::forbidden;
}
if ($receiver->hasAttr('no-fl-gifts')) {
return Enum\BonusItemPurchaseStatus::declined;
}
/* Take the bonus points from the giver and give tokens to the
* receiver, unless the latter have asked to refuse receiving
* tokens. Yes, we have just checked this and possibly sent a
* 'declined' response. But race conditions being what they
* are, the proof is in the database update. If this fails,
* we will consider it is due to insufficient funds (the more
* likely scenario anyway) and the purchaser can try again.
*/
self::$db->prepared_query("
UPDATE user_bonus ub
INNER JOIN users_main self ON (self.ID = ub.user_id),
users_main other
INNER JOIN user_flt other_uf ON (other_uf.user_id = other.ID)
LEFT JOIN user_has_attr noFL ON (noFL.UserID = other.ID AND noFL.UserAttrId
= (SELECT ua.ID FROM user_attr ua WHERE ua.Name = 'no-fl-gifts')
)
SET
other_uf.tokens = other_uf.tokens + ?,
ub.points = ub.points - ?
WHERE noFL.UserID IS NULL
AND ub.points >= ?
AND other.Enabled = '1'
AND other.ID = ?
AND self.ID = ?
", $this->amount(), $price, $price, $receiver->id, $user->id
);
if (self::$db->affected_rows() != 2) {
return Enum\BonusItemPurchaseStatus::insufficientFunds;
}
$amount = $this->amount();
$receiver->inbox()->createSystem(
"Here " . ($amount == 1 ? 'is' : 'are') . ' ' . article($amount)
. " freeleech token" . plural($amount) . "!",
self::$twig->render('bonus/token-other-message.bbcode.twig', [
'amount' => $amount,
'from' => $user->username(),
'to' => $receiver->username(),
'message' => $extra['message'] ?? '',
'wiki_id' => 57,
])
);
} else {
switch ($this->label()) {
case 'collage-1':
$price = (int)($price * 2 ** $user->paidPersonalCollages());
self::$db->begin_transaction();
self::$db->prepared_query('
UPDATE user_bonus ub
INNER JOIN users_main um ON (um.ID = ub.user_id) SET
ub.points = ub.points - ?,
um.collage_total = um.collage_total + 1
WHERE ub.points >= ?
AND ub.user_id = ?
', $price, $price, $user->id
);
$rows = self::$db->affected_rows();
if (($price > 0 && $rows !== 2) || ($price === 0 && $rows !== 1)) {
self::$db->rollback();
return Enum\BonusItemPurchaseStatus::insufficientFunds;
}
$user->ordinal()
->set(
'personal-collage',
(int)self::$db->scalar("
SELECT collage_total FROM users_main WHERE ID = ?
", $user->id
)
);
self::$db->commit();
break;
case 'file-count':
self::$db->begin_transaction();
self::$db->prepared_query('
UPDATE user_bonus ub SET
ub.points = ub.points - ?
WHERE ub.points >= ?
AND ub.user_id = ?
', $price, $price, $user->id
);
if (self::$db->affected_rows() != 1) {
self::$db->rollback();
return Enum\BonusItemPurchaseStatus::insufficientFunds;
}
try {
self::$db->prepared_query("
INSERT INTO user_has_attr
(UserID, UserAttrID)
VALUES (?, (SELECT ID FROM user_attr WHERE Name = ?))
", $user->id, 'feature-file-count'
);
} catch (\Gazelle\DB\MysqlDuplicateKeyException) {
// no point in buying a second time
self::$db->rollback();
return Enum\BonusItemPurchaseStatus::alreadyPurchased;
}
self::$db->commit();
break;
case 'invite':
if (!$user->canPurchaseInvite()) {
return Enum\BonusItemPurchaseStatus::forbidden;
}
self::$db->prepared_query('
UPDATE user_bonus ub
INNER JOIN users_main um ON (um.ID = ub.user_id) SET
ub.points = ub.points - ?,
um.Invites = um.Invites + 1
WHERE ub.points >= ?
AND ub.user_id = ?
', $price, $price, $user->id
);
if (self::$db->affected_rows() != 2) {
return Enum\BonusItemPurchaseStatus::insufficientFunds;
}
break;
case 'seedbox':
self::$db->begin_transaction();
self::$db->prepared_query('
UPDATE user_bonus ub SET
ub.points = ub.points - ?
WHERE ub.points >= ?
AND ub.user_id = ?
', $price, $price, $user->id
);
if (self::$db->affected_rows() != 1) {
self::$db->rollback();
return Enum\BonusItemPurchaseStatus::insufficientFunds;
}
try {
self::$db->prepared_query("
INSERT INTO user_has_attr
(UserID, UserAttrID)
VALUES (?, (SELECT ID FROM user_attr WHERE Name = ?))
", $user->id, 'feature-seedbox'
);
} catch (DB\MysqlDuplicateKeyException) {
// no point in buying a second time
self::$db->rollback();
return Enum\BonusItemPurchaseStatus::alreadyPurchased;
}
self::$db->commit();
break;
case 'title-bb-n':
case 'title-bb-y':
if (!isset($extra['title'])) {
return Enum\BonusItemPurchaseStatus::incomplete;
}
if ($price > 0 && !new User\Bonus($user)->removePoints($price)) {
return Enum\BonusItemPurchaseStatus::insufficientFunds;
}
if (
!$user->setTitle(
$this->label() === 'title-bb-y'
? \Text::span_format($extra['title'])
: \Text::strip_bbcode($extra['title'])
)
) {
return Enum\BonusItemPurchaseStatus::incomplete;
}
$user->modify();
break;
case 'title-off':
$user->removeTitle()->modify();
break;
}
}
self::$db->prepared_query("
INSERT INTO bonus_history
(ItemID, UserID, Price, OtherUserID)
VALUES (?, ?, ?, ?)
", $this->id,
$user->id,
$price,
isset($extra['receiver']) ? $extra['receiver']->id : 0,
);
new User\Bonus($user)->flush();
$user->flush();
return Enum\BonusItemPurchaseStatus::success;
}
}

View File

@@ -0,0 +1,12 @@
<?php
namespace Gazelle\Enum;
enum BonusItemPurchaseStatus {
case success;
case insufficientFunds;
case declined;
case forbidden;
case alreadyPurchased;
case incomplete;
}

View File

@@ -0,0 +1,25 @@
<?php
namespace Gazelle\Json;
use Gazelle\Manager\Bonus as BonusManager;
class BonusItemTitle extends \Gazelle\Json {
public function __construct(
protected string $label,
protected string $title,
protected BonusManager $manager = new BonusManager(),
) {}
public function payload(): array {
$item = $this->manager->findBonusItemByLabel($this->label);
if (is_null($item)) {
return [];
}
return [
$item->label() === 'title-bb-y'
? \Text::full_format($this->title)
: \Text::strip_bbcode($this->title)
];
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace Gazelle\Json;
use Gazelle\Manager\User as UserManager;
class BonusUserOther extends \Gazelle\Json {
public function __construct(
protected string $username,
protected UserManager $manager = new UserManager(),
) {}
public function payload(): array {
$other = $this->manager->findByUsername($this->username);
return is_null($other)
? [
'found' => false,
'username' => $this->username,
]
: [
'found' => true,
'accept' => !$other->hasAttr('no-fl-gifts'),
'enabled' => $other->isEnabled(),
'id' => $other->id,
'username' => $other->username(),
];
}
}

View File

@@ -1,15 +1,37 @@
<?php
declare(strict_types=1);
namespace Gazelle\Manager;
use Gazelle\BonusItem;
use Gazelle\Enum\BonusItemPurchaseStatus;
use Gazelle\Enum\UserStatus;
use Gazelle\User;
class Bonus extends \Gazelle\Base {
final public const CACHE_OPEN_POOL = 'bonus_pool'; // also defined in \Gazelle\Bonus
final protected const CACHE_ITEM = 'bonus_item';
final protected const CACHE_KEY = 'bonus_item_list';
protected array $items;
public function flush(): static {
foreach ($this->itemList() as $item) {
$item->flush();
}
unset($this->items);
self::$cache->delete_value(self::CACHE_KEY);
return $this;
}
public function findBonusItemByLabel(string $label): ?BonusItem {
$idBonusItem = $this->pg()->scalar('
select id_bonus_item from bonus_item where label = ?
', $label
);
return is_null($idBonusItem) ? null : new BonusItem($idBonusItem);
}
/**
* Return the global discount rate for the shop
*
@@ -21,36 +43,23 @@ class Bonus extends \Gazelle\Base {
public function itemList(): array {
if (!isset($this->items)) {
$items = self::$cache->get_value(self::CACHE_ITEM);
if ($items === false) {
$discount = $this->discount();
self::$db->prepared_query("
SELECT ID,
Price * (greatest(0, least(100, 100 - ?)) / 100) as Price,
Amount, MinClass, FreeClass, Label, Title, sequence,
IF (Label REGEXP '^other-', 'NoOp', 'ConfirmPurchase') AS JS_on_click,
IF (Label REGEXP '^title-bb-[yn]', 'NoOp', 'ConfirmPurchase') AS JS_on_click
FROM bonus_item
ORDER BY sequence
", $discount
);
$items = [];
foreach (self::$db->to_array(false, MYSQLI_ASSOC) as $row) {
$row['Price'] = (int)$row['Price'];
$items[$row['Label']] = $row;
}
self::$cache->cache_value(self::CACHE_ITEM, $items, 0);
$idList = self::$cache->get_value(self::CACHE_KEY);
if ($idList === false) {
$idList = $this->pg()->column("
select id_bonus_item from bonus_item order by sequence
");
self::$cache->cache_value(self::CACHE_KEY, $idList, 0);
}
$this->items = $items;
$list = [];
foreach ($idList as $idBonusItem) {
$item = new BonusItem($idBonusItem);
$list[$item->label()] = $item;
}
$this->items = $list;
}
return $this->items;
}
public function flushPriceCache(): void {
unset($this->items);
self::$cache->delete_value(self::CACHE_ITEM);
}
public function openPoolList(): array {
$key = self::CACHE_OPEN_POOL;
$pool = self::$cache->get_value($key);
@@ -219,4 +228,38 @@ class Bonus extends \Gazelle\Base {
return $processed;
}
/**
* return array<BonusItem>
*/
public function offerTokenOther(User $user): array {
$balance = $user->bonusPointsTotal();
$offer = [];
foreach ($this->itemList() as $item) {
if (str_starts_with($item->label(), 'other-') && $balance >= $item->price()) {
$offer[] = $item;
}
}
return $offer;
}
public function purchaseTokenOther(
User $giver,
User $receiver,
string $label,
string $message,
): BonusItemPurchaseStatus {
$item = $this->findBonusItemByLabel($label);
if (is_null($item)) {
return BonusItemPurchaseStatus::incomplete;
}
return $item->purchase(
$giver,
$item->price(),
[
'message' => $message,
'receiver' => $receiver,
],
);
}
}

View File

@@ -2,6 +2,7 @@
namespace Gazelle\User;
use Gazelle\BonusItem;
use Gazelle\BonusPool;
use Gazelle\Util\SortableTableHeader;
@@ -26,6 +27,8 @@ class Bonus extends \Gazelle\BaseUser {
self::$cache->delete_multi([
sprintf(self::CACHE_HISTORY, $this->user->id, 0),
sprintf(self::CACHE_POOL_HISTORY, $this->user->id),
sprintf(self::CACHE_PURCHASE, $this->user->id),
sprintf(self::CACHE_SUMMARY, $this->user->id),
]);
return $this;
}
@@ -37,59 +40,6 @@ class Bonus extends \Gazelle\BaseUser {
);
}
protected function items(): array {
return new \Gazelle\Manager\Bonus()->itemList();
}
public function itemList(): array {
$items = $this->items();
$allowed = [];
foreach ($items as $item) {
if ($item['Label'] === 'seedbox' && $this->user->hasAttr('feature-seedbox')) {
continue;
} elseif ($item['Label'] === 'file-count' && $this->user->hasAttr('feature-file-count')) {
continue;
} elseif (
$item['Label'] === 'invite'
&& ($this->user->permitted('site_send_unlimited_invites') || !$this->user->canPurchaseInvite())
) {
continue;
}
$item['Price'] = $this->effectivePrice($item['Label']);
$allowed[] = $item;
}
return $allowed;
}
public function item(string $label): ?array {
$items = $this->items();
return $items[$label] ?? null;
}
public function effectivePrice(string $label): int {
$item = $this->items()[$label];
if (preg_match('/^collage-\d$/', $label)) {
return (int)($item['Price'] * 2 ** $this->user->paidPersonalCollages());
}
return $this->user->privilege()->effectiveClassLevel() >= $item['FreeClass'] ? 0 : (int)$item['Price'];
}
public function otherList(): array {
$balance = $this->user->bonusPointsTotal();
$other = [];
foreach ($this->items() as $label => $item) {
if (preg_match('/^other-\d$/', (string)$label) && $balance >= $item['Price']) {
$other[] = [
'Label' => $item['Label'],
'Name' => $item['Title'],
'Price' => $item['Price'],
'After' => $balance - $item['Price'],
];
}
}
return $other;
}
public function otherLatest(\Gazelle\User $other): array {
return self::$db->rowAssoc("
SELECT bi.Title AS title,
@@ -223,246 +173,6 @@ class Bonus extends \Gazelle\BaseUser {
return $history;
}
public function purchaseInvite(): bool {
if (!$this->user->canPurchaseInvite()) {
return false;
}
$item = $this->items()['invite'];
$price = $item['Price'];
self::$db->prepared_query('
UPDATE user_bonus ub
INNER JOIN users_main um ON (um.ID = ub.user_id) SET
ub.points = ub.points - ?,
um.Invites = um.Invites + 1
WHERE ub.points >= ?
AND ub.user_id = ?
', $price, $price, $this->user->id
);
if (self::$db->affected_rows() != 2) {
return false;
}
$this->addPurchaseHistory($item['ID'], $price);
$this->flush();
return true;
}
public function purchaseTitle(string $label, string $title): bool {
$item = $this->items()[$label];
$title = $label === 'title-bb-y' ? \Text::span_format($title) : \Text::strip_bbcode($title);
if (!$this->user->setTitle($title)) {
return false;
}
/* if the price is 0, nothing changes so avoid hitting the db */
$price = $this->effectivePrice($label);
if ($price > 0) {
if (!$this->removePoints($price)) {
return false;
}
}
$this->user->modify();
$this->addPurchaseHistory($item['ID'], $price);
$this->flush();
return true;
}
public function purchaseCollage(string $label): bool {
$item = $this->items()[$label];
$price = $this->effectivePrice($label);
self::$db->prepared_query('
UPDATE user_bonus ub
INNER JOIN users_main um ON (um.ID = ub.user_id) SET
ub.points = ub.points - ?,
um.collage_total = um.collage_total + 1
WHERE ub.points >= ?
AND ub.user_id = ?
', $price, $price, $this->user->id
);
$rows = self::$db->affected_rows();
if (($price > 0 && $rows !== 2) || ($price === 0 && $rows !== 1)) {
return false;
}
$this->user->ordinal()
->set(
'personal-collage',
(int)self::$db->scalar("
SELECT collage_total FROM users_main WHERE ID = ?
", $this->user->id
)
);
$this->addPurchaseHistory($item['ID'], $price);
$this->flush();
return true;
}
public function unlockSeedbox(): bool {
$item = $this->items()['seedbox'];
$price = $this->effectivePrice('seedbox');
self::$db->begin_transaction();
self::$db->prepared_query('
UPDATE user_bonus ub SET
ub.points = ub.points - ?
WHERE ub.points >= ?
AND ub.user_id = ?
', $price, $price, $this->user->id
);
if (self::$db->affected_rows() != 1) {
self::$db->rollback();
return false;
}
try {
self::$db->prepared_query("
INSERT INTO user_has_attr
(UserID, UserAttrID)
VALUES (?, (SELECT ID FROM user_attr WHERE Name = ?))
", $this->user->id, 'feature-seedbox'
);
} catch (\Gazelle\DB\MysqlDuplicateKeyException) {
// no point in buying a second time
self::$db->rollback();
return false;
}
self::$db->commit();
$this->addPurchaseHistory($item['ID'], $price);
$this->flush();
return true;
}
public function purchaseFeatureFilecount(): bool {
$item = $this->items()['file-count'];
$price = $this->effectivePrice('file-count');
self::$db->begin_transaction();
self::$db->prepared_query('
UPDATE user_bonus ub SET
ub.points = ub.points - ?
WHERE ub.points >= ?
AND ub.user_id = ?
', $price, $price, $this->user->id
);
if (self::$db->affected_rows() != 1) {
self::$db->rollback();
return false;
}
try {
self::$db->prepared_query("
INSERT INTO user_has_attr
(UserID, UserAttrID)
VALUES (?, (SELECT ID FROM user_attr WHERE Name = ?))
", $this->user->id, 'feature-file-count'
);
} catch (\Gazelle\DB\MysqlDuplicateKeyException) {
// no point in buying a second time
self::$db->rollback();
return false;
}
self::$db->commit();
$this->addPurchaseHistory($item['ID'], $price);
$this->flush();
return true;
}
public function purchaseToken(string $label): bool {
$item = $this->items()[$label];
if (!$item) {
return false;
}
$amount = (int)$item['Amount'];
$price = $item['Price'];
self::$db->prepared_query('
UPDATE user_bonus ub
INNER JOIN user_flt uf USING (user_id) SET
ub.points = ub.points - ?,
uf.tokens = uf.tokens + ?
WHERE ub.user_id = ?
AND ub.points >= ?
', $price, $amount, $this->user->id, $price
);
if (self::$db->affected_rows() != 2) {
return false;
}
$this->addPurchaseHistory($item['ID'], $price);
$this->flush();
return true;
}
/**
* This method does not return a boolean success, but rather the number of
* tokens purchased (for use in a response to the receiver).
*/
public function purchaseTokenOther(\Gazelle\User $receiver, string $label, string $message): int {
if ($this->user->id === $receiver->id) {
return 0;
}
$item = $this->items()[$label];
if (!$item) {
return 0;
}
$amount = (int)$item['Amount'];
$price = $item['Price'];
/* Take the bonus points from the giver and give tokens
* to the receiver, unless the latter have asked to
* refuse receiving tokens.
*/
self::$db->prepared_query("
UPDATE user_bonus ub
INNER JOIN users_main self ON (self.ID = ub.user_id),
users_main other
INNER JOIN user_flt other_uf ON (other_uf.user_id = other.ID)
LEFT JOIN user_has_attr noFL ON (noFL.UserID = other.ID AND noFL.UserAttrId
= (SELECT ua.ID FROM user_attr ua WHERE ua.Name = 'no-fl-gifts')
)
SET
ub.points = ub.points - ?,
other_uf.tokens = other_uf.tokens + ?
WHERE noFL.UserID IS NULL
AND other.Enabled = '1'
AND other.ID = ?
AND self.ID = ?
AND ub.points >= ?
", $price, $amount, $receiver->id, $this->user->id, $price
);
if (self::$db->affected_rows() != 2) {
return 0;
}
$this->addPurchaseHistory($item['ID'], $price, $receiver->id);
$this->sendPmToOther($receiver, $amount, $message);
$this->flush();
$receiver->flush();
return $amount;
}
public function sendPmToOther(\Gazelle\User $receiver, int $amount, string $message): \Gazelle\PM {
return $receiver->inbox()->createSystem(
"Here " . ($amount == 1 ? 'is' : 'are') . ' ' . article($amount) . " freeleech token" . plural($amount) . "!",
self::$twig->render('bonus/token-other-message.bbcode.twig', [
'to' => $receiver->username(),
'from' => $this->user->username(),
'amount' => $amount,
'wiki_id' => 57,
'message' => $message
])
);
}
private function addPurchaseHistory(int $itemId, int $price, int|null $otherUserId = null): int {
self::$cache->delete_multi([
sprintf(self::CACHE_PURCHASE, $this->user->id),
sprintf(self::CACHE_SUMMARY, $this->user->id),
sprintf(self::CACHE_HISTORY, $this->user->id, 0)
]);
self::$db->prepared_query("
INSERT INTO bonus_history
(ItemID, UserID, Price, OtherUserID)
VALUES (?, ?, ?, ?)
", $itemId, $this->user->id, $price, $otherUserId
);
return self::$db->affected_rows();
}
public function setPoints(float $points): int {
self::$db->prepared_query("
UPDATE user_bonus SET

View File

@@ -183,7 +183,7 @@ $tgMan = new Manager\TGroup();
foreach ($groupList as $tgroupId) {
$tgroup = $tgMan->findById($tgroupId);
if ($tgroup instanceof TGroup) {
echo "tgroup $torrentId ({$tgroup->name()})\n";
echo "tgroup $torrentId ({$tgroup?->name()})\n";
$db->prepared_query("
DELETE FROM bookmarks_torrents bt WHERE GroupID = ?
", $tgroupId

View File

@@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
use Phinx\Util\Literal;
final class BonusItem extends AbstractMigration {
public function up(): void {
// An earlier migration created this table as a mirror of the Mysql
// table, but no code was ever written to make use of it. Therefore
// delete if it exists. If this migration is rolled back, it does
// not bother to recreate the table, which means that when it is
// migrated a second time, the table will no longer be present.
// Hence "if exists".
$this->execute('drop table if exists bonus_item');
$this->query("create type bonus_item_freq_t as enum ('one-time', 'recurring')");
$this->table('bonus_item', ['id' => false, 'primary_key' => 'id_bonus_item'])
->addColumn('id_bonus_item', 'integer', ['identity' => true])
->addColumn('sequence', 'integer')
->addColumn('price', 'integer')
->addColumn('amount', 'integer')
->addColumn('userclass_min', 'integer')
->addColumn('userclass_free', 'integer')
->addColumn('frequency', Literal::from('bonus_item_freq_t'))
->addColumn('prepare', 'boolean')
->addColumn('label', 'string', ['length' => 32])
->addColumn('title', 'string', ['length' => 64])
->save();
$this->execute('create unique index bi_l_uidx on bonus_item (label);');
$this->execute('create unique index bi_s_uidx on bonus_item (sequence);');
$this->query(<<<END_SQL
insert into bonus_item (
id_bonus_item,
sequence,
price,
amount,
userclass_min,
userclass_free,
frequency,
label,
title,
prepare
)
select "ID",
sequence,
"Price",
coalesce("Amount", 0),
coalesce("MinClass", 1),
"FreeClass",
cast(
case when "Label" in ('seedbox', 'file-count')
then 'one-time'
else 'recurring'
end
as bonus_item_freq_t
),
"Label",
case
when "Title" = 'Buy an Invite' then 'Personal Invite'
when "Title" = 'Buy a Personal Collage Slot' then 'Personal Collage'
when "Title" = 'Unlock the Seedbox viewer' then 'Seedbox Viewer'
else "Title"
end,
case when "Label" ~ '^(other-|title-bb-[yn])'
then true
else false
end
from relay.bonus_item
END_SQL
);
}
public function down(): void {
$this->table('bonus_item')->drop()->save();
$this->query('drop type bonus_item_freq_t');
}
}

View File

@@ -1,34 +1,71 @@
function PreviewTitle(BBCode) {
$.post('bonus.php?action=title&preview=true', {
title: $('#title').val(),
BBCode: BBCode
}, function(response) {
$('#preview').html(response);
});
"use strict";
async function previewTitle() {
const form = new FormData();
form.append('auth', document.body.dataset.auth);
form.append('title', document.getElementById('title').value);
form.append('label', document.forms['custom-title-preview'].elements['label'].value);
const response = await fetch(
'?action=prepare', {
'method': 'POST',
'body': form,
}
);
const data = await response.json();
document.getElementById('preview').innerHTML =
data.status === 'success'
? data.response[0]
: data.status;
}
function NoOp(event, item, next, element) {
return next && next(event, element);
}
/**
* @param {Object} event
* @param {String} item
* @param {Function} next
* @param {Object} element
* @return {boolean}
*/
function ConfirmPurchase(event, item, next, element) {
var check = (next) ? next(event, element) : true;
if (!check) {
event.preventDefault();
return false;
async function validateBonusUsername() {
const status = document.getElementById('bonus-other-status');
const username = document.getElementById('bonus-user-other').value.trim();
const purchase = document.getElementById('bonus-other-purchase')
purchase.disabled = true;
if (username === '') {
status.innerHTML = '';
return;
}
check = confirm('Are you sure you want to purchase ' + item + '?');
if (!check) {
event.preventDefault();
return false;
const form = new FormData();
form.append('auth', document.body.dataset.auth);
form.append('bonus-user-other', username);
const response = await fetch(
'?action=prepare', {
'method': 'POST',
'body': form,
}
);
const data = await response.json();
let message;
if (data.status !== 'success') {
message = status;
} else {
const user = data.response;
if (!user.found) {
message = '⛔️ ' + 'user not found';
} else if (user.id == document.body.dataset.id) {
message = '⛔️ You cannot gift tokens to yourself';
} else if (!user.enabled) {
message = '⛔️ ' + user.username + ' is currently not enabled';
} else if (!user.accept) {
message = '🚫 ' + user.username + ' does not wish to receive tokens';
} else {
message = '✅';
document.forms['bonus-other'].elements['user'].value = user.username;
purchase.disabled = false;
}
}
return true;
status.innerHTML = message;
}
document.addEventListener('DOMContentLoaded', () => {
const button = document.getElementById('preview-title');
if (button) {
button.addEventListener('click', () => previewTitle());
}
const username = document.getElementById('bonus-user-other');
if (username) {
username.addEventListener('change', () => validateBonusUsername());
}
});

View File

@@ -9,48 +9,25 @@ if ($Viewer->disableBonusPoints()) {
Error403::error('Your bonus points have been deactivated.');
}
const DEFAULT_PAGE = 'store.php';
switch ($_GET['action'] ?? '') {
case 'purchase':
/* handle validity and cost as early as possible */
if (preg_match('/^[a-z]{1,15}(-\w{1,15}){0,4}/', $_REQUEST['label'] ?? '')) {
$viewerBonus = new \Gazelle\User\Bonus($Viewer);
$Label = $_REQUEST['label'];
$Item = $viewerBonus->item($Label);
if (!$Item) {
include_once DEFAULT_PAGE;
break;
}
$Price = $viewerBonus->effectivePrice($Label);
if ($Price > $Viewer->bonusPointsTotal()) {
Error400::error('You cannot afford this item.');
}
include_once match ($Label) {
'invite' => 'invite.php',
'collage-1', 'seedbox', 'file-count' => 'purchase.php',
'title-bb-y', 'title-bb-n', 'title-off' => 'title.php',
'token-1', 'token-2', 'token-3', 'token-4' => 'tokens.php',
'other-1', 'other-2', 'other-3', 'other-4' => 'token_other.php',
default => DEFAULT_PAGE,
};
}
break;
switch ($_REQUEST['action'] ?? '') {
case 'bprates':
include_once 'bprates.php';
break;
case 'title':
include_once 'title.php';
break;
case 'cacheflush':
new Manager\Bonus()->flush();
header("Location: bonus.php");
exit;
case 'history':
include_once 'history.php';
break;
case 'cacheflush':
new \Gazelle\Manager\Bonus()->flushPriceCache();
header("Location: bonus.php");
exit;
case 'prepare':
include_once 'prepare.php';
break;
case 'purchase':
include_once 'purchase.php';
break;
case 'donate':
default:
include_once DEFAULT_PAGE;
include_once 'shop.php';
break;
}

View File

@@ -1,14 +0,0 @@
<?php
/** @phpstan-var \Gazelle\User $Viewer */
declare(strict_types=1);
namespace Gazelle;
authorize();
if (!new User\Bonus($Viewer)->purchaseInvite()) {
Error400::error(
"You cannot purchase an invite (either you don't have the privilege or you don't have enough bonus points)."
);
}
header('Location: bonus.php?complete=invite');

View File

@@ -0,0 +1,39 @@
<?php
/** @phpstan-var \Gazelle\User $Viewer */
/** @phpstan-var \Twig\Environment $Twig */
declare(strict_types=1);
namespace Gazelle;
if (isset($_POST['label'], $_POST['title'])) {
authorize(ajax: true);
echo new Json\BonusItemTitle($_POST['label'], $_POST['title'])->response();
exit;
}
if (isset($_POST['bonus-user-other'])) {
authorize(ajax: true);
echo new Json\BonusUserOther($_POST['bonus-user-other'])->response();
exit;
}
$item = new Manager\Bonus()->findBonusItemByLabel($_REQUEST['item'] ?? '');
if (is_null($item)) {
Error400::error('Unknown bonus shop item');
}
if (str_starts_with($item->label(), 'title-bb-')) {
echo $Twig->render('bonus/title.twig', [
'item' => $item,
'viewer' => $Viewer,
]);
} elseif (str_starts_with($item->label(), 'other-')) {
echo $Twig->render('bonus/token-other.twig', [
'item' => $item,
'message' => new Util\Textarea('message', ''),
'viewer' => $Viewer,
]);
} else {
Error400::error("Buying this item requires no preparation");
}

View File

@@ -7,24 +7,54 @@ namespace Gazelle;
authorize();
$label = $_REQUEST['label'];
$bonus = new User\Bonus($Viewer);
if ($label === 'collage-1') {
if (!$bonus->purchaseCollage($label)) {
Error400::error('Could not purchase a personal collage slot due to lack of funds.');
}
header("Location: bonus.php?complete=$label");
} elseif ($label === 'seedbox') {
if (!$bonus->unlockSeedbox()) {
Error400::error('Could not unlock the seedbox viewer. Either you have already unlocked it, or you lack the required bonus points.');
}
header("Location: bonus.php?complete=$label");
} elseif ($label === 'file-count') {
if (!$bonus->purchaseFeatureFilecount()) {
Error400::error('Could not purchase the file count feature. Either you have already own it, or you lack the required bonus points.');
}
header("Location: bonus.php?complete=$label");
} else {
Error403::error();
$label = $_POST['label'] // from a prepare page
?? current(
array_keys(
array_filter(
$_POST,
fn ($i) => $i === 'Purchase!' // from the shop page
)
)
);
if (!is_string($label)) {
Error400::error("Not sure what you were planning to purchase");
}
$item = new Manager\Bonus()->findBonusItemByLabel($label);
if (is_null($item)) {
Error404::error("We don't stock that item");
}
$extra = [];
if (str_starts_with($item->label(), 'title-bb-')) {
$extra['title'] = $_POST['title'] ?? '';
} elseif (str_starts_with($item->label(), 'other-')) {
$user = new Manager\User()->findByUsername($_POST['user'] ?? '');
if (is_null($user)) {
Error404::error(
'Nobody with that name found. Try a user search and give them tokens from their profile page.'
);
} elseif ($user->id == $Viewer->id) {
Error400::error('You cannot gift yourself tokens, they are cheaper to buy directly.');
}
$extra['receiver'] = $user;
$extra['message'] = $_POST['message'] ?? '';
}
// phpcs:disable PSR2.ControlStructures.SwitchDeclaration.TerminatingComment
switch ($item->purchase($Viewer, $item->price(), $extra)) {
case Enum\BonusItemPurchaseStatus::success:
header("Location: bonus.php?complete={$item->label()}");
exit;
case Enum\BonusItemPurchaseStatus::insufficientFunds:
Error400::error("Could not purchase {$item->title()} due to lack of funds.");
case Enum\BonusItemPurchaseStatus::forbidden:
Error400::error("You are not allowed to purchase {$item->title()}.");
case Enum\BonusItemPurchaseStatus::alreadyPurchased:
Error400::error("You already own {$item->title()}.");
case Enum\BonusItemPurchaseStatus::incomplete:
Error400::error("Information lacking to complete purchase of {$item->title()}.");
}

View File

@@ -9,7 +9,7 @@ namespace Gazelle;
$bonus = new User\Bonus($Viewer);
$bonusMan = new Manager\Bonus();
$purchase = isset($_GET['complete']) ? $bonus->item($_GET['complete'])['Title'] : false;
$purchase = $bonusMan->findBonusItemByLabel($_GET['complete'] ?? '');
if (($_GET['action'] ?? '') !== 'donate') {
$donate = false;
} else {
@@ -34,10 +34,11 @@ if (($_GET['action'] ?? '') !== 'donate') {
}
}
echo $Twig->render('bonus/store.twig', [
echo $Twig->render('bonus/shop.twig', [
'bonus' => $bonus,
'discount' => $bonusMan->discount(),
'donate' => $donate,
'list' => $bonusMan->itemList(),
'pool' => $bonusMan->openPoolList(),
'purchase' => $purchase,
'viewer' => $Viewer,

View File

@@ -6,52 +6,12 @@ declare(strict_types=1);
namespace Gazelle;
/**
* @var array $Item
* @var int $Price
*/
if (isset($_REQUEST['preview']) && isset($_REQUEST['title']) && isset($_REQUEST['BBCode'])) {
echo $_REQUEST['BBCode'] === 'true'
? \Text::full_format($_REQUEST['title'])
: \Text::strip_bbcode($_REQUEST['title']);
exit;
}
$Label = $_REQUEST['label'];
if ($Label === 'title-off') {
authorize();
$Viewer->removeTitle()->modify();
header('Location: bonus.php?complete=' . urlencode($Label));
exit;
}
if ($Label === 'title-bb-y') {
$BBCode = 'true';
} elseif ($Label === 'title-bb-n') {
$BBCode = 'false';
} else {
Error403::error();
}
if (isset($_POST['confirm'])) {
authorize();
if (!isset($_POST['title'])) {
Error403::error();
}
$viewerBonus = new \Gazelle\User\Bonus($Viewer);
if (!$viewerBonus->purchaseTitle($Label, $_POST['title'])) {
Error400::error(
'This title is too long, you must reduce the length (or you do not have enough bonus points).'
);
}
header('Location: bonus.php?complete=' . urlencode($Label));
exit;
$item = new Manager\Bonus()->findBonusItemByLabel($_REQUEST['label'] ?? '');
if (is_null($item)) {
Error400::error('Unknown bonus shop item');
}
echo $Twig->render('bonus/title.twig', [
'bbcode' => $BBCode,
'label' => $Label,
'price' => $Price,
'title' => $Item['Title'],
'item' => $item,
'viewer' => $Viewer,
]);

View File

@@ -6,36 +6,15 @@ declare(strict_types=1);
namespace Gazelle;
/**
* @var array $Item
* @var string $Label
* @var int $Price
*/
authorize();
if (isset($_POST['confirm'])) {
authorize();
if (empty($_POST['user'])) {
Error404::error('You have to enter a username to give tokens to.');
}
$user = new Manager\User()->findByUsername(urldecode($_POST['user']));
if (is_null($user)) {
Error404::error(
'Nobody with that name found. Try a user search and give them tokens from their profile page.'
);
} elseif ($user->id == $Viewer->id()) {
Error400::error('You cannot gift yourself tokens, they are cheaper to buy directly.');
}
$viewerBonus = new \Gazelle\User\Bonus($Viewer);
if (!$viewerBonus->purchaseTokenOther($user, $Label, $_POST['message'] ?? '')) {
Error400::error('Purchase for other not concluded. Either you lacked funds or they have chosen to decline FL tokens.');
}
header('Location: bonus.php?complete=' . urlencode($Label));
$item = new Manager\Bonus()->findBonusItemByLabel($_REQUEST['label'] ?? '');
if (is_null($item)) {
Error400::error('Unknown bonus shop item');
}
echo $Twig->render('bonus/token-other.twig', [
'auth' => $Viewer->auth(),
'price' => $Price,
'label' => $Label,
'textarea' => new Util\Textarea('message', ''),
'item' => $Item['Title']
'item' => $item,
'message' => new Util\Textarea('message', ''),
'viewer' => $Viewer,
]);

View File

@@ -1,25 +0,0 @@
<?php
/** @phpstan-var \Gazelle\User $Viewer */
declare(strict_types=1);
namespace Gazelle;
/**
* @var string $Label
*/
authorize();
if (!preg_match('/^token-[1-4]$/', $Label, $match)) {
Error403::error();
}
$viewerBonus = new \Gazelle\User\Bonus($Viewer);
if (!$viewerBonus->purchaseToken($Label)) {
Error400::error(
"You aren't able to buy those tokens. Do you have enough bonus points?"
);
}
header('Location: bonus.php?complete=' . urlencode($Label));

View File

@@ -10,6 +10,7 @@ declare(strict_types=1);
namespace Gazelle;
use Gazelle\Enum\BonusItemPurchaseStatus;
use Gazelle\Enum\UserTokenType;
use Gazelle\User\Vote;
@@ -28,34 +29,44 @@ $userBonus = new User\Bonus($user);
$viewerBonus = new User\Bonus($Viewer);
$history = new User\History($user);
$limiter = new User\UserclassRateLimit($user);
$bonusMan = new Manager\Bonus();
$donorMan = new Manager\Donation();
$ipv4 = new Manager\IPv4();
$tgMan = new Manager\TGroup();
$resetToken = $Viewer->permitted('users_mod')
? new Manager\UserToken()->findByUser($user, UserTokenType::password)
: null;
if (!empty($_POST)) {
if (isset($_POST['flsubmit'], $_POST['fltype']) && ($_POST['action'] ?? '') === 'fltoken') {
authorize();
foreach (['action', 'flsubmit', 'fltype'] as $arg) {
if (!isset($_POST[$arg])) {
Error403::error();
}
if (str_starts_with($_POST['fltype'], 'fl-')) {
// backwards compatibility
$_POST['fltype'] = substr($_POST['fltype'], 3);
}
if ($_POST['action'] !== 'fltoken' || $_POST['flsubmit'] !== 'Send') {
Error403::error();
}
if (!preg_match('/^fl-(other-[1-4])$/', $_POST['fltype'], $match)) {
Error403::error();
}
$FL_OTHER_tokens = $viewerBonus->purchaseTokenOther($user, $match[1], $_POST['message'] ?? '');
if (!$FL_OTHER_tokens) {
$status = $bonusMan->purchaseTokenOther(
$Viewer, $user, $_POST['fltype'], $_POST['message'] ?? ''
);
if ($status === BonusItemPurchaseStatus::success) {
header("Location: {$user->location()}");
exit;
} else {
Error400::error(
'Purchase of tokens not concluded. Either you lacked funds or they have chosen to decline FL tokens.'
match ($status) { /* @phpstan-ignore match.unhandled (alreadyPurchased) */
BonusItemPurchaseStatus::declined
=> "{$user->username()} does not wish to receive tokens",
BonusItemPurchaseStatus::insufficientFunds
=> "You do not have enough bonus points to buy that",
BonusItemPurchaseStatus::forbidden
=> "{$user->username()} cannot receive tokens from you",
BonusItemPurchaseStatus::incomplete
=> "Something was wrong with the parameters provided",
}
);
}
}
if ($userId == $Viewer->id()) {
if ($userId == $Viewer->id) {
$Preview = (bool)($_GET['preview'] ?? false);
$OwnProfile = !$Preview;
$user->forceCacheFlush(true);
@@ -90,8 +101,7 @@ echo $Twig->render('user/header.twig', [
'bonus' => $userBonus,
'donor' => $donor,
'freeleech' => [
'item' => $OwnProfile ? [] : $viewerBonus->otherList(),
'other' => $FL_OTHER_tokens ?? null,
'offer' => $OwnProfile ? [] : $bonusMan->offerTokenOther($Viewer),
'latest' => $viewerBonus->otherLatest($user),
],
'friend' => new User\Friend($Viewer),

View File

@@ -11,10 +11,10 @@
{% endif %}
</div>
{% if purchase %}
<div class="alertbar blend">{{ purchase }} purchased!</div>
<div class="save_message">✅ {{ purchase.title }} purchased!</div>
{% endif %}
{% if donate %}
<div class="alertbar blend">{{ donate }}</div>
<div class="save_message">{{ donate }}</div>
{% endif %}
{% if pool %}
{% include 'bonus/bonus-pool.twig' with {
@@ -23,48 +23,58 @@
} only
%}
{% endif %}
{% for item in bonus.itemList %}
{% for item in list %}
{% if loop.first %}
<div class="thin">
{% if discount %}
<h3 style="text-align: center; color: lime;">All prices currently {{ min(100, max(0, discount))
}}% off &mdash; Hurry, sale ends soon &mdash; While stocks last!</h3>
}}% off Hurry, sale ends soon While stocks last!</h3>
{% endif %}
{% if viewer.permitted('admin_bp_history') %}
<div class="pad box">
<div class="thin">NB: Bonus Shop discounts are set in the <a href="/tools.php?action=site_options">Site Options</a>.</div>
</div>
{% endif %}
<form action="" method="post">
<input type="hidden" name="auth" value="{{ viewer.auth }}" />
<input type="hidden" name="action" value="purchase" />
<table>
<thead>
<tr class="colhead">
<td>Description</td>
<td style="width:60px">Points</td>
<td style="width:120px">Checkout</td>
<td class="number_column">Points</td>
<td style="text-align:center">Checkout</td>
</tr>
</thead>
<tbody>
{% endif %}
{% if item.MinClass <= viewer.classLevel %}
<tr class="row{{ cycle(['a', 'b'], loop.index0) }}">
<td>{{ item.Title }}</pre></td>
<td style="text-align:right">{{ item.Price|number_format }}</td>
<td>
{% if viewer.bonusPointsTotal >= item.Price %}
<a id="bonusconfirm" href="bonus.php?action=purchase&amp;label={{ item.Label
}}&amp;auth={{ viewer.auth }}" onclick="{{ item.JS_on_clic
}}(event, '{{ item.Title }}', {{ item.JS_next_function|default('null')
}}, this);">Purchase</a>
{% else %}
<span style="font-style: italic">Too Expensive</span>
{% endif %}
<td>{{ item.title }}</td>
<td style="text-align:right">{{ item.priceForUser(viewer)|number_format }}</td>
<td style="text-align:center">
{% if not item.permitted(viewer) %}
<i>Forbidden</i>
{% elseif item.userclassMin > viewer.classLevel %}
<i>Locked</i>
{% elseif
(item.label == 'seedbox' and viewer.hasAttr('feature-seedbox'))
or
(item.label == 'file-count' and viewer.hasAttr('feature-file-count'))
%}
<i>You own this</i>
{% elseif item.priceForUser(viewer) > viewer.bonusPointsTotal %}
<i>Too Expensive</i>
{% elseif item.needsPreparation %}
<a href="bonus.php?action=prepare&amp;item={{ item.label }}">Prepare</a>
{% else %}
<input style="font-size:10px;margin:0px;" type="submit" name="{{ item.label }}" value="Purchase!" />
{% endif %}
</td>
</tr>
</tr>
{% if loop.last %}
</tbody>
</table>
</form>
<br />
</div>
{% endif %}

View File

@@ -3,17 +3,23 @@
<table>
<thead>
<tr>
<td>Custom Title, {% if not bbcode %}no {% endif %} allowed - {{ price|number_format }} Points</td>
<td>
<a href="bonus.php">Shop</a> Custom Title, BBCode {% if item.label != 'title-bb-y' %}not {% endif
%} allowed - {{ item.priceForUser(viewer)|number_format }} Points</td>
</tr>
</thead>
<tbody>
<tr>
<td>
<form action="bonus.php?action=purchase&label={{ label }}" method="post">
<form name="custom-title-preview" action="bonus.php" method="post">
<input type="hidden" name="action" value="purchase" />
<input type="hidden" name="auth" value="{{ viewer.auth }}" />
<input type="hidden" name="confirm" value="true" />
<input type="hidden" name="label" value="{{ item.label }}" />
<input type="text" style="width: 98%" id="title" name="title" placeholder="Custom Title"/> <br />
<input type="submit" onclick="ConfirmPurchase(event, '{{ title }}')" value="Submit" />&nbsp;<input type="button" onclick="PreviewTitle({{ bbcode }});" value="Preview" /><br /><br />
<br />
<input type="button" id="preview-title" value="Preview" />
<input type="submit" value="Purchase" />
<br /><br />
<div id="preview"></div>
</form>
</td>

View File

@@ -1,37 +1,39 @@
{{ header("Bonus Points - Gift Tokens ", {'js': 'bonus'}) }}
<div class="thin">
<div class="header">
<h2>Gift Tokens - {{price | number_format}} Points</a></h2>
<h2><a href="bonus.php">Shop</a> Gift Tokens - {{ item.price | number_format }} Points</a></h2>
</div>
<form action="bonus.php?action=purchase&amp;label={{label}}" method="post">
<form name="bonus-other" action="bonus.php" method="post">
<div class="pad">
<table cellpadding="6" cellspacing="1" border="0" class="layout border" width="100%">
<tbody>
<input type="hidden" name="auth" value="{{auth}}" />
<input type="hidden" name="confirm" value="true" />
<input type="hidden" name="action" value="purchase" />
<input type="hidden" name="auth" value="{{ viewer.auth }}" />
<input type="hidden" name="label" value="{{ item.label }}" />
<input type="hidden" name="user" value="" />
<tr>
<td class="label">Username:</td>
<td>
<input type="text" id="user" name="user" style="width:90%" /> <br />
<input type="text" id="bonus-user-other" name="bonus-user-other" length="20" />
<span id="bonus-other-status"></span>
</td>
</tr>
<tr>
<td class="label">Message:</td>
<td>
<div style="width:90%">
{{ textarea.preview|raw }}
{{ textarea.field|raw }}
{{ message.preview|raw }}
{{ message.field|raw }}
</div>
</td>
</tr>
</tbody>
</table>
<div id="buttons" class="center">
{{ textarea.button|raw }}
<input type="submit" onclick="ConfirmPurchase(event,'{{item}}')" value="Submit" />
{{ message.button|raw }}
<input type="submit" id="bonus-other-purchase" disabled="disabled" value="Purchase" />
</div>
</div>
</form>
</div>
{{ footer() }}

View File

@@ -83,37 +83,30 @@
</div>
{% endif %}
{% if user.isEnabled and (not user.hasAttr('no-fl-gifts')) and (freeleech.item or freeleech.other) %}
{% if user.isEnabled and (not user.hasAttr('no-fl-gifts')) and freeleech.offer %}
<div class="box box_info box_userinfo_give_FL">
{% if freeleech.other %}
<div class="head colhead_dark">Freeleech Tokens Given</div>
<ul class="stats nobullet">
{% if freeleech.other > 0 %}
<li>You gave {{ freeleech.other }} token{{ freeleech.other|plural }} to {{ user.username }}. Your generosity is most appreciated!</li>
{% else %}
<li>You attempted to give some tokens to {{ user.username }} but something didn't work out.
No points were spent.</li>
{% endif %}
</ul>
{% else %}
<div class="head colhead_dark">Give Freeleech Tokens</div>
<form class="fl_form" name="user" id="fl_form" action="user.php?id={{ user_id }}" method="post">
<ul class="stats nobullet">
{% for f in freeleech.item %}
<li><input type="radio" name="fltype" id="fl-{{ f.Label }}" value="fl-{{ f.Label }}" />
<label title="This costs {{ f.Price|number_format }} BP, which will leave you with {{ f.After|number_format
}} afterwards" for="fl-{{ f.Label }}"> {{ f.Name }}</label></li>
{% for item in freeleech.offer %}
<li><input type="radio" name="fltype" id="{{ item.label }}" value="{{ item.label }}" />
<label title="This costs {{ item.price|number_format }} BP, which will leave you with {{
(viewer.bonusPointsTotal - item.price)|number_format}} afterwards" for="{{
item.label }}"> {{ item.title }}</label></li>
{% endfor %}
<li><input type="text" id="message" name="message" placeholder="Message"/> <br /></li>
<li><input type="submit" name="flsubmit" value="Send" /></li>
{% if freeleech.latest %}
<li>(You gave them {{ freeleech.latest.title|trim(' to Other') }} {{ freeleech.latest.purchase_date|time_diff }})</li>
{% set when = freeleech.latest.purchase_date|time_diff %}
<li>
You gave them {{ freeleech.latest.title|trim(' to Other') }} {{ when|raw }}
{%- if 'Just now' in when %}. Your generosity is most appreciated!{% endif -%}
</li>
{% endif %}
</ul>
<input type="hidden" name="action" value="fltoken" />
<input type="hidden" name="auth" value="{{ viewer.auth }}" />
</form>
{% endif %}
</div>
{% endif %}

View File

@@ -3,7 +3,9 @@
namespace Gazelle;
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\Attributes\DataProvider;
use GazelleUnitTest\Helper;
use Gazelle\Enum\BonusItemPurchaseStatus;
use Gazelle\Enum\UserStatus;
class BonusTest extends TestCase {
@@ -23,12 +25,13 @@ class BonusTest extends TestCase {
}
}
public function testBonus(): void {
$this->userList['giver'] = Helper::makeUser('bonusg.' . randomString(6), 'bonus', true);
public function testBonusBasic(): void {
$this->userList['giver'] = Helper::makeUser('bonusg.' . randomString(6), 'bonus', true);
$this->userList['receiver'] = Helper::makeUser('bonusr.' . randomString(6), 'bonus', true);
$startingPoints = 10000;
$giver = new User\Bonus($this->userList['giver']);
$user = $this->userList['giver'];
$giver = new User\Bonus($user);
$this->assertEquals(0.0, $giver->hourlyRate(), 'bonus-per-hour');
$this->assertEquals(0, $giver->user()->bonusPointsTotal(), 'bonus-points-initial');
$this->assertEquals(0, $giver->user()->tokenCount(), 'bonus-fltokens-intial');
@@ -50,93 +53,337 @@ class BonusTest extends TestCase {
);
$giver->setPoints($startingPoints);
$this->assertEquals($startingPoints, $giver->user()->bonusPointsTotal(), 'bonus-set-points');
$this->assertEquals(
$startingPoints,
$giver->user()->bonusPointsTotal(),
'bonus-set-points',
);
$manager = new Manager\Bonus();
$manager->flushPriceCache();
$itemList = $manager->itemList();
$this->assertArrayHasKey('token-1', $itemList, 'item-token-1');
$token = $giver->item('token-1');
$this->assertArrayHasKey('Price', $token, 'item-price-1');
$price = $token['Price'];
$this->assertEquals($price, $giver->effectivePrice('token-1'), 'item-price-token-1');
$manager->flush();
$this->assertCount(13, $manager->itemList(), 'bonus-item-list');
$this->assertNull($manager->findBonusItemByLabel('nope'), 'bonus-item-null');
// buy a token
$this->assertTrue($giver->purchaseToken('token-1'), 'item-purchase-token-1');
$this->assertEquals(1, $giver->user()->tokenCount(), 'bonus-fltokens-bought');
$this->assertEquals($startingPoints - $price, $giver->user()->bonusPointsTotal(), 'bonus-spent-points');
$flt = $manager->findBonusItemByLabel('token-1');
$this->assertFalse($flt->needsPreparation(), 'bonus-token-no-prepare');
$this->assertInstanceOf(BonusItem::class, $flt->flush(), 'bonus-item-flush');
$this->assertEquals('', $flt->link(), 'bonus-item-link');
$this->assertEquals('bonus.php', $flt->location(), 'bonus-item-location');
$this->assertEquals(
BonusItemPurchaseStatus::success,
$flt->purchase($user, $flt->price()),
'bonus-item-purchase-token-1',
);
$this->assertEquals(
1,
$giver->user()->tokenCount(),
'bonus-fltokens-bought',
);
$this->assertEquals(
$startingPoints - $flt->price(),
$giver->user()->bonusPointsTotal(),
'bonus-spent-points',
);
$history = $giver->history(10, 0);
$this->assertEquals('1 Freeleech Token', $history[0]['Title'], 'bonus-history-title');
$this->assertEquals(
'1 Freeleech Token',
$history[0]['Title'],
'bonus-history-title',
);
// buy a seedbox
$this->assertTrue($giver->unlockSeedbox(), 'item-purchase-seedbox');
$seedbox = $manager->findBonusItemByLabel('seedbox');
$this->assertEquals(
'Seedbox Viewer',
$seedbox->title(),
'bonus-item-title',
);
$this->assertEquals(
BonusItemPurchaseStatus::forbidden,
$seedbox->purchase($user, $seedbox->price()),
'bonus-item-purchase-seedbox-forbidden',
);
$user->setField('PermissionID', MEMBER)->modify();
$this->assertEquals(
BonusItemPurchaseStatus::success,
$seedbox->purchase($user, $seedbox->price()),
'bonus-item-purchase-seedbox-success',
);
$this->assertTrue($giver->user()->hasAttr('feature-seedbox'), 'giver-has-seedbox');
$this->assertCount(2, $giver->history(10, 0), 'bonus-history-new');
$this->assertFalse($seedbox->isRecurring(), 'bonus-item-recurring');
$this->assertEquals(
BonusItemPurchaseStatus::alreadyPurchased,
$seedbox->purchase($user, 0),
'bonus-item-purchase-seedbox-duplicate',
);
// not enough point to buy a fifty
$this->assertFalse($giver->purchaseToken('token-50'), 'item-purchase-token-50');
$flt50 = $manager->findBonusItemByLabel('token-3');
$this->assertEquals(
BonusItemPurchaseStatus::insufficientFunds,
$flt50->purchase($user, $flt50->price()),
'bonus-item-purchase-token-50',
);
$other1 = $manager->findBonusItemByLabel('other-1');
$this->assertTrue($other1->needsPreparation(), 'bonus-token-other-prepare');
$other50 = $manager->findBonusItemByLabel('other-3');
$giver->addPoints(
(float)($giver->item('other-1')['Price'])
+ (float)($giver->item('other-3')['Price'])
(float)($other1->price() + $other50->price())
);
$this->assertEquals(
$giver->item('other-3')['Amount'],
$giver->purchaseTokenOther($this->userList['receiver'], 'other-3', 'phpunit gift'),
'item-purchase-other-50'
BonusItemPurchaseStatus::incomplete,
$other50->purchase(
$user,
$other50->price(),
),
'bonus-item-purchase-incomplete-other-50',
);
$other = $giver->otherList();
$this->assertEquals('other-1', $other[0]['Label'], 'item-all-I-can-give');
$this->userList['receiver']->toggleAttr('no-fl-gifts', true);
$this->assertEquals(
BonusItemPurchaseStatus::declined,
$other50->purchase(
$user,
$other50->price(),
[
'message' => 'phpunit gift',
'receiver' => $this->userList['receiver'],
],
),
'bonus-item-purchase-declined-other-50',
);
$this->userList['receiver']->toggleAttr('no-fl-gifts', false);
$this->assertEquals(
BonusItemPurchaseStatus::success,
$other50->purchase(
$user,
$other50->price(),
[
'message' => 'phpunit gift',
'receiver' => $this->userList['receiver'],
],
),
'bonus-item-purchase-other-50',
);
$offer = $manager->offerTokenOther($user);
$this->assertEquals('other-1', $offer[0]->label(), 'bonus-item-all-I-can-give');
// buy file count feature
$giver->addPoints(
(float)($giver->item('file-count')['Price'])
$fileCount = $manager->findBonusItemByLabel('file-count');
$giver->addPoints((float)$fileCount->price());
$this->assertEquals(
BonusItemPurchaseStatus::success,
$fileCount->purchase($user, $fileCount->price()),
'bonus-item-purchase-file-count',
);
$this->assertTrue($user->hasAttr('feature-file-count'), 'giver-has-file-count');
$this->assertEquals(
BonusItemPurchaseStatus::alreadyPurchased,
$fileCount->purchase($user, $fileCount->price()),
'bonus-item-repurchase-file-count',
);
$this->assertTrue($giver->purchaseFeatureFilecount(), 'item-purchase-file-count');
$this->assertTrue($giver->user()->hasAttr('feature-seedbox'), 'giver-has-file-count');
$this->assertEquals(
$giver->item('token-1')['Price'] /** @phpstan-ignore-line it is an int, get over it */
+ $giver->item('other-3')['Price']
+ $giver->item('seedbox')['Price']
+ $giver->item('file-count')['Price'],
$flt->price()
+ $other50->price()
+ $seedbox->price()
+ $fileCount->price(),
$giver->pointsSpent(),
'bonus-points-spent'
'bonus-points-spent',
);
$latest = $giver->otherLatest($this->userList['receiver']);
$this->assertEquals('50 Freeleech Tokens to Other', $latest['title'], 'item-given');
$this->assertEquals('50 Freeleech Tokens to Other', $latest['title'], 'bonus-item-given');
$giver->addPoints($giver->item('title-bb-n')['Price']);
$this->assertTrue($giver->purchaseTitle('title-bb-n', '[b]i got u[/b]'), 'item-title-no-bb');
$this->assertEquals('i got u', $giver->user()->title(), 'item-user-has-title-no-bb');
$bbn = $manager->findBonusItemByLabel('title-bb-n');
$giver->addPoints($bbn->price());
$this->assertEquals(
BonusItemPurchaseStatus::success,
$bbn->purchase($user, $bbn->price(), ['title' => '[b]i got u[/b]']),
'bonus-item-title-no-bb',
);
$this->assertEquals('i got u', $user->title(), 'bonus-item-user-has-title-no-bb');
$giver->addPoints($giver->item('title-bb-y')['Price']);
$this->assertTrue($giver->purchaseTitle('title-bb-y', '[b]i got u[/b]'), 'item-title-yes-bb');
$this->assertEquals('<strong>i got u</strong>', $giver->user()->title(), 'item-user-has-title-yes-bb');
$bby = $manager->findBonusItemByLabel('title-bb-y');
$giver->addPoints($bby->price());
$this->assertEquals(
BonusItemPurchaseStatus::success,
$bby->purchase($user, $bby->price(), ['title' => '[b]i got u[/b]']),
'bonus-item-title-yes-bb',
);
$this->assertEquals(
'<strong>i got u</strong>',
$user->title(),
'bonus-item-user-has-title-yes-bb',
);
$giver->addPoints($giver->item('collage-1')['Price']);
$this->assertTrue($giver->purchaseCollage('collage-1'), 'item-purchase-collage');
$off = $manager->findBonusItemByLabel('title-off');
$this->assertEquals(
BonusItemPurchaseStatus::success,
$off->purchase($user, $off->price()),
'bonus-item-title-off',
);
$this->assertEquals(
'',
$user->title(),
'bonus-item-user-has-no-title',
);
$collage = $manager->findBonusItemByLabel('collage-1');
$giver->addPoints($collage->price());
$this->assertEquals(
BonusItemPurchaseStatus::success,
$collage->purchase($user, $collage->price()),
'bonus-item-purchase-collage',
);
$this->assertEquals(
$collage->price() * 2,
$collage->priceForUser($giver->user()),
'bonus-item-price-for-user'
);
$this->assertEquals(
$collage->price(),
$collage->priceForUser($this->userList['receiver']),
'bonus-item-price-for-other-user'
);
$history = $giver->history(10, 0);
$this->assertCount(7, $history, 'bonus-history-final');
$this->assertCount(8, $history, 'bonus-history-final');
$this->assertEquals(
[
'nr' => 7,
'total' => $giver->item('token-1')['Price'] /** @phpstan-ignore-line */
+ $giver->item('other-3')['Price']
+ $giver->item('seedbox')['Price']
+ $giver->item('file-count')['Price']
+ $giver->item('collage-1')['Price']
+ $giver->item('title-bb-y')['Price']
+ $giver->item('title-bb-n')['Price']
'nr' => 8,
'total' => $flt->price()
+ $other50->price()
+ $seedbox->price()
+ $fileCount->price()
+ $collage->price()
+ $bbn->price()
+ $bby->price()
+ $off->price()
],
$giver->summary(),
'bonus-summary-initial'
);
$this->assertTrue($giver->removePoints(1.125), 'bonus-taketh-away');
$this->assertEquals(
BonusItemPurchaseStatus::insufficientFunds,
$bby->purchase($user, $bby->price(), ['title' => 'whatever']),
'bonus-item-no-funds-title-yes-bb',
);
// higher userclasses get some items for free
$user->setField('PermissionID', SYSOP)->modify();
$this->assertEquals(
BonusItemPurchaseStatus::success,
$bby->purchase($user, $bby->price(), ['title' => 'whatever']),
'bonus-item-free-title-yes-bb',
);
$this->assertEquals(
BonusItemPurchaseStatus::incomplete,
$bby->purchase($user, $bby->price()),
'bonus-item-free-title-yes-bb',
);
$userBonus = new User\Bonus($user);
$history = $userBonus->purchaseHistory();
$this->assertCount(8, $history, 'bonus-history-count');
$this->assertEquals(
['id', 'title', 'total', 'cost'],
array_keys(current($history)),
'bonus-history-shape',
);
$this->assertCount(0, $userBonus->seedList(5, 0), 'bonus-history-seedlist');
$this->assertCount(0, $userBonus->poolHistory(), 'bonus-history-pool');
// Here is as good a place as any
$this->assertEquals(0, $manager->discount(), 'bonus-discount');
}
public static function providerBonusItem(): array {
return [
['collage-1'],
['file-count'],
['invite'],
['seedbox'],
['other-1'],
['other-2'],
['other-3'],
['token-1'],
['token-2'],
['token-3'],
];
}
#[DataProvider('providerBonusItem')]
public function testBonusBroke(string $label): void {
$this->userList = [Helper::makeUser('bonusg.' . randomString(6), 'bonus', true)];
$this->userList[0]->setField('PermissionID', MEMBER)->modify();
$item = new Manager\Bonus()->findBonusItemByLabel($label);
if (str_starts_with($label, 'other-')) {
$this->userList[] = Helper::makeUser('bonusg.' . randomString(6), 'bonus', true);
$this->assertEquals(
BonusItemPurchaseStatus::insufficientFunds,
$item->purchase($this->userList[0], $item->price(), ['receiver' => $this->userList[1]]),
"bonus-item-broke-$label",
);
} else {
$this->assertEquals(
BonusItemPurchaseStatus::insufficientFunds,
$item->purchase($this->userList[0], $item->price()),
"bonus-item-broke-$label",
);
}
}
public function testBonusPurchaseOther(): void {
$this->userList['giver'] = Helper::makeUser('bonusg.' . randomString(6), 'bonus', true);
$this->userList['receiver'] = Helper::makeUser('bonusr.' . randomString(6), 'bonus', true);
$manager = new Manager\Bonus();
$this->assertEquals(
BonusItemPurchaseStatus::incomplete,
$manager->purchaseTokenOther(
$this->userList['giver'],
$this->userList['receiver'],
'bad-label',
'',
),
'bonus-purchase-token-other-fail',
);
$this->assertEquals(
BonusItemPurchaseStatus::insufficientFunds,
$manager->purchaseTokenOther(
$this->userList['giver'],
$this->userList['receiver'],
'other-1',
'',
),
'bonus-purchase-token-other-no-money',
);
$this->assertCount(0,
$manager->offerTokenOther($this->userList['giver']),
'bonus-offer-other-none',
);
new User\Bonus($this->userList['giver'])->addPoints(2000000);
$this->assertCount(
3,
$manager->offerTokenOther($this->userList['giver']),
'bonus-offer-other-all',
);
$this->assertEquals(
BonusItemPurchaseStatus::success,
$manager->purchaseTokenOther(
$this->userList['giver'],
$this->userList['receiver'],
'other-1',
'',
),
'bonus-purchase-token-other-success',
);
}
public function testBonusPool(): void {
@@ -150,6 +397,94 @@ class BonusTest extends TestCase {
);
}
public function testBonusInvite(): void {
$user = Helper::makeUser('bonusinvite.' . randomString(6), 'bonus');
$this->userList = [$user];
$invite = new Manager\Bonus()->findBonusItemByLabel('invite');
$this->assertTrue($invite->permitted($user), 'bonus-invite-yes');
$user->toggleAttr('disable-invites', true);
$this->assertFalse($invite->permitted($user), 'bonus-invite-no');
$userBonus = new User\Bonus($user);
$this->assertTrue(
$userBonus->removePoints(1000, force: true),
'bonus-remove-force'
);
$this->assertEquals(
-1000,
$user->bonusPointsTotal(),
'bonus-points-negative'
);
}
public function testBonusTitle(): void {
$this->assertEquals(
[],
new Json\BonusItemTitle('nope', 'title')->payload(),
'bonus-title-bad',
);
$this->assertEquals(
['phpunit'],
new Json\BonusItemTitle('title-bb-n', '[b]phpunit[/b]')->payload(),
'bonus-title-plain',
);
$this->assertEquals(
['<strong>phpunit</strong>'],
new Json\BonusItemTitle('title-bb-y', '[b]phpunit[/b]')->payload(),
'bonus-title-rich',
);
}
public function testBonusUserOther(): void {
$user = Helper::makeUser('bonusother.' . randomString(6), 'bonus', enable: false);
$this->userList = [$user];
$this->assertEquals(
[
'found' => false,
'username' => '#nope',
],
new Json\BonusUserOther('#nope')->payload(),
'bonus-user-other-404',
);
$this->assertEquals(
[
'found' => true,
'accept' => true,
'enabled' => false,
'id' => $user->id,
'username' => $user->username(),
],
new Json\BonusUserOther($user->username())->payload(),
'bonus-user-other-not-enabled',
);
$user->setField('Enabled', UserStatus::enabled->value)->modify();
$this->assertEquals(
[
'found' => true,
'accept' => true,
'enabled' => true,
'id' => $user->id,
'username' => $user->username(),
],
new Json\BonusUserOther($user->username())->payload(),
'bonus-user-other-accept',
);
$user->toggleAttr('no-fl-gifts', true);
$this->assertEquals(
[
'found' => true,
'accept' => false,
'enabled' => true,
'id' => $user->id,
'username' => $user->username(),
],
new Json\BonusUserOther($user->username())->payload(),
'bonus-user-other-no-fl',
);
}
public function testAddPoints(): void {
$this->userList = [
Helper::makeUser('bonusadd.' . randomString(6), 'bonus', enable: false),

View File

@@ -35,36 +35,45 @@ class InviteTest extends TestCase {
$this->assertEquals(0, $this->user->invite()->pendingTotal(), 'invite-pending-0-initial');
// USER cannot invite, but MEMBER can
$this->assertFalse($this->user->canInvite(), 'invite-cannot-invite');
$this->assertFalse($this->user->canPurchaseInvite(), 'invite-cannot-purchase');
$this->assertFalse($this->user->canInvite(), 'inviter-cannot-invite');
$this->assertFalse($this->user->canPurchaseInvite(), 'inviter-cannot-purchase');
$this->user->setField('PermissionID', MEMBER)->modify();
$this->assertTrue($this->user->canInvite(), 'invite-can-invite');
$this->assertTrue($this->user->canPurchaseInvite(), 'invite-can-now-purchase');
$this->assertTrue($this->user->canInvite(), 'inviter-can-invite');
$this->assertTrue($this->user->canPurchaseInvite(), 'inviter-can-now-purchase');
// add some BP to play with
$bonus = new User\Bonus($this->user);
$this->assertEquals(1, $bonus->addPoints(1_000_000), 'invite-add-bp');
$this->assertTrue($bonus->purchaseInvite(), 'invite-purchase-invite');
$this->assertEquals(1, $this->user->unusedInviteTotal(), 'invite-unused-1');
$this->assertEquals(1, $bonus->addPoints(1_000_000), 'inviter-add-bp');
$invite = new Manager\Bonus()->findBonusItemByLabel('invite');
$this->assertEquals(
Enum\BonusItemPurchaseStatus::success,
$invite->purchase($this->user, $invite->price()),
'inviter-purchase-invite'
);
$this->assertEquals(1, $this->user->unusedInviteTotal(), 'inviter-unused-1');
$this->assertTrue($this->user->invite()->issueInvite(), 'invite-issue-true');
$this->assertFalse($this->user->invite()->issueInvite(), 'invite-decrement-none-left');
$this->assertTrue($this->user->invite()->issueInvite(), 'inviter-issue-true');
$this->assertFalse($this->user->invite()->issueInvite(), 'inviter-decrement-none-left');
$this->user->setField('Invites', 1)->modify();
// invite someone
$this->assertTrue(new Stats\Users()->newUsersAllowed($this->user), 'invite-new-users-allowed');
$this->assertTrue(new Stats\Users()->newUsersAllowed($this->user), 'inviter-new-users-allowed');
$manager = new Manager\Invite();
$email = randomString(10) . "@invitee.example.com";
$this->assertFalse($manager->emailExists($this->user, $email), 'invitee-email-not-pending');
$invite = $manager->create($this->user, $email, 'unittest notes', 'unittest reason', '');
$this->assertInstanceOf(Invite::class, $invite, 'invite-invitee-created');
$this->assertEquals(1, $this->user->invite()->pendingTotal(), 'invite-pending-1');
$this->assertEquals(0, $this->user->unusedInviteTotal(), 'invite-unused-0-again');
$this->assertEquals($invite->email(), $this->user->invite()->pendingList()[$invite->key()]['email'], 'invite-invitee-email');
$invitation = $manager->create($this->user, $email, 'unittest notes', 'unittest reason', '');
$this->assertInstanceOf(Invite::class, $invitation, 'inviter-invitee-created');
$this->assertEquals(1, $this->user->invite()->pendingTotal(), 'inviter-pending-1');
$this->assertEquals(0, $this->user->unusedInviteTotal(), 'inviter-unused-0-again');
$this->assertEquals(
$invitation->email(),
$this->user->invite()->pendingList()[$invitation->key()]['email'],
'inviter-invitee-email',
);
// respond to invite
$this->assertTrue($manager->inviteExists($invite->key()), 'invite-key-found');
$this->invitee = Helper::makeUserByInvite('invitee.' . randomString(6), $invite->key());
$this->assertTrue($manager->inviteExists($invitation->key()), 'invite-key-found');
$this->invitee = Helper::makeUserByInvite('invitee.' . randomString(6), $invitation->key());
$this->assertInstanceOf(User::class, $this->invitee, 'invitee-class');
$this->assertEquals($this->user->id, $this->invitee->inviter()?->id, 'invitee-invited-by');
$this->assertEquals($this->user->id, $this->invitee->inviterId(), 'invitee-invited-id');

View File

@@ -115,7 +115,12 @@ class TGroupTest extends TestCase {
$bonus = (new User\Bonus($this->userList['user']));
$this->assertEquals(1, $bonus->addPoints(10000), 'tgroup-user-add-bp');
$this->assertEquals(1, $bonus->purchaseToken('token-1'), 'tgroup-user-buy-token');
$token = new Manager\Bonus()->findBonusItemByLabel('token-1');
$this->assertEquals(
Enum\BonusItemPurchaseStatus::success,
$token->purchase($this->userList['user'], $token->price()),
'tgroup-user-buy-fltoken'
);
$this->assertTrue($this->userList['user']->canSpendFLToken($torrent), 'tgroup-user-fltoken');
new Stats\Users()->refresh();