merge frontend-cypress

This commit is contained in:
Yisroel Baum 2026-05-06 23:27:09 +03:00
commit 72cdd7dc4e
Signed by: yisroelbaum
GPG key ID: 0FA60884F75520A9
15 changed files with 992 additions and 4 deletions

View file

@ -47,14 +47,14 @@ REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
MAIL_MAILER=log
MAIL_MAILER=smtp
MAIL_SCHEME=null
MAIL_HOST=127.0.0.1
MAIL_PORT=2525
MAIL_PORT=1025
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_FROM_ADDRESS="hello@example.com"
MAIL_FROM_NAME="${APP_NAME}"
MAIL_FROM_ADDRESS="noreply@tide.test"
MAIL_FROM_NAME="TIDE"
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=

View file

@ -0,0 +1,59 @@
<?php
namespace App\Console\Commands;
use App\Shared\ValueObject\EmailAddress;
use App\User\User;
use App\User\UserRepository;
use Illuminate\Console\Command;
use InvalidArgumentException;
class UserPromoteCommand extends Command
{
protected $signature = 'user:promote {email}';
protected $description = 'Mark the user with the given email as an admin';
public function handle(UserRepository $userRepo): int
{
$rawEmail = $this->argument('email');
if (! is_string($rawEmail) || $rawEmail === '') {
$this->error('email is required');
return self::FAILURE;
}
try {
$email = new EmailAddress($rawEmail);
} catch (InvalidArgumentException $exception) {
$this->error($exception->getMessage());
return self::FAILURE;
}
$user = $userRepo->findByEmail($email);
if ($user === null) {
$this->error("user not found: {$rawEmail}");
return self::FAILURE;
}
if ($user->isAdmin()) {
$this->info("{$rawEmail} is already an admin");
return self::SUCCESS;
}
$userRepo->update(new User(
id: $user->getId(),
email: $user->getEmail(),
displayName: $user->getDisplayName(),
passwordHash: $user->getPasswordHash(),
isAdmin: true,
emailConfirmedAt: $user->getEmailConfirmedAt(),
));
$this->info("{$rawEmail} is now an admin");
return self::SUCCESS;
}
}

View file

@ -24,6 +24,7 @@
typescript
postgresql
process-compose
mailpit
];
shellHook = ''

View file

@ -3,6 +3,7 @@ import { execSync } from "node:child_process";
import { resolve } from "node:path";
const backendDir = resolve(__dirname, "../../backend");
const mailpitBase = "http://localhost:8025/api/v1";
export default defineConfig({
e2e: {
@ -23,6 +24,25 @@ export default defineConfig({
});
return null;
},
"db:promote": function (email: string) {
execSync(`php artisan user:promote ${JSON.stringify(email)}`, {
cwd: backendDir,
stdio: "pipe",
});
return null;
},
"mailpit:clear": async function () {
await fetch(`${mailpitBase}/messages`, { method: "DELETE" });
return null;
},
"mailpit:messages": async function () {
const response = await fetch(`${mailpitBase}/messages`);
return response.json();
},
"mailpit:message": async function (id: string) {
const response = await fetch(`${mailpitBase}/message/${id}`);
return response.json();
},
});
},
},

View file

