4 Commits

66 changed files with 976 additions and 2225 deletions

View File

@ -36,7 +36,7 @@ RUN mkdir -p public/build/ && npm i && npm run build
# ========================================
# RUN
FROM php:8.3-alpine AS final
FROM php:8.2-alpine AS final
ARG APP_ENV_FILE=.env.docker
@ -59,10 +59,6 @@ RUN apk update && apk add --no-cache \
RUN docker-php-ext-configure zip && docker-php-ext-install zip
RUN docker-php-ext-install gd pdo pdo_mysql zip
# Install latest version of the linux binary of yt-dlp into /bin/yt-dlp
# Get the file from https://github.com/yt-dlp/yt-dlp-master-builds/releases/latest/download/yt-dlp
RUN curl -L https://github.com/yt-dlp/yt-dlp-master-builds/releases/latest/download/yt-dlp -o /bin/yt-dlp \
&& chmod +x /bin/yt-dlp
# Get latest Composer
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer

View File

@ -5,8 +5,6 @@ namespace App\Browser;
use App\Models\Job;
use App\Models\JobArtifact;
use App\Models\JobRun;
use App\Notification\Notifications\JobErrorNotification;
use App\Notification\Providers\AllNotification;
use Closure;
use Exception;
use Facebook\WebDriver\Chrome\ChromeOptions;
@ -58,8 +56,7 @@ abstract class BrowserJob implements ShouldQueue
// throw $e;
}
catch (Throwable $e) {
$browser->screenshot(JobErrorScreenshot::getFileName($this->jobId));
AllNotification::send(new JobErrorNotification($this->jobId, $e->getMessage()));
$browser->screenshot("failure-{$this->jobId}");
dump($e);
throw $e;
} finally {
@ -159,6 +156,7 @@ abstract class BrowserJob implements ShouldQueue
'--whitelisted-ips=""',
'--disable-dev-shm-usage',
'--user-data-dir=/home/seluser/profile/',
'--auto-open-devtools-for-tabs',
])->all());
return RemoteWebDriver::create(
@ -181,7 +179,8 @@ abstract class BrowserJob implements ShouldQueue
*/
protected function hasHeadlessDisabled(): bool
{
return config('dusk.headlessDisabled', false);
return isset($_SERVER['DUSK_HEADLESS_DISABLED']) ||
isset($_ENV['DUSK_HEADLESS_DISABLED']);
}
/**
@ -189,7 +188,8 @@ abstract class BrowserJob implements ShouldQueue
*/
protected function shouldStartMaximized(): bool
{
return config('dusk.shouldStartMaximized', false);
return isset($_SERVER['DUSK_START_MAXIMIZED']) ||
isset($_ENV['DUSK_START_MAXIMIZED']);
}
/**

View File

@ -0,0 +1,47 @@
<?php
namespace App\Browser\Components\Hellcase;
use Laravel\Dusk\Browser;
use Laravel\Dusk\Component as BaseComponent;
class EpicGamesLogin extends BaseComponent
{
/**
* Get the root selector for the component.
*/
public function selector(): string
{
return 'form';
}
/**
* Assert that the browser page contains the component.
*/
public function assert(Browser $browser): void
{
$browser->assertVisible($this->selector());
}
/**
* Get the element shortcuts for the component.
*
* @return array<string, string>
*/
public function elements(): array
{
return [
'@email' => 'input#email',
'@password' => 'input#password',
'@signin-button' => 'button[type="submit"]',
];
}
public function fillForm(Browser $browser, $email, $password) {
$browser->type('@email', $email);
sleep(1);
$browser->type('@password', $password);
sleep(1);
$browser->click('@signin-button');
}
}

View File

@ -1,27 +0,0 @@
<?php
namespace App\Browser;
use Laravel\Dusk\Browser;
use function rtrim;
class JobDebugScreenshot {
public const IMG_FILE_NAME = "debug-";
public static function getFileName(int $jobId, bool $withExtension = false): string {
return static::IMG_FILE_NAME . $jobId . ($withExtension ? ".png": "");
}
public static function getImgFileAbsolutePath(int $jobId): string {
return rtrim(Browser::$storeScreenshotsAt, '/') . "/" . static::getFileName($jobId, true);
}
public static function getImgFileProjectPath(int $jobId): string {
return app_path("Browser/screenshots/" . static::getFileName($jobId, true));
}
public static function getImgFileExternalPath(int $jobId): string {
return "screenshots/" . static::getFileName($jobId, true);
}
}

View File

@ -1,27 +0,0 @@
<?php
namespace App\Browser;
use Laravel\Dusk\Browser;
use function rtrim;
class JobErrorScreenshot {
public const IMG_FILE_NAME = "failure-";
public static function getFileName(int $jobId, bool $withExtension = false): string {
return static::IMG_FILE_NAME . $jobId . ($withExtension ? ".png": "");
}
public static function getImgFileAbsolutePath(int $jobId): string {
return rtrim(Browser::$storeScreenshotsAt, '/') . "/" . static::getFileName($jobId, true);
}
public static function getImgFileProjectPath(int $jobId): string {
return app_path("Browser/screenshots/" . static::getFileName($jobId, true));
}
public static function getImgFileExternalPath(int $jobId): string {
return "screenshots/" . static::getFileName($jobId, true);
}
}

View File

@ -0,0 +1,174 @@
<?php
namespace App\Browser\Jobs\EpicGames;
use App\Browser\BrowserJob;
use App\Browser\Components\Hellcase\EpicGamesLogin;
use App\Models\JobInfo;
use App\Models\JobRun;
use App\Notification\Notifications\SimpleNotification;
use App\Notification\Providers\AllNotification;
use Exception;
use Facebook\WebDriver\WebDriverBy;
use Illuminate\Contracts\Queue\ShouldBeUniqueUntilProcessing;
use Illuminate\Support\Facades\Log;
use Laravel\Dusk\Browser;
class EpicGamesJob extends BrowserJob implements ShouldBeUniqueUntilProcessing
{
private const APPROXIMATIVE_RUNNING_MINUTES = 2;
private const WEBSITE_URL = "https://www.epicgames.com/store/en-US/";
private JobRun $jobRun;
public function __construct()
{
Log::info("Constructing " . self::class);
parent::__construct(3);
}
public function run(Browser $browser): ?JobRun
{
// $browser->visit("https://bscscan.com/contractsVerified");
// sleep(3);
Log::info("Running " . self::class);
$this->jobRun = new JobRun([
"job_id" => $this->jobId,
"success" => false,
]);
$this->jobRun->save();
// $this->goToEpicGamesWebsite($browser);
// $this->removePopups($browser);
sleep(5);
$this->signin($browser);
$this->getFreeGames($browser);
$this->jobRun->success = true;
$this->jobRun->save();
Log::info(self::class . " run ended");
return $this->jobRun;
}
/**
* @inheritDoc
*/
public function runTest(Browser $browser): ?JobRun
{
try {
$this->goToEpicGamesWebsite($browser);
sleep(2);
$this->removePopups($browser);
sleep(2);
$this->signin($browser);
return $this->makeSimpleJobRun(
true,
"Connexion réussie",
"Datboi a réussi à se connecter sur EpicGames"
);
} catch (Exception $e) {
return $this->makeSimpleJobRun(
true,
"Connexion échouée",
"Datboi n'a pas réussi à se connecter sur EpicGames :\n" . $e->getMessage()
);
}
}
private function goToEpicGamesWebsite(Browser $browser)
{
sleep(3);
$browser->visit(self::WEBSITE_URL);
sleep(3);
$this->assertNotDetected($browser);
$browser->waitForText("Store", 30, true);
}
private function signin(Browser $browser)
{
// $browser->visit("https://store.epicgames.com/login?state=%2Fen-US%2F");
$browser->driver->executeScript('window.open("https://store.epicgames.com/login?state=%2Fen-US%2F")');
sleep(5);
$this->assertNotDetected($browser);
$browser->waitForText("Sign In", 30, true);
sleep(3);
$jobInfos = JobInfo::where("job_id", $this->jobId)->get();
$email = $jobInfos->where("key", "epicgames_account_email")->first()->value;
$password = $jobInfos->where("key", "epicgames_account_password")->first()->value;
$browser->within(new EpicGamesLogin, function (Browser $browser) use ($email, $password) {
$browser->fillForm($email, $password);
});
sleep(40);
}
private function getFreeGames(Browser $browser)
{
$browser->driver->executeScript('window.open("https://www.epicgames.com/store/en-US/free-games")');
// $browser->visit('https://www.epicgames.com/store/en-US/free-games');
$browser->waitForText("Free Games", 30, true);
$freeGamesLinkElements = $browser->driver->findElements(WebDriverBy::xpath('//a[contains(@aria-label, "Free Now")]'));
$freeGamesLinks = [];
foreach ($freeGamesLinkElements as $element) {
$freeGamesLinks[] = $element->getAttribute("href");
}
foreach ($freeGamesLinks as $link) {
$browser->visit($link);
$this->claimCurrentGame($browser);
}
}
private function claimCurrentGame(Browser $browser)
{
sleep(5);
$this->assertNotDetected($browser);
if ($this->unratedContent($browser)) {
throw new Exception("Le jeu demande un âge et datboi a la flemme de le mettre");
}
$this->waitForAndClickElementContainingText($browser, '//button', "Get", 30, true);
sleep(5);
$this->assertNotDetected($browser);
$browser->waitForText("Place Order", 30, true);
$browser->click("Place Order");
sleep(5);
$this->assertNotDetected($browser);
$browser->waitForText("Order Confirmation", 30, true);
$browser->click("Close");
sleep(5);
AllNotification::send(
new SimpleNotification($this->jobId, "Un jeu a été ajouté à votre bibliothèque", "Un jeu a été ajouté à votre bibliothèque EpicGames")
);
}
private function unratedContent(Browser $browser)
{
try {
$browser->waitForText("please provide your date of birth", 5, true);
return true;
} catch (Exception $_) {
return false;
}
}
private function removePopups(Browser $browser)
{
// $browser->script('document.querySelector("div.app-modal")[0].remove();');
// $browser->driver->executeScript('document.querySelector("div.app-modal")[0].remove();');
}
private function assertNotDetected(Browser $browser)
{
try {
$browser->waitForText("One more step", 10, true);
} catch (Exception $_) {
return;
}
throw new Exception("Détecté par cloudflare");
}
}

View File

