diff --git a/test/meson.build b/test/meson.build
index 363a2829d895..62f8c7da8b30 100644
--- a/test/meson.build
+++ b/test/meson.build
@@ -32,6 +32,7 @@ subdir('ipa')
 subdir('ipc')
 subdir('log')
 subdir('media_device')
+subdir('pipeline')
 subdir('process')
 subdir('py')
 subdir('serialization')
diff --git a/test/pipeline/duplicate_camera_id.cpp b/test/pipeline/duplicate_camera_id.cpp
new file mode 100644
index 000000000000..d91cb02f53e6
--- /dev/null
+++ b/test/pipeline/duplicate_camera_id.cpp
@@ -0,0 +1,202 @@
+/* SPDX-License-Identifier: GPL-2.0-or-later */
+/*
+ * Copyright (C) 2026 Ideas on Board Oy.
+ */
+
+#include <iostream>
+#include <stdlib.h>
+
+#include <libcamera/camera.h>
+#include <libcamera/property_ids.h>
+
+#include "libcamera/internal/camera.h"
+#include "libcamera/internal/pipeline_handler.h"
+
+#include "test.h"
+
+using namespace libcamera;
+
+namespace {
+
+/*
+ * There's no easy way to communicate to our custom test pipeline
+ * handler. Use a global here to state and track how many cameras should
+ * be constructed.
+ */
+struct MatchState {
+	unsigned int requested;
+	unsigned int created;
+} matchState;
+
+std::string cameraId = "NotVeryUniqueCameraId";
+
+} /* namespace */
+
+namespace libcamera {
+
+class TestCameraData : public Camera::Private
+{
+public:
+	TestCameraData(PipelineHandler *pipe)
+		: Camera::Private(pipe)
+	{
+	}
+};
+
+class PipelineHandlerTest : public PipelineHandler
+{
+public:
+	PipelineHandlerTest(CameraManager *manager)
+		: PipelineHandler(manager)
+	{
+		(void)manager;
+	}
+
+	std::unique_ptr<CameraConfiguration>
+	generateConfiguration(Camera *camera,
+			      std::span<const StreamRole> roles) override
+	{
+		(void)camera;
+		(void)roles;
+		return {};
+	}
+
+	int configure(Camera *camera, CameraConfiguration *config) override
+	{
+		(void)camera;
+		(void)config;
+		return true;
+	}
+
+	int exportFrameBuffers(Camera *camera, Stream *stream,
+			       std::vector<std::unique_ptr<FrameBuffer>> *buffers) override
+	{
+		(void)camera;
+		(void)stream;
+		(void)buffers;
+		return true;
+	}
+
+	int start(Camera *camera, const ControlList *controls) override
+	{
+		(void)camera;
+		(void)controls;
+		return false;
+	}
+
+	void stopDevice(Camera *camera) override
+	{
+		(void)camera;
+	}
+
+	int queueRequestDevice(Camera *camera, Request *request) override
+	{
+		(void)camera;
+		(void)request;
+		return false;
+	}
+
+	bool match(DeviceEnumerator *enumerator) override
+	{
+		(void)enumerator;
+
+		/* Only construct requested number of pipelines */
+		if (matchState.created >= matchState.requested)
+			return false;
+
+		std::unique_ptr<TestCameraData> data = std::make_unique<TestCameraData>(this);
+
+		std::set<Stream *> streams; /* No streams yet on this camera... */
+		std::shared_ptr<Camera> camera = Camera::create(std::move(data), cameraId, streams);
+
+		registerCamera(std::move(camera));
+
+		matchState.created++;
+
+		return true;
+	}
+};
+
+REGISTER_PIPELINE_HANDLER(PipelineHandlerTest, "test")
+
+} /* namespace libcamera */
+
+namespace {
+
+class CameraIdTest : public Test
+{
+protected:
+	int init() override
+	{
+		/* Make sure CameraManager only uses our custom test pipeline */
+		if (setenv("LIBCAMERA_PIPELINES_MATCH_LIST", "test", 1) != 0) {
+			std::cerr << "Failed to set pipeline match list" << std::endl;
+			return TestFail;
+		}
+
+		return TestPass;
+	}
+
+	int cameraConstructionTest()
+	{
+		libcamera::CameraManager cm;
+
+		cm.start();
+
+		/*
+		 * Even if we try to create multiple cameras, we always expect
+		 * only one to succeed.
+		 */
+		const std::vector<std::shared_ptr<Camera>> &cameras = cm.cameras();
+		if (cameras.size() != 1) {
+			std::cerr
+				<< "Expected 1 camera, got "
+				<< cameras.size() << std::endl;
+			return TestFail;
+		}
+
+		/*
+		 * I can't see how it could ever be anything but this here, but
+		 * let's check anyway as the intent of the test is that we get
+		 * only a single camera with the expected ID.
+		 */
+		const std::string &id = cameras[0]->id();
+		if (id != cameraId) {
+			std::cerr
+				<< "Expected '" << cameraId
+				<< "', got '" << id << "'" << std::endl;
+			return TestFail;
+		}
+
+		return TestPass;
+	}
+
+	int run() override
+	{
+		int ret;
+
+		/* Validate a single camera construction */
+		matchState.created = 0;
+		matchState.requested = 1;
+
+		ret = cameraConstructionTest();
+		if (ret != TestPass) {
+			return ret;
+		}
+
+		/* Validate multiple camera construction */
+		matchState.created = 0;
+		matchState.requested = 3;
+
+		ret = cameraConstructionTest();
+		if (ret != TestPass) {
+			return ret;
+		}
+
+		return TestPass;
+	}
+};
+
+} /* namespace */
+
+TEST_REGISTER(CameraIdTest)
diff --git a/test/pipeline/meson.build b/test/pipeline/meson.build
new file mode 100644
index 000000000000..c1f4ee969202
--- /dev/null
+++ b/test/pipeline/meson.build
@@ -0,0 +1,14 @@
+# SPDX-License-Identifier: CC0-1.0
+
+pipeline_tests = [
+    {'name': 'duplicate_camera_id', 'sources': ['duplicate_camera_id.cpp']},
+]
+
+foreach test : pipeline_tests
+    exe = executable(test['name'], test['sources'],
+                     dependencies : libcamera_private,
+                     link_with : test_libraries,
+                     include_directories : test_includes_internal)
+
+    test(test['name'], exe, suite : 'pipeline', is_parallel : true)
+endforeach