@ -0,0 +1,99 @@
describe("admin actions", function () {
beforeEach(function () {
cy.resetDb();
cy.clearMail();
cy.seedAdmin({
email: "admin@example.com",
displayName: "rootadmin",
password: "longenoughpassword",
});
cy.seedConfirmedUser({
email: "alice@example.com",
displayName: "alice",
password: "longenoughpassword",
});
cy.seedConfirmedUser({
email: "bob@example.com",
displayName: "bob",
password: "longenoughpassword",
});
cy.seedPostAs({
email: "alice@example.com",
password: "longenoughpassword",
title: "Alice post",
body: "Body.",
}).as("alicePost");
});
it("admin promotes another user via the profile page", function () {
cy.loginViaApi({
email: "admin@example.com",
password: "longenoughpassword",
});
cy.visit("/users/alice");
cy.contains("button", "Promote to admin").click();
cy.logoutViaApi();
cy.loginViaApi({
email: "alice@example.com",
password: "longenoughpassword",
});
cy.get<{ id: number }>("@alicePost").then(function (post) {
cy.visit(`/posts/${post.id}`);
cy.contains("button", "Feature in slot 1").should("be.visible");
});
});
it("admin features a post and it shows up on the home page", function () {
cy.loginViaApi({
email: "admin@example.com",
password: "longenoughpassword",
});
cy.get<{ id: number }>("@alicePost").then(function (post) {
cy.visit(`/posts/${post.id}`);
cy.contains("button", "Feature in slot 1").click();
cy.contains(".badge", "Featured (slot 1)").should("be.visible");
});
cy.logoutViaApi();
cy.visit("/");
cy.contains("h2", "Featured").should("be.visible");
cy.get(".featured .post-card").should("have.length", 1);
cy.contains(".featured .post-card", "Alice post").should("be.visible");
});
it("admin unfeaturing a post removes it from the home page", function () {
cy.loginViaApi({
email: "admin@example.com",
password: "longenoughpassword",
});
cy.get<{ id: number }>("@alicePost").then(function (post) {
cy.visit(`/posts/${post.id}`);
cy.contains("button", "Feature in slot 1").click();
cy.contains(".badge", "Featured (slot 1)").should("be.visible");
cy.contains("button", "Unfeature").click();
cy.contains(".badge", "Featured").should("not.exist");
});
cy.logoutViaApi();
cy.visit("/");
cy.contains("h2", "Featured").should("not.exist");
});
it("a newly promoted admin can also feature posts", function () {
cy.promoteAdmin("bob@example.com");
cy.loginViaApi({
email: "bob@example.com",
password: "longenoughpassword",
});
cy.get<{ id: number }>("@alicePost").then(function (post) {
cy.visit(`/posts/${post.id}`);
cy.contains("button", "Feature in slot 2").click();
cy.contains(".badge", "Featured (slot 2)").should("be.visible");
});
});
});

View file

@ -0,0 +1,48 @@
describe("confirm email page", function () {
beforeEach(function () {
cy.resetDb();
cy.clearMail();
});
it("redirects to /login when no token is present", function () {
cy.visit("/confirm-email");
cy.location("pathname").should("eq", "/login");
});
it("sets the password and redirects to /login on success", function () {
cy.signupViaApi({
email: "alice@example.com",
displayName: "alice",
});
cy.fetchLatestConfirmToken("alice@example.com").then(function (token) {
cy.visit(`/confirm-email?token=${token}`);
cy.get('input[type="password"]').type("longenoughpassword");
cy.contains("button", "Confirm").click();
cy.location("pathname").should("eq", "/login");
cy.loginViaApi({
email: "alice@example.com",
password: "longenoughpassword",
});
});
});
it("shows the backend error when password is too short", function () {
cy.signupViaApi({
email: "alice@example.com",
displayName: "alice",
});
cy.fetchLatestConfirmToken("alice@example.com").then(function (token) {
cy.visit(`/confirm-email?token=${token}`);
cy.get('input[type="password"]')
.invoke("removeAttr", "minlength")
.type("short");
cy.contains("button", "Confirm").click();
cy.contains(".error", "password must be at least 8").should(
"be.visible",
);
});
});
});

View file

@ -0,0 +1,41 @@
describe("guest route guards", function () {
beforeEach(function () {
cy.resetDb();
cy.clearMail();
});
it("redirects anonymous visitors away from /users/:displayName/posts/new", function () {
cy.visit("/users/alice/posts/new");
cy.location("pathname").should("eq", "/login");
});
it("redirects authenticated visitors away from /login", function () {
cy.seedConfirmedUser({
email: "alice@example.com",
displayName: "alice",
password: "longenoughpassword",
});
cy.loginViaApi({
email: "alice@example.com",
password: "longenoughpassword",
});
cy.visit("/login");
cy.location("pathname").should("eq", "/");
});
it("redirects authenticated visitors away from /signup", function () {
cy.seedConfirmedUser({
email: "alice@example.com",
displayName: "alice",
password: "longenoughpassword",
});
cy.loginViaApi({
email: "alice@example.com",
password: "longenoughpassword",
});
cy.visit("/signup");
cy.location("pathname").should("eq", "/");
});
});

