705 lines
24 KiB
TypeScript
705 lines
24 KiB
TypeScript
import {beforeEach, describe, expect, it} from 'vitest';
|
|
import {ContactService} from '../ContactService';
|
|
import {factories, getPrismaClient} from '../../../../../test/helpers';
|
|
|
|
describe('ContactService - Duplicate Prevention & Data Merging', () => {
|
|
let projectId: string;
|
|
const prisma = getPrismaClient();
|
|
|
|
beforeEach(async () => {
|
|
const {project} = await factories.createUserWithProject();
|
|
projectId = project.id;
|
|
});
|
|
|
|
describe('Duplicate Email Prevention', () => {
|
|
it('should REJECT creating duplicate email in same project', async () => {
|
|
const email = 'test@example.com';
|
|
|
|
await ContactService.create(projectId, {email});
|
|
|
|
await expect(ContactService.create(projectId, {email})).rejects.toThrow(/already exists/i);
|
|
});
|
|
|
|
it('should ALLOW same email in different projects (multi-tenancy)', async () => {
|
|
const {project: project1} = await factories.createUserWithProject();
|
|
const {project: project2} = await factories.createUserWithProject();
|
|
|
|
const email = 'test@example.com';
|
|
|
|
const contact1 = await ContactService.create(project1.id, {email});
|
|
const contact2 = await ContactService.create(project2.id, {email});
|
|
|
|
expect(contact1.id).not.toBe(contact2.id);
|
|
expect(contact1.email).toBe(email);
|
|
expect(contact2.email).toBe(email);
|
|
});
|
|
|
|
it('should REJECT updating contact to duplicate email in same project', async () => {
|
|
const email1 = 'user1@example.com';
|
|
const email2 = 'user2@example.com';
|
|
|
|
await ContactService.create(projectId, {email: email1});
|
|
const contact2 = await ContactService.create(projectId, {email: email2});
|
|
|
|
await expect(ContactService.update(projectId, contact2.id, {email: email1})).rejects.toThrow(/already exists/i);
|
|
});
|
|
|
|
it('should ALLOW updating contact to same email (no-op)', async () => {
|
|
const email = 'test@example.com';
|
|
const contact = await ContactService.create(projectId, {email});
|
|
|
|
const updated = await ContactService.update(projectId, contact.id, {
|
|
email,
|
|
data: {firstName: 'John'},
|
|
});
|
|
|
|
expect(updated.email).toBe(email);
|
|
});
|
|
|
|
it('should handle race condition when creating same email simultaneously', async () => {
|
|
const email = 'race@example.com';
|
|
|
|
const promises = Array.from({length: 10}, () => ContactService.create(projectId, {email}));
|
|
|
|
const results = await Promise.allSettled(promises);
|
|
const successful = results.filter(r => r.status === 'fulfilled').length;
|
|
const failed = results.filter(r => r.status === 'rejected').length;
|
|
|
|
expect(successful).toBe(1);
|
|
expect(failed).toBe(9);
|
|
|
|
const contacts = await prisma.contact.findMany({
|
|
where: {projectId, email},
|
|
});
|
|
expect(contacts).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
describe('Upsert Data Merging Logic', () => {
|
|
it('should merge new data with existing data without losing fields', async () => {
|
|
const email = 'test@example.com';
|
|
|
|
const contact = await ContactService.upsert(projectId, email, {
|
|
firstName: 'John',
|
|
plan: 'free',
|
|
signupDate: '2024-01-01',
|
|
});
|
|
|
|
expect(contact.data).toMatchObject({
|
|
firstName: 'John',
|
|
plan: 'free',
|
|
signupDate: '2024-01-01',
|
|
});
|
|
|
|
const updated = await ContactService.upsert(projectId, email, {
|
|
lastName: 'Doe',
|
|
company: 'Acme Inc',
|
|
});
|
|
|
|
expect(updated.data).toMatchObject({
|
|
firstName: 'John',
|
|
plan: 'free',
|
|
signupDate: '2024-01-01',
|
|
lastName: 'Doe',
|
|
company: 'Acme Inc',
|
|
});
|
|
});
|
|
|
|
it('should overwrite existing fields with new values', async () => {
|
|
const email = 'test@example.com';
|
|
|
|
await ContactService.upsert(projectId, email, {
|
|
plan: 'free',
|
|
credits: 100,
|
|
});
|
|
|
|
const updated = await ContactService.upsert(projectId, email, {
|
|
plan: 'pro',
|
|
credits: 1000,
|
|
});
|
|
|
|
expect(updated.data).toMatchObject({
|
|
plan: 'pro',
|
|
credits: 1000,
|
|
});
|
|
});
|
|
|
|
it('should NOT persist non-persistent data', async () => {
|
|
const email = 'test@example.com';
|
|
|
|
const contact = await ContactService.upsert(projectId, email, {
|
|
firstName: 'John',
|
|
tempToken: {value: 'abc123', persistent: false},
|
|
oneTimeCode: {value: '123456', persistent: false},
|
|
});
|
|
|
|
expect(contact.data).toHaveProperty('firstName', 'John');
|
|
expect(contact.data).not.toHaveProperty('tempToken');
|
|
expect(contact.data).not.toHaveProperty('oneTimeCode');
|
|
});
|
|
|
|
it('should ignore reserved fields (plunk_id, plunk_email)', async () => {
|
|
const email = 'test@example.com';
|
|
|
|
const contact = await ContactService.upsert(projectId, email, {
|
|
firstName: 'John',
|
|
plunk_id: 'malicious-id',
|
|
plunk_email: 'hacker@evil.com',
|
|
});
|
|
|
|
expect(contact.data).toHaveProperty('firstName', 'John');
|
|
expect(contact.data).not.toHaveProperty('plunk_id');
|
|
expect(contact.data).not.toHaveProperty('plunk_email');
|
|
});
|
|
|
|
it('should handle null data gracefully', async () => {
|
|
const email = 'test@example.com';
|
|
|
|
const contact = await ContactService.upsert(projectId, email, undefined);
|
|
|
|
expect(contact.email).toBe(email);
|
|
});
|
|
|
|
it('should handle empty object data', async () => {
|
|
const email = 'test@example.com';
|
|
|
|
const contact = await ContactService.upsert(projectId, email, {});
|
|
|
|
expect(contact.email).toBe(email);
|
|
});
|
|
|
|
it('should update subscription status independently of data', async () => {
|
|
const email = 'test@example.com';
|
|
|
|
await ContactService.upsert(projectId, email, {firstName: 'John'}, true);
|
|
|
|
const subscribed = await prisma.contact.findFirst({
|
|
where: {projectId, email},
|
|
});
|
|
expect(subscribed?.subscribed).toBe(true);
|
|
|
|
await ContactService.upsert(projectId, email, {lastName: 'Doe'}, false);
|
|
|
|
const unsubscribed = await prisma.contact.findFirst({
|
|
where: {projectId, email},
|
|
});
|
|
expect(unsubscribed?.subscribed).toBe(false);
|
|
expect(unsubscribed?.data).toMatchObject({
|
|
firstName: 'John',
|
|
lastName: 'Doe',
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('getMergedData - Template Rendering', () => {
|
|
it('should include reserved plunk_id and plunk_email fields', async () => {
|
|
const contact = await factories.createContact({
|
|
projectId,
|
|
email: 'test@example.com',
|
|
});
|
|
|
|
const merged = ContactService.getMergedData(contact);
|
|
|
|
expect(merged.plunk_id).toBe(contact.id);
|
|
expect(merged.plunk_email).toBe('test@example.com');
|
|
});
|
|
|
|
it('should merge persistent contact data', async () => {
|
|
const contact = await factories.createContact({
|
|
projectId,
|
|
data: {firstName: 'John', plan: 'pro'},
|
|
});
|
|
|
|
const merged = ContactService.getMergedData(contact);
|
|
|
|
expect(merged.firstName).toBe('John');
|
|
expect(merged.plan).toBe('pro');
|
|
});
|
|
|
|
it('should merge temporary (non-persistent) data for rendering', async () => {
|
|
const contact = await factories.createContact({
|
|
projectId,
|
|
data: {firstName: 'John'},
|
|
});
|
|
|
|
const temporaryData = {
|
|
resetToken: {value: 'temp123', persistent: false},
|
|
resetUrl: {value: 'https://app.com/reset?token=temp123', persistent: false},
|
|
};
|
|
|
|
const merged = ContactService.getMergedData(contact, temporaryData);
|
|
|
|
expect(merged.firstName).toBe('John');
|
|
expect(merged.resetToken).toBe('temp123');
|
|
expect(merged.resetUrl).toBe('https://app.com/reset?token=temp123');
|
|
});
|
|
|
|
it('should override persistent data with temporary data', async () => {
|
|
const contact = await factories.createContact({
|
|
projectId,
|
|
data: {name: 'Stored Name'},
|
|
});
|
|
|
|
const temporaryData = {
|
|
name: 'Override Name',
|
|
};
|
|
|
|
const merged = ContactService.getMergedData(contact, temporaryData);
|
|
|
|
expect(merged.name).toBe('Override Name');
|
|
});
|
|
|
|
it('should not allow overriding reserved fields via temporary data', async () => {
|
|
const contact = await factories.createContact({
|
|
projectId,
|
|
email: 'real@example.com',
|
|
});
|
|
|
|
const temporaryData = {
|
|
plunk_id: 'fake-id',
|
|
plunk_email: 'fake@example.com',
|
|
};
|
|
|
|
const merged = ContactService.getMergedData(contact, temporaryData);
|
|
|
|
expect(merged.plunk_id).toBe(contact.id);
|
|
expect(merged.plunk_email).toBe('real@example.com');
|
|
});
|
|
});
|
|
|
|
describe('Data Integrity Edge Cases', () => {
|
|
it('should handle deeply nested object data', async () => {
|
|
const email = 'test@example.com';
|
|
|
|
const contact = await ContactService.upsert(projectId, email, {
|
|
profile: {
|
|
name: 'John',
|
|
address: {
|
|
city: 'NYC',
|
|
zip: '10001',
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(contact.data).toMatchObject({
|
|
profile: {
|
|
name: 'John',
|
|
address: {
|
|
city: 'NYC',
|
|
zip: '10001',
|
|
},
|
|
},
|
|
});
|
|
});
|
|
|
|
it('should handle array data', async () => {
|
|
const email = 'test@example.com';
|
|
|
|
const contact = await ContactService.upsert(projectId, email, {
|
|
tags: ['vip', 'beta-tester', 'early-adopter'],
|
|
});
|
|
|
|
expect(contact.data).toMatchObject({
|
|
tags: ['vip', 'beta-tester', 'early-adopter'],
|
|
});
|
|
});
|
|
|
|
it('should handle special characters in field names', async () => {
|
|
const email = 'test@example.com';
|
|
|
|
const contact = await ContactService.upsert(projectId, email, {
|
|
'custom-field': 'value',
|
|
'field.with.dots': 'value2',
|
|
'field with spaces': 'value3',
|
|
});
|
|
|
|
expect(contact.data).toHaveProperty('custom-field', 'value');
|
|
expect(contact.data).toHaveProperty('field.with.dots', 'value2');
|
|
expect(contact.data).toHaveProperty('field with spaces', 'value3');
|
|
});
|
|
|
|
it('should handle boolean, number, and string values', async () => {
|
|
const email = 'test@example.com';
|
|
|
|
const contact = await ContactService.upsert(projectId, email, {
|
|
isPremium: true,
|
|
credits: 100,
|
|
name: 'John Doe',
|
|
discount: 0.15,
|
|
});
|
|
|
|
expect(contact.data).toMatchObject({
|
|
isPremium: true,
|
|
credits: 100,
|
|
name: 'John Doe',
|
|
discount: 0.15,
|
|
});
|
|
});
|
|
|
|
it('should handle null values in data', async () => {
|
|
const email = 'test@example.com';
|
|
|
|
const contact = await ContactService.upsert(projectId, email, {
|
|
firstName: 'John',
|
|
middleName: null,
|
|
lastName: 'Doe',
|
|
});
|
|
|
|
expect(contact.data).toHaveProperty('firstName', 'John');
|
|
expect(contact.data).toHaveProperty('middleName', null);
|
|
expect(contact.data).toHaveProperty('lastName', 'Doe');
|
|
});
|
|
});
|
|
|
|
describe('Contact CRUD Operations', () => {
|
|
it('should find contact by email', async () => {
|
|
const email = 'find@example.com';
|
|
const created = await ContactService.create(projectId, {email});
|
|
|
|
const found = await ContactService.findByEmail(projectId, email);
|
|
|
|
expect(found?.id).toBe(created.id);
|
|
expect(found?.email).toBe(email);
|
|
});
|
|
|
|
it('should return null when contact not found by email', async () => {
|
|
const found = await ContactService.findByEmail(projectId, 'nonexistent@example.com');
|
|
|
|
expect(found).toBeNull();
|
|
});
|
|
|
|
it('should get contact count for project', async () => {
|
|
await factories.createContact({projectId});
|
|
await factories.createContact({projectId});
|
|
await factories.createContact({projectId});
|
|
|
|
const count = await ContactService.count(projectId);
|
|
|
|
expect(count).toBe(3);
|
|
});
|
|
|
|
it('should delete contact', async () => {
|
|
const contact = await factories.createContact({projectId});
|
|
|
|
await ContactService.delete(projectId, contact.id);
|
|
|
|
const deleted = await prisma.contact.findUnique({
|
|
where: {id: contact.id},
|
|
});
|
|
|
|
expect(deleted).toBeNull();
|
|
});
|
|
|
|
it('should throw 404 when deleting non-existent contact', async () => {
|
|
await expect(ContactService.delete(projectId, 'non-existent')).rejects.toThrow(/not found/i);
|
|
});
|
|
|
|
it('should throw 404 when getting non-existent contact', async () => {
|
|
await expect(ContactService.get(projectId, 'non-existent')).rejects.toThrow(/not found/i);
|
|
});
|
|
});
|
|
|
|
describe('Public Contact Operations (Unsubscribe)', () => {
|
|
it('should get contact by ID without project authentication', async () => {
|
|
const contact = await factories.createContact({projectId});
|
|
|
|
const fetched = await ContactService.getById(contact.id);
|
|
|
|
expect(fetched.id).toBe(contact.id);
|
|
expect(fetched.email).toBe(contact.email);
|
|
});
|
|
|
|
it('should subscribe contact', async () => {
|
|
const contact = await factories.createContact({
|
|
projectId,
|
|
subscribed: false,
|
|
});
|
|
|
|
await ContactService.subscribe(contact.id);
|
|
|
|
const subscribed = await prisma.contact.findUnique({
|
|
where: {id: contact.id},
|
|
});
|
|
|
|
expect(subscribed?.subscribed).toBe(true);
|
|
});
|
|
|
|
it('should unsubscribe contact', async () => {
|
|
const contact = await factories.createContact({
|
|
projectId,
|
|
subscribed: true,
|
|
});
|
|
|
|
await ContactService.unsubscribe(contact.id);
|
|
|
|
const unsubscribed = await prisma.contact.findUnique({
|
|
where: {id: contact.id},
|
|
});
|
|
|
|
expect(unsubscribed?.subscribed).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('Bulk Contact Operations', () => {
|
|
describe('bulkSubscribe', () => {
|
|
it('should subscribe multiple unsubscribed contacts', async () => {
|
|
const contact1 = await factories.createContact({projectId, subscribed: false});
|
|
const contact2 = await factories.createContact({projectId, subscribed: false});
|
|
const contact3 = await factories.createContact({projectId, subscribed: false});
|
|
|
|
const result = await ContactService.bulkSubscribe(projectId, [contact1.id, contact2.id, contact3.id]);
|
|
|
|
expect(result.updated).toBe(3);
|
|
|
|
const contacts = await prisma.contact.findMany({
|
|
where: {id: {in: [contact1.id, contact2.id, contact3.id]}},
|
|
});
|
|
|
|
expect(contacts.every(c => c.subscribed)).toBe(true);
|
|
});
|
|
|
|
it('should only update unsubscribed contacts, not already subscribed ones', async () => {
|
|
const unsubscribed1 = await factories.createContact({projectId, subscribed: false});
|
|
const unsubscribed2 = await factories.createContact({projectId, subscribed: false});
|
|
const alreadySubscribed = await factories.createContact({projectId, subscribed: true});
|
|
|
|
const result = await ContactService.bulkSubscribe(projectId, [
|
|
unsubscribed1.id,
|
|
unsubscribed2.id,
|
|
alreadySubscribed.id,
|
|
]);
|
|
|
|
expect(result.updated).toBe(2);
|
|
});
|
|
|
|
it('should return 0 if no contacts need updating', async () => {
|
|
const contact1 = await factories.createContact({projectId, subscribed: true});
|
|
const contact2 = await factories.createContact({projectId, subscribed: true});
|
|
|
|
const result = await ContactService.bulkSubscribe(projectId, [contact1.id, contact2.id]);
|
|
|
|
expect(result.updated).toBe(0);
|
|
});
|
|
|
|
it('should only update contacts belonging to the specified project', async () => {
|
|
const {project: otherProject} = await factories.createUserWithProject();
|
|
const ownContact = await factories.createContact({projectId, subscribed: false});
|
|
const otherContact = await factories.createContact({projectId: otherProject.id, subscribed: false});
|
|
|
|
const result = await ContactService.bulkSubscribe(projectId, [ownContact.id, otherContact.id]);
|
|
|
|
expect(result.updated).toBe(1);
|
|
|
|
const ownContactAfter = await prisma.contact.findUnique({where: {id: ownContact.id}});
|
|
const otherContactAfter = await prisma.contact.findUnique({where: {id: otherContact.id}});
|
|
|
|
expect(ownContactAfter?.subscribed).toBe(true);
|
|
expect(otherContactAfter?.subscribed).toBe(false);
|
|
});
|
|
|
|
it('should handle empty contact IDs array', async () => {
|
|
const result = await ContactService.bulkSubscribe(projectId, []);
|
|
|
|
expect(result.updated).toBe(0);
|
|
});
|
|
|
|
it('should handle non-existent contact IDs gracefully', async () => {
|
|
const result = await ContactService.bulkSubscribe(projectId, ['non-existent-1', 'non-existent-2']);
|
|
|
|
expect(result.updated).toBe(0);
|
|
});
|
|
|
|
it('should handle large batches efficiently', async () => {
|
|
const contacts = await Promise.all(
|
|
Array.from({length: 150}, () => factories.createContact({projectId, subscribed: false})),
|
|
);
|
|
const contactIds = contacts.map(c => c.id);
|
|
|
|
const result = await ContactService.bulkSubscribe(projectId, contactIds);
|
|
|
|
expect(result.updated).toBe(150);
|
|
|
|
const updatedContacts = await prisma.contact.findMany({
|
|
where: {id: {in: contactIds}},
|
|
});
|
|
|
|
expect(updatedContacts.every(c => c.subscribed)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('bulkUnsubscribe', () => {
|
|
it('should unsubscribe multiple subscribed contacts', async () => {
|
|
const contact1 = await factories.createContact({projectId, subscribed: true});
|
|
const contact2 = await factories.createContact({projectId, subscribed: true});
|
|
const contact3 = await factories.createContact({projectId, subscribed: true});
|
|
|
|
const result = await ContactService.bulkUnsubscribe(projectId, [contact1.id, contact2.id, contact3.id]);
|
|
|
|
expect(result.updated).toBe(3);
|
|
|
|
const contacts = await prisma.contact.findMany({
|
|
where: {id: {in: [contact1.id, contact2.id, contact3.id]}},
|
|
});
|
|
|
|
expect(contacts.every(c => !c.subscribed)).toBe(true);
|
|
});
|
|
|
|
it('should only update subscribed contacts, not already unsubscribed ones', async () => {
|
|
const subscribed1 = await factories.createContact({projectId, subscribed: true});
|
|
const subscribed2 = await factories.createContact({projectId, subscribed: true});
|
|
const alreadyUnsubscribed = await factories.createContact({projectId, subscribed: false});
|
|
|
|
const result = await ContactService.bulkUnsubscribe(projectId, [
|
|
subscribed1.id,
|
|
subscribed2.id,
|
|
alreadyUnsubscribed.id,
|
|
]);
|
|
|
|
expect(result.updated).toBe(2);
|
|
});
|
|
|
|
it('should return 0 if no contacts need updating', async () => {
|
|
const contact1 = await factories.createContact({projectId, subscribed: false});
|
|
const contact2 = await factories.createContact({projectId, subscribed: false});
|
|
|
|
const result = await ContactService.bulkUnsubscribe(projectId, [contact1.id, contact2.id]);
|
|
|
|
expect(result.updated).toBe(0);
|
|
});
|
|
|
|
it('should only update contacts belonging to the specified project', async () => {
|
|
const {project: otherProject} = await factories.createUserWithProject();
|
|
const ownContact = await factories.createContact({projectId, subscribed: true});
|
|
const otherContact = await factories.createContact({projectId: otherProject.id, subscribed: true});
|
|
|
|
const result = await ContactService.bulkUnsubscribe(projectId, [ownContact.id, otherContact.id]);
|
|
|
|
expect(result.updated).toBe(1);
|
|
|
|
const ownContactAfter = await prisma.contact.findUnique({where: {id: ownContact.id}});
|
|
const otherContactAfter = await prisma.contact.findUnique({where: {id: otherContact.id}});
|
|
|
|
expect(ownContactAfter?.subscribed).toBe(false);
|
|
expect(otherContactAfter?.subscribed).toBe(true);
|
|
});
|
|
|
|
it('should handle empty contact IDs array', async () => {
|
|
const result = await ContactService.bulkUnsubscribe(projectId, []);
|
|
|
|
expect(result.updated).toBe(0);
|
|
});
|
|
|
|
it('should handle non-existent contact IDs gracefully', async () => {
|
|
const result = await ContactService.bulkUnsubscribe(projectId, ['non-existent-1', 'non-existent-2']);
|
|
|
|
expect(result.updated).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('bulkDelete', () => {
|
|
it('should delete multiple contacts', async () => {
|
|
const contact1 = await factories.createContact({projectId});
|
|
const contact2 = await factories.createContact({projectId});
|
|
const contact3 = await factories.createContact({projectId});
|
|
|
|
const result = await ContactService.bulkDelete(projectId, [contact1.id, contact2.id, contact3.id]);
|
|
|
|
expect(result.deleted).toBe(3);
|
|
|
|
const contacts = await prisma.contact.findMany({
|
|
where: {id: {in: [contact1.id, contact2.id, contact3.id]}},
|
|
});
|
|
|
|
expect(contacts).toHaveLength(0);
|
|
});
|
|
|
|
it('should only delete contacts belonging to the specified project', async () => {
|
|
const {project: otherProject} = await factories.createUserWithProject();
|
|
const ownContact = await factories.createContact({projectId});
|
|
const otherContact = await factories.createContact({projectId: otherProject.id});
|
|
|
|
const result = await ContactService.bulkDelete(projectId, [ownContact.id, otherContact.id]);
|
|
|
|
expect(result.deleted).toBe(1);
|
|
|
|
const ownContactAfter = await prisma.contact.findUnique({where: {id: ownContact.id}});
|
|
const otherContactAfter = await prisma.contact.findUnique({where: {id: otherContact.id}});
|
|
|
|
expect(ownContactAfter).toBeNull();
|
|
expect(otherContactAfter).not.toBeNull();
|
|
});
|
|
|
|
it('should handle empty contact IDs array', async () => {
|
|
const result = await ContactService.bulkDelete(projectId, []);
|
|
|
|
expect(result.deleted).toBe(0);
|
|
});
|
|
|
|
it('should handle non-existent contact IDs gracefully', async () => {
|
|
const result = await ContactService.bulkDelete(projectId, ['non-existent-1', 'non-existent-2']);
|
|
|
|
expect(result.deleted).toBe(0);
|
|
});
|
|
|
|
it('should handle large batches efficiently', async () => {
|
|
const contacts = await Promise.all(Array.from({length: 200}, () => factories.createContact({projectId})));
|
|
const contactIds = contacts.map(c => c.id);
|
|
|
|
const result = await ContactService.bulkDelete(projectId, contactIds);
|
|
|
|
expect(result.deleted).toBe(200);
|
|
|
|
const remainingContacts = await prisma.contact.findMany({
|
|
where: {id: {in: contactIds}},
|
|
});
|
|
|
|
expect(remainingContacts).toHaveLength(0);
|
|
});
|
|
|
|
it('should delete both subscribed and unsubscribed contacts', async () => {
|
|
const subscribed = await factories.createContact({projectId, subscribed: true});
|
|
const unsubscribed = await factories.createContact({projectId, subscribed: false});
|
|
|
|
const result = await ContactService.bulkDelete(projectId, [subscribed.id, unsubscribed.id]);
|
|
|
|
expect(result.deleted).toBe(2);
|
|
});
|
|
|
|
it('should handle partial matches (some exist, some do not)', async () => {
|
|
const existingContact = await factories.createContact({projectId});
|
|
|
|
const result = await ContactService.bulkDelete(projectId, [existingContact.id, 'non-existent-id']);
|
|
|
|
expect(result.deleted).toBe(1);
|
|
|
|
const contact = await prisma.contact.findUnique({where: {id: existingContact.id}});
|
|
expect(contact).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('Bulk Operations - Project Isolation', () => {
|
|
it('should never leak contacts between projects in bulk operations', async () => {
|
|
const {project: project1} = await factories.createUserWithProject();
|
|
const {project: project2} = await factories.createUserWithProject();
|
|
|
|
const p1Contact1 = await factories.createContact({projectId: project1.id, subscribed: false});
|
|
const p1Contact2 = await factories.createContact({projectId: project1.id, subscribed: false});
|
|
const p2Contact1 = await factories.createContact({projectId: project2.id, subscribed: false});
|
|
const p2Contact2 = await factories.createContact({projectId: project2.id, subscribed: false});
|
|
|
|
await ContactService.bulkSubscribe(project1.id, [p1Contact1.id, p1Contact2.id, p2Contact1.id, p2Contact2.id]);
|
|
|
|
const p1ContactsAfter = await prisma.contact.findMany({
|
|
where: {projectId: project1.id},
|
|
});
|
|
const p2ContactsAfter = await prisma.contact.findMany({
|
|
where: {projectId: project2.id},
|
|
});
|
|
|
|
expect(p1ContactsAfter.every(c => c.subscribed)).toBe(true);
|
|
expect(p2ContactsAfter.every(c => !c.subscribed)).toBe(true);
|
|
});
|
|
});
|
|
});
|
|
});
|