@ -4,7 +4,6 @@ namespace App\Browser\Jobs\Hellcase;
use App\Browser\BrowserJob;
use App\Browser\Components\Hellcase\MainNav;
use App\Browser\JobDebugScreenshot;
use App\Browser\Jobs\Hellcase\HellcaseLoginQrCode;
use App\Models\JobArtifact;
use App\Models\JobRun;
@ -12,8 +11,6 @@ use App\Notification\NotificationBody\Hellcase\HellcaseNotificationDailyFreeBody
use App\Notification\NotificationBody\Hellcase\HellcaseNotificationLoginBody;
use App\Notification\Notifications\Hellcase\HellcaseNotificationDailyFree;
use App\Notification\Notifications\Hellcase\HellcaseNotificationLogin;
use App\Notification\Notifications\JobDebugNotification;
use App\Notification\Notifications\JobErrorNotification;
use App\Notification\Providers\AllNotification;
use Facebook\WebDriver\WebDriverBy;
use Illuminate\Contracts\Queue\ShouldBeUniqueUntilProcessing;
@ -24,41 +21,37 @@ class HellcaseJob extends BrowserJob implements ShouldBeUniqueUntilProcessing
{
private const STEAM_LOGIN_THRESHOLD = 5 * 60; // 5 minutes
private const APPROXIMATIVE_RUNNING_MINUTES = 2;
private const WEBSITE_URL = "https://hellcase.com";
protected JobRun $jobRun;
private JobRun $jobRun;
public function __construct($jobId = 2)
public function __construct()
{
parent::__construct($jobId);
Log::info("Constructing " . self::class);
parent::__construct(2);
}
public function run(Browser $browser): ?JobRun
{
Log::info("Running HellcaseJob");
Log::info("Running " . self::class);
$this->jobRun = new JobRun([
"job_id" => $this->jobId,
"success" => false,
]);
$this->jobRun->save();
$browser->visit('https://hellcase.com');
$browser->visit(self::WEBSITE_URL);
$browser->waitForText("CASES", 30, true);
$this->removePopups($browser);
sleep(5);
$this->signin($browser);
try {
$this->joinFreeGiveaways($browser);
} catch (\Exception $e) {
$this->jobRun->success = false;
$this->jobRun->save();
AllNotification::send(new JobErrorNotification($this->jobId, "Erreur lors de la participation aux concours gratuits : " . $e->getMessage()));
}
$this->joinFreeGiveaways($browser);
$this->getDailyFree($browser);
$this->jobRun->success = true;
$this->jobRun->save();
Log::info("HellcaseJob run ended");
Log::info(self::class . " run ended");
return $this->jobRun;
}
@ -69,7 +62,7 @@ class HellcaseJob extends BrowserJob implements ShouldBeUniqueUntilProcessing
public function runTest(Browser $browser): ?JobRun
{
try {
$browser->visit('https://hellcase.com');
$browser->visit(self::WEBSITE_URL);
$browser->waitForText("CASES", 30, true);
$this->removePopups($browser);
sleep(2);
@ -81,14 +74,14 @@ class HellcaseJob extends BrowserJob implements ShouldBeUniqueUntilProcessing
);
} catch (\Exception $e) {
return $this->makeSimpleJobRun(
false,
true,
"Connexion échouée",
"Datboi n'a pas réussi à se connecter sur Hellcase :\n" . $e->getMessage()
);
}
}
protected function signin(Browser $browser)
private function signin(Browser $browser)
{
try {
$browser->clickAtXPath('//button[.//span[text() = "Sign in"]]');
@ -99,20 +92,13 @@ class HellcaseJob extends BrowserJob implements ShouldBeUniqueUntilProcessing
sleep(5);
$browser->waitForText("Sign in with Steam", 30, true);
sleep(3);
$browser->driver->findElement(WebDriverBy::xpath('//button[contains(@class,"_base_zvftr_1 _accent-1_zvftr_105 _m_zvftr_52 _full_zvftr_94 _primary_zvftr_100")]'))->click();
$browser->driver->findElement(WebDriverBy::xpath('//button[@class = "_base_1uydq_1 _accent-1_1uydq_105 _m_1uydq_52 _full_1uydq_94 _primary_1uydq_100"]'))->click();
sleep(5);
// QR CODE SCANNING
try {
$browser->waitForTextIn("div", "Or sign in with QR", 30, true);
sleep(10);
try {
$qrCode = $browser->driver->findElement(WebDriverBy::xpath('//div[./*[contains(text(), "Or sign in with QR")]]'));
} catch (\Exception $e) {
$browser->screenshot(JobDebugScreenshot::getFileName($this->jobId));
AllNotification::send(new JobDebugNotification($this->jobId, "Le QR code de la page de connexion de Steam n'a pas été trouvé"));
throw $e;
}
$qrCode = $browser->driver->findElement(WebDriverBy::xpath('//div[./*[contains(text(), "Or sign in with QR")]]'));
// Wait to be redirected to the Steam login page, while waiting take a new screenshot every 30 seconds
$isBackOnHellcase = false;
@ -137,11 +123,9 @@ class HellcaseJob extends BrowserJob implements ShouldBeUniqueUntilProcessing
}
} catch (\Exception $e) {
// If the QR code is not found, we are not on the QR code page
Log::debug("Exception because qrcode not found : " . $e);
$isBackOnHellcase = true;
} catch (\Throwable $e) {
// If the QR code is not found, we are not on the QR code page
Log::debug("Exception because qrcode not found : " . $e);
$isBackOnHellcase = true;
}
@ -158,8 +142,6 @@ class HellcaseJob extends BrowserJob implements ShouldBeUniqueUntilProcessing
try {
$buttons = $browser->driver->findElements(WebDriverBy::xpath('//a[text() = "Join for free"]'));
} catch (\Exception $e) {
$browser->screenshot(JobDebugScreenshot::getFileName($this->jobId));
AllNotification::send(new JobDebugNotification($this->jobId, "No join for free buttons found"));
return;
}
@ -169,32 +151,9 @@ class HellcaseJob extends BrowserJob implements ShouldBeUniqueUntilProcessing
"content" => ""
]));
}
try {
// $nextSlideButton = $browser->driver->findElement(WebDriverBy::xpath('//button[@class="_button_1ygbm_7 _next_1ygbm_24"]'));
$nextSlideButton = "Button next slide is deactivated";
} catch (\Exception $e) {
$browser->screenshot(JobDebugScreenshot::getFileName($this->jobId));
AllNotification::send(new JobDebugNotification($this->jobId, "No next slide button found"));
return;
}
foreach ($buttons as $button) {
// Click the next slide button if the button is not clickable
$clickedFailsCounter = 0;
while ($clickedFailsCounter < 7 && $clickedFailsCounter >= 0) {
try {
$button->click();
} catch (\Exception $e) {
$clickedFailsCounter++;
try {
// $nextSlideButton->click();
} catch (\Exception $_) {}
sleep(3);
continue;
}
$clickedFailsCounter = -1;
}
sleep(5); // Wait a bit for loading
$button->click();
sleep(5);
$this->joinGiveaway($browser);
$browser->within(new MainNav, function (Browser $browser) {
$browser->goToHome();
@ -226,11 +185,7 @@ class HellcaseJob extends BrowserJob implements ShouldBeUniqueUntilProcessing
private function getDailyFree(Browser $browser)
{
$browser->visit('https://hellcase.com/dailyfree');
try {
$browser->waitForText("Get Daily free loot", 30, true);
} catch (\Exception $e) {
return; // Pobably means we do not have access to a daily free loot
}
$browser->waitForText("Get Daily free loot", 30, true);
// Do we fill the conditions ?
if (sizeof(value: $browser->driver->findElements(WebDriverBy::xpath('//p[contains(text(), "Fulfill the conditions below")]'))) > 0) {
@ -242,12 +197,9 @@ class HellcaseJob extends BrowserJob implements ShouldBeUniqueUntilProcessing
if ($availibleInButton->getAttribute("disabled") == "true") {
$hours = $availibleInButton->getText();
// If the text is like "in 26 sec." we need to put one minute
if (str_contains(strtolower($hours), "seconds")) {
$browser->screenshot(JobDebugScreenshot::getFileName($this->jobId));
AllNotification::send(new JobDebugNotification($this->jobId, "I hate niggers"));
// $this->reschedule(1);
sleep(60);
return $this->getDailyFree($browser);
if (str_contains($hours, "sec")) {
$this->reschedule(1);
return;
}
$hours = explode(" ", $hours);
$minutes = $hours[4];
@ -394,7 +346,7 @@ class HellcaseJob extends BrowserJob implements ShouldBeUniqueUntilProcessing
$browser->clickAtXPath('//*[contains(text(), "Edit Profile")]');
}
protected function removePopups(Browser $browser)
private function removePopups(Browser $browser)
{
// $browser->script('document.querySelector("div.app-modal")[0].remove();');
// $browser->driver->executeScript('document.querySelector("div.app-modal")[0].remove();');

View File

@ -1,23 +0,0 @@
<?php
namespace App\Browser\Jobs\HellcaseBattles;
use Laravel\Dusk\Browser;
use function rtrim;
class HellcaseBattleScreenshot {
public const IMG_FILE_NAME = "Hellcase-battle";
public static function getImgFileAbsolutePath(): string {
return rtrim(Browser::$storeScreenshotsAt, '/') . "/HellcaseBattles/" . static::IMG_FILE_NAME;
}
public static function getImgFileProjectPath(): string {
return app_path("Browser/screenshots/HellcaseBattles/" . static::IMG_FILE_NAME);
}
public static function getImgFileExternalPath(): string {
return "screenshots/HellcaseBattles/" . static::IMG_FILE_NAME;
}
}

View File

@ -1,176 +0,0 @@
<?php
namespace App\Browser\Jobs\HellcaseBattles;
use App\Browser\JobDebugScreenshot;
use App\Browser\Jobs\Hellcase\HellcaseJob;
use App\Models\HellcaseBattle;
use App\Models\Job;
use App\Models\JobArtifact;
use App\Models\JobRun;
use App\Notification\Notifications\JobDebugNotification;
use App\Notification\Providers\AllNotification;
use Exception;
use Facebook\WebDriver\WebDriver;
use Facebook\WebDriver\WebDriverBy;
use Illuminate\Contracts\Queue\ShouldBeUniqueUntilProcessing;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use Laravel\Dusk\Browser;
class HellcaseBattlesJob extends HellcaseJob implements ShouldBeUniqueUntilProcessing
{
private Collection $jobInfos;
private array $battlesToAdd = [];
private array $battlesSent = [];
public function __construct()
{
parent::__construct(3);
}
public function run(Browser $browser): ?JobRun
{
$this->jobInfos = Job::find($this->jobId)->jobInfosTable();
Log::info("Running HellcaseBattlesJob");
$this->jobRun = new JobRun([
"job_id" => $this->jobId,
"success" => false,
]);
$this->jobRun->save();
$browser->visit('https://hellcase.com');
$browser->waitForText("CASES", 30, true);
$this->removePopups($browser);
sleep(5);
$this->signin($browser);
$this->saveInterestingBattles($browser);
$this->sendFinishedBattles($browser);
$this->jobRun->addArtifact(new JobArtifact([
"name" => count($this->battlesSent) . " battailles envoyées",
]));
$this->createNewBattles();
$this->jobRun->addArtifact(new JobArtifact([
"name" => count($this->battlesToAdd) . " nouvelles battailles ajoutées pour surveillage",
]));
$this->jobRun->success = true;
$this->jobRun->save();
Log::info("HellcaseBattlesJob run ended");
return $this->jobRun;
}
/**
* Save current cases battles to database for later processing
* @param \Laravel\Dusk\Browser $browser
* @return void
*/
private function saveInterestingBattles(Browser $browser)
{
$battleIndex = 0; // Index of the battle to get info from
$running = true;
while ($running) {
$browser->visit('https://hellcase.com/casebattle');
$browser->waitForText("CASES", 30, true);
// Sort by price
try {
$sortByPriceDiv = $browser->driver->findElement(WebDriverBy::xpath("//*[span[contains(text(), 'Value')]]"));
$sortByPriceDiv->click();
} catch (Exception $e) {
$browser->screenshot(JobDebugScreenshot::getFileName($this->jobId));
AllNotification::send(new JobDebugNotification($this->jobId, "Failed to sort by price"));
return;
}
sleep(5);
$battles = $browser->driver->findElements(WebDriverBy::xpath("//*[contains(@class, 'casebattle-table__item')]"));
$battle = $battles[$battleIndex];
$battleIndex++;
$browser->scrollIntoView(".casebattle-table__item:nth-child(" . max($battleIndex -1, 1) . ")");
sleep(2);
$battleValue = floatval(
explode(
"\n",
$battle->findElement(WebDriverBy::xpath("./div/div[contains(@class, 'core-price')]"))->getDomProperty("innerText")
)[1]
);
if ($battleValue < floatval($this->jobInfos->get("hellcase_battles_minimum_value"))) {
$running = false;
break;
}
$battleLinkButton = $battle->findElement(WebDriverBy::xpath('./div//button[text() = "watch"]'));
$battleLinkButton->sendKeys("\n");
sleep(3);
try { // If we still are on the casebattle page, it means the battle was cancelled or something else
$browser->waitForLocation("https://hellcase.com/casebattle/", 3);
} catch (Exception $e) {
$battleLink = $browser->driver->getCurrentURL();
$this->battlesToAdd[$battleLink] = $battleValue;
}
}
}
private function sendFinishedBattles(Browser $browser) {
// foreach battle that we didn"t already planned to add with $this->battlesToAdd
foreach (HellcaseBattle::all() as $battle) {
if (!array_key_exists($battle->getUrl(), $this->battlesToAdd)) {
$browser->visit($battle->getUrl());
sleep(2);
$browser->waitForText("Case Battle");
if ($this->findElementContainingText($browser, "Started at:") != null) { // battle is finished
// Send the battle
$this->sendBattle($browser, $battle);
}
$battle->delete();
}
}
}
private function sendBattle(Browser $browser, HellcaseBattle $battle) {
try {
$battleHeader = $browser->driver->findElement(WebDriverBy::xpath("//*[contains(@class, 'case-battle-game__header')]"));
sleep(2); // Wait for the animations to finish
$battleHeader->takeElementScreenshot(HellcaseBattleScreenshot::getImgFileAbsolutePath());
} catch (Exception $e) {
$browser->screenshot(JobDebugScreenshot::getFileName($this->jobId));
AllNotification::send(new JobDebugNotification($this->jobId, "Failed to screenshot battle"));
}
$this->battlesSent[$battle->getUrl()] = $battle->value;
$options = [];
if ($this->jobInfos->get("hellcase_battles_discord_webhook_url") !== null) { // Custom discord webhook
$options["discord_webhook_url"] = $this->jobInfos->get("hellcase_battles_discord_webhook_url");
}
AllNotification::send(new HellcaseBattlesNofication($this->jobId, $battle), $options);
}
private function createNewBattles() {
foreach ($this->battlesToAdd as $battleLink => $battleValue) {
$battleLink = explode("/", $battleLink);
try {
HellcaseBattle::create([
"battle_id" => $battleLink[count($battleLink) - 1],
"value" => $battleValue,
]);
} catch (Exception $e) {
}
}
}
}

View File

@ -1,43 +0,0 @@
<?php
namespace App\Browser\Jobs\HellcaseBattles;
use App\Models\HellcaseBattle;
use App\Notification\Notification;
use App\Notification\Stringifiable;
use App\Notification\Stringifiable\StringifiableSimpleText;
class HellcaseBattlesNofication extends Notification {
private HellcaseBattle $battle;
public function __construct(int $jobId, HellcaseBattle $battle) {
parent::__construct($jobId);
$this->battle = $battle;
$this->setBody($this->generateBody());
}
private function generateBody() {
return new HellcaseBattlesNotificationBody($this->battle);
}
public function getTitle(): Stringifiable {
return new StringifiableSimpleText("Nouvelle bataille de caisses Hellcase");
}
/**
* @inheritDoc
*/
public function getImageProjectPath(): string|null {
return HellcaseBattleScreenshot::getImgFileProjectPath();
}
/**
* @inheritDoc
*/
public function getLinkURL(): string|null {
return $this->battle->getUrl();
}
}

View File

@ -1,37 +0,0 @@
<?php
namespace App\Browser\Jobs\HellcaseBattles;
use App\Models\HellcaseBattle;
use App\Notification\NotificationBody;
class HellcaseBattlesNotificationBody extends NotificationBody {
private HellcaseBattle $battle;
public function __construct(HellcaseBattle $battle) {
parent::__construct();
$this->battle = $battle;
}
/**
* @inheritDoc
*/
public function toMarkdownString(): string {
return "
- Valeur : **{$this->battle->value} €**
- Lien : **{$this->battle->getUrl()}**
";
}
/**
* @inheritDoc
*/
public function toString(): string {
return "
- Valeur : {$this->battle->value}
- Lien : {$this->battle->getUrl()}
";
}
}

View File

@ -1,54 +0,0 @@
<?php
namespace App\Browser\Jobs\InstagramRepost;
use DateTimeImmutable;
/**
* Interface for Instagram video handling.
* Holds the data of a video from Instagram.
*/
interface IInstagramVideo
{
/**
* Get the URL of the video.
*
* @return string The URL of the video.
*/
public function getUrl(): string;
/**
* Get the title of the video.
*
* @return string The title of the video.
*/
public function getTitle(): string;
/**
* Get the caption of the video.
*
* @return string The caption of the video.
*/
public function getDescription(): string;
/**
* Get the date when the video was posted.
*
* @return ?DateTimeImmutable The date of the post.
*/
public function getPostDate(): ?DateTimeImmutable;
/**
* Get the filename of the video.
*
* @return string The filename of the video.
*/
public function getFilename(): string;
/**
* Set the filename of the video.
*
* @param string $filename The filename to set.
*/
public function setFilename(string $filename): void;
}

View File

@ -1,23 +0,0 @@
<?php
namespace App\Browser\Jobs\InstagramRepost;
use App\Models\JobRun;
use Illuminate\Support\Collection;
interface IInstagramVideoDownloader
{
/**
* Download the video from the given Instagram post URL.
*
* @param int $jobId The ID of the job for which the video is being downloaded.
* @param JobRun $jobRun The job run instance for logging and tracking.
* @param string $postUrl The URL of the Instagram post.
* @param Collection $jobInfos The job information collection.
* @param string $downloadFolder The folder where the video should be downloaded.
* @param string $accountEmail The email or username of the Instagram account.
* @param string $accountPassword The password for the Instagram account.
* @return IInstagramVideo|null The path to the downloaded video file, or null if the download failed.
*/
public function downloadVideo(int $jobId, JobRun $jobRun, string $postUrl, string $downloadFolder, string $accountEmail, string $accountPassword): ?IInstagramVideo;
}

View File

@ -1,439 +0,0 @@
<?php
namespace App\Browser\Jobs\InstagramRepost;
use App\Browser\BrowserJob;
use App\Browser\JobDebugScreenshot;
use App\Models\InstagramRepost;
use App\Models\InstagramAccount;
use App\Models\Job;
use App\Models\JobArtifact;
use App\Models\JobRun;
use App\Notification\Notifications\JobDebugNotification;
use App\Notification\Providers\AllNotification;
use Facebook\WebDriver\WebDriverBy;
use Illuminate\Contracts\Queue\ShouldBeUniqueUntilProcessing;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use Laravel\Dusk\Browser;
class InstagramRepostJob extends BrowserJob implements ShouldBeUniqueUntilProcessing
{
// === CONFIGURATION ===
// TODO : put that in laravel config file
/**
* Maximum number of posts to repost per account
* @var int
*/
private const MAX_REPOSTS_PER_ACCOUNT = 2;
/**
* Max number of reposts per job
* This is the maximum number of posts that will be reposted in a single job run.
* @var int
*/
private const MAX_REPOSTS_PER_JOB = 3;
/**
* Maximum number of tries to repost a reel
* If a reel fails to be reposted, it will be retried up to this number of times.
* @var int
*/
private const MAX_REPOST_TRIES = 3;
private const APPROXIMATIVE_RUNNING_MINUTES = 2;
private Collection $jobInfos;
protected JobRun $jobRun;
protected IInstagramVideoDownloader $videoDownloader;
protected string $downloadFolder = "app/Browser/downloads/InstagramRepost/";
public function __construct($jobId = 4)
{
parent::__construct($jobId);
$this->downloadFolder = base_path($this->downloadFolder);
$this->videoDownloader = new YTDLPDownloader();
}
public function run(Browser $browser): ?JobRun
{
Log::info("Running InstagramRepostJob");
$this->jobInfos = Job::find($this->jobId)->jobInfosTable();
$this->jobRun = new JobRun([
"job_id" => $this->jobId,
"success" => false,
]);
$this->jobRun->save();
$browser->visit('https://instagram.com');
sleep(5);
$this->removePopups($browser);
sleep(2);
$this->signin($browser);
sleep(2);
$this->repostLatestPosts($browser);
sleep(5);
$this->jobRun->success = true;
$this->jobRun->save();
Log::info("InstagramRepostJob run ended");
return $this->jobRun;
}
/**
* @inheritDoc
*/
public function runTest(Browser $browser): ?JobRun
{
$this->jobInfos = Job::find($this->jobId)->jobInfosTable();
try {
$browser->visit('https://instagram.com');
sleep(2);
$this->removePopups($browser);
sleep(2);
$this->signin($browser);
sleep(3);
return $this->makeSimpleJobRun(
true,
"Connexion réussie",
"Datboi a réussi à se connecter sur Instagram"
);
} catch (\Exception $e) {
return $this->makeSimpleJobRun(
false,
"Connexion échouée",
"Datboi n'a pas réussi à se connecter sur Instagram :\n" . $e->getMessage()
);
}
}
protected function repostLatestPosts(Browser $browser) {
try {
// Download the latest reels from the accounts specified in the job infos
$toDownloadReels = []; // Array to store to download reels to post later
$accounts = explode(",", $this->jobInfos->get("instagram_repost_accounts"));
foreach ($accounts as &$account) {
$account = trim($account);
$toDownloadReels = array_merge($toDownloadReels, $this->getLatestReelsFromAccount($browser, $account));
}
// Add unreposted reels to the job run if not enough reels were downloaded
if (count($toDownloadReels) < self::MAX_REPOSTS_PER_JOB) {
$toDownloadReelsIds = array_map(function ($reel) {
return $reel->reel_id;
}, $toDownloadReels);
$unrepostedReels = InstagramRepost::where("reposted", false)
->where("repost_tries", "<", self::MAX_REPOST_TRIES) // Limit to 3 tries
->whereNotIn("reel_id", $toDownloadReelsIds) // Avoid already downloaded reels
->whereIn("account_id", InstagramAccount::whereIn("username", $accounts)->pluck("id"))
->take(self::MAX_REPOSTS_PER_JOB - count($toDownloadReels))
->get();
foreach ($unrepostedReels as $reel) {
$toDownloadReels[] = $reel;
}
}
// Shuffling and keeping only the x first posts
shuffle($toDownloadReels);
$toDownloadReels = array_slice($toDownloadReels, 0, self::MAX_REPOSTS_PER_JOB);
$this->jobRun->addArtifact(new JobArtifact([
"name" => count($toDownloadReels) . " reels sélectionnés pour être repost",
"content" => ""
]));
// Download the reels
$downloadedReels = [];
foreach ($toDownloadReels as $repost) {
$downloadedReels[] = [
$repost,
$this->downloadReel(
$browser,
$repost
)
];
}
$this->jobRun->addArtifact(new JobArtifact([
"name" => count($downloadedReels) . " reels sélectionnés qui ont été téléchargés",
"content" => ""
]));
// Now repost all downloaded reels
$repostedReelsCounter = 0;
foreach ($downloadedReels as $infos) {
$reel = $infos[0];
$videoInfo = $infos[1];
try {
// TODO : Avoid getting the reel from the db again, store it in the downloadedReels array
$repostedReelsCounter += $this->repostReel($browser, InstagramRepost::where('reel_id', $reel->reel_id)->first(), $videoInfo);
} catch (\Exception $e) {
Log::error("Failed to repost reel: {$videoInfo->getTitle()} - " . $e->getMessage());
AllNotification::send(new JobDebugNotification($this->jobId, "Failed to repost reel: {$videoInfo->getTitle()} - " . $e->getMessage()));
}
}
$this->jobRun->addArtifact(new JobArtifact([
"name" => $repostedReelsCounter . " reels sélectionnés qui ont été repostés",
"content" => ""
]));
} catch (\Exception $e) {
$browser->screenshot(JobDebugScreenshot::getFileName($this->jobId));
AllNotification::send(new JobDebugNotification($this->jobId, "Unknown error when trying to repost reels : " . $e->getMessage()));
} finally {
// Removes all videos in the download folder
$files = glob($this->downloadFolder . '*'); // Get all files in the download folder
foreach ($files as $file) {
if (is_file($file)) {
unlink($file); // Delete the file
}
}
}
}
private function getLatestReelsFromAccount(Browser $browser, string $account): array
{
$accountReels = []; // Indexed array to store new reels from the account
$browser->visit("https://instagram.com/{$account}/reels");
sleep(3);
$browser->waitForText("followers", 10, true);
// If we are here, the account exists
$accountModel = InstagramAccount::where("username", $account)->first();
if ($accountModel == null) { // Does not exist in the database yet
$accountModel = new InstagramAccount([
"username" => $account,
]);
$accountModel->save();
Log::info("New Instagram account added: {$account}");
} else {
Log::debug("Instagram account already exists: {$account}");
}
$repostedPosts = $accountModel->reposts()->where("reposted", true)->pluck("reel_id");
// Posts must be sorted by latest post date first
// TODO : Scroll when not enough post are shown
$posts = $browser->driver->findElements(WebDriverBy::xpath('//a[contains(@href, "'.$account.'/reel/")][not(.//*[local-name() = "svg"][@aria-label="Pinned post icon"])]'));
foreach ($posts as $post) {
$postUrl = $post->getAttribute('href');
$postId = explode("/", $postUrl)[3] ?? null;
if ($postId === null) {
AllNotification::send(new JobDebugNotification($this->jobId, "Can't get Instagram post ID from url"));
continue;
}
// Break the loop if the post has already been reposted
if ($repostedPosts->contains($postId)) {
Log::debug("Post already reposted: {$postUrl}");
break;
}
$reelModel = InstagramRepost::firstOrCreate(
["reel_id" => $postId, "account_id" => $accountModel->id],
["reposted" => false, "repost_tries" => 0]
);
if (count($accountReels) < self::MAX_REPOSTS_PER_ACCOUNT) {
$accountReels[] = $reelModel; // Add it to the to be downloaded reels array
}
}
return $accountReels;
}
private function downloadReel(Browser $browser, InstagramRepost $reel): ?IInstagramVideo
{
$videoInfo = $this->videoDownloader->downloadVideo(
$this->jobId,
$this->jobRun,
$reel->getUrl(),
$this->downloadFolder,
$this->jobInfos->get("instagram_repost_account_email"),
$this->jobInfos->get("instagram_repost_account_password")
);
if ($videoInfo === null) {
Log::error("Failed to download video for post: {$reel->reel_id}");
return null;
}
else {
// Set the filename to the post ID
$newFilename = $this->downloadFolder . $reel->reel_id . ".mp4";
rename($videoInfo->getFilename(), $newFilename);
$videoInfo->setFilename($newFilename);
}
Log::info("Downloaded video: {$videoInfo->getTitle()} : {$videoInfo->getDescription()}");
return $videoInfo;
}
protected function repostReel(Browser $browser, InstagramRepost $reel, IInstagramVideo $videoInfo): bool
{
try {
Log::info("Reposting reel: {$reel->reel_id} - {$videoInfo->getTitle()}");
// Increment the repost tries
$reel->repost_tries++;
$reel->save();
// TODO Reset if a problem occurs and try again with a limit of 3 attempts
$browser->visit('https://instagram.com');
sleep(2);
// Navigate to the reel upload page
$createButton = $browser->driver->findElement(WebDriverBy::xpath('//a[./div//span[contains(text(), "Create")]]'));
$createButton->click();
sleep(2);
$newPostButton = $browser->driver->findElement(WebDriverBy::xpath('//a[./div//span[contains(text(), "Post")]][@href="#"]'));
$newPostButton->click();
sleep(3);
// Upload the video file
$selectFileButton = $browser->driver->findElement(WebDriverBy::xpath('//button[contains(text(), "Select from computer")]'));
$selectFileButton->click();
sleep(2);
$browser->attach('input[type="file"]._ac69', $this->downloadFolder . $reel->reel_id . ".mp4");
sleep(10); // TODO : Wait for the file to be uploaded
$this->removePopups($browser);
sleep(2);
// Put original resolution
$this->putOriginalResolution($browser);
$this->clickNext($browser);
$this->clickNext($browser); // Skip cover photo and trim
// Add a caption
$captionInput = $browser->driver->findElement(WebDriverBy::xpath('//div[@contenteditable]'));
$captionInput->sendKeys($videoInfo->getDescription());
sleep(2); // Wait for the caption to be added
$this->clickNext($browser); // Share the post
sleep(5); // Wait for the post to be completed
// Check if the post was successful
try {
$browser->waitForText("Your reel has been shared.", 60, true);
Log::info("Reel reposted successfully: {$reel->reel_id}");
// Mark the reel as reposted in the database
$reel->reposted = true;
$reel->save();
return true;
} catch (\Exception $e) {
try {
$browser->waitForText("Your post was shared", 60, true);
$closeButton = $browser->driver->findElement(WebDriverBy::xpath('//div[./div/*[local-name() = "svg"][@aria-label="Close"]]'));
} catch (\Exception $e) {
Log::error("Failed to repost reel: {$reel->reel_id} - " . $e->getMessage());
$browser->screenshot(JobDebugScreenshot::getFileName($this->jobId));
AllNotification::send(new JobDebugNotification($this->jobId, "Failed to repost reel: {$reel->reel_id} - " . $e->getMessage()));
$closeButton->click();
}
return false;
}
} catch (\Exception $e) {
return false;
}
}
private function clickNext(Browser $browser) {
$nextButton = $browser->driver->findElement(WebDriverBy::xpath('//div[contains(text(), "Next") or contains(text(), "Share")]'));
$nextButton->click();
sleep(2);
}
private function putOriginalResolution(Browser $browser)
{
try {
$chooseResolutionButton = $browser->driver->findElement(WebDriverBy::xpath('//button[./div/*[local-name() = "svg"][@aria-label="Select crop"]]'));
$chooseResolutionButton->click();
sleep(2);
// Choos "original" resolution
$originalResolutionButton = $browser->driver->findElement(WebDriverBy::xpath('//div[./div/div/span[contains(text(), "Original")]]'));
$originalResolutionButton->click();
sleep(2);
} catch (\Exception $e) {
Log::error("Failed to set original resolution: " . $e->getMessage());
$browser->screenshot(JobDebugScreenshot::getFileName($this->jobId));
AllNotification::send(new JobDebugNotification($this->jobId, "Failed to set \"original\" resolution: " . $e->getMessage()));
}
}
protected function signin(Browser $browser)
{
try {
$browser->waitForText("Log in", 10, true);
sleep(3);
$emailButton = $browser->driver->findElement(WebDriverBy::xpath('//input[contains(@aria-label, "email")]'));
$emailButton->click();
$emailButton->sendKeys($this->jobInfos->get("instagram_repost_account_email"));
sleep(3);
$passwordButton = $browser->driver->findElement(WebDriverBy::xpath('//input[contains(@aria-label, "Password")]'));
$passwordButton->click();
$passwordButton->sendKeys($this->jobInfos->get("instagram_repost_account_password") . "\n");
sleep(5);
} catch (\Exception $e) {
// Probably no need to signin
}
try {
$browser->waitForText("Search", 15, true);
$this->removePopups($browser);
} catch (\Exception $e) {
Log::error("Failed to sign in: " . $e->getMessage());
$browser->screenshot(JobDebugScreenshot::getFileName($this->jobId));
AllNotification::send(new JobDebugNotification($this->jobId, "Failed to sign in: " . $e->getMessage()));
}
}
protected function removePopups(Browser $browser)
{
$popupsTypes = [
['//button[contains(text(), "Allow all cookies")]'], // Allow all cookies
['//button[contains(text(), "Not Now")]', ["Popup Not Now clicked"]], // Not now
['//button[contains(text(), "OK")]', ["Popup Ok clicked"]], // OK
];
foreach ($popupsTypes as $popup) {
try {
$button = $browser->driver->findElement(WebDriverBy::xpath($popup[0]));
if ($button === null) {
continue; // No button found, continue to the next popup
}
if (isset($popup[1])) {
$browser->screenshot(JobDebugScreenshot::getFileName($this->jobId));
AllNotification::send(new JobDebugNotification($this->jobId, $popup[1][0]));
}
$button->click();
sleep(2);
return; // Exit after clicking the first popup found
} catch (\Exception $e) {
// Porbably no popup found, continue
}
}
}
}

View File

@ -1,56 +0,0 @@
<?php
namespace App\Browser\Jobs\InstagramRepost;
use App\Models\JobArtifact;
use App\Models\JobRun;
use App\Notification\Notifications\JobDebugNotification;
use App\Notification\Providers\AllNotification;
use Illuminate\Support\Facades\Log;
use YoutubeDl\Options;
use YoutubeDl\YoutubeDl;
class YTDLPDownloader implements IInstagramVideoDownloader
{
/**
* @inheritDoc
*/
public function downloadVideo(int $jobId, JobRun $jobRun, string $postUrl, string $downloadFolder, string $accountEmail, string $accountPassword): ?YTDLPVideo
{
$dl = new YoutubeDl();
$options = Options::create()
->downloadPath($downloadFolder)
->apLogin($accountEmail, $accountPassword)
->checkAllFormats(true)
//->format('bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]') // TODO allow downloading audio and video separately and then merging them
->format('best[ext=mp4]')
->url($postUrl);
try {
$videosCollection = $dl->download($options);
foreach ($videosCollection->getVideos() as $video) {
if ($video->getError() !== null) {
$jobRun->addArtifact(new JobArtifact([
"name" => "Erreur lors du téléchargement de la vidéo \"{$video->getTitle()}\"",
"content" => $video->getError(),
]));
Log::error("Error downloading video: " . $video->getError());
return null; // Return null if there was an error downloading the video
} else {
$IVideo = new YTDLPVideo(
$video->getWebpageUrl(),
$video->getTitle(),
$video->getDescription() ?? "",
$video->getUploadDate(),
$video->getFilename(),
);
return $IVideo; // Return the video object if download was successful
}
}
} catch (\Exception $e) {
AllNotification::send(new JobDebugNotification($jobId, "Error while downloading video: " . $e->getMessage()));
return null;
}
}
}

View File

@ -1,71 +0,0 @@
<?php
namespace App\Browser\Jobs\InstagramRepost;
use DateTimeImmutable;
class YTDLPVideo implements IInstagramVideo
{
private string $url;
private string $title;
private string $description;
private ?DateTimeImmutable $postDate;
private string $fileName;
public function __construct(string $url, string $title, string $description, ?DateTimeImmutable $postDate, string $filename)
{
$this->url = $url;
$this->title = $title;
$this->description = $description;
$this->postDate = $postDate;
$this->fileName = $filename;
}
/**
* @inheritDoc
*/
public function getUrl(): string
{
return $this->url;
}
/**
* @inheritDoc
*/
public function getTitle(): string
{
return $this->title;
}
/**
* @inheritDoc
*/
public function getDescription(): string
{
return $this->description;
}
/**
* @inheritDoc
*/
public function getPostDate(): ?DateTimeImmutable
{
return $this->postDate;
}
/**
* @inheritDoc
*/
public function getFilename(): string
{
return $this->fileName;
}
/**
* @inheritDoc
*/
public function setFilename(string $filename): void
{
$this->fileName = $filename;
}
}

View File

@ -21,7 +21,7 @@ class JobController extends Controller
public function show($jobId, Request $request)
{
return Inertia::render('Job', [
'job' => Job::where('id', $jobId)->with('jobInfos', 'jobRuns')->first(),
'job' => Job::where('id', $jobId)->with('jobInfos')->first(),
'error' => $request->input('error'),
]);
}

View File

@ -1,17 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class HellcaseBattle extends Model
{
protected $fillable = [
"battle_id",
"value",
];
public function getUrl() {
return "https://hellcase.com/casebattle/{$this->battle_id}";
}
}

View File

@ -1,19 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class InstagramAccount extends Model
{
protected $table = 'instagram_repost_accounts';
protected $fillable = [
'username',
];
public function reposts()
{
return $this->hasMany(InstagramRepost::class, 'account_id');
}
}

View File

@ -1,26 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class InstagramRepost extends Model
{
protected $table = 'instagram_reposts';
protected $fillable = [
'reel_id',
'reposted',
'account_id',
];
public function account()
{
return $this->belongsTo(InstagramAccount::class, 'account_id');
}
public function getUrl(): string
{
return "https://www.instagram.com/reel/{$this->reel_id}/";
}
}

View File

@ -21,16 +21,6 @@ class Job extends Model
return $this->hasMany(JobInfo::class)->with("jobInfoType")->orderBy("created_at");
}
/**
* Get an associative collection of the job infos with their values
* @return \Illuminate\Database\Eloquent\Collection<string, string>>
*/
public function jobInfosTable() {
return $this->jobInfos->mapWithKeys(function ($jobInfo) {
return [$jobInfo->key => $jobInfo->value];
});
}
public function jobRuns()
{
return $this->hasMany(JobRun::class)->orderBy("created_at");

View File

@ -4,7 +4,6 @@ namespace App\Notification;
use App\Models\Job;
use App\Notification\Stringifiable\StringifiableSimpleText;
use function PHPUnit\Framework\isNull;
abstract class Notification {
@ -13,16 +12,10 @@ abstract class Notification {
public bool $isError;
public function __construct(int $jobId, NotificationBody $body = null, bool $isError = false) {
public function __construct(int $jobId, NotificationBody $body, bool $isError = false) {
$this->job = Job::find($jobId);
if ($body !== null) {
$this->body = $body;
}
$this->isError = $isError;
}
public function setBody(NotificationBody $body) {
$this->body = $body;
$this->isError = $isError;
}
public function getTitle(): Stringifiable {

View File

@ -1,51 +0,0 @@
<?php
namespace App\Notification\NotificationBody;
use App\Models\Job;
use App\Notification\NotificationBody;
use App\Notification\Stringifiable;
class JobDebugNotificationBody extends NotificationBody {
private Job $job;
private string $body;
private ?string $error;
private bool $hasScreenshot;
public function __construct(Job $job, string $body, string $error = null, bool $hasScreenshot = false) {
$this->job = $job;
$this->body = $body;
$this->error = $error;
$this->hasScreenshot = $hasScreenshot;
}
private function constructString(bool $inMarkdown = false) {
$mdBody = "";
if ($this->body !== null) {
$mdBody .= $this->body;
}
if ($this->error !== null) {
$errorWrapper = $inMarkdown ? "```" : "";
$mdBody .= " :\n" . $errorWrapper . $this->error . $errorWrapper;
}
if ($inMarkdown && $this->hasScreenshot) {
$mdBody .= "\nScreenshot : ";
}
return $mdBody;
}
/**
* @inheritDoc
*/
public function toMarkdownString(): string {
return $this->constructString(true);
}
/**
* @inheritDoc
*/
public function toString(): string {
return $this->constructString();
}
}

View File

@ -1,31 +0,0 @@
<?php
namespace App\Notification\NotificationBody;
use App\Models\Job;
use App\Notification\NotificationBody;
use App\Notification\Stringifiable;
class JobErrorNotificationBody extends NotificationBody {
private Job $job;
private string $error;
public function __construct(Job $job, $error) {
$this->job = $job;
$this->error = $error;
}
/**
* @inheritDoc
*/
public function toMarkdownString(): string {
return "Le job \"{$this->job->name}\" a échoué avec l'erreur :\n ```" . $this->error . "``` \nScreenshot : ";
}
/**
* @inheritDoc
*/
public function toString(): string {
return "Le job \"{$this->job->name}\" a échoué avec l'erreur :\n " . $this->error;
}
}

View File

@ -3,5 +3,5 @@
namespace App\Notification;
abstract class NotificationProvider {
abstract public static function send(Notification $notification, array $options): void;
abstract public static function send(Notification $notification): void;
}

View File

@ -3,9 +3,12 @@
namespace App\Notification\Notifications\Hellcase;
use App\Browser\Jobs\Hellcase\HellcaseDailyFreeScreenshot;
use App\Browser\Jobs\Hellcase\HellcaseLoginQrCode;
use App\Notification\Notification;
use App\Notification\Notifications\NotificationLogin;
use Laravel\Dusk\Browser;
class HellcaseNotificationDailyFree extends Notification {
class HellcaseNotificationDailyFree extends NotificationLogin {
public function __construct(int $jobId, \App\Notification\NotificationBody $body) {
parent::__construct($jobId, $body);

View File

@ -1,44 +0,0 @@
<?php
namespace App\Notification\Notifications;
use App\Browser\JobDebugScreenshot;
use App\Notification\Notification;
use App\Notification\NotificationBody\JobDebugNotificationBody;
use App\Notification\Stringifiable;
use App\Notification\Stringifiable\StringifiableSimpleText;
use Illuminate\Support\Facades\Log;
class JobDebugNotification extends Notification {
private string|null $title;
private string|null $screenShotProjectPath;
public function __construct(int $jobId, string $body, string $title = null, string $error = null, ?string $screenshotProjectPath = "", bool $isError = true) {
parent::__construct($jobId, isError:$isError);
$this->title = $title;
if ($screenshotProjectPath === "") {
$screenshotProjectPath = JobDebugScreenshot::getImgFileProjectPath($jobId);
}
$this->screenShotProjectPath = $screenshotProjectPath;
$this->setBody(new JobDebugNotificationBody($this->job, $body, $error, $this->screenShotProjectPath != null));
}
public function getTitle(): Stringifiable {
return new StringifiableSimpleText($this->title ?? "DEBUG Job {$this->job->name}");
}
/**
* @inheritDoc
*/
public function getImageProjectPath(): string|null {
return $this->screenShotProjectPath;
}
/**
* @inheritDoc
*/
public function getLinkURL(): string|null {
return route('jobs.show', ['job' => $this->job->id]);
}
}

View File

@ -1,37 +0,0 @@
<?php
namespace App\Notification\Notifications;
use App\Browser\JobErrorScreenshot;
use App\Models\Job;
use App\Notification\Notification;
use App\Notification\NotificationBody\JobErrorNotificationBody;
use App\Notification\Stringifiable;
use App\Notification\Stringifiable\StringifiableSimpleText;
use Illuminate\Support\Facades\Log;
class JobErrorNotification extends Notification {
public function __construct(int $jobId, string $error) {
parent::__construct($jobId, isError:true);
$this->setBody(new JobErrorNotificationBody($this->job, $error));
}
public function getTitle(): Stringifiable {
return new StringifiableSimpleText("Le job {$this->job->name} a échoué");
}
/**
* @inheritDoc
*/
public function getImageProjectPath(): string|null {
return JobErrorScreenshot::getImgFileProjectPath($this->job->id);
}
/**
* @inheritDoc
*/
public function getLinkURL(): string|null {
return route('jobs.show', ['job' => $this->job->id]);
}
}

View File

@ -3,6 +3,9 @@
namespace App\Notification\Providers;
use App\Notification\NotificationProvider;
use App\Notification\INotificationProvider;
use App\Models\JobInfo;
use Illuminate\Support\Facades\Cache;
class AllNotification extends NotificationProvider {
private const NOTIFICATIONS_PROVIDERS = [
@ -12,9 +15,9 @@ class AllNotification extends NotificationProvider {
/**
* @inheritDoc
*/
public static function send(\App\Notification\Notification $notification, array $options = []): void {
public static function send(\App\Notification\Notification $notification): void {
foreach (self::NOTIFICATIONS_PROVIDERS as $provider) {
$provider::send($notification, $options);
$provider::send($notification);
}
}
}

View File

@ -3,6 +3,7 @@
namespace App\Notification\Providers;
use App\Notification\NotificationProvider;
use App\Notification\INotificationProvider;
use App\Models\JobInfo;
use Illuminate\Support\Facades\Cache;
@ -13,7 +14,7 @@ class DiscordWebHookNotification extends NotificationProvider {
/**
* @inheritDoc
*/
public static function send(\App\Notification\Notification $notification, array $options): void {
public static function send(\App\Notification\Notification $notification): void {
/*
Test Json for a complete embed :
{
@ -44,7 +45,7 @@ class DiscordWebHookNotification extends NotificationProvider {
"avatar_url": "https://www.fairytailrp.com/t40344-here-come-dat-boi"
}
*/
$webHookUrl = static::getDiscordWebHookUrl($notification->isError, $options);
$webHookUrl = static::getDiscordWebHookUrl($notification->isError);
$body = [
"content"=> "",
"tts"=> false,
@ -73,28 +74,20 @@ class DiscordWebHookNotification extends NotificationProvider {
'payload_json' => $payloadJson,
];
if ($notification->getImageURL() !== null && is_file($notification->getImageProjectPath())) {
if ($notification->getImageURL() !== null) {
$formData['file'] = curl_file_create($notification->getImageProjectPath(), 'image/png', 'image.png');
}
$formData['file'] = curl_file_create($notification->getImageProjectPath(), 'image/png', 'image.png');
$ch = curl_init($webHookUrl);
curl_setopt($ch, CURLOPT_HTTPHEADER, array('Content-type: multipart/form-data'));
curl_setopt($ch, CURLOPT_POST, 1);
curl_setopt($ch, CURLOPT_POSTFIELDS, $formData);
// curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
if (!curl_exec($ch)) {
$error = curl_error($ch);
\Log::error("Discord WebHook Notification failed: {$error}");
throw new \Exception("Discord WebHook Notification failed: {$error}");
}
$response = curl_exec($ch);
curl_close($ch);
}
private static function getDiscordWebHookUrl(bool $isError, array $options): string {
if (isset($options["discord_webhook_url"]) && $options["discord_webhook_url"] !== null) {
return $options["discord_webhook_url"];
}
private static function getDiscordWebHookUrl(bool $isError): string {
$generalWebHookUrlKey = 'discord_webhook_url';
$generalWebHookUrl = Cache::rememberForever($generalWebHookUrlKey, function () use ($generalWebHookUrlKey) {
return JobInfo::where('key', $generalWebHookUrlKey)->first()->value;

View File

@ -2,6 +2,7 @@
"$schema": "https://shadcn-vue.com/schema.json",
"style": "default",
"typescript": true,
"tsConfigPath": "./tsconfig.json",
"tailwind": {
"config": "tailwind.config.js",
"css": "resources/css/app.css",
@ -9,12 +10,9 @@
"cssVariables": true,
"prefix": ""
},
"framework": "laravel",
"aliases": {
"components": "@/Components",
"composables": "@/composables",
"utils": "@/lib/utils",
"ui": "@/Components/ui",
"lib": "@/lib"
},
"iconLibrary": "lucide"
"utils": "@/lib/utils"
}
}

View File

@ -9,16 +9,15 @@
],
"license": "MIT",
"require": {
"php": "8.3",
"php": "^8.2",
"erusev/parsedown": "^1.7",
"inertiajs/inertia-laravel": "^2.0",
"laravel/dusk": "^8.2",
"laravel/framework": "^12.0",
"laravel/framework": "^11.31",
"laravel/reverb": "^1.0",
"laravel/sanctum": "^4.0",
"laravel/telescope": "^5.5",
"laravel/tinker": "^2.9",
"norkunas/youtube-dl-php": "dev-master",
"tightenco/ziggy": "^2.0"
},
"require-dev": {

504
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -4,7 +4,5 @@ return [
"driver" => [
"url" => $_ENV['DUSK_DRIVER_URL'] ?? env('DUSK_DRIVER_URL') ?? null
],
"shouldStartMaximized" => $_ENV['DUSK_START_MAXIMIZED'] ?? env('DUSK_START_MAXIMIZED') ?? false,
"headlessDisabled" => $_ENV['DUSK_HEADLESS_DISABLED'] ?? env('DUSK_HEADLESS_DISABLED') ?? false
]
];

View File

@ -0,0 +1,44 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
/**
* Run the migrations.
*/
public function up(): void
{
$jobId = 3;
\App\Models\Job::forcecreate([
'id' => $jobId,
'name' => 'Jeu gratuit Epic Games',
'description' => 'Prends le jeu gratuit Epic games. Tourne tous les jours.',
]);
\App\Models\JobInfo::forceCreate([
"key" => "epicgames_account_email",
"name" => "E-mail",
"description" => "L'adresse e-mail utilisée pour votre compte Epic Games.",
"job_info_type_id" => 2,
"job_id" => $jobId,
], );
\App\Models\JobInfo::forceCreate([
"key" => "epicgames_account_password",
"name" => "Mot de passe",
"description" => "Le mot de passe utilisé pour votre compte Epic Games.",
"job_info_type_id" => 3,
"job_id" => $jobId,
]);
}
/**
* Reverse the migrations.
*/
public function down(): void
{
\App\Models\Job::find(3)->delete();
\App\Models\JobInfo::where('job_id', 3)->delete();
}
};

View File

@ -1,82 +0,0 @@
<?php
use App\Models\Job;
use App\Models\JobInfo;
use App\Models\JobInfoType;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
$newJobId = 3;
Job::forceCreate([
"id" => $newJobId,
"name" => "Hellcase Battles",
"description" => "Envoie les meilleures battles d'Hellcase",
]);
JobInfo::forceCreate([
"key" => "hellcase_battles_discord_webhook_url",
"name" => "Webhook Discord",
"description" => "Le lien discord webhook utilisé pour envoyer les meilleures battles d'Hellcase.\nSi aucun n'est spécifié, le webhook Discord des paramètres généraux sera utilisé.",
"placeholder" => "https://discord.com/api/webhooks/...",
"is_required" => false,
"job_info_type_id" => 4,
"job_id" => $newJobId,
]);
JobInfoType::forceCreate([
"id" => 5,
"name" => "number",
]);
JobInfoType::forceCreate([
"id" => 6,
"name" => "boolean",
]);
JobInfo::forceCreate([
"key" => "hellcase_battles_minimum_value",
"name" => "Valeur minimum des battles",
"description" => "La valeur minimale qu'une battle doit avoir pour être envoyée, en euros.",
"placeholder" => "1000",
"job_info_type_id" => 5,
"job_id" => $newJobId,
]);
JobInfo::forceCreate([
"key" => "hellcase_battles_allow_bots",
"name" => "Autoriser les battles avec bots",
"description" => "Envoyer les battles avec un seul joueur et des bots.",
"is_required" => false,
"job_info_type_id" => 6,
"job_id" => $newJobId,
]);
Schema::create('hellcase_battles', function (Blueprint $table) {
$table->id();
$table->string("battle_id")->unique();
$table->float("value");
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Job::where("id", 3)->delete();
JobInfo::where("job_id", 3)->delete();
JobInfoType::whereIn("id", [5, 6])->delete();
Schema::dropIfExists('hellcase_battles');
}
};

View File

@ -1,114 +0,0 @@
<?php
use App\Models\InstagramAccount;
use App\Models\InstagramRepost;
use App\Models\Job;
use App\Models\JobInfo;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
$newJobId = 4;
Job::forceCreate([
"id" => $newJobId,
"name" => "Instagram Repost",
"description" => "Reposte les publications Instagram des comptes donnés.",
]);
JobInfo::forceCreate([
"key" => "instagram_repost_accounts",
"name" => "Comptes Instagram à reposter",
"description" => "Liste des noms des comptes Instagram à partir desquels les publications seront repostées.\nSéparez les comptes par des virgules.",
"placeholder" => "is.it.ninluc, freddiedredd",
"is_required" => true,
"job_info_type_id" => 1,
"job_id" => $newJobId,
]);
JobInfo::forceCreate([
"key" => "instagram_repost_account_email",
"name" => "Identifiant",
"description" => "L'adresse e-mail/nom d'utilisateur/N° de téléphone utilisée pour le compte Instagram de repost.",
"is_required" => true,
"job_info_type_id" => 1,
"job_id" => $newJobId,
]);
JobInfo::forceCreate([
"key" => "instagram_repost_account_password",
"name" => "Mot de passe",
"description" => "Le mot de passe utilisée pour le compte Instagram de repost.",
"is_required" => true,
"job_info_type_id" => 3,
"job_id" => $newJobId,
]);
Schema::create('instagram_repost_accounts', function (Blueprint $table) {
$table->id();
$table->string("username")->unique();
$table->timestamps();
});
Schema::create('instagram_reposts', function (Blueprint $table) {
$table->id();
$table->string("reel_id")->unique();
$table->boolean("reposted")->default(false);
$table->integer("repost_tries")->default(0);
$table->foreignIdFor(InstagramAccount::class, "account_id")
->constrained('instagram_repost_accounts')
->cascadeOnDelete();
$table->timestamps();
});
// Already reposted posts
$notDeadLmaoAccount = InstagramAccount::forceCreate([
"username" => "notdeadlmao69",
]);
$negusflexAccount = InstagramAccount::forceCreate([
"username" => "negusflex",
]);
InstagramRepost::forceCreate([
"reel_id" => "DKbW7M_RWV7",
"reposted" => true,
"account_id" => $notDeadLmaoAccount->id,
]);
InstagramRepost::forceCreate([
"reel_id" => "DKccuTMTmP_",
"reposted" => true,
"account_id" => $negusflexAccount->id,
]);
InstagramRepost::forceCreate([
"reel_id" => "DJmUjhWSnqm",
"reposted" => true,
"account_id" => $negusflexAccount->id,
]);
InstagramRepost::forceCreate([
"reel_id" => "DKcdSGnv6uq",
"reposted" => true,
"account_id" => $negusflexAccount->id,
]);
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Job::where("id", 4)->delete();
JobInfo::where("job_id", 4)->delete();
Schema::dropIfExists('instagram_repost_accounts');
Schema::dropIfExists('instagram_reposts');
}
};

77
package-lock.json generated
View File

@ -11,7 +11,6 @@
"lucide-react": "^0.474.0",
"lucide-vue-next": "^0.474.0",
"radix-vue": "^1.9.13",
"reka-ui": "^2.1.0",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7"
},
@ -22,10 +21,8 @@
"autoprefixer": "^10.4.12",
"axios": "^1.7.4",
"concurrently": "^9.0.1",
"laravel-echo": "^2.0.2",
"laravel-vite-plugin": "^1.2.0",
"postcss": "^8.4.31",
"pusher-js": "^8.4.0",
"sass-embedded": "^1.83.4",
"tailwindcss": "^3.2.1",
"typescript": "^5.6.3",
@ -959,20 +956,20 @@
}
},
"node_modules/@tanstack/virtual-core": {
"version": "3.13.4",
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.4.tgz",
"integrity": "sha512-fNGO9fjjSLns87tlcto106enQQLycCKR4DPNpgq3djP5IdcPFdPAmaKjsgzIeRhH7hWrELgW12hYnRthS5kLUw==",
"version": "3.11.3",
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.11.3.tgz",
"integrity": "sha512-v2mrNSnMwnPJtcVqNvV0c5roGCBqeogN8jDtgtuHCphdwBasOZ17x8UV8qpHUh+u0MLfX43c0uUHKje0s+Zb0w==",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/vue-virtual": {
"version": "3.13.4",
"resolved": "https://registry.npmjs.org/@tanstack/vue-virtual/-/vue-virtual-3.13.4.tgz",
"integrity": "sha512-1fPrd3hE1SS4R/9JbX1AlzueY4duCK7ixuLcMW5GMnk9N6WbLo9MioNKiv22V+UaXKOLNy8tLdzT8NYerOFTOQ==",
"version": "3.11.3",
"resolved": "https://registry.npmjs.org/@tanstack/vue-virtual/-/vue-virtual-3.11.3.tgz",
"integrity": "sha512-BVZ00i5XBucetRj2doVd32jOPtJthvZSVJvx9GL4gSQsyngliSCtzlP1Op7TFrEtmebRKT8QUQE1tRhOQzWecQ==",
"dependencies": {
"@tanstack/virtual-core": "3.13.4"
"@tanstack/virtual-core": "3.11.3"
},
"funding": {
"type": "github",
@ -2242,15 +2239,6 @@
"jiti": "bin/jiti.js"
}
},
"node_modules/laravel-echo": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/laravel-echo/-/laravel-echo-2.0.2.tgz",
"integrity": "sha512-Ciai6hA7r35MFqNRb8G034cvm9WiveSTFQQKRGJhWtZGbng7C8BBa5QvqDxk/Mw5GeJ+q19jrEwQhf7r1b1lcg==",
"dev": true,
"engines": {
"node": ">=20"
}
},
"node_modules/laravel-vite-plugin": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/laravel-vite-plugin/-/laravel-vite-plugin-1.2.0.tgz",
@ -2499,11 +2487,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/ohash": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/ohash/-/ohash-1.1.6.tgz",
"integrity": "sha512-TBu7PtV8YkAZn0tSxobKY2n2aAQva936lhRrj6957aDaCf9IEtqsKbgMzXE/F/sjqYOwmrukeORHNLe5glk7Cg=="
},
"node_modules/package-json-from-dist": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
@ -2717,15 +2700,6 @@
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"dev": true
},
"node_modules/pusher-js": {
"version": "8.4.0",
"resolved": "https://registry.npmjs.org/pusher-js/-/pusher-js-8.4.0.tgz",
"integrity": "sha512-wp3HqIIUc1GRyu1XrP6m2dgyE9MoCsXVsWNlohj0rjSkLf+a0jLvEyVubdg58oMk7bhjBWnFClgp8jfAa6Ak4Q==",
"dev": true,
"dependencies": {
"tweetnacl": "^1.0.3"
}
},
"node_modules/qs": {
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
@ -2873,37 +2847,6 @@
"node": ">=8.10.0"
}
},
"node_modules/reka-ui": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/reka-ui/-/reka-ui-2.1.0.tgz",
"integrity": "sha512-w4kEDEyXhIqv4QeFJeiuBc4mQP37hH/UTRpEb9dMbPdR49JG5TcV/s0+ntNRONUUW4LDLX7E1ZPcwBw5hnu0yw==",
"dependencies": {
"@floating-ui/dom": "^1.6.13",
"@floating-ui/vue": "^1.1.6",
"@internationalized/date": "^3.5.0",
"@internationalized/number": "^3.5.0",
"@tanstack/vue-virtual": "^3.12.0",
"@vueuse/core": "^12.5.0",
"@vueuse/shared": "^12.5.0",
"aria-hidden": "^1.2.4",
"defu": "^6.1.4",
"ohash": "^1.1.4"
},
"peerDependencies": {
"vue": ">= 3.2.0"
}
},
"node_modules/reka-ui/node_modules/@vueuse/shared": {
"version": "12.8.2",
"resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-12.8.2.tgz",
"integrity": "sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w==",
"dependencies": {
"vue": "^3.5.13"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
@ -3754,12 +3697,6 @@
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
},
"node_modules/tweetnacl": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz",
"integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==",
"dev": true
},
"node_modules/typescript": {
"version": "5.7.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz",

View File

@ -24,14 +24,12 @@
"vue-tsc": "^2.0.24"
},
"dependencies": {
"@tanstack/vue-table": "^8.21.2",
"@vueuse/core": "^12.5.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.474.0",
"lucide-vue-next": "^0.474.0",
"radix-vue": "^1.9.13",
"reka-ui": "^2.1.0",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7"
}

50
pnpm-lock.yaml generated
View File

@ -8,9 +8,6 @@ importers:
.:
dependencies:
'@tanstack/vue-table':
specifier: ^8.21.2
version: 8.21.2(vue@3.5.13(typescript@5.7.3))
'@vueuse/core':
specifier: ^12.5.0
version: 12.7.0(typescript@5.7.3)
@ -29,9 +26,6 @@ importers:
radix-vue:
specifier: ^1.9.13
version: 1.9.16(vue@3.5.13(typescript@5.7.3))
reka-ui:
specifier: ^2.1.0
version: 2.1.0(typescript@5.7.3)(vue@3.5.13(typescript@5.7.3))
tailwind-merge:
specifier: ^2.6.0
version: 2.6.0
@ -431,19 +425,9 @@ packages:
peerDependencies:
tailwindcss: '>=3.0.0 || >= 3.0.0-alpha.1 || >= 4.0.0-alpha.20 || >= 4.0.0-beta.1'
'@tanstack/table-core@8.21.2':
resolution: {integrity: sha512-uvXk/U4cBiFMxt+p9/G7yUWI/UbHYbyghLCjlpWZ3mLeIZiUBSKcUnw9UnKkdRz7Z/N4UBuFLWQdJCjUe7HjvA==}
engines: {node: '>=12'}
'@tanstack/virtual-core@3.13.2':
resolution: {integrity: sha512-Qzz4EgzMbO5gKrmqUondCjiHcuu4B1ftHb0pjCut661lXZdGoHeze9f/M8iwsK1t5LGR6aNuNGU7mxkowaW6RQ==}
'@tanstack/vue-table@8.21.2':
resolution: {integrity: sha512-KBgOWxha/x4m1EdhVWxOpqHb661UjqAxzPcmXR3QiA7aShZ547x19Gw0UJX9we+m+tVcPuLRZ61JsYW47QZFfQ==}
engines: {node: '>=12'}
peerDependencies:
vue: '>=3.2'
'@tanstack/vue-virtual@3.13.2':
resolution: {integrity: sha512-z4swzjdhzCh95n9dw9lTvw+t3iwSkYRlVkYkra3C9mul/m5fTzHR7KmtkwH4qXMTXGJUbngtC/bz2cHQIHkO8g==}
peerDependencies:
@ -968,9 +952,6 @@ packages:
resolution: {integrity: sha512-kDCGIbxkDSXE3euJZZXzc6to7fCrKHNI/hSRQnRuQ+BWjFNzZwiFF8fj/6o2t2G9/jTj8PSIYTfCLelLZEeRpA==}
engines: {node: '>= 0.4'}
ohash@1.1.6:
resolution: {integrity: sha512-TBu7PtV8YkAZn0tSxobKY2n2aAQva936lhRrj6957aDaCf9IEtqsKbgMzXE/F/sjqYOwmrukeORHNLe5glk7Cg==}
package-json-from-dist@1.0.1:
resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
@ -1073,11 +1054,6 @@ packages:
resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
engines: {node: '>=8.10.0'}
reka-ui@2.1.0:
resolution: {integrity: sha512-w4kEDEyXhIqv4QeFJeiuBc4mQP37hH/UTRpEb9dMbPdR49JG5TcV/s0+ntNRONUUW4LDLX7E1ZPcwBw5hnu0yw==}
peerDependencies:
vue: '>= 3.2.0'
require-directory@2.1.1:
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
engines: {node: '>=0.10.0'}
@ -1705,15 +1681,8 @@ snapshots:
mini-svg-data-uri: 1.4.4
tailwindcss: 3.4.17
'@tanstack/table-core@8.21.2': {}
'@tanstack/virtual-core@3.13.2': {}
'@tanstack/vue-table@8.21.2(vue@3.5.13(typescript@5.7.3))':
dependencies:
'@tanstack/table-core': 8.21.2
vue: 3.5.13(typescript@5.7.3)
'@tanstack/vue-virtual@3.13.2(vue@3.5.13(typescript@5.7.3))':
dependencies:
'@tanstack/virtual-core': 3.13.2
@ -2255,8 +2224,6 @@ snapshots:
object-inspect@1.13.3: {}
ohash@1.1.6: {}
package-json-from-dist@1.0.1: {}
path-browserify@1.0.1: {}
@ -2354,23 +2321,6 @@ snapshots:
dependencies:
picomatch: 2.3.1
reka-ui@2.1.0(typescript@5.7.3)(vue@3.5.13(typescript@5.7.3)):
dependencies:
'@floating-ui/dom': 1.6.13
'@floating-ui/vue': 1.1.6(vue@3.5.13(typescript@5.7.3))
'@internationalized/date': 3.7.0
'@internationalized/number': 3.6.0
'@tanstack/vue-virtual': 3.13.2(vue@3.5.13(typescript@5.7.3))
'@vueuse/core': 12.7.0(typescript@5.7.3)
'@vueuse/shared': 12.7.0(typescript@5.7.3)
aria-hidden: 1.2.4
defu: 6.1.4
ohash: 1.1.6
vue: 3.5.13(typescript@5.7.3)
transitivePeerDependencies:
- '@vue/composition-api'
- typescript
require-directory@2.1.1: {}
resolve@1.22.10:

View File

@ -18,7 +18,7 @@ const jobInfoType = props.jobInfo.job_info_type.name;
<div>
<Label :for="'' + jobInfo.id" class="text">{{ jobInfo.name }}<span v-if="jobInfo.is_required" class="cursor-help" title="Requis" aria-label="Requis">*</span></Label>
<Description>{{ jobInfo.description }}</Description>
<Input v-if="['text', 'email', 'password', 'url', 'number'].includes(jobInfoType)" :type="jobInfoType" :id="'' + jobInfo.id" :name="'' + jobInfo.id" :placeholder="jobInfo.placeholder" v-model="jobInfo.value as string" :required="jobInfo.is_required" />
<Input v-if="jobInfoType != 'checkbox'" :type="jobInfoType" :id="'' + jobInfo.id" :name="'' + jobInfo.id" :placeholder="jobInfo.placeholder" v-model="jobInfo.value as string" :required="jobInfo.is_required" />
<VModelCheckbox v-else :id="'' + jobInfo.id" :class="''" v-model="jobInfo.value as boolean" />
</div>

View File

@ -1,24 +0,0 @@
<script setup lang="ts">
import Separator from "@/Components/ui/separator/Separator.vue";
import { JobRunArtifact } from "@/types/Jobs/job";
defineProps<{
jobRun: JobRunArtifact;
}>();
</script>
<template>
<ul>
<li v-for="artifact in jobRun.artifacts" :key="artifact.id">
<p>{{ artifact.name }}</p>
<p class="italic">{{ artifact.content }}</p>
</li>
</ul>
</template>
<style lang="scss" scoped>
ul {
list-style-type: circle;
padding-left: 1rem;
}
</style>

View File

@ -1,28 +0,0 @@
<script setup lang="ts">
import AccordionContent from "@/Components/ui/accordion/AccordionContent.vue";
import AccordionItem from "@/Components/ui/accordion/AccordionItem.vue";
import AccordionTrigger from "@/Components/ui/accordion/AccordionTrigger.vue";
import { JobRunArtifact } from "@/types/Jobs/job";
import JobRunArtifacts from "./JobRunArtifacts.vue";
defineProps<{
jobRun: JobRunArtifact;
}>();
</script>
<template>
<AccordionItem :value="''+jobRun.id" :class="[jobRun.success ? 'bg-green-100' : 'bg-red-200', 'first:rounded-t last:rounded-b', 'px-3']">
<AccordionTrigger>
{{ new Date(Date.parse(jobRun.created_at)).toLocaleTimeString(undefined, {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
})
}}
</AccordionTrigger>
<AccordionContent>
<JobRunArtifacts :jobRun="jobRun" />
</AccordionContent>
</AccordionItem>
</template>

View File

@ -1,30 +0,0 @@
<script setup lang="ts">
import { Job, JobRunArtifact } from "@/types/Jobs/job";
import JobRunItem from "./JobRunItem.vue";
import Accordion from "@/Components/ui/accordion/Accordion.vue";
import ScrollArea from "@/Components/ui/scroll-area/ScrollArea.vue";
defineProps<{
job: Job;
}>();
</script>
<template>
<div v-if="job.job_runs.length > 0">
<h2>Ancien jobs</h2>
<ScrollArea class="min-h-[300px] max-h-[20vh] overflow-auto pr-2">
<Accordion type="multiple" collapsible>
<JobRunItem
:jobRun="jobRun"
v-for="jobRun in job.job_runs.sort((a, b) => {
return (
new Date(b.created_at).getTime() -
new Date(a.created_at).getTime()
);
})"
:key="jobRun.id"
/>
</Accordion>
</ScrollArea>
</div>
</template>

View File

@ -1,19 +0,0 @@
<script setup lang="ts">
import {
AccordionRoot,
type AccordionRootEmits,
type AccordionRootProps,
useForwardPropsEmits,
} from 'reka-ui'
const props = defineProps<AccordionRootProps>()
const emits = defineEmits<AccordionRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<AccordionRoot v-bind="forwarded">
<slot />
</AccordionRoot>
</template>

View File

@ -1,24 +0,0 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
import { AccordionContent, type AccordionContentProps } from 'reka-ui'
import { computed, type HTMLAttributes } from 'vue'
const props = defineProps<AccordionContentProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
</script>
<template>
<AccordionContent
v-bind="delegatedProps"
class="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
>
<div :class="cn('pb-4 pt-0', props.class)">
<slot />
</div>
</AccordionContent>
</template>

View File

@ -1,24 +0,0 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
import { AccordionItem, type AccordionItemProps, useForwardProps } from 'reka-ui'
import { computed, type HTMLAttributes } from 'vue'
const props = defineProps<AccordionItemProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<AccordionItem
v-bind="forwardedProps"
:class="cn('border-b', props.class)"
>
<slot />
</AccordionItem>
</template>

View File

@ -1,39 +0,0 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
import { ChevronDown } from 'lucide-vue-next'
import {
AccordionHeader,
AccordionTrigger,
type AccordionTriggerProps,
} from 'reka-ui'
import { computed, type HTMLAttributes } from 'vue'
const props = defineProps<AccordionTriggerProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
</script>
<template>
<AccordionHeader class="flex">
<AccordionTrigger
v-bind="delegatedProps"
:class="
cn(
'flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180',
props.class,
)
"
>
<slot />
<slot name="icon">
<ChevronDown
class="h-4 w-4 shrink-0 transition-transform duration-200"
/>
</slot>
</AccordionTrigger>
</AccordionHeader>
</template>

View File

@ -1,4 +0,0 @@
export { default as Accordion } from './Accordion.vue'
export { default as AccordionContent } from './AccordionContent.vue'
export { default as AccordionItem } from './AccordionItem.vue'
export { default as AccordionTrigger } from './AccordionTrigger.vue'

View File

@ -1,35 +0,0 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
import { Separator, type SeparatorProps } from 'reka-ui'
import { computed, type HTMLAttributes } from 'vue'
const props = defineProps<
SeparatorProps & { class?: HTMLAttributes['class'], label?: string }
>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
</script>
<template>
<Separator
v-bind="delegatedProps"
:class="
cn(
'shrink-0 bg-border relative',
props.orientation === 'vertical' ? 'w-px h-full' : 'h-px w-full',
props.class,
)
"
>
<span
v-if="props.label"
:class="cn('text-xs text-muted-foreground bg-background absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 flex justify-center items-center',
props.orientation === 'vertical' ? 'w-[1px] px-1 py-2' : 'h-[1px] py-1 px-2',
)"
>{{ props.label }}</span>
</Separator>
</template>

View File

@ -1 +0,0 @@
export { default as Separator } from './Separator.vue'

View File

@ -1,7 +1,6 @@
<script setup lang="ts">
import JobForm from '../Components/Layout/Job/JobForm.vue'
import JobCard from '../Components/Layout/Job/JobCard.vue'
import JobRuns from '../Components/Layout/Job/JobRuns/JobRuns.vue';
import { Job } from "@/types/Jobs/job";
import { Head } from "@inertiajs/vue3";
@ -17,6 +16,4 @@ defineProps<{
<JobCard :job="job" />
<JobForm :job="job" :error="error" />
<JobRuns :job="job" />
</template>

View File

@ -1,17 +1,8 @@
import type { Updater } from '@tanstack/vue-table'
import type { Ref } from 'vue'
import { type ClassValue, clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
export function valueUpdater<T extends Updater<any>>(updaterOrValue: T, ref: Ref) {
ref.value
= typeof updaterOrValue === 'function'
? updaterOrValue(ref.value)
: updaterOrValue
return twMerge(clsx(inputs));
}
export async function httpApi<T>(route: string): Promise<T> {

View File

@ -5,9 +5,8 @@ export type Job = {
is_active: boolean;
job_infos: JobInfo[];
job_runs: JobRunArtifact[];
created_at: string;
created_at: Date;
}
export type JobInfo = {
@ -27,23 +26,16 @@ export type JobInfo = {
export type JobInfoType = {
id: number;
name: string;
created_at: string;
created_at: Date;
}
export type JobRunArtifact = {
id: number;
job_id: number;
jobId: number;
artifacts: JobArtifact[];
success: boolean;
created_at: string;
}
export type JobArtifact = {
id: number;
name: string;
content: string;
created_at: string;
}

View File

@ -12,8 +12,7 @@ Route::get('/jobs', function (Request $request) {
Route::get('/test/{id}', function (Request $request, $id, BrowserJobsInstances $BrowserJobsInstances) {
$log = $BrowserJobsInstances->getJobInstance($id)->execute();
dump($log);
dump(response()->json(['message' => 'Job ' . $id . ' ran', 'jobRun' => $log->load('artifacts')]));
return response()->json(['message' => 'Job ' . $id . ' ran', 'jobRun' => $log->load('artifacts')]);
});
Route::get('jobs/{job}/test', [JobController::class, 'test'])->name('jobs.test');

View File

@ -1,8 +1,7 @@
<?php
use App\Browser\Jobs\EpicGames\EpicGamesJob;
use App\Browser\Jobs\Hellcase\HellcaseJob;
use App\Browser\Jobs\HellcaseBattles\HellcaseBattlesJob;
use App\Browser\Jobs\InstagramRepost\InstagramRepostJob;
use App\Jobs\PruneOldJobRuns;
use App\Services\BrowserJobsInstances;
use Illuminate\Foundation\Inspiring;
@ -23,5 +22,6 @@ Schedule::job(new PruneOldJobRuns)->monthly()->onOneServer()->withoutOverlapping
// Jobs
Schedule::job(new HellcaseJob)->daily()->onOneServer()->withoutOverlapping()->name('hellcase')->description('Hellcase job');
// Schedule::job(new HellcaseJob)->everyMinute()->onOneServer()->withoutOverlapping()->name('hellcase')->description('Hellcase job');
Schedule::job(new HellcaseBattlesJob)->hourly()->onOneServer()->withoutOverlapping()->name('hellcase_battles')->description('Hellcase battles job');
Schedule::job(new InstagramRepostJob())->everyThreeHours()->onOneServer()->withoutOverlapping()->name('instagram_reposts')->description('Intagrame reposts job');
Schedule::job(new EpicGamesJob())->daily()->onOneServer()->withoutOverlapping()->name('epic-games')->description('Epic Games job');

View File

@ -0,0 +1,54 @@
<?php
namespace Tests\Feature\Auth;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class AuthenticationTest extends TestCase
{
use RefreshDatabase;
public function test_login_screen_can_be_rendered(): void
{
$response = $this->get('/login');
$response->assertStatus(200);
}
public function test_users_can_authenticate_using_the_login_screen(): void
{
$user = User::factory()->create();
$response = $this->post('/login', [
'email' => $user->email,
'password' => 'password',
]);
$this->assertAuthenticated();
$response->assertRedirect(route('dashboard', absolute: false));
}
public function test_users_can_not_authenticate_with_invalid_password(): void
{
$user = User::factory()->create();
$this->post('/login', [
'email' => $user->email,
'password' => 'wrong-password',
]);
$this->assertGuest();
}
public function test_users_can_logout(): void
{
$user = User::factory()->create();
$response = $this->actingAs($user)->post('/logout');
$this->assertGuest();
$response->assertRedirect('/');
}
}

View File

@ -0,0 +1,58 @@
<?php
namespace Tests\Feature\Auth;
use App\Models\User;
use Illuminate\Auth\Events\Verified;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\URL;
use Tests\TestCase;
class EmailVerificationTest extends TestCase
{
use RefreshDatabase;
public function test_email_verification_screen_can_be_rendered(): void
{
$user = User::factory()->unverified()->create();
$response = $this->actingAs($user)->get('/verify-email');
$response->assertStatus(200);
}
public function test_email_can_be_verified(): void
{
$user = User::factory()->unverified()->create();
Event::fake();
$verificationUrl = URL::temporarySignedRoute(
'verification.verify',
now()->addMinutes(60),
['id' => $user->id, 'hash' => sha1($user->email)]
);
$response = $this->actingAs($user)->get($verificationUrl);
Event::assertDispatched(Verified::class);
$this->assertTrue($user->fresh()->hasVerifiedEmail());
$response->assertRedirect(route('dashboard', absolute: false).'?verified=1');
}
public function test_email_is_not_verified_with_invalid_hash(): void
{
$user = User::factory()->unverified()->create();
$verificationUrl = URL::temporarySignedRoute(
'verification.verify',
now()->addMinutes(60),
['id' => $user->id, 'hash' => sha1('wrong-email')]
);
$this->actingAs($user)->get($verificationUrl);
$this->assertFalse($user->fresh()->hasVerifiedEmail());
}
}

View File

@ -0,0 +1,44 @@
<?php
namespace Tests\Feature\Auth;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class PasswordConfirmationTest extends TestCase
{
use RefreshDatabase;
public function test_confirm_password_screen_can_be_rendered(): void
{
$user = User::factory()->create();
$response = $this->actingAs($user)->get('/confirm-password');
$response->assertStatus(200);
}
public function test_password_can_be_confirmed(): void
{
$user = User::factory()->create();
$response = $this->actingAs($user)->post('/confirm-password', [
'password' => 'password',
]);
$response->assertRedirect();
$response->assertSessionHasNoErrors();
}
public function test_password_is_not_confirmed_with_invalid_password(): void
{
$user = User::factory()->create();
$response = $this->actingAs($user)->post('/confirm-password', [
'password' => 'wrong-password',
]);
$response->assertSessionHasErrors();
}
}

View File

@ -0,0 +1,73 @@
<?php
namespace Tests\Feature\Auth;
use App\Models\User;
use Illuminate\Auth\Notifications\ResetPassword;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Notification;
use Tests\TestCase;
class PasswordResetTest extends TestCase
{
use RefreshDatabase;
public function test_reset_password_link_screen_can_be_rendered(): void
{
$response = $this->get('/forgot-password');
$response->assertStatus(200);
}
public function test_reset_password_link_can_be_requested(): void
{
Notification::fake();
$user = User::factory()->create();
$this->post('/forgot-password', ['email' => $user->email]);
Notification::assertSentTo($user, ResetPassword::class);
}
public function test_reset_password_screen_can_be_rendered(): void
{
Notification::fake();
$user = User::factory()->create();
$this->post('/forgot-password', ['email' => $user->email]);
Notification::assertSentTo($user, ResetPassword::class, function ($notification) {
$response = $this->get('/reset-password/'.$notification->token);
$response->assertStatus(200);
return true;
});
}
public function test_password_can_be_reset_with_valid_token(): void
{
Notification::fake();
$user = User::factory()->create();
$this->post('/forgot-password', ['email' => $user->email]);
Notification::assertSentTo($user, ResetPassword::class, function ($notification) use ($user) {
$response = $this->post('/reset-password', [
'token' => $notification->token,
'email' => $user->email,
'password' => 'password',
'password_confirmation' => 'password',
]);
$response
->assertSessionHasNoErrors()
->assertRedirect(route('login'));
return true;
});
}
}

View File

@ -0,0 +1,51 @@
<?php
namespace Tests\Feature\Auth;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Hash;
use Tests\TestCase;
class PasswordUpdateTest extends TestCase
{
use RefreshDatabase;
public function test_password_can_be_updated(): void
{
$user = User::factory()->create();
$response = $this
->actingAs($user)
->from('/profile')
->put('/password', [
'current_password' => 'password',
'password' => 'new-password',
'password_confirmation' => 'new-password',
]);
$response
->assertSessionHasNoErrors()
->assertRedirect('/profile');
$this->assertTrue(Hash::check('new-password', $user->refresh()->password));
}
public function test_correct_password_must_be_provided_to_update_password(): void
{
$user = User::factory()->create();
$response = $this
->actingAs($user)
->from('/profile')
->put('/password', [
'current_password' => 'wrong-password',
'password' => 'new-password',
'password_confirmation' => 'new-password',
]);
$response
->assertSessionHasErrors('current_password')
->assertRedirect('/profile');
}
}

View File

@ -0,0 +1,31 @@
<?php
namespace Tests\Feature\Auth;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class RegistrationTest extends TestCase
{
use RefreshDatabase;
public function test_registration_screen_can_be_rendered(): void
{
$response = $this->get('/register');
$response->assertStatus(200);
}
public function test_new_users_can_register(): void
{
$response = $this->post('/register', [
'name' => 'Test User',
'email' => 'test@example.com',
'password' => 'password',
'password_confirmation' => 'password',
]);
$this->assertAuthenticated();
$response->assertRedirect(route('dashboard', absolute: false));
}
}

View File

@ -0,0 +1,19 @@
<?php
namespace Tests\Feature;
// use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class ExampleTest extends TestCase
{
/**
* A basic test example.
*/
public function test_the_application_returns_a_successful_response(): void
{
$response = $this->get('/');
$response->assertStatus(200);
}
}

View File

@ -0,0 +1,99 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class ProfileTest extends TestCase
{
use RefreshDatabase;
public function test_profile_page_is_displayed(): void
{
$user = User::factory()->create();
$response = $this
->actingAs($user)
->get('/profile');
$response->assertOk();
}
public function test_profile_information_can_be_updated(): void
{
$user = User::factory()->create();
$response = $this
->actingAs($user)
->patch('/profile', [
'name' => 'Test User',
'email' => 'test@example.com',
]);
$response
->assertSessionHasNoErrors()
->assertRedirect('/profile');
$user->refresh();
$this->assertSame('Test User', $user->name);
$this->assertSame('test@example.com', $user->email);
$this->assertNull($user->email_verified_at);
}
public function test_email_verification_status_is_unchanged_when_the_email_address_is_unchanged(): void
{
$user = User::factory()->create();
$response = $this
->actingAs($user)
->patch('/profile', [
'name' => 'Test User',
'email' => $user->email,
]);
$response
->assertSessionHasNoErrors()
->assertRedirect('/profile');
$this->assertNotNull($user->refresh()->email_verified_at);
}
public function test_user_can_delete_their_account(): void
{
$user = User::factory()->create();
$response = $this
->actingAs($user)
->delete('/profile', [
'password' => 'password',
]);
$response
->assertSessionHasNoErrors()
->assertRedirect('/');
$this->assertGuest();
$this->assertNull($user->fresh());
}
public function test_correct_password_must_be_provided_to_delete_account(): void
{
$user = User::factory()->create();
$response = $this
->actingAs($user)
->from('/profile')
->delete('/profile', [
'password' => 'wrong-password',
]);
$response
->assertSessionHasErrors('password')
->assertRedirect('/profile');
$this->assertNotNull($user->fresh());
}
}

View File

@ -0,0 +1,16 @@
<?php
namespace Tests\Unit;
use PHPUnit\Framework\TestCase;
class ExampleTest extends TestCase
{
/**
* A basic test example.
*/
public function test_that_true_is_true(): void
{
$this->assertTrue(true);
}
}

View File

@ -13,9 +13,6 @@
- Websocket installé
- Serveur php plus propre (nginx, apache, n'importe)
- Epic games
Pas l'air possible avec cloudflare
- Petit bug, quand l'on enregistre un formulaire avec une erreur, l'url a un argument GET ?error=mon%24erreur
Du coup dans la nav le job actuel n'est plus reconnu
## Pour deploy Lama