View file

@ -0,0 +1,101 @@
describe("home page", function () {
beforeEach(function () {
cy.resetDb();
cy.clearMail();
cy.seedConfirmedUser({
email: "alice@example.com",
displayName: "alice",
password: "longenoughpassword",
});
cy.seedConfirmedUser({
email: "bob@example.com",
displayName: "bob",
password: "longenoughpassword",
});
cy.seedPostAs({
email: "alice@example.com",
password: "longenoughpassword",
title: "Alice first",
body: "Alice's first post.",
});
cy.seedPostAs({
email: "bob@example.com",
password: "longenoughpassword",
title: "Bob says hi",
body: "Bob's only post.",
});
cy.seedPostAs({
email: "alice@example.com",
password: "longenoughpassword",
title: "Alice second",
body: "Alice's second post.",
});
});
it("renders recent posts in newest-first order with author links", function () {
cy.visit("/");
cy.contains("h2", "Recent posts").should("be.visible");
cy.get(".recent .post-card").should("have.length", 3);
cy.get(".recent .post-card").first().within(function () {
cy.contains("Alice second");
cy.contains("a", "alice");
});
});
it("hides the featured section when no posts are featured", function () {
cy.visit("/");
cy.contains("h2", "Featured").should("not.exist");
});
it("shows featured posts in slot order once an admin features them", function () {
cy.seedAdmin({
email: "admin@example.com",
displayName: "rootadmin",
password: "longenoughpassword",
});
cy.request("/api/posts").then(function (response) {
const posts = response.body.posts as Array<{
id: number;
title: string;
}>;
const aliceSecond = posts.find(function (entry) {
return entry.title === "Alice second";
})!;
const bobPost = posts.find(function (entry) {
return entry.title === "Bob says hi";
})!;
cy.seedFeaturedPost({
adminEmail: "admin@example.com",
adminPassword: "longenoughpassword",
postId: bobPost.id,
slot: 2,
});
cy.seedFeaturedPost({
adminEmail: "admin@example.com",
adminPassword: "longenoughpassword",
postId: aliceSecond.id,
slot: 1,
});
});
cy.visit("/");
cy.contains("h2", "Featured").should("be.visible");
cy.get(".featured .post-card").should("have.length", 2);
cy.get(".featured .post-card").eq(0).contains("Alice second");
cy.get(".featured .post-card").eq(1).contains("Bob says hi");
});
it("searches users by display name and email prefix", function () {
cy.visit("/");
cy.get('input[type="search"]').type("al");
cy.get(".results li").should("have.length", 1);
cy.get(".results li").contains("alice");
cy.get('input[type="search"]').clear().type("b");
cy.get(".results li").should("have.length", 1);
cy.get(".results li").contains("bob");
cy.get('input[type="search"]').clear().type("zzz");
cy.get(".results li").should("have.length", 0);
});
});

View file

@ -0,0 +1,41 @@
describe("login page", function () {
beforeEach(function () {
cy.resetDb();
cy.clearMail();
cy.seedConfirmedUser({
email: "alice@example.com",
displayName: "alice",
password: "longenoughpassword",
});
});
it("logs in with valid credentials and shows the user in the header", function () {
cy.visit("/login");
cy.get('input[type="email"]').type("alice@example.com");
cy.get('input[type="password"]').type("longenoughpassword");
cy.contains("button", "Log in").click();
cy.location("pathname").should("eq", "/");
cy.get(".app-header").contains("alice").should("be.visible");
});
it("shows an error on wrong password", function () {
cy.visit("/login");
cy.get('input[type="email"]').type("alice@example.com");
cy.get('input[type="password"]').type("wrongpassword");
cy.contains("button", "Log in").click();
cy.location("pathname").should("eq", "/login");
cy.contains(".error", "invalid credentials").should("be.visible");
});
it("redirects authenticated users away from /login", function () {
cy.loginViaApi({
email: "alice@example.com",
password: "longenoughpassword",
});
cy.visit("/login");
cy.location("pathname").should("eq", "/");
});
});

View file

