mirror of
https://github.com/HeyPuter/puter.git
synced 2026-08-24 15:07:17 +00:00
test: cover the feedback service and store layers directly
The service owns every feedback business rule — target resolution, eligibility, message normalization, the durable caps, and the owner-email preconditions — but was only reachable through the controller's tests. Give it and the store their own suites so a regression names the layer it broke. Service coverage adds the branches the route tests could not reach: a blocked origin resolving to null rather than surfacing a 403, owners who are suspended or unsubscribed, length measured after normalization, the 24h cap window boundary, subject-header injection via the app title, and the email links being rooted at config.origin. The two describes already labelled `AppFeedbackService ...` move out of the controller test, which keeps only the caller-facing promise that a failed send still returns success.
This commit is contained in:
@@ -565,122 +565,12 @@ describe('AppFeedbackController POST /', () => {
|
||||
|
||||
// ── Owner email delivery ────────────────────────────────────────────
|
||||
|
||||
describe('AppFeedbackService owner email', () => {
|
||||
const mockEmailReady = () => {
|
||||
mockEmailConfigured();
|
||||
return vi
|
||||
.spyOn(server.clients.email, 'send')
|
||||
.mockResolvedValue(undefined);
|
||||
};
|
||||
|
||||
it('emails the confirmed owner with the verified sender email + reply-to', async () => {
|
||||
const send = mockEmailReady();
|
||||
const { userId: ownerId } = await makeUser();
|
||||
await confirmOwnerEmail(ownerId);
|
||||
const app = await makeApp(ownerId, { feedbackEnabled: true });
|
||||
const { actor, userId } = await makeUser();
|
||||
// A verified sender email is what gets shared and used as reply-to.
|
||||
await confirmOwnerEmail(userId);
|
||||
const sender = (await server.stores.user.getById(userId))!;
|
||||
|
||||
await submit(actor, { app: app.name, message: 'hello dev' });
|
||||
|
||||
const owner = (await server.stores.user.getById(ownerId))!;
|
||||
expect(send).toHaveBeenCalledTimes(1);
|
||||
expect(send).toHaveBeenCalledWith(
|
||||
owner.email,
|
||||
'app-user-feedback',
|
||||
expect.objectContaining({
|
||||
owner_username: owner.username,
|
||||
sender_username: sender.username,
|
||||
sender_email: sender.email,
|
||||
app_name: app.name,
|
||||
message: 'hello dev',
|
||||
}),
|
||||
expect.objectContaining({ replyTo: sender.email }),
|
||||
);
|
||||
|
||||
const rows = (await server.clients.db.read(
|
||||
'SELECT `email_sent` FROM `app_feedback` WHERE `user_id` = ?',
|
||||
[userId],
|
||||
)) as Array<{ email_sent: unknown }>;
|
||||
expect(Boolean(rows[0]?.email_sent)).toBe(true);
|
||||
});
|
||||
|
||||
it('does not share an unverified sender email (no reply-to)', async () => {
|
||||
const send = mockEmailReady();
|
||||
const { userId: ownerId } = await makeUser();
|
||||
await confirmOwnerEmail(ownerId);
|
||||
const app = await makeApp(ownerId, { feedbackEnabled: true });
|
||||
// Sender's email is left unverified (makeUser does not confirm it).
|
||||
const { actor } = await makeUser();
|
||||
|
||||
await submit(actor, { app: app.name, message: 'hello dev' });
|
||||
|
||||
expect(send).toHaveBeenCalledTimes(1);
|
||||
const [, , values, options] = send.mock.calls[0];
|
||||
expect((values as Record<string, unknown>).sender_email).toBeNull();
|
||||
expect((options as { replyTo?: string } | undefined)?.replyTo).toBeUndefined();
|
||||
});
|
||||
|
||||
it('stores but does not email when the owner email is unconfirmed', async () => {
|
||||
const send = mockEmailReady();
|
||||
const { userId: ownerId } = await makeUser();
|
||||
const app = await makeApp(ownerId, { feedbackEnabled: true });
|
||||
const { actor, userId } = await makeUser();
|
||||
|
||||
await submit(actor, { app: app.name, message: 'hello dev' });
|
||||
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
const rows = (await server.clients.db.read(
|
||||
'SELECT `email_sent` FROM `app_feedback` WHERE `user_id` = ?',
|
||||
[userId],
|
||||
)) as Array<{ email_sent: unknown }>;
|
||||
expect(Boolean(rows[0]?.email_sent)).toBe(false);
|
||||
});
|
||||
|
||||
it('suppresses email past the per-app daily cap but still stores', async () => {
|
||||
const send = mockEmailReady();
|
||||
const { userId: ownerId } = await makeUser();
|
||||
await confirmOwnerEmail(ownerId);
|
||||
const app = await makeApp(ownerId, { feedbackEnabled: true });
|
||||
|
||||
// Seed the cap with already-emailed rows from other users.
|
||||
for (
|
||||
let i = 0;
|
||||
i < AppFeedbackService.PER_APP_DAILY_EMAIL_LIMIT;
|
||||
i++
|
||||
) {
|
||||
const { userId: seedUserId } = await makeUser();
|
||||
const row = await server.stores.appFeedback.create({
|
||||
appId: app.id,
|
||||
appUid: app.uid,
|
||||
userId: seedUserId,
|
||||
message: `seed ${i}`,
|
||||
});
|
||||
await server.stores.appFeedback.markEmailSent(row.id);
|
||||
}
|
||||
|
||||
const { actor, userId } = await makeUser();
|
||||
const captured = await submit(actor, {
|
||||
app: app.name,
|
||||
message: 'past the cap',
|
||||
});
|
||||
expect(captured.body).toEqual({});
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
|
||||
const rows = (await server.clients.db.read(
|
||||
'SELECT `email_sent` FROM `app_feedback` WHERE `user_id` = ?',
|
||||
[userId],
|
||||
)) as Array<{ email_sent: unknown }>;
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(Boolean(rows[0]?.email_sent)).toBe(false);
|
||||
});
|
||||
|
||||
// Which submissions get emailed, and what the mail contains, is service
|
||||
// logic covered in AppFeedbackService.test.ts. What the controller owes the
|
||||
// caller is that mail trouble never becomes the sender's problem.
|
||||
describe('AppFeedbackController owner email', () => {
|
||||
it('a failing email send never fails the request', async () => {
|
||||
vi.spyOn(server.clients.email, 'isConfigured', 'get').mockReturnValue(
|
||||
true,
|
||||
);
|
||||
mockEmailConfigured();
|
||||
vi.spyOn(server.clients.email, 'send').mockRejectedValue(
|
||||
new Error('smtp down'),
|
||||
);
|
||||
@@ -702,25 +592,3 @@ describe('AppFeedbackService owner email', () => {
|
||||
expect(Boolean(rows[0]?.email_sent)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Message normalization ───────────────────────────────────────────
|
||||
|
||||
describe('AppFeedbackService.normalizeMessage', () => {
|
||||
it('unifies newlines, strips control chars, and trims', () => {
|
||||
const service = server.services.appFeedback;
|
||||
expect(service.normalizeMessage(' a\r\nb\rc | ||||