hid device: test GET_REPORT stall on a zero-length report
diff --git a/test/unit-test/project.yml b/test/unit-test/project.yml
index 758bf49..bc9bf76 100644
--- a/test/unit-test/project.yml
+++ b/test/unit-test/project.yml
@@ -142,6 +142,9 @@
     :test_dfu_rt_device:
       - CFG_TUD_MSC=0
       - CFG_TUD_DFU_RUNTIME=1
+    :test_hid_device:
+      - CFG_TUD_MSC=0
+      - CFG_TUD_HID=1
     :test_hid_host:
       - CFG_TUH_ENABLED=1
       - CFG_TUH_HID=2
diff --git a/test/unit-test/test/device/hid/test_hid_device.c b/test/unit-test/test/device/hid/test_hid_device.c
new file mode 100644
index 0000000..ca60ee8
--- /dev/null
+++ b/test/unit-test/test/device/hid/test_hid_device.c
@@ -0,0 +1,157 @@
+/*
+ * SPDX-FileCopyrightText: Copyright (c) 2026 TinyUSB contributors
+ * SPDX-License-Identifier: MIT
+ */
+
+#include "unity.h"
+
+#include "tusb_option.h"
+#include "osal/osal.h"
+#include "tusb_fifo.h"
+#include "tusb.h"
+#include "device/usbd.h"
+#include "device/usbd_pvt.h"
+TEST_SOURCE_FILE("usbd.c")
+TEST_SOURCE_FILE("usbd_control.c")
+TEST_SOURCE_FILE("hid_device.c")
+
+#include "mock_dcd.h"
+
+enum { RHPORT = 0, EP_CTRL_OUT = 0x00, EP_CTRL_IN = 0x80, EP_HID_IN = 0x81 };
+enum { REPORT_ID = 3 };
+
+static const uint8_t desc_report[] = { TUD_HID_REPORT_DESC_KEYBOARD(HID_REPORT_ID(REPORT_ID)) };
+
+#define CONFIG_TOTAL_LEN (TUD_CONFIG_DESC_LEN + TUD_HID_DESC_LEN)
+static const uint8_t desc_config[] = {
+  TUD_CONFIG_DESCRIPTOR(1, 1, 0, CONFIG_TOTAL_LEN, 0, 100),
+  TUD_HID_DESCRIPTOR(0, 0, HID_ITF_PROTOCOL_NONE, sizeof(desc_report), EP_HID_IN, CFG_TUD_HID_EP_BUFSIZE, 10)
+};
+
+uint32_t tusb_time_millis_api(void) { return 0; }
+const uint8_t* tud_descriptor_device_cb(void) { return NULL; }
+const uint8_t* tud_descriptor_configuration_cb(uint8_t index) { (void) index; return desc_config; }
+const uint16_t* tud_descriptor_string_cb(uint8_t index, uint16_t langid) { (void) index; (void) langid; return NULL; }
+const uint8_t* tud_hid_descriptor_report_cb(uint8_t instance) { (void) instance; return desc_report; }
+
+static const uint8_t report_payload[] = { 0x11, 0x22, 0x33, 0x44 };
+static uint16_t get_report_ret;
+static uint8_t get_report_id;
+static uint16_t get_report_reqlen;
+
+uint16_t tud_hid_get_report_cb(uint8_t instance, uint8_t report_id, hid_report_type_t report_type, uint8_t* buffer,
+                               uint16_t reqlen) {
+  (void) instance; (void) report_type;
+  get_report_id = report_id;
+  get_report_reqlen = reqlen;
+  memcpy(buffer, report_payload, get_report_ret);
+  return get_report_ret;
+}
+
+void tud_hid_set_report_cb(uint8_t instance, uint8_t report_id, hid_report_type_t report_type, uint8_t const* buffer,
+                           uint16_t bufsize) {
+  (void) instance; (void) report_id; (void) report_type; (void) buffer; (void) bufsize;
+}
+
+// EP0 transfers the stack queued, with IN data snapshotted at submission
+typedef struct {
+  uint8_t ep;
+  uint16_t len;
+  uint8_t data[CFG_TUD_ENDPOINT0_SIZE];
+} ep0_xfer_t;
+static ep0_xfer_t ep0_log[16];
+static unsigned ep0_count;
+static unsigned ep0_cursor;
+static bool ep0_stalled;
+
+static bool stub_edpt_xfer(uint8_t rhport, uint8_t ep, uint8_t* buf, uint16_t len, bool is_isr, int n) {
+  (void) rhport; (void) is_isr; (void) n;
+  TEST_ASSERT_EQUAL(0, tu_edpt_number(ep));
+  TEST_ASSERT_LESS_THAN(TU_ARRAY_SIZE(ep0_log), ep0_count);
+  ep0_xfer_t* x = &ep0_log[ep0_count++];
+  x->ep = ep;
+  x->len = len;
+  if (tu_edpt_dir(ep) == TUSB_DIR_IN && len) memcpy(x->data, buf, len);
+  return true;
+}
+static void stub_edpt_stall(uint8_t rhport, uint8_t ep, int n) {
+  (void) rhport; (void) ep; (void) n;
+  ep0_stalled = true;
+}
+
+// the next queued EP0 transfer must be `ep`/`len`; the host completes it
+static const ep0_xfer_t* host_complete_ep0(uint8_t ep, uint16_t len) {
+  TEST_ASSERT_LESS_THAN_MESSAGE(ep0_count, ep0_cursor, "no EP0 transfer queued");
+  const ep0_xfer_t* x = &ep0_log[ep0_cursor++];
+  TEST_ASSERT_EQUAL_HEX8(ep, x->ep);
+  TEST_ASSERT_EQUAL(len, x->len);
+  dcd_event_xfer_complete(RHPORT, ep, len, XFER_RESULT_SUCCESS, false);
+  tud_task();
+  return x;
+}
+
+static void host_setup(const tusb_control_request_t* req) {
+  dcd_event_setup_received(RHPORT, (const uint8_t*) req, false);
+  tud_task();
+}
+
+static void open_device(void) {
+  dcd_event_bus_reset(RHPORT, TUSB_SPEED_FULL, false);
+  tud_task();
+  const tusb_control_request_t req = { .bmRequestType = 0x00, .bRequest = TUSB_REQ_SET_CONFIGURATION, .wValue = 1 };
+  host_setup(&req);
+  TEST_ASSERT_FALSE(ep0_stalled);
+  host_complete_ep0(EP_CTRL_IN, 0);
+}
+
+static const tusb_control_request_t req_get_report = {
+  .bmRequestType = 0xA1, .bRequest = HID_REQ_CONTROL_GET_REPORT,
+  .wValue = (HID_REPORT_TYPE_INPUT << 8) | REPORT_ID, .wIndex = 0, .wLength = 16 };
+
+void setUp(void) {
+  dcd_int_disable_Ignore();
+  dcd_int_enable_Ignore();
+  dcd_edpt_close_all_Ignore();
+  dcd_set_address_Ignore();
+  dcd_edpt0_status_complete_Ignore();
+  dcd_sof_enable_Ignore();
+  dcd_edpt_open_IgnoreAndReturn(true);
+  dcd_edpt_xfer_Stub(stub_edpt_xfer);
+  dcd_edpt_stall_Stub(stub_edpt_stall);
+  ep0_count = ep0_cursor = 0;
+  ep0_stalled = false;
+  get_report_id = 0;
+  get_report_reqlen = 0;
+
+  if (!tud_inited()) {
+    tusb_rhport_init_t dev_init = { .role = TUSB_ROLE_DEVICE, .speed = TUSB_SPEED_AUTO };
+    dcd_init_ExpectAndReturn(RHPORT, &dev_init, true);
+    tusb_init(RHPORT, &dev_init);
+  }
+  open_device();
+}
+
+void tearDown(void) {}
+
+// a zero-length report stalls rather than send the report ID byte alone
+void test_get_report_zero_length_stalls(void) {
+  get_report_ret = 0;
+  const unsigned queued = ep0_count;
+  host_setup(&req_get_report);
+  TEST_ASSERT_EQUAL(REPORT_ID, get_report_id);
+  TEST_ASSERT_TRUE(ep0_stalled);
+  TEST_ASSERT_EQUAL(queued, ep0_count); // no data stage
+}
+
+void test_get_report_prefixes_report_id(void) {
+  get_report_ret = sizeof(report_payload);
+  host_setup(&req_get_report);
+  TEST_ASSERT_FALSE(ep0_stalled);
+  TEST_ASSERT_EQUAL(REPORT_ID, get_report_id);
+  TEST_ASSERT_EQUAL(req_get_report.wLength - 1, get_report_reqlen);
+
+  const ep0_xfer_t* x = host_complete_ep0(EP_CTRL_IN, 1 + sizeof(report_payload));
+  TEST_ASSERT_EQUAL_HEX8(REPORT_ID, x->data[0]);
+  TEST_ASSERT_EQUAL_HEX8_ARRAY(report_payload, x->data + 1, sizeof(report_payload));
+  host_complete_ep0(EP_CTRL_OUT, 0);
+}