merge frontend-cypress
This commit is contained in:
commit
72cdd7dc4e
15 changed files with 992 additions and 4 deletions
|
|
@ -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=
|
||||
|
|
|
|||
59
backend/app/Console/Commands/UserPromoteCommand.php
Normal file
59
backend/app/Console/Commands/UserPromoteCommand.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -24,6 +24,7 @@
|
|||
typescript
|
||||
postgresql
|
||||
process-compose
|
||||
mailpit
|
||||
];
|
||||
|
||||
shellHook = ''
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
|
|
|
|||
99
frontend/blog_portal/cypress/e2e/admin_actions.cy.ts
Normal file
99
frontend/blog_portal/cypress/e2e/admin_actions.cy.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
48
frontend/blog_portal/cypress/e2e/confirm_email_page.cy.ts
Normal file
48
frontend/blog_portal/cypress/e2e/confirm_email_page.cy.ts
Normal 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",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
41
frontend/blog_portal/cypress/e2e/guest_routes.cy.ts
Normal file
41
frontend/blog_portal/cypress/e2e/guest_routes.cy.ts
Normal 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", "/");
|
||||
});
|
||||
});
|
||||
101
frontend/blog_portal/cypress/e2e/home_page.cy.ts
Normal file
101
frontend/blog_portal/cypress/e2e/home_page.cy.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
41
frontend/blog_portal/cypress/e2e/login_page.cy.ts
Normal file
41
frontend/blog_portal/cypress/e2e/login_page.cy.ts
Normal 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", "/");
|
||||
});
|
||||
});
|
||||
63
frontend/blog_portal/cypress/e2e/new_post_page.cy.ts
Normal file
63
frontend/blog_portal/cypress/e2e/new_post_page.cy.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
136
frontend/blog_portal/cypress/e2e/post_page.cy.ts
Normal file
136
frontend/blog_portal/cypress/e2e/post_page.cy.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
86
frontend/blog_portal/cypress/e2e/profile_page.cy.ts
Normal file
86
frontend/blog_portal/cypress/e2e/profile_page.cy.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
67
frontend/blog_portal/cypress/e2e/signup_page.cy.ts
Normal file
67
frontend/blog_portal/cypress/e2e/signup_page.cy.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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 {};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue