Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion src/services/messaging/providers/dotnet-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,13 +200,17 @@ export class DotnetMessagingProvider implements MessagingDataProvider {
const q = new URLSearchParams();
if (params.cursor !== null) q.set('cursor', String(params.cursor));
q.set('limit', String(params.limit));
return this.request<MessagesPage>(
// The server emits snake_case (global JsonNamingPolicy.SnakeCaseLower), so
// the pagination flag arrives as `has_more`; map it to the domain's camelCase
// `hasMore` (the rows are already snake_case MessageRow, matching the wire).
const raw = await this.request<{ rows: MessageRow[]; has_more: boolean }>(
ctx,
'GET',
`/api/messaging/conversations/${encodeURIComponent(
params.conversationId
)}/messages?${q.toString()}`
);
return { rows: raw.rows, hasMore: raw.has_more };
}

/**
Expand Down
148 changes: 148 additions & 0 deletions tests/contract/messaging-provider.contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,12 @@ export interface ConformanceHarness {
userBId: string;
conversationId: string;

/**
* A GROUP conversation (is_group=true) with userA + userB as active members;
* the outsider is NOT a member. For C1/C2 group-scoping cases.
*/
groupConversationId: string;

/** Provider bound to userA's authenticated session. */
providerA: MessagingDataProvider;
ctxA: AuthContext;
Expand All @@ -51,11 +57,14 @@ export interface ConformanceHarness {
* Insert a message row directly via the service client (bypasses RLS +
* sequence trigger control), returning the row id. `createdAtIso` lets a test
* backdate a message to exercise the 15-minute edit window (C10).
* `conversationId` defaults to the 1:1 conversation; pass the group id for
* group cases.
*/
seedMessage(opts: {
senderId: string;
ciphertext?: string;
createdAtIso?: string;
conversationId?: string;
}): Promise<{ id: string }>;

/** Read a message row directly (service client) for assertions. */
Expand All @@ -65,8 +74,18 @@ export interface ConformanceHarness {
edited: boolean;
encrypted_content: string;
read_at: string | null;
delivered_at: string | null;
sequence_number: number;
} | null>;

/** Read a conversation row directly (service client) for archive/meta asserts. */
readConversation(id: string): Promise<{
id: string;
is_group: boolean;
participant_1_id: string | null;
archived_by_participant_1: boolean;
archived_by_participant_2: boolean;
} | null>;
}

export interface ConformanceConfig {
Expand Down Expand Up @@ -304,5 +323,134 @@ export function runMessagingProviderContract(config: ConformanceConfig): void {
const withCgid = all.rows.filter((r) => r.client_generated_id === cgid);
expect(withCgid.length).toBe(1);
});

// ── C7 — getMessageById is membership-scoped ─────────────────────────
it('C7: getMessageById returns the row for a participant, null for an outsider', async () => {
const { id } = await h.seedMessage({
senderId: h.userAId,
ciphertext: 'YnlJZA==', // base64("byId")
});
const asParticipant = await h.providerA.getMessageById(h.ctxA, id);
expect(asParticipant?.id).toBe(id);
const asOutsider = await h.providerOutsider.getMessageById(
h.ctxOutsider,
id
);
expect(asOutsider).toBeNull();
});

// ── getProfiles — batch display-name/avatar lookup ───────────────────
it('getProfiles returns the requested users profiles', async () => {
const profiles = await h.providerA.getProfiles(h.ctxA, [
h.userAId,
h.userBId,
]);
const ids = profiles.map((p) => p.id).sort();
expect(ids).toEqual([h.userAId, h.userBId].sort());
});

// ── markAsDelivered — participant-scoped receipt (delivered_at only) ──
it('markAsDelivered sets delivered_at for a participant', async () => {
const { id } = await h.seedMessage({ senderId: h.userAId });
await h.providerB.markAsDelivered(h.ctxB, [id]);
const row = await h.readMessage(id);
expect(row?.delivered_at).not.toBeNull();
});

it('markAsDelivered by a non-participant outsider does not set delivered_at', async () => {
const { id } = await h.seedMessage({ senderId: h.userAId });
await h.providerOutsider
.markAsDelivered(h.ctxOutsider, [id])
.catch(() => {
/* the row check is authoritative */
});
const row = await h.readMessage(id);
expect(row?.delivered_at).toBeNull();
});

// ── C7 — pagination: newest-first, cursor + hasMore ──────────────────
it('C7: getMessages paginates newest-first with a cursor and hasMore', async () => {
// The shared conversation already carries several messages from prior
// cases; add a few more so a small page definitely reports hasMore.
for (let i = 0; i < 3; i++) await h.seedMessage({ senderId: h.userAId });

const page1 = await h.providerA.getMessages(h.ctxA, {
conversationId: h.conversationId,
cursor: null,
limit: 2,
});
expect(page1.rows.length).toBe(2);
expect(page1.hasMore).toBe(true);
// newest-first: descending by sequence_number.
expect(page1.rows[0].sequence_number).toBeGreaterThan(
page1.rows[1].sequence_number
);

const cursor = page1.rows[page1.rows.length - 1].sequence_number;
const page2 = await h.providerA.getMessages(h.ctxA, {
conversationId: h.conversationId,
cursor,
limit: 2,
});
// The next page is strictly older than the cursor (no overlap, no gap-skip).
expect(page2.rows.every((r) => r.sequence_number < cursor)).toBe(true);
});

// ── C5 — archive is a per-participant view flag ──────────────────────
it('C5: a participant can archive then unarchive their own view', async () => {
await h.providerA.archiveConversation(h.ctxA, h.conversationId);
let conv = await h.readConversation(h.conversationId);
const aIsP1 = conv?.participant_1_id === h.userAId;
const aFlag = () =>
aIsP1
? conv?.archived_by_participant_1
: conv?.archived_by_participant_2;
const otherFlag = () =>
aIsP1
? conv?.archived_by_participant_2
: conv?.archived_by_participant_1;
expect(aFlag()).toBe(true);
// The other participant's view is untouched.
expect(otherFlag()).toBe(false);

await h.providerA.unarchiveConversation(h.ctxA, h.conversationId);
conv = await h.readConversation(h.conversationId);
expect(aFlag()).toBe(false);
});

// ── C1/C2 — group membership scoping ─────────────────────────────────
it('C1/C2: a group member can read the group; a non-member cannot', async () => {
await h.seedMessage({
senderId: h.userAId,
conversationId: h.groupConversationId,
ciphertext: 'Z3JvdXA=', // base64("group")
});

const metaMember = await h.providerA.getConversationMeta(
h.ctxA,
h.groupConversationId
);
expect(metaMember).not.toBeNull();
expect(metaMember?.is_group).toBe(true);
const pageMember = await h.providerA.getMessages(h.ctxA, {
conversationId: h.groupConversationId,
cursor: null,
limit: 50,
});
expect(pageMember.rows.length).toBeGreaterThan(0);

// The outsider is not a member — no metadata, no rows leaked.
const metaOutsider = await h.providerOutsider.getConversationMeta(
h.ctxOutsider,
h.groupConversationId
);
expect(metaOutsider).toBeNull();
const pageOutsider = await h.providerOutsider.getMessages(h.ctxOutsider, {
conversationId: h.groupConversationId,
cursor: null,
limit: 50,
});
expect(pageOutsider.rows).toEqual([]);
});
});
}
56 changes: 53 additions & 3 deletions tests/contract/messaging-provider.dotnet.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,33 @@ if (!DOTNET_API_URL || !hasRlsTestEnvironment()) {
}
const conversationId = conv.id;