@ -0,0 +1,63 @@
describe("new post page", function () {
beforeEach(function () {
cy.resetDb();
cy.clearMail();
cy.seedConfirmedUser({
email: "alice@example.com",
displayName: "alice",
password: "longenoughpassword",
});
cy.seedConfirmedUser({
email: "bob@example.com",
displayName: "bob",
password: "longenoughpassword",
});
});
it("redirects anonymous visitors to /login", function () {
cy.visit("/users/alice/posts/new");
cy.location("pathname").should("eq", "/login");
});
it("redirects users away from another user's new-post page", function () {
cy.loginViaApi({
email: "alice@example.com",
password: "longenoughpassword",
});
cy.visit("/users/bob/posts/new");
cy.location("pathname").should("eq", "/");
});
it("publishes a post and redirects to the new post page", function () {
cy.loginViaApi({
email: "alice@example.com",
password: "longenoughpassword",
});
cy.visit("/users/alice/posts/new");
cy.get('input[type="text"]').type("Hello world");
cy.get("textarea").type("This is the body of my first post.");
cy.contains("button", "Publish").click();
cy.location("pathname").should("match", /^\/posts\/\d+$/);
cy.contains("h1", "Hello world").should("be.visible");
cy.contains(".body", "This is the body of my first post.").should(
"be.visible",
);
});
it("blocks submission when the title is empty (HTML5 validation)", function () {
cy.loginViaApi({
email: "alice@example.com",
password: "longenoughpassword",
});
cy.visit("/users/alice/posts/new");
cy.get("textarea").type("body only");
cy.contains("button", "Publish").click();
cy.location("pathname").should("eq", "/users/alice/posts/new");
cy.get('input[type="text"]:invalid').should("exist");
});
});

View file

@ -0,0 +1,136 @@
describe("post page", function () {
beforeEach(function () {
cy.resetDb();
cy.clearMail();
cy.seedConfirmedUser({
email: "alice@example.com",
displayName: "alice",
password: "longenoughpassword",
});
cy.seedConfirmedUser({
email: "bob@example.com",
displayName: "bob",
password: "longenoughpassword",
});
cy.seedPostAs({
email: "alice@example.com",
password: "longenoughpassword",
title: "Alice post",
body: "Body of alice's post.",
}).as("alicePost");
});
it("renders the post for anonymous visitors with a login CTA", function () {
cy.get<{ id: number }>("@alicePost").then(function (post) {
cy.visit(`/posts/${post.id}`);
cy.contains("h1", "Alice post").should("be.visible");
cy.contains("a", "alice").should("be.visible");
cy.contains("Comments (0)").should("be.visible");
cy.contains("Log in").should("be.visible");
cy.get(".comment-form").should("not.exist");
});
});
it("lets a signed-in user add a comment", function () {
cy.loginViaApi({
email: "bob@example.com",
password: "longenoughpassword",
});
cy.get<{ id: number }>("@alicePost").then(function (post) {
cy.visit(`/posts/${post.id}`);
cy.get(".comment-form textarea").type("nice post");
cy.contains(".comment-form button", "Comment").click();
cy.contains(".comment-list li", "nice post").should("be.visible");
cy.contains(".comment-list li", "bob").should("be.visible");
cy.contains("Comments (1)").should("be.visible");
});
});
it("only shows the Delete control on comments you can delete", function () {
cy.loginViaApi({
email: "bob@example.com",
password: "longenoughpassword",
});
cy.get<{ id: number }>("@alicePost").then(function (post) {
cy.request("POST", `/api/posts/${post.id}/comments`, {
body: "bob's comment",
});
cy.logoutViaApi();
cy.loginViaApi({
email: "alice@example.com",
password: "longenoughpassword",
});
cy.request("POST", `/api/posts/${post.id}/comments`, {
body: "alice's comment",
});
cy.visit(`/posts/${post.id}`);
cy.contains(".comment-list li", "alice's comment")
.find("button.delete")
.should("exist");
cy.contains(".comment-list li", "bob's comment")
.find("button.delete")
.should("not.exist");
});
});
it("hides the Delete post control for non-author non-admin users", function () {
cy.loginViaApi({
email: "bob@example.com",
password: "longenoughpassword",
});
cy.get<{ id: number }>("@alicePost").then(function (post) {
cy.visit(`/posts/${post.id}`);
cy.contains("button", "Delete post").should("not.exist");
cy.contains("button", "Feature in slot 1").should("not.exist");
});
});
it("lets the author delete their post", function () {
cy.loginViaApi({
email: "alice@example.com",
password: "longenoughpassword",
});
cy.get<{ id: number }>("@alicePost").then(function (post) {
cy.visit(`/posts/${post.id}`);
cy.contains("button", "Delete post").click();
cy.location("pathname").should("eq", "/");
});
});
it("shows admin controls (delete, feature, unfeature) to admins", function () {
cy.seedAdmin({
email: "admin@example.com",
displayName: "rootadmin",
password: "longenoughpassword",
});
cy.loginViaApi({
email: "admin@example.com",
password: "longenoughpassword",
});
cy.get<{ id: number }>("@alicePost").then(function (post) {
cy.visit(`/posts/${post.id}`);
cy.contains("button", "Delete post").should("be.visible");
cy.contains("button", "Feature in slot 1").should("be.visible");
cy.contains("button", "Feature in slot 2").should("be.visible");
cy.contains("button", "Feature in slot 1").click();
cy.contains(".badge", "Featured (slot 1)").should("be.visible");
cy.contains("button", "Unfeature").should("be.visible");
cy.contains("button", "Unfeature").click();
cy.contains(".badge", "Featured").should("not.exist");
});
});
it("shows a not-found panel for unknown post ids", function () {
cy.visit("/posts/9999");
cy.contains("h1", "Post not found").should("be.visible");
});
});

