Skip to content

Commit a68f1d9

Browse files
authored
Merge pull request #136 from debdevops/fix/fab-topic-entity-type
fix: FAB Send/Generate targeted a queue named after the topic on topi…
2 parents 943841b + 1b42820 commit a68f1d9

10 files changed

Lines changed: 363 additions & 68 deletions

File tree

apps/web/src/__tests__/components/fab/MessageFAB.test.tsx

Lines changed: 84 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,15 +22,27 @@ vi.mock('react-hot-toast', () => ({
2222
}),
2323
}));
2424

25-
// Mock child modals — keep them lightweight
25+
// Mock child modals — keep them lightweight, expose the entity defaults they receive
2626
vi.mock('@/components/fab/SendMessageModal', () => ({
27-
SendMessageModal: ({ isOpen }: { isOpen: boolean }) =>
28-
isOpen ? <div data-testid="send-modal">SendMessageModal</div> : null,
27+
SendMessageModal: ({ isOpen, defaultEntityType, defaultEntityName }: { isOpen: boolean; defaultEntityType?: string; defaultEntityName?: string | null }) =>
28+
isOpen ? (
29+
<div data-testid="send-modal">
30+
SendMessageModal
31+
<span data-testid="send-modal-entity-type">{defaultEntityType ?? 'unset'}</span>
32+
<span data-testid="send-modal-entity-name">{defaultEntityName ?? 'unset'}</span>
33+
</div>
34+
) : null,
2935
}));
3036

3137
vi.mock('@/components/fab/MessageGeneratorModal', () => ({
32-
MessageGeneratorModal: ({ isOpen }: { isOpen: boolean }) =>
33-
isOpen ? <div data-testid="generator-modal">MessageGeneratorModal</div> : null,
38+
MessageGeneratorModal: ({ isOpen, defaultTargetType, defaultEntityName }: { isOpen: boolean; defaultTargetType?: string; defaultEntityName?: string | null }) =>
39+
isOpen ? (
40+
<div data-testid="generator-modal">
41+
MessageGeneratorModal
42+
<span data-testid="generator-modal-target-type">{defaultTargetType ?? 'unset'}</span>
43+
<span data-testid="generator-modal-entity-name">{defaultEntityName ?? 'unset'}</span>
44+
</div>
45+
) : null,
3446
}));
3547