// A GROUP conversation with userA (creator) + userB as active members;
// the outsider is deliberately NOT a member (C1/C2 scoping). Both providers
// branch on is_group and gate access via creator-or-active-member.
const { data: group, error: groupErr } = await svc
.from('conversations')
.insert({
// Groups carry NULL participants (check_group_participants); membership
// is in conversation_members. Access is creator-or-active-member only.
participant_1_id: null,
participant_2_id: null,
is_group: true,
current_key_version: 1,
created_by: userA.id,
})
.select()
.single();
if (groupErr || !group) {
throw new Error(
`Failed to seed group conversation: ${groupErr?.message ?? 'no row'}`
);
}
const groupConversationId = group.id;
await svc.from('conversation_members').insert([
{ conversation_id: groupConversationId, user_id: userA.id },
{ conversation_id: groupConversationId, user_id: userB.id },
]);

const a = await buildProviderFor(EMAILS.a, baseUrl);
const b = await buildProviderFor(EMAILS.b, baseUrl);
const out = await buildProviderFor(EMAILS.outsider, baseUrl);
Expand All @@ -117,9 +144,10 @@ if (!DOTNET_API_URL || !hasRlsTestEnvironment()) {
senderId,
ciphertext = 'c2VlZA==',
createdAtIso,
conversationId: convId,
}) => {
const row: Record<string, unknown> = {
conversation_id: conversationId,
conversation_id: convId ?? conversationId,
sender_id: senderId,
encrypted_content: ciphertext,
initialization_vector: 'aXY=',
Expand All @@ -142,7 +170,20 @@ if (!DOTNET_API_URL || !hasRlsTestEnvironment()) {
const { data } = await svc
.from('messages')
.select(
'id, deleted, edited, encrypted_content, read_at, sequence_number'
'id, deleted, edited, encrypted_content, read_at, delivered_at, sequence_number'
)
.eq('id', id)
.maybeSingle();
return data ?? null;
};

const readConversation: ConformanceHarness['readConversation'] = async (
id
) => {
const { data } = await svc
.from('conversations')
.select(
'id, is_group, participant_1_id, archived_by_participant_1, archived_by_participant_2'
)
.eq('id', id)
.maybeSingle();
Expand All @@ -154,6 +195,7 @@ if (!DOTNET_API_URL || !hasRlsTestEnvironment()) {
userAId: userA.id,
userBId: userB.id,
conversationId,
groupConversationId,
providerA: a.provider,
ctxA: a.ctx,
providerB: b.provider,
Expand All @@ -163,12 +205,20 @@ if (!DOTNET_API_URL || !hasRlsTestEnvironment()) {
ctxOutsider: out.ctx,
seedMessage,
readMessage,
readConversation,
};
},

async teardown(h: ConformanceHarness): Promise<void> {
const { svc } = h as DotnetHarness;
await svc.from('conversations').delete().eq('id', h.conversationId);
await svc
.from('conversation_members')
.delete()
.eq('conversation_id', h.groupConversationId);
await svc
.from('conversations')
.delete()
.in('id', [h.conversationId, h.groupConversationId]);
await svc
.from('user_connections')
.delete()
Expand Down
55 changes: 52 additions & 3 deletions tests/contract/messaging-provider.supabase.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,32 @@ if (!hasRlsTestEnvironment()) {
}
const conversationId = conv.id;

// A GROUP conversation with userA (creator) + userB as active members;
// the outsider is deliberately NOT a member (C1/C2 scoping).
const { data: group, error: groupErr } = await svc
.from('conversations')
.insert({
// Groups carry NULL participants (check_group_participants); membership
// is in conversation_members. Access is creator-or-active-member only.
participant_1_id: null,
participant_2_id: null,
is_group: true,
current_key_version: 1,
created_by: userA.id,
})
.select()
.single();
if (groupErr || !group) {
throw new Error(
`Failed to seed group conversation: ${groupErr?.message ?? 'no row'}`
);
}
const groupConversationId = group.id;
await svc.from('conversation_members').insert([
{ conversation_id: groupConversationId, user_id: userA.id },
{ conversation_id: groupConversationId, user_id: userB.id },
]);

const a = await buildProviderFor(EMAILS.a);
const b = await buildProviderFor(EMAILS.b);
const out = await buildProviderFor(EMAILS.outsider);
Expand All @@ -113,9 +139,10 @@ if (!hasRlsTestEnvironment()) {
senderId,
ciphertext = 'c2VlZA==',
createdAtIso,
conversationId: convId,
}) => {
const row: Record<string, unknown> = {
conversation_id: conversationId,
conversation_id: convId ?? conversationId,
sender_id: senderId,
encrypted_content: ciphertext,
initialization_vector: 'aXY=',
Expand All @@ -138,7 +165,20 @@ if (!hasRlsTestEnvironment()) {
const { data } = await svc
.from('messages')
.select(
'id, deleted, edited, encrypted_content, read_at, sequence_number'
'id, deleted, edited, encrypted_content, read_at, delivered_at, sequence_number'
)
.eq('id', id)
.maybeSingle();
return data ?? null;
};

const readConversation: ConformanceHarness['readConversation'] = async (
id
) => {
const { data } = await svc
.from('conversations')
.select(
'id, is_group, participant_1_id, archived_by_participant_1, archived_by_participant_2'
)
.eq('id', id)
.maybeSingle();
Expand All @@ -150,6 +190,7 @@ if (!hasRlsTestEnvironment()) {
userAId: userA.id,
userBId: userB.id,
conversationId,
groupConversationId,
providerA: a.provider,
ctxA: a.ctx,
providerB: b.provider,
Expand All @@ -159,13 +200,21 @@ if (!hasRlsTestEnvironment()) {
ctxOutsider: out.ctx,
seedMessage,
readMessage,
readConversation,
};
},

async teardown(h: ConformanceHarness): Promise<void> {
const { svc } = h as SupabaseHarness;
// Cascade: deleting the conversation drops its messages; then the users.
await svc.from('conversations').delete().eq('id', h.conversationId);
await svc
.from('conversation_members')
.delete()
.eq('conversation_id', h.groupConversationId);
await svc
.from('conversations')
.delete()
.in('id', [h.conversationId, h.groupConversationId]);
await svc
.from('user_connections')
.delete()
Expand Down