View file

@ -0,0 +1,86 @@
describe("profile page", function () {
beforeEach(function () {
cy.resetDb();
cy.clearMail();
cy.seedConfirmedUser({
email: "alice@example.com",
displayName: "alice",
password: "longenoughpassword",
});
cy.seedConfirmedUser({
email: "bob@example.com",
displayName: "bob",
password: "longenoughpassword",
});
cy.seedPostAs({
email: "alice@example.com",
password: "longenoughpassword",
title: "Alice's only post",
body: "Hello.",
});
});
it("shows posts to anonymous visitors without owner controls", function () {
cy.visit("/users/alice");
cy.contains("h1", "alice").should("be.visible");
cy.contains(".post-card", "Alice's only post").should("be.visible");
cy.contains("button", "New post").should("not.exist");
cy.contains("button", "Promote to admin").should("not.exist");
});
it("shows the New post button when viewing your own profile", function () {
cy.loginViaApi({
email: "alice@example.com",
password: "longenoughpassword",
});
cy.visit("/users/alice");
cy.contains("button", "New post").should("be.visible");
cy.contains("button", "Promote to admin").should("not.exist");
});
it("hides the Promote button from non-admin viewers", function () {
cy.loginViaApi({
email: "bob@example.com",
password: "longenoughpassword",
});
cy.visit("/users/alice");
cy.contains("button", "Promote to admin").should("not.exist");
});
it("shows the Promote button to an admin viewing someone else", function () {
cy.seedAdmin({
email: "admin@example.com",
displayName: "rootadmin",
password: "longenoughpassword",
});
cy.loginViaApi({
email: "admin@example.com",
password: "longenoughpassword",
});
cy.visit("/users/alice");
cy.contains("button", "Promote to admin").should("be.visible");
});
it("hides the Promote button when an admin views their own profile", function () {
cy.seedAdmin({
email: "admin@example.com",
displayName: "rootadmin",
password: "longenoughpassword",
});
cy.loginViaApi({
email: "admin@example.com",
password: "longenoughpassword",
});
cy.visit("/users/rootadmin");
cy.contains("button", "Promote to admin").should("not.exist");
});
it("renders a not-found panel for unknown display names", function () {
cy.visit("/users/nobody");
cy.contains("h1", "User not found").should("be.visible");
});
});

View file