3648
vi.mock('@/lib/api/messages', () => ({
@@ -161,4 +173,71 @@ describe('MessageFAB', () => {
161173
const dlqButtons = document.querySelectorAll('button[disabled]');
162174
expect(dlqButtons.length).toBeGreaterThan(0);
163175
});
176+
177+
// ── Entity-type forwarding to modals ─────────────────────────────────────────
178+
// Regression: modals used to default to 'queue' even on topic pages, so Send/
179+
// Generate posted to a queue named after the topic (404 for every message).
180+
181+
it('forwards queue entity type and queue name to SendMessageModal', async () => {
182+
renderFAB({ entityType: 'queue', queueName: 'orders' });
183+
await userEvent.click(screen.getByTitle('Open message menu'));
184+
await userEvent.click(screen.getByText('Send Message'));
185+
expect(screen.getByTestId('send-modal-entity-type')).toHaveTextContent('queue');
186+
expect(screen.getByTestId('send-modal-entity-name')).toHaveTextContent('orders');
187+
});
188+
189+
it('forwards topic entity type and topic name to SendMessageModal on topic pages', async () => {
190+
renderFAB({ entityType: 'topic', queueName: null, topicName: 'orders-topic', subscriptionName: 'sub-1' });
191+
await userEvent.click(screen.getByTitle('Open message menu'));
192+
await userEvent.click(screen.getByText('Send Message'));
193+
expect(screen.getByTestId('send-modal-entity-type')).toHaveTextContent('topic');
194+
expect(screen.getByTestId('send-modal-entity-name')).toHaveTextContent('orders-topic');
195+
});
196+
197+
it('forwards topic target type and topic name to MessageGeneratorModal on topic pages', async () => {
198+
renderFAB({ entityType: 'topic', queueName: null, topicName: 'orders-topic', subscriptionName: 'sub-1' });
199+
await userEvent.click(screen.getByTitle('Open message menu'));
200+
await userEvent.click(screen.getByText('Generate Messages'));
201+
expect(screen.getByTestId('generator-modal-target-type')).toHaveTextContent('topic');
202+
expect(screen.getByTestId('generator-modal-entity-name')).toHaveTextContent('orders-topic');
203+
});
204+
205+
// ── Provider gating ───────────────────────────────────────────────────────────
206+
// Test actions are enabled for all providers: Azure (native), AWS (SQS send /
207+
// SNS publish / manual DLQ via redrive target), GCP (publish / manual DLQ by
208+
// republishing to the subscription's dead-letter topic).
209+
210+
it.each(['aws', 'gcp'] as const)('enables Send, Generate, and Test DLQ for %s namespaces', async (provider) => {
211+
renderFAB({ cloudProvider: provider });
212+
await userEvent.click(screen.getByTitle('Open message menu'));
213+
expect(screen.getByText('Send Message').closest('button')).not.toBeDisabled();
214+
expect(screen.getByText('Generate Messages').closest('button')).not.toBeDisabled();
215+
expect(screen.getByText('Test DLQ').closest('button')).not.toBeDisabled();
216+
});
217+
218+
it.each(['aws', 'gcp'] as const)('opens SendMessageModal for %s namespaces', async (provider) => {
219+
renderFAB({ cloudProvider: provider });
220+
await userEvent.click(screen.getByTitle('Open message menu'));
221+
await userEvent.click(screen.getByText('Send Message'));
222+
expect(screen.getByTestId('send-modal')).toBeInTheDocument();
223+
});
224+
225+
it.each(['aws', 'gcp'] as const)('dead-letters messages for %s namespaces when Test DLQ is clicked', async (provider) => {
226+
const { messagesApi } = await import('@/lib/api/messages');
227+
renderFAB({ cloudProvider: provider });
228+
await userEvent.click(screen.getByTitle('Open message menu'));
229+
await userEvent.click(screen.getByText('Test DLQ'));
230+
expect(messagesApi.deadLetter).toHaveBeenCalledWith(
231+
'ns-1', 'orders', 3, 'TestingDLQ', expect.any(String), 'queue'
232+
);
233+
});
234+
235+
it.each(['azure', 'aws', 'gcp'] as const)('keeps destructive actions disabled in production for %s', async (provider) => {
236+
renderFAB({ cloudProvider: provider, environment: 'prod' });
237+
await userEvent.click(screen.getByTitle('Open message menu'));
238+
expect(screen.getByText('Send Message').closest('button')).toBeDisabled();
239+
expect(screen.getByText('Generate Messages').closest('button')).toBeDisabled();
240+
expect(screen.getByText('Test DLQ').closest('button')).toBeDisabled();
241+
expect(screen.getByText(/Production destructive actions disabled/)).toBeInTheDocument();
242+
});
164243
});

apps/web/src/components/fab/MessageFAB.tsx

Lines changed: 24 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,10 @@ import type { CloudProviderType } from '@/lib/api/types';
99

1010
// ============================================================================
1111
// MessageFAB - Enhanced Floating Action Button with multiple actions
12-
// Master control for all Service Bus testing operations
12+
// Master control for messaging test operations across all providers:
13+
// Azure Service Bus, AWS SQS/SNS, and GCP Pub/Sub. Rendered only for DEV
14+
// namespaces with Manage permission (gated in MainLayout); production
15+
// namespaces additionally disable all destructive actions here.
1316
// Handles both Queues and Topic Subscriptions
1417
// ============================================================================
1518

@@ -39,8 +42,11 @@ export function MessageFAB({
3942
onMessagesGenerated,
4043
}: MessageFABProps) {
4144
const isProd = environment?.toLowerCase() === 'prod';
42-
const isNonAzure = cloudProvider === 'aws' || cloudProvider === 'gcp';
43-
const providerLabel = cloudProvider === 'aws' ? 'AWS SQS' : cloudProvider === 'gcp' ? 'GCP Pub/Sub' : 'Azure Service Bus';
45+
// Test actions are supported for all providers: Azure (native), AWS (SQS send /
46+
// SNS publish / manual DLQ via the queue's redrive target), and GCP (publish /
47+
// manual DLQ by republishing to the subscription's dead-letter topic). AWS and
48+
// GCP require a DLQ/dead-letter policy on the entity — a clear error explains
49+
// this when it is missing.
4450
const fabBgColor = cloudProvider === 'aws'
4551
? 'bg-gradient-to-br from-orange-500 to-orange-600 hover:from-orange-600 hover:to-orange-700 ring-orange-200'
4652
: cloudProvider === 'gcp'
@@ -229,12 +235,6 @@ export function MessageFAB({
229235
</div>
230236
</button>
231237

232-
{isNonAzure && (
233-
<div className="px-3 py-1.5 text-[11px] font-medium text-purple-700 bg-purple-50 border border-purple-200 rounded-lg mb-2 flex items-center gap-1.5">
234-
<span></span> {providerLabel} — write operations in Phase 2
235-
</div>
236-
)}
237-
238238
{isProd && (
239239
<div className="px-3 py-1.5 text-[11px] font-medium text-amber-700 bg-amber-50 border border-amber-200 rounded-lg mb-2 flex items-center gap-1.5">
240240
<span></span> Production — destructive actions disabled
@@ -244,29 +244,29 @@ export function MessageFAB({
244244
{/* Dead-Letter Messages Option */}
245245
<button
246246
onClick={isProd ? undefined : handleDeadLetter}
247-
disabled={!hasValidEntity || isDeadLettering || isProd || isNonAzure}
247+
disabled={!hasValidEntity || isDeadLettering || isProd}
248248
className={`
249249
flex items-center gap-3
250250
px-4 py-3
251251
border rounded-xl shadow-lg
252252
transition-all duration-150
253253
group
254-
${!hasValidEntity || isProd || isNonAzure
254+
${!hasValidEntity || isProd
255255
? 'bg-gray-100 border-gray-200 cursor-not-allowed opacity-50'
256256
: 'bg-white hover:bg-red-50 border-gray-200 hover:border-red-300 hover:shadow-xl'
257257
}
258258
`}
259-
title={isProd ? 'Quick Actions are disabled for Production namespaces' : isNonAzure ? `Write operations for ${providerLabel} coming in Phase 2` : 'For testing: moves up to 3 messages from the active queue to the dead-letter queue'}
259+
title={isProd ? 'Quick Actions are disabled for Production namespaces' : 'For testing: moves up to 3 messages from the active queue to the dead-letter queue'}
260260
>
261261
<div className={`p-2 rounded-lg transition-colors ${
262-
!hasValidEntity || isProd || isNonAzure
262+
!hasValidEntity || isProd
263263
? 'bg-gray-200'
264264
: 'bg-red-100 group-hover:bg-red-200'
265265
}`}>
266-
<Skull className={`w-5 h-5 ${!hasValidEntity || isProd || isNonAzure ? 'text-gray-400' : 'text-red-600'}`} />
266+
<Skull className={`w-5 h-5 ${!hasValidEntity || isProd ? 'text-gray-400' : 'text-red-600'}`} />
267267
</div>
268268
<div className="text-left">
269-
<div className={`text-sm font-semibold ${!hasValidEntity || isProd || isNonAzure ? 'text-gray-400' : 'text-gray-800'}`}>
269+
<div className={`text-sm font-semibold ${!hasValidEntity || isProd ? 'text-gray-400' : 'text-gray-800'}`}>
270270
{isDeadLettering ? 'Moving...' : 'Test DLQ'}
271271
</div>
272272
<div className="text-xs text-gray-500 max-w-[180px]">
@@ -278,19 +278,19 @@ export function MessageFAB({
278278
{/* Generate Messages Option */}
279279
<button
280280
onClick={handleOpenGenerate}
281-
disabled={isProd || isNonAzure}
281+
disabled={isProd}
282282
className={`
283283
flex items-center gap-3
284284
px-4 py-3
285285
border rounded-xl shadow-lg
286286
transition-all duration-150
287287
group
288-
${isProd || isNonAzure
288+
${isProd
289289
? 'bg-gray-100 border-gray-200 cursor-not-allowed opacity-50'
290290
: 'bg-white hover:bg-amber-50 border-gray-200 hover:border-amber-300 hover:shadow-xl'
291291
}
292292
`}
293-
title={isProd ? 'Quick Actions are disabled for Production namespaces' : isNonAzure ? `Write operations for ${providerLabel} coming in Phase 2` : undefined}
293+
title={isProd ? 'Quick Actions are disabled for Production namespaces' : undefined}
294294
>
295295
<div className="p-2 bg-amber-100 rounded-lg group-hover:bg-amber-200 transition-colors">
296296
<Wand2 className="w-5 h-5 text-amber-600" />
@@ -304,19 +304,19 @@ export function MessageFAB({
304304
{/* Send Message Option */}
305305
<button
306306
onClick={handleOpenSend}
307-
disabled={isProd || isNonAzure}
307+
disabled={isProd}
308308
className={`
309309
flex items-center gap-3
310310
px-4 py-3
311311
border rounded-xl shadow-lg
312312
transition-all duration-150
313313
group
314-
${isProd || isNonAzure
314+
${isProd
315315
? 'bg-gray-100 border-gray-200 cursor-not-allowed opacity-50'
316316
: 'bg-white hover:bg-sky-50 border-gray-200 hover:border-sky-300 hover:shadow-xl'
317317
}
318318
`}
319-
title={isProd ? 'Quick Actions are disabled for Production namespaces' : isNonAzure ? `Write operations for ${providerLabel} coming in Phase 2` : undefined}
319+
title={isProd ? 'Quick Actions are disabled for Production namespaces' : undefined}
320320
>
321321
<div className="p-2 bg-sky-100 rounded-lg group-hover:bg-sky-200 transition-colors">
322322
<Send className="w-5 h-5 text-sky-600" />
@@ -357,7 +357,8 @@ export function MessageFAB({
357357
onClose={() => setActiveModal(null)}
358358
onSend={handleSend}
359359
defaultNamespaceId={namespaceId}
360-
defaultQueueName={queueName}
360+
defaultEntityName={entityType === 'topic' ? topicName : queueName}
361+
defaultEntityType={entityType}
361362
/>
362363

363364
{/* Message Generator Modal */}
@@ -366,6 +367,7 @@ export function MessageFAB({
366367
onClose={() => setActiveModal(null)}
367368
defaultNamespaceId={namespaceId}
368369
defaultEntityName={entityType === 'topic' ? topicName : queueName}
370+
defaultTargetType={entityType}
369371
onGenerated={handleGenerated}
370372
/>
371373
</>

apps/web/src/components/fab/MessageGeneratorModal.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ interface MessageGeneratorModalProps {
4040
onClose: () => void;
4141
defaultNamespaceId?: string | null;
4242
defaultEntityName?: string | null;
43+
defaultTargetType?: 'queue' | 'topic';
4344
onGenerated?: () => void;
4445
}
4546

@@ -91,6 +92,7 @@ export function MessageGeneratorModal({
9192
onClose,
9293
defaultNamespaceId,
9394
defaultEntityName,
95+
defaultTargetType,
9496
onGenerated,
9597
}: MessageGeneratorModalProps) {
9698
// Data fetching
@@ -101,7 +103,7 @@ export function MessageGeneratorModal({
101103
const { data: topics } = useTopics(selectedNamespace);
102104

103105
// Form state
104-
const [targetType, setTargetType] = useState<TargetType>('queue');
106+
const [targetType, setTargetType] = useState<TargetType>(defaultTargetType || 'queue');
105107
const [selectedEntity, setSelectedEntity] = useState(defaultEntityName || '');
106108
const [volume, setVolume] = useState<VolumePreset>(50);
107109
const [selectedScenarios, setSelectedScenarios] = useState<MessageScenario[]>(getDefaultScenarios());
@@ -113,7 +115,8 @@ export function MessageGeneratorModal({
113115
useEffect(() => {
114116
if (defaultNamespaceId) setSelectedNamespace(defaultNamespaceId);
115117
if (defaultEntityName) setSelectedEntity(defaultEntityName);
116-
}, [defaultNamespaceId, defaultEntityName]);
118+
if (defaultTargetType) setTargetType(defaultTargetType);
119+
}, [defaultNamespaceId, defaultEntityName, defaultTargetType]);
117120

118121
// Reset entity when namespace changes
119122
useEffect(() => {

apps/web/src/components/fab/MessageSendFAB.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ export function MessageSendFAB({ namespaceId, queueName, onMessageSent }: Messag
6767
onClose={() => setIsModalOpen(false)}
6868
onSend={handleSend}
6969
defaultNamespaceId={namespaceId}
70-
defaultQueueName={queueName}
70+
defaultEntityName={queueName}
7171
/>
7272
</>
7373
);

apps/web/src/components/fab/SendMessageModal.tsx

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@ interface SendMessageModalProps {
1515
onClose: () => void;
1616
onSend: (data: MessagePayload) => void;
1717
defaultNamespaceId?: string | null;
18-
defaultQueueName?: string | null;
18+
defaultEntityName?: string | null;
19+
defaultEntityType?: 'queue' | 'topic';
1920
}
2021

2122
interface MessageProperty {
@@ -41,16 +42,17 @@ export function SendMessageModal({
4142
onClose,
4243
onSend,
4344
defaultNamespaceId,
44-
defaultQueueName
45+
defaultEntityName,
46+
defaultEntityType
4547
}: SendMessageModalProps) {
4648
const { data: namespaces } = useNamespaces();
4749
const [selectedNamespace, setSelectedNamespace] = useState(defaultNamespaceId || '');
4850
const { data: queues } = useQueues(selectedNamespace);
4951
const { data: topics } = useTopics(selectedNamespace);
5052
const sendMessage = useSendMessage();
5153

52-
const [entityType, setEntityType] = useState<'queue' | 'topic'>('queue');
53-
const [entity, setEntity] = useState(defaultQueueName || '');
54+
const [entityType, setEntityType] = useState<'queue' | 'topic'>(defaultEntityType || 'queue');
55+
const [entity, setEntity] = useState(defaultEntityName || '');
5456
const [body, setBody] = useState('{\n "orderId": "ORD-2026-12345",\n "amount": 99.99,\n "currency": "USD"\n}');
5557
const [contentType, setContentType] = useState('application/json');
5658
const [properties, setProperties] = useState<MessageProperty[]>([
@@ -65,11 +67,12 @@ export function SendMessageModal({
6567
const [sendMultiple, setSendMultiple] = useState(false);
6668
const [messageCount, setMessageCount] = useState(1);
6769

68-
// Update selected namespace and queue when defaults change
70+
// Update selected namespace, entity, and entity type when defaults change
6971
useEffect(() => {
7072
if (defaultNamespaceId) setSelectedNamespace(defaultNamespaceId);
71-
if (defaultQueueName) setEntity(defaultQueueName);
72-
}, [defaultNamespaceId, defaultQueueName]);
73+
if (defaultEntityName) setEntity(defaultEntityName);
74+
if (defaultEntityType) setEntityType(defaultEntityType);
75+
}, [defaultNamespaceId, defaultEntityName, defaultEntityType]);
7376

7477
// Handle Escape key to close modal
7578
useEffect(() => {

apps/web/src/components/layout/MainLayout.tsx

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -86,9 +86,8 @@ export function MainLayout() {
8686
// FAB: only in DEV environment with Manage (write) permission — never in UAT/Prod or read-only connections
8787
const canUseFab = currentNamespace?.environment === 'dev' && currentNamespace?.hasManagePermission === true;
8888

89-
// Determine entity type and names for FAB
89+
// Determine entity type for FAB
9090
const entityType: 'queue' | 'topic' = topicName ? 'topic' : 'queue';
91-
const entityName = queueName || topicName || '';
9291

9392
const handleMessagesGenerated = () => {
9493
// Mark stale without triggering an immediate burst refetch — the next
@@ -130,9 +129,9 @@ export function MainLayout() {
130129

131130
{/* FAB - Show on messages page in DEV with Manage permission, never in Demo mode */}
132131
{isMessagesPage && canUseFab && !isDemoMode && (
133-
<MessageFAB
132+
<MessageFAB
134133
namespaceId={namespaceId}
135-
queueName={entityName}
134+
queueName={queueName}
136135
entityType={entityType}
137136
topicName={topicName}
138137
subscriptionName={subscriptionName}

apps/web/src/pages/CrossCloudTracePage.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ function FlowDiagram({ clouds, hops, summaries }: FlowDiagramProps) {
6565
{hopsPerCloud(cloud)} hop{hopsPerCloud(cloud) !== 1 ? 's' : ''}
6666
</span>
6767
{!wasSearched(cloud) && (
68-
<span className="text-xs text-amber-600 font-medium">Phase 2</span>
68+
<span className="text-xs text-amber-600 font-medium">Not searched</span>
6969
)}
7070
</div>
7171
{/* Connector arrow (not after last node) */}
@@ -169,7 +169,7 @@ function NamespaceSummaryRow({ summary }: { summary: CrossCloudNamespaceSummary
169169
) : (
170170
<span className="flex items-center gap-1 text-amber-600 text-xs font-medium" title={summary.skipReason ?? ''}>
171171
<Clock className="w-3.5 h-3.5" />
172-
{summary.skipReason?.startsWith('AWS') || summary.skipReason?.startsWith('GCP') ? 'Phase 2' : 'Skipped'}
172+
{summary.skipReason?.includes('not enabled') ? 'Not enabled' : 'Skipped'}
173173
</span>
174174
)}
175175
</div>
@@ -368,7 +368,8 @@ export function CrossCloudTracePage() {
368368
{result.namespaceSummaries.some(s => !s.wasSearched && (s.cloudProvider === 'aws' || s.cloudProvider === 'gcp')) && (
369369
<p className="mt-2 text-xs text-gray-400 flex items-center gap-1">
370370
<Info className="w-3.5 h-3.5" />
371-
AWS and GCP namespace search will be available in Phase 2.
371+
AWS/GCP namespaces are searched when their provider is enabled on the
372+
server (CloudProviders:Aws/Gcp:Enabled) — skipped ones show the reason above.
372373
</p>
373374
)}
374375
</div>

0 commit comments

Comments
 (0)