mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-22 03:49:18 +02:00
fix(inventory): one structured 409 for a tag another spool holds (issue #3110)
The two tag-link routes answered the same conflict differently. The
built-in one said "Tag UID already linked to another active spool" and
named nobody -- while holding the conflicting spool row it had just
loaded -- and Spoolman mode named the spool inside a different English
sentence. Neither was machine-readable, so a client had to parse prose
to learn which spool to look at, and could only do it in one mode.
Both now raise one shared constructor: code tag_already_linked, the
holder's id, and which identifier collided. That is the detail shape
insufficient_filament and printer_connection_failed already use, so
ApiError parses it with no frontend change.
Two active spools can carry one tag -- no unique index on either
column, no conflict check on PATCH /spools/{id}, and /spools/bulk
copies one payload including the tag into every row it creates -- and
the lookup read that with scalar_one_or_none(), which raises on two
rows. The exception escaped into the auth middleware's fail-closed
handler, so the caller was told the authentication service was
unavailable. Both lookups are now ordered and take the first row, as
get_spool_by_tag earlier in the same file always has.
Naming the lowest id means the Spoolman scan reads every row where it
used to stop at its first match, so it now reads extra.tag defensively:
that field is edited outside Bambuddy, and a single null further down
the list would otherwise take the request down in place of the 409.
The kiosk reads the new code: a refused link showed a flat "Failed to
assign spool" and now names the spool holding the tag, reusing the
inventory.tagAlreadyLinked key that no code referenced.
This commit is contained in:
@@ -42,13 +42,37 @@ vi.mock('../../api/client', () => ({
|
||||
spoolbuddyApi: {
|
||||
getDevices: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
// Real class, not a stub: the link handler branches on `instanceof ApiError`
|
||||
// to decide whether a failure carries a structured code (#3110).
|
||||
ApiError: class ApiError extends Error {
|
||||
status: number;
|
||||
code: string | null;
|
||||
detail: Record<string, unknown> | null;
|
||||
constructor(message: string, status: number, code: string | null = null, detail: Record<string, unknown> | null = null) {
|
||||
super(message);
|
||||
this.name = 'ApiError';
|
||||
this.status = status;
|
||||
this.code = code;
|
||||
this.detail = detail;
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
// Hoisted so the react-i18next factory can reach it: what the toast shows is
|
||||
// only half the contract -- the other half is that the spool id reaches the
|
||||
// interpolation bag (#3110), and the key-as-text mock cannot show that.
|
||||
const i18nSpy = vi.hoisted(() => ({ t: vi.fn() }));
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
// Mirrors i18next's (key, defaultValue, options) signature with simple
|
||||
// {{var}} interpolation so tests can assert on the rendered text.
|
||||
t: (key: string, fallback?: string, options?: Record<string, unknown>) => {
|
||||
// Mirrors i18next's overloaded (key, defaultValue?, options?) signature --
|
||||
// including the (key, options) form, where the second argument is the
|
||||
// interpolation bag and there is no default value -- with simple {{var}}
|
||||
// interpolation so tests can assert on the rendered text.
|
||||
t: (key: string, fallbackOrOptions?: string | Record<string, unknown>, maybeOptions?: Record<string, unknown>) => {
|
||||
i18nSpy.t(key, fallbackOrOptions, maybeOptions);
|
||||
const fallback = typeof fallbackOrOptions === 'string' ? fallbackOrOptions : undefined;
|
||||
const options = typeof fallbackOrOptions === 'object' ? fallbackOrOptions : maybeOptions;
|
||||
const text = fallback ?? key;
|
||||
if (!options) return text;
|
||||
return text.replace(/\{\{(\w+)\}\}/g, (_m, k) => String(options[k] ?? ''));
|
||||
@@ -438,6 +462,79 @@ describe('SpoolBuddyDashboard', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('names the spool holding the tag when Spoolman refuses the link (#3110)', async () => {
|
||||
const { api, ApiError } = await import('../../api/client');
|
||||
(api.getSpoolmanSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
spoolman_enabled: 'true',
|
||||
spoolman_url: 'http://localhost:7912',
|
||||
spoolman_sync_mode: 'off',
|
||||
spoolman_disable_weight_sync: 'false',
|
||||
spoolman_report_partial_usage: 'false',
|
||||
});
|
||||
(api.getSpoolmanInventorySpools as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||
{ id: 30, material: 'TPU', brand: 'Bambu', tag_uid: null, tray_uuid: null, archived_at: null, color_name: 'Orange', rgba: 'FF6600FF', subtype: null, label_weight: 1000, core_weight: 250, weight_used: 0 },
|
||||
]);
|
||||
(api.linkTagToSpoolmanSpool as ReturnType<typeof vi.fn>).mockRejectedValue(
|
||||
new ApiError('Tray UUID is already linked to spool 42', 409, 'tag_already_linked', {
|
||||
code: 'tag_already_linked',
|
||||
message: 'Tray UUID is already linked to spool 42',
|
||||
spool_id: 42,
|
||||
field: 'tray_uuid',
|
||||
}),
|
||||
);
|
||||
|
||||
renderPage({
|
||||
unknownTagUid: 'AABB1122334455FF',
|
||||
unknownTrayUuid: 'DEADBEEFDEADBEEFDEADBEEFDEADBEEF',
|
||||
});
|
||||
|
||||
const linkBtn = await waitFor(() => screen.getByText('Assign Spool'));
|
||||
fireEvent.click(linkBtn);
|
||||
fireEvent.click(await waitFor(() => screen.getByText('Orange')));
|
||||
fireEvent.click(await waitFor(() => screen.getByText('Link Tag')));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockShowToast).toHaveBeenCalledWith('inventory.tagAlreadyLinked', 'error');
|
||||
});
|
||||
// The operator can only walk to the other spool if the id is in the
|
||||
// sentence, so assert it reached the interpolation bag rather than
|
||||
// trusting the key-as-text mock's output.
|
||||
expect(i18nSpy.t).toHaveBeenCalledWith('inventory.tagAlreadyLinked', { id: 42 }, undefined);
|
||||
});
|
||||
|
||||
it('keeps the generic toast for a link failure that carries no code', async () => {
|
||||
const { api, ApiError } = await import('../../api/client');
|
||||
(api.getSpoolmanSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
spoolman_enabled: 'true',
|
||||
spoolman_url: 'http://localhost:7912',
|
||||
spoolman_sync_mode: 'off',
|
||||
spoolman_disable_weight_sync: 'false',
|
||||
spoolman_report_partial_usage: 'false',
|
||||
});
|
||||
(api.getSpoolmanInventorySpools as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||
{ id: 30, material: 'TPU', brand: 'Bambu', tag_uid: null, tray_uuid: null, archived_at: null, color_name: 'Orange', rgba: 'FF6600FF', subtype: null, label_weight: 1000, core_weight: 250, weight_used: 0 },
|
||||
]);
|
||||
// A 409 from something other than a tag conflict, and a plain-string
|
||||
// detail, must not be dressed up as one.
|
||||
(api.linkTagToSpoolmanSpool as ReturnType<typeof vi.fn>).mockRejectedValue(
|
||||
new ApiError('Spoolman unavailable', 503),
|
||||
);
|
||||
|
||||
renderPage({
|
||||
unknownTagUid: 'AABB1122334455FF',
|
||||
unknownTrayUuid: 'DEADBEEFDEADBEEFDEADBEEFDEADBEEF',
|
||||
});
|
||||
|
||||
const linkBtn = await waitFor(() => screen.getByText('Assign Spool'));
|
||||
fireEvent.click(linkBtn);
|
||||
fireEvent.click(await waitFor(() => screen.getByText('Orange')));
|
||||
fireEvent.click(await waitFor(() => screen.getByText('Link Tag')));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockShowToast).toHaveBeenCalledWith('spoolman.linkFailed', 'error');
|
||||
});
|
||||
});
|
||||
|
||||
it('clears justLinkedSpool and shows new UnknownTagCard when a different tag is placed', async () => {
|
||||
const { api } = await import('../../api/client');
|
||||
(api.getSpoolmanSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
|
||||
@@ -4923,7 +4923,7 @@ export default {
|
||||
linkToSpool: 'Mit Spule verknüpfen',
|
||||
tagLinked: 'Tag mit Spule verknüpft',
|
||||
tagLinkFailed: 'Tag-Verknüpfung fehlgeschlagen',
|
||||
tagAlreadyLinked: 'Tag bereits mit anderer Spule verknüpft',
|
||||
tagAlreadyLinked: 'Tag bereits mit Spule #{{id}} verknüpft',
|
||||
unknownTag: 'Unbekannter RFID-Tag erkannt',
|
||||
// Verbrauchshistorie
|
||||
usageHistory: 'Verbrauchshistorie',
|
||||
|
||||
@@ -4968,7 +4968,7 @@ export default {
|
||||
linkToSpool: 'Link to Spool',
|
||||
tagLinked: 'Tag linked to spool',
|
||||
tagLinkFailed: 'Failed to link tag',
|
||||
tagAlreadyLinked: 'Tag already linked to another spool',
|
||||
tagAlreadyLinked: 'Tag already linked to spool #{{id}}',
|
||||
unknownTag: 'Unknown RFID tag detected',
|
||||
// Usage history
|
||||
usageHistory: 'Usage History',
|
||||
|
||||
@@ -4930,7 +4930,7 @@ export default {
|
||||
linkToSpool: 'Vincular a bobina',
|
||||
tagLinked: 'Etiqueta vinculada a la bobina',
|
||||
tagLinkFailed: 'Error al vincular la etiqueta',
|
||||
tagAlreadyLinked: 'La etiqueta ya está vinculada a otra bobina',
|
||||
tagAlreadyLinked: 'La etiqueta ya está vinculada a la bobina #{{id}}',
|
||||
unknownTag: 'Se detectó una etiqueta RFID desconocida',
|
||||
// Usage history
|
||||
usageHistory: 'Historial de uso',
|
||||
|
||||
@@ -4912,7 +4912,7 @@ export default {
|
||||
linkToSpool: 'Lier à une Bobine',
|
||||
tagLinked: 'Tag lié à la bobine',
|
||||
tagLinkFailed: 'Échec lien tag',
|
||||
tagAlreadyLinked: 'Tag déjà lié à une autre bobine',
|
||||
tagAlreadyLinked: 'Tag déjà lié à la bobine #{{id}}',
|
||||
unknownTag: 'Tag RFID inconnu détecté',
|
||||
// Usage history
|
||||
usageHistory: 'Historique de Consommation',
|
||||
|
||||
@@ -4911,7 +4911,7 @@ export default {
|
||||
linkToSpool: 'Collega a bobina',
|
||||
tagLinked: 'Tag collegato alla bobina',
|
||||
tagLinkFailed: 'Impossibile collegare il tag',
|
||||
tagAlreadyLinked: 'Tag già collegato a un\'altra bobina',
|
||||
tagAlreadyLinked: 'Tag già collegato alla bobina #{{id}}',
|
||||
unknownTag: 'Tag RFID sconosciuto rilevato',
|
||||
// Usage history
|
||||
usageHistory: 'Cronologia utilizzo',
|
||||
|
||||
@@ -4923,7 +4923,7 @@ export default {
|
||||
linkToSpool: 'スプールにリンク',
|
||||
tagLinked: 'タグがスプールにリンクされました',
|
||||
tagLinkFailed: 'タグのリンクに失敗しました',
|
||||
tagAlreadyLinked: 'タグは既に別のスプールにリンクされています',
|
||||
tagAlreadyLinked: 'タグは既にスプール #{{id}} にリンクされています',
|
||||
unknownTag: '不明なRFIDタグが検出されました',
|
||||
// Usage history
|
||||
usageHistory: '使用履歴',
|
||||
|
||||
@@ -4690,7 +4690,7 @@ export default {
|
||||
linkToSpool: '스풀에 연결',
|
||||
tagLinked: '태그가 스풀에 연결됨',
|
||||
tagLinkFailed: '태그 연결 실패',
|
||||
tagAlreadyLinked: '태그가 이미 다른 스풀에 연결됨',
|
||||
tagAlreadyLinked: '태그가 이미 스풀 #{{id}}에 연결됨',
|
||||
unknownTag: '알 수 없는 RFID 태그 감지됨',
|
||||
usageHistory: '사용 기록',
|
||||
noUsageHistory: '사용 기록 없음',
|
||||
|
||||
@@ -4968,7 +4968,7 @@ export default {
|
||||
linkToSpool: 'Koppelen aan spoel',
|
||||
tagLinked: 'Tag aan spoel gekoppeld',
|
||||
tagLinkFailed: 'Tag koppelen mislukt',
|
||||
tagAlreadyLinked: 'Tag is al aan een andere spoel gekoppeld',
|
||||
tagAlreadyLinked: 'Tag is al aan spoel #{{id}} gekoppeld',
|
||||
unknownTag: 'Onbekende RFID-tag gedetecteerd',
|
||||
// Usage history
|
||||
usageHistory: 'Verbruiksgeschiedenis',
|
||||
|
||||
@@ -4911,7 +4911,7 @@ export default {
|
||||
linkToSpool: 'Vincular ao Carretel',
|
||||
tagLinked: 'Tag vinculada ao carretel',
|
||||
tagLinkFailed: 'Falha ao vincular tag',
|
||||
tagAlreadyLinked: 'Tag já vinculada a outro carretel',
|
||||
tagAlreadyLinked: 'Tag já vinculada ao carretel #{{id}}',
|
||||
unknownTag: 'Tag RFID desconhecida detectada',
|
||||
// Usage history
|
||||
usageHistory: 'Histórico de Uso',
|
||||
|
||||
@@ -4690,7 +4690,7 @@ export default {
|
||||
linkToSpool: "Связать с катушкой",
|
||||
tagLinked: "Метка связана с катушкой",
|
||||
tagLinkFailed: "Не удалось связать метку",
|
||||
tagAlreadyLinked: "Метка уже связана с другой катушкой",
|
||||
tagAlreadyLinked: 'Метка уже связана с катушкой #{{id}}',
|
||||
unknownTag: "Обнаружена неизвестная RFID-метка",
|
||||
usageHistory: "История расхода",
|
||||
noUsageHistory: "Расход пока не зарегистрирован",
|
||||
|
||||
@@ -4902,7 +4902,7 @@ export default {
|
||||
linkToSpool: 'Makaraya Bağla',
|
||||
tagLinked: 'Etiket makaraya bağlandı',
|
||||
tagLinkFailed: 'Etiket bağlanamadı',
|
||||
tagAlreadyLinked: 'Etiket zaten başka bir makaraya bağlı',
|
||||
tagAlreadyLinked: 'Etiket zaten #{{id}} numaralı makaraya bağlı',
|
||||
unknownTag: 'Bilinmeyen RFID etiketi algılandı',
|
||||
usageHistory: 'Kullanım Geçmişi',
|
||||
noUsageHistory: 'Henüz kullanım kaydedilmedi',
|
||||
|
||||
@@ -4965,7 +4965,7 @@ export default {
|
||||
linkToSpool: "Прив’язати до котушки",
|
||||
tagLinked: "Тег пов’язано з котушкою",
|
||||
tagLinkFailed: "Не вдалося зв’язати тег",
|
||||
tagAlreadyLinked: "Тег уже пов’язано з іншою котушкою",
|
||||
tagAlreadyLinked: 'Тег уже пов’язано з котушкою #{{id}}',
|
||||
unknownTag: "Виявлено невідомий тег RFID.",
|
||||
// Usage history
|
||||
usageHistory: "Історія використання",
|
||||
|
||||
@@ -4917,7 +4917,7 @@ export default {
|
||||
linkToSpool: '链接到耗材',
|
||||
tagLinked: '标签已链接到耗材',
|
||||
tagLinkFailed: '链接标签失败',
|
||||
tagAlreadyLinked: '标签已链接到其他耗材',
|
||||
tagAlreadyLinked: '标签已链接到耗材 #{{id}}',
|
||||
unknownTag: '检测到未知 RFID 标签',
|
||||
// Usage history
|
||||
usageHistory: '使用历史',
|
||||
|
||||
@@ -4917,7 +4917,7 @@ export default {
|
||||
linkToSpool: '連結到耗材',
|
||||
tagLinked: '標籤已連結到耗材',
|
||||
tagLinkFailed: '連結標籤失敗',
|
||||
tagAlreadyLinked: '標籤已連結到其他耗材',
|
||||
tagAlreadyLinked: '標籤已連結到耗材 #{{id}}',
|
||||
unknownTag: '偵測到未知 RFID 標籤',
|
||||
// Usage history
|
||||
usageHistory: '使用歷史',
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useOutletContext } from 'react-router-dom';
|
||||
import { useQuery, useQueries, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { SpoolBuddyOutletContext } from '../../components/spoolbuddy/SpoolBuddyLayout';
|
||||
import { api, type InventorySpool, type Printer, type PrinterStatus } from '../../api/client';
|
||||
import { api, ApiError, type InventorySpool, type Printer, type PrinterStatus } from '../../api/client';
|
||||
import type { MatchedSpool } from '../../hooks/useSpoolBuddyState';
|
||||
import { useToast } from '../../contexts/ToastContext';
|
||||
import { SpoolIcon } from '../../components/spoolbuddy/SpoolIcon';
|
||||
@@ -390,7 +390,18 @@ export function SpoolBuddyDashboard() {
|
||||
refetchSpools();
|
||||
} catch (e) {
|
||||
console.error('Failed to link tag:', e);
|
||||
showToast(t('spoolman.linkFailed'), 'error');
|
||||
// The tag is already on another spool -- name it, so the operator can
|
||||
// walk to that spool instead of retrying a scan that cannot succeed.
|
||||
// Both inventory modes answer this with the same structured 409 (#3110);
|
||||
// every other failure keeps the generic toast.
|
||||
const holder =
|
||||
e instanceof ApiError && e.status === 409 && e.code === 'tag_already_linked'
|
||||
? e.detail?.spool_id
|
||||
: undefined;
|
||||
showToast(
|
||||
typeof holder === 'number' ? t('inventory.tagAlreadyLinked', { id: holder }) : t('spoolman.linkFailed'),
|
||||
'error',
|
||||
);
|
||||
} finally {
|
||||
setShowLinkModal(false);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user