@ -0,0 +1,67 @@
describe("signup page", function () {
beforeEach(function () {
cy.resetDb();
cy.clearMail();
});
it("creates an unconfirmed user and redirects to check-email", function () {
cy.visit("/signup");
cy.get('input[type="email"]').type("alice@example.com");
cy.get('input[autocomplete="username"]').type("alice");
cy.contains("button", "Continue").click();
cy.location("pathname").should("eq", "/check-email");
cy.contains("h1", "Check your email").should("be.visible");
cy.getMail().then(function (inbox) {
expect(inbox.messages).to.have.length(1);
expect(inbox.messages[0].To[0].Address).to.equal(
"alice@example.com",
);
});
});
it("surfaces a 409 when the email is already registered", function () {
cy.seedConfirmedUser({
email: "alice@example.com",
displayName: "alice",
password: "longenoughpassword",
});
cy.clearMail();
cy.visit("/signup");
cy.get('input[type="email"]').type("alice@example.com");
cy.get('input[autocomplete="username"]').type("alice2");
cy.contains("button", "Continue").click();
cy.location("pathname").should("eq", "/signup");
cy.contains(".error", "email already registered").should("be.visible");
});
it("surfaces a 409 when the display name is taken", function () {
cy.seedConfirmedUser({
email: "alice@example.com",
displayName: "alice",
password: "longenoughpassword",
});
cy.clearMail();
cy.visit("/signup");
cy.get('input[type="email"]').type("other@example.com");
cy.get('input[autocomplete="username"]').type("alice");
cy.contains("button", "Continue").click();
cy.location("pathname").should("eq", "/signup");
cy.contains(".error", "displayName already taken").should("be.visible");
});
it("blocks invalid display name characters client-side", function () {
cy.visit("/signup");
cy.get('input[type="email"]').type("alice@example.com");
cy.get('input[autocomplete="username"]').type("Has Spaces");
cy.contains("button", "Continue").click();
cy.location("pathname").should("eq", "/signup");
cy.get('input[autocomplete="username"]:invalid').should("exist");
});
});

View file

@ -1,15 +1,104 @@
/// <reference types="cypress" />
interface MailpitAddress {
Address: string;
Name: string;
}
interface MailpitMessageSummary {
ID: string;
From: MailpitAddress;
To: MailpitAddress[];
Subject: string;
Snippet: string;
Created: string;
}
interface MailpitMessages {
total: number;
unread: number;
count: number;
messages: MailpitMessageSummary[];
}
interface MailpitMessageBody {
ID: string;
Text: string;
HTML: string;
}
interface SignupArgs {
email: string;
displayName: string;
}
interface ConfirmArgs {
token: string;
password: string;
}
interface LoginArgs {
email: string;
password: string;
}
interface SeedConfirmedUserArgs {
email: string;
displayName: string;
password: string;
}
type SeedAdminArgs = SeedConfirmedUserArgs;
interface SeedPostArgs {
email: string;
password: string;
title: string;
body: string;
}
interface CreatedPost {
id: number;
userId: number;
authorDisplayName: string;
title: string;
body: string;
createdAt: string;
featureSlot: number | null;
}
interface SeedFeaturedPostArgs {
adminEmail: string;
adminPassword: string;
postId: number;
slot: number;
}
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace Cypress {
interface Chainable {
resetDb(): Chainable<null>;
seedDb(): Chainable<null>;
promoteAdmin(email: string): Chainable<null>;
clearMail(): Chainable<null>;
getMail(): Chainable<MailpitMessages>;
getMailBody(id: string): Chainable<MailpitMessageBody>;
signupViaApi(args: SignupArgs): Chainable<void>;
fetchLatestConfirmToken(email: string): Chainable<string>;
confirmViaApi(args: ConfirmArgs): Chainable<void>;
loginViaApi(args: LoginArgs): Chainable<void>;
logoutViaApi(): Chainable<void>;
seedConfirmedUser(args: SeedConfirmedUserArgs): Chainable<void>;
seedAdmin(args: SeedAdminArgs): Chainable<void>;
seedPostAs(args: SeedPostArgs): Chainable<CreatedPost>;
seedFeaturedPost(args: SeedFeaturedPostArgs): Chainable<void>;
}
}
}
const apiBase = "/api";
Cypress.Commands.add("resetDb", function () {
return cy.task<null>("db:reset");
});
@ -18,4 +107,129 @@ Cypress.Commands.add("seedDb", function () {
return cy.task<null>("db:seed");
});
Cypress.Commands.add("promoteAdmin", function (email: string) {
return cy.task<null>("db:promote", email);
});
Cypress.Commands.add("clearMail", function () {
return cy.task<null>("mailpit:clear");
});
Cypress.Commands.add("getMail", function () {
return cy.task<MailpitMessages>("mailpit:messages");
});
Cypress.Commands.add("getMailBody", function (id: string) {
return cy.task<MailpitMessageBody>("mailpit:message", id);
});
Cypress.Commands.add("signupViaApi", function (args: SignupArgs) {
cy.request({
method: "POST",
url: `${apiBase}/signup`,
body: { email: args.email, displayName: args.displayName },
}).then(function (response) {
expect(response.status).to.equal(201);
});
});
Cypress.Commands.add("fetchLatestConfirmToken", function (email: string) {
return cy.getMail().then(function (inbox) {
const match = inbox.messages.find(function (message) {
return message.To.some(function (to) {
return to.Address.toLowerCase() === email.toLowerCase();
});
});
if (!match) {
throw new Error(`no mailpit message for ${email}`);
}
return cy.getMailBody(match.ID).then(function (body) {
const text = body.Text || body.HTML;
const tokenMatch = text.match(/token=([a-f0-9]+)/);
if (!tokenMatch) {
throw new Error("confirmation token not found in mail body");
}
return tokenMatch[1];
});
});
});
Cypress.Commands.add("confirmViaApi", function (args: ConfirmArgs) {
cy.request({
method: "POST",
url: `${apiBase}/confirm-email`,
body: { token: args.token, password: args.password },
}).then(function (response) {
expect(response.status).to.equal(200);
});
});
Cypress.Commands.add("loginViaApi", function (args: LoginArgs) {
cy.request({
method: "POST",
url: `${apiBase}/login`,
body: { email: args.email, password: args.password },
}).then(function (response) {
expect(response.status).to.equal(200);
});
});
Cypress.Commands.add("logoutViaApi", function () {
cy.request({
method: "POST",
url: `${apiBase}/logout`,
failOnStatusCode: false,
});
});
Cypress.Commands.add(
"seedConfirmedUser",
function (args: SeedConfirmedUserArgs) {
cy.signupViaApi({ email: args.email, displayName: args.displayName });
cy.fetchLatestConfirmToken(args.email).then(function (token) {
cy.confirmViaApi({ token, password: args.password });
});
cy.logoutViaApi();
},
);
Cypress.Commands.add("seedAdmin", function (args: SeedAdminArgs) {
cy.seedConfirmedUser({
email: args.email,
displayName: args.displayName,
password: args.password,
});
cy.promoteAdmin(args.email);
});
Cypress.Commands.add("seedPostAs", function (args: SeedPostArgs) {
cy.loginViaApi({ email: args.email, password: args.password });
return cy
.request({
method: "POST",
url: `${apiBase}/posts`,
body: { title: args.title, body: args.body },
})
.then(function (response) {
expect(response.status).to.equal(201);
cy.logoutViaApi();
return response.body.post as CreatedPost;
});
});
Cypress.Commands.add(
"seedFeaturedPost",
function (args: SeedFeaturedPostArgs) {
cy.loginViaApi({ email: args.adminEmail, password: args.adminPassword });
cy.request({
method: "POST",
url: `${apiBase}/admin/posts/feature`,
body: { postId: args.postId, slot: args.slot },
}).then(function (response) {
expect(response.status).to.equal(200);
});
cy.logoutViaApi();
},
);
export {};

View file

@ -11,12 +11,24 @@ processes:
initial_delay_seconds: 1
period_seconds: 2
mailpit:
command: mailpit --smtp 127.0.0.1:1025 --listen 127.0.0.1:8025
readiness_probe:
http_get:
host: 127.0.0.1
port: 8025
path: /
initial_delay_seconds: 1
period_seconds: 2
backend:
command: php artisan serve --host=127.0.0.1 --port=8000
working_dir: ./backend
depends_on:
postgres:
condition: process_healthy
mailpit:
condition: process_healthy
readiness_probe:
http_get:
host: 127.0.0.1