Repository: meilisearch/meilisearch-go

Issue:
[v1.15.1] Add support for conversational search
This issue follows the changes related to Meilisearch v1.16.0 [mega issue](https://github.com/meilisearch/integration-guides/issues/317).

## Description

Add the necessary methods to interact with the [chats API](https://www.meilisearch.com/docs/reference/api/chats). You can learn how to use the feature by taking a look at the [getting started](https://www.meilisearch.com/docs/learn/chat/getting_started_with_chat) guide.

For more context, see the related issue in Meilisearch: https://github.com/meilisearch/meilisearch/issues/5713

NB: the chat API was released in [Meilisearch 1.15.1](https://github.com/meilisearch/meilisearch/releases/tag/v1.15.1).

## Tasks

- [ ] Add the new methods to interact with the Meilisearch API
- [ ] Add new tests cases

Discussion:

Solution patch:
diff --git a/chat.go b/chat.go
index f31ce47..b7c1630 100644
--- a/chat.go
+++ b/chat.go
@@ -2,10 +2,61 @@ package meilisearch
 
 import (
 	"context"
+	"fmt"
 	"net/http"
 	"strconv"
 )
 
+func (m *meilisearch) ChatCompletionStream(workspace string, query *ChatCompletionQuery) (*Stream[*ChatCompletionStreamChunk], error) {
+	return m.ChatCompletionStreamWithContext(context.Background(), workspace, query)
+}
+
+func (m *meilisearch) ChatCompletionStreamWithContext(ctx context.Context, workspace string, query *ChatCompletionQuery) (*Stream[*ChatCompletionStreamChunk], error) {
+	if query == nil {
+		return nil, fmt.Errorf("query cannot be nil")
+	}
+
+	if !query.Stream {
+		query.Stream = true
+	}
+
+	req := &internalRequest{
+		endpoint:            fmt.Sprintf("/chats/%s/chat/completions", workspace),
+		method:              http.MethodPost,
+		contentType:         contentTypeJSON,
+		withRequest:         query,
+		acceptedStatusCodes: []int{http.StatusOK},
+	}
+
+	internalError := &Error{
+		Endpoint:         req.endpoint,
+		Method:           req.method,
+		Function:         req.functionName,
+		RequestToString:  "empty request",
+		ResponseToString: "empty response",
+		MeilisearchApiError: meilisearchApiError{
+			Message: "empty meilisearch message",
+		},
+		StatusCodeExpected: req.acceptedStatusCodes,
+		encoder:            m.client.encoder,
+	}
+
+	resp, err := m.client.sendRequest(ctx, req, internalError)
+	if err != nil {
+		return nil, fmt.Errorf("failed to execute request: %w", err)
+	}
+
+	dec := NewDecoder(resp)
+	if dec == nil {
+		// Defensive: if something went wrong constructing the decoder,
+		// close the response to avoid leaking the connection.
+		_ = resp.Body.Close()
+		return nil, fmt.Errorf("failed to create stream decoder: nil decoder")
+	}
+
+	return NewStream[*ChatCompletionStreamChunk](dec, m.client.jsonUnmarshal), nil
+}
+
 func (m *meilisearch) GetChatWorkspace(id string) (*ChatWorkspace, error) {
 	return m.GetChatWorkspaceWithContext(context.Background(), id)
 }
diff --git a/chat_interface.go b/chat_interface.go
index abde8ff..d4c2bdb 100644
--- a/chat_interface.go
+++ b/chat_interface.go
@@ -17,6 +17,11 @@ type ChatManager interface {
 }
 
 type ChatReader interface {
+	// ChatCompletionStream retrieves a stream of chat completions for a given workspace and query.
+	ChatCompletionStream(workspace string, query *ChatCompletionQuery) (*Stream[*ChatCompletionStreamChunk], error)
+	// ChatCompletionStreamWithContext retrieves a stream of chat completions for a given workspace and query with a context.
+	ChatCompletionStreamWithContext(ctx context.Context, workspace string, query *ChatCompletionQuery) (*Stream[*ChatCompletionStreamChunk], error)
+
 	// ListChatWorkspaces retrieves all chat workspaces.
 	ListChatWorkspaces(query *ListChatWorkSpaceQuery) (*ListChatWorkspace, error)
 	// ListChatWorkspacesWithContext retrieves all chat workspaces with a context.
diff --git a/enum.go b/enum.go
index b6f575a..737dbf6 100644
--- a/enum.go
+++ b/enum.go
@@ -146,3 +146,12 @@ const (
 	// ForceClsEmbedderPooling always use CLS pooling
 	ForceClsEmbedderPooling EmbedderPooling = "forceCls"
 )
+
+// ChatRole The role of a message in a chat conversation.
+type ChatRole string
+
+const (
+	ChatRoleUser      ChatRole = "user"
+	ChatRoleAssistant ChatRole = "assistant"
+	ChatRoleSystem    ChatRole = "system"
+)
diff --git a/stream.go b/stream.go
new file mode 100644
index 0000000..6db384b
--- /dev/null
+++ b/stream.go
@@ -0,0 +1,247 @@
+package meilisearch
+
+import (
+	"bufio"
+	"bytes"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"io"
+	"mime"
+	"net/http"
+	"strings"
+)
+
+var decoderTypes = map[string]func(io.ReadCloser) Decoder{}
+
+func RegisterDecoder(contentType string, decoder func(io.ReadCloser) Decoder) {
+	// Normalize key to the media type only (e.g., "text/event-stream")
+	mediaType, _, err := mime.ParseMediaType(contentType)
+	if err != nil || mediaType == "" {
+		mediaType = strings.ToLower(strings.TrimSpace(contentType))
+	}
+	decoderTypes[strings.ToLower(mediaType)] = decoder
+}
+
+type Decoder interface {
+	Next() bool   // advances to next event; false on EOF or error
+	Event() Event // returns the current event
+	Err() error   // returns the terminal error (if any)
+	Close() error // closes the underlying stream
+}
+
+type Event struct {
+	Type string
+	Data []byte
+}
+
+func NewDecoder(res *http.Response) Decoder {
+	if res == nil || res.Body == nil {
+		return &errDecoder{err: errors.New("nil http response/body")}
+	}
+
+	mediaType, _, _ := mime.ParseMediaType(res.Header.Get("Content-Type"))
+	mediaType = strings.ToLower(mediaType)
+
+	if f, ok := decoderTypes[mediaType]; ok {
+		return f(res.Body)
+	}
+
+	// Default: assume SSE if server didn’t set proper header (many do this in dev)
+	// We still use our SSE decoder because Meilisearch chat streaming uses SSE.
+	return newSSEDecoder(res.Body)
+}
+
+// sseDecoder implements parsing per WHATWG SSE spec basics:
+// - Fields: "event:" and "data:" are supported (others ignored).
+// - An empty line terminates one event.
+// - Multiple "data:" lines are joined with "\n" between lines.
+type sseDecoder struct {
+	rc      io.ReadCloser
+	scn     *bufio.Scanner
+	cur     Event
+	err     error
+	closed  bool
+	bufSize int
+}
+
+func newSSEDecoder(rc io.ReadCloser) *sseDecoder {
+	scn := bufio.NewScanner(rc)
+
+	// Increase max token size to safely handle large SSE chunks.
+	// Default is 64K; we raise to 8MB to be safe with LLM chunks.
+	const maxToken = 8 << 20
+	scn.Buffer(make([]byte, 0, 64<<10), maxToken)
+
+	// Use a split that returns full lines WITHOUT the trailing newline,
+	// and handles LF/CRLF. bufio.ScanLines already does this well.
+	// If you need binary safety, you could write a custom SplitFunc.
+	scn.Split(bufio.ScanLines)
+
+	return &sseDecoder{
+		rc:      rc,
+		scn:     scn,
+		bufSize: maxToken,
+	}
+}
+
+func (d *sseDecoder) Next() bool {
+	if d.err != nil || d.closed {
+		return false
+	}
+
+	var eventType string
+	var data bytes.Buffer
+	haveAny := false
+
+	for d.scn.Scan() {
+		line := d.scn.Text()
+
+		// Empty line => dispatch accumulated event (if any)
+		if len(line) == 0 {
+			if haveAny {
+				d.cur = Event{Type: eventType, Data: data.Bytes()}
+				return true
+			}
+			// If we see multiple empty lines, just keep scanning.
+			continue
+		}
+
+		// Comments (lines starting with ':') are ignored per spec.
+		if line[0] == ':' {
+			continue
+		}
+
+		name, val, found := strings.Cut(line, ":")
+		if !found {
+			// Entire line is the field name with empty value; spec allows it.
+			// We only care about "event" and "data". Others ignored.
+			if name == "event" {
+				eventType = ""
+				haveAny = true
+			}
+			continue
+		}
+
+		// Trim one optional leading space after colon.
+		if len(val) > 0 && val[0] == ' ' {
+			val = val[1:]
+		}
+
+		switch name {
+		case "event":
+			eventType = val
+			haveAny = true
+		case "data":
+			if data.Len() > 0 {
+				_ = data.WriteByte('\n')
+			}
+			if _, wErr := data.WriteString(val); wErr != nil {
+				d.err = wErr
+				return false
+			}
+			haveAny = true
+		default:
+			// Ignore other fields like id:, retry:, etc.
+		}
+	}
+
+	// Flush last event if stream ended without trailing blank line
+	if haveAny && d.scn.Err() == nil {
+		d.cur = Event{Type: eventType, Data: data.Bytes()}
+		return true
+	}
+
+	// Propagate scanner error (including io.EOF as nil)
+	if scanErr := d.scn.Err(); scanErr != nil {
+		d.err = scanErr
+	}
+	return false
+}
+
+func (d *sseDecoder) Event() Event { return d.cur }
+
+func (d *sseDecoder) Err() error { return d.err }
+
+func (d *sseDecoder) Close() error {
+	if d.closed {
+		return nil
+	}
+	d.closed = true
+	return d.rc.Close()
+}
+
+type errDecoder struct {
+	err error
+}
+
+func (e *errDecoder) Next() bool   { return false }
+func (e *errDecoder) Event() Event { return Event{} }
+func (e *errDecoder) Err() error   { return e.err }
+func (e *errDecoder) Close() error { return nil }
+
+func init() {
+	RegisterDecoder("text/event-stream", func(rc io.ReadCloser) Decoder {
+		return newSSEDecoder(rc)
+	})
+}
+
+type Stream[T any] struct {
+	decoder   Decoder
+	current   T
+	err       error
+	done      bool
+	unmarshal JSONUnmarshal
+}
+
+func NewStream[T any](decoder Decoder, unmarshal JSONUnmarshal) *Stream[T] {
+	if decoder == nil {
+		return &Stream[T]{err: errors.New("nil decoder")}
+	}
+	if unmarshal == nil {
+		unmarshal = json.Unmarshal
+	}
+	return &Stream[T]{decoder: decoder, unmarshal: unmarshal}
+}
+
+func (s *Stream[T]) Next() bool {
+	if s.err != nil || s.decoder == nil {
+		return false
+	}
+
+	for s.decoder.Next() {
+		ev := s.decoder.Event()
+
+		// Handle [DONE] (ignore trailing newline if present)
+		data := bytes.TrimSpace(ev.Data)
+		if bytes.Equal(data, []byte("[DONE]")) {
+			s.done = true
+			continue
+		}
+
+		// Meilisearch returns OpenAI-style chunks directly in data lines.
+		// Optionally: detect error envelope if your server sends one.
+		var next T
+		if err := s.unmarshal(data, &next); err != nil {
+			s.err = fmt.Errorf("unmarshal stream chunk: %w", err)
+			return false
+		}
+		s.current = next
+		return true
+	}
+
+	// Drain terminal error from decoder (nil on clean EOF)
+	if derr := s.decoder.Err(); derr != nil {
+		s.err = derr
+	}
+	return false
+}
+
+func (s *Stream[T]) Current() T { return s.current }
+func (s *Stream[T]) Err() error { return s.err }
+func (s *Stream[T]) Close() error {
+	if s.decoder == nil {
+		return nil
+	}
+	return s.decoder.Close()
+}
diff --git a/types.go b/types.go
index 1fcf2ab..578c434 100644
--- a/types.go
+++ b/types.go
@@ -726,3 +726,50 @@ type ListChatWorkSpaceQuery struct {
 	Limit  int64
 	Offset int64
 }
+
+type ChatCompletionQuery struct {
+	Model    string                   `json:"model"`
+	Messages []*ChatCompletionMessage `json:"messages"`
+	Stream   bool                     `json:"stream"`
+}
+
+type ChatCompletionMessage struct {
+	Role    ChatRole `json:"role"`
+	Content string   `json:"content"`
+}
+
+type ChatCompletionStreamChunk struct {
+	ID                string                  `json:"id"`
+	Object            *string                 `json:"object,omitempty"`
+	Created           Timestampz              `json:"created,omitempty"`
+	Model             string                  `json:"model,omitempty"`
+	Choices           []*ChatCompletionChoice `json:"choices"`
+	ServiceTier       *string                 `json:"service_tier,omitempty"`
+	SystemFingerprint *string                 `json:"system_fingerprint,omitempty"`
+	Usage             any                     `json:"usage,omitempty"`
+}
+
+type ChatCompletionChoice struct {
+	Index        int64                      `json:"index"`
+	Delta        *ChatCompletionChoiceDelta `json:"delta"`
+	FinishReason *string                    `json:"finish_reason,omitempty"`
+	Logprobs     any                        `json:"logprobs"`
+}
+
+type ChatCompletionChoiceDelta struct {
+	Content      *string   `json:"content,omitempty"`
+	Role         *ChatRole `json:"role,omitempty"`
+	Refusal      *string   `json:"refusal,omitempty"`
+	FunctionCall *string   `json:"function_call,omitempty"`
+	ToolCalls    *string   `json:"tool_calls,omitempty"`
+}
+
+type Timestampz int64
+
+func (t Timestampz) String() string {
+	return time.Unix(int64(t), 0).UTC().Format(time.RFC3339)
+}
+
+func (t Timestampz) ToTime() time.Time {
+	return time.Unix(int64(t), 0).UTC()
+}



Test patch:
diff --git a/integration/chat_test.go b/integration/chat_test.go
index 6861b81..cc8539b 100644
--- a/integration/chat_test.go
+++ b/integration/chat_test.go
@@ -1,6 +1,7 @@
 package integration
 
 import (
+	"os"
 	"testing"
 
 	"github.com/meilisearch/meilisearch-go"
@@ -271,3 +272,65 @@ func Test_ResetChatWorkspaceSettings(t *testing.T) {
 	require.NotNil(t, got)
 	assert.Equal(t, meilisearch.OpenaiChatSource, got.Source)
 }
+
+func Test_ChatCompletionStream(t *testing.T) {
+	sv := setup(t, "")
+	t.Cleanup(cleanupChat(sv))
+
+	resp, err := sv.ExperimentalFeatures().SetChatCompletions(true).Update()
+	require.NoError(t, err)
+	require.NotNil(t, resp)
+	require.True(t, resp.ChatCompletions)
+
+	chat := sv.ChatManager()
+	require.NotNil(t, chat)
+
+	uid := "test-workspace"
+	apiKey := os.Getenv("OPENAI_API_KEY")
+	if apiKey == "" {
+		t.Skip("OPENAI_API_KEY not set; skipping live chat completion stream integration test")
+	}
+
+	_, err = chat.UpdateChatWorkspace(uid, &meilisearch.ChatWorkspaceSettings{
+		Source: meilisearch.OpenaiChatSource,
+		ApiKey: apiKey,
+		Prompts: &meilisearch.ChatWorkspaceSettingsPrompts{
+			System: "You are a helpful assistant that answers questions based on the provided context.",
+		},
+	})
+	require.NoError(t, err)
+
+	query := &meilisearch.ChatCompletionQuery{
+		Model: "gpt-4o-mini",
+		Messages: []*meilisearch.ChatCompletionMessage{
+			{
+				Role:    meilisearch.ChatRoleUser,
+				Content: "Hello, how are you?",
+			},
+		},
+		Stream: true,
+	}
+
+	stream, err := chat.ChatCompletionStream(uid, query)
+	require.NoError(t, err)
+	require.NotNil(t, stream)
+	defer func() { _ = stream.Close() }()
+
+	content := ""
+
+	for stream.Next() {
+		require.NoError(t, stream.Err())
+		chunk := stream.Current()
+		require.NotNil(t, chunk)
+		assert.NotEmpty(t, chunk.Choices)
+		for _, choice := range chunk.Choices {
+			require.NotNil(t, choice.Delta)
+			if choice.Delta.Content != nil {
+				content += *choice.Delta.Content
+			}
+		}
+	}
+
+	assert.NotEmpty(t, content)
+	t.Log(content)
+}
diff --git a/stream_test.go b/stream_test.go
new file mode 100644
index 0000000..75dd53f
--- /dev/null
+++ b/stream_test.go
@@ -0,0 +1,222 @@
+package meilisearch
+
+import (
+	"io"
+	"net/http"
+	"strings"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func httpRespWithBody(t *testing.T, body string, contentType string) *http.Response {
+	t.Helper()
+	if contentType == "" {
+		contentType = "text/event-stream"
+	}
+	return &http.Response{
+		StatusCode: 200,
+		Header:     http.Header{"Content-Type": []string{contentType}},
+		Body:       io.NopCloser(strings.NewReader(body)),
+	}
+}
+
+func TestDecoder_SingleEventsAndDone(t *testing.T) {
+	data := "" +
+		"event: message\n" +
+		"data: {\"key\": \"value\"}\n" +
+		"\n" +
+		"event: done\n" +
+		"data: [DONE]\n" +
+		"\n"
+
+	res := httpRespWithBody(t, data, "text/event-stream")
+	decoder := NewDecoder(res)
+	require.NotNil(t, decoder)
+
+	require.True(t, decoder.Next(), "expected Next() to return true for first event")
+	event := decoder.Event()
+	assert.Equal(t, "message", event.Type, "unexpected event type")
+	// New decoder DOES NOT append a trailing newline after the last data line
+	assert.Equal(t, []byte("{\"key\": \"value\"}"), event.Data, "unexpected event data")
+
+	require.True(t, decoder.Next(), "expected Next() to return true for second event")
+	event = decoder.Event()
+	assert.Equal(t, "done", event.Type, "unexpected event type")
+	assert.Equal(t, []byte("[DONE]"), event.Data, "unexpected event data")
+
+	assert.False(t, decoder.Next(), "expected Next() to return false (EOF)")
+	assert.NoError(t, decoder.Err(), "unexpected error")
+	assert.NoError(t, decoder.Close(), "unexpected error on Close")
+}
+
+func TestStream_BasicFlow(t *testing.T) {
+	type Message struct {
+		Key string `json:"key"`
+	}
+
+	data := "" +
+		"event: message\n" +
+		"data: {\"key\": \"value\"}\n" +
+		"\n" +
+		"event: done\n" +
+		"data: [DONE]\n" +
+		"\n"
+
+	res := httpRespWithBody(t, data, "text/event-stream")
+	decoder := NewDecoder(res)
+	stream := NewStream[Message](decoder, nil)
+
+	require.True(t, stream.Next(), "expected Next() to return true")
+	current := stream.Current()
+	assert.Equal(t, "value", current.Key, "unexpected current.Key")
+
+	// [DONE] should be consumed internally and stop the stream
+	assert.False(t, stream.Next(), "expected Next() to return false after [DONE]")
+	assert.NoError(t, stream.Err(), "unexpected error")
+	assert.NoError(t, stream.Close(), "unexpected error on Close")
+}
+
+func TestStream_ErrorOnInvalidJSON(t *testing.T) {
+	data := "" +
+		"event: message\n" +
+		"data: invalid-json\n" +
+		"\n"
+
+	res := httpRespWithBody(t, data, "text/event-stream")
+	decoder := NewDecoder(res)
+	stream := NewStream[map[string]string](decoder, nil)
+
+	assert.False(t, stream.Next(), "expected Next() to return false on invalid JSON")
+	require.Error(t, stream.Err(), "expected an error")
+	assert.NoError(t, stream.Close(), "unexpected error on Close")
+}
+
+func TestDecoder_ReadErrorPropagates(t *testing.T) {
+	res := &http.Response{
+		StatusCode: 200,
+		Header:     http.Header{"Content-Type": []string{"text/event-stream"}},
+		Body:       io.NopCloser(&errorReader{}),
+	}
+
+	decoder := NewDecoder(res)
+	assert.False(t, decoder.Next(), "expected Next() to return false on read error")
+	require.Error(t, decoder.Err(), "expected an error")
+	assert.NoError(t, decoder.Close(), "unexpected error on Close")
+}
+
+func TestStream_MultipleMessagesThenDone(t *testing.T) {
+	type Message struct {
+		Key string `json:"key"`
+	}
+
+	data := "" +
+		"event: message\n" +
+		"data: {\"key\": \"value1\"}\n" +
+		"\n" +
+		"event: message\n" +
+		"data: {\"key\": \"value2\"}\n" +
+		"\n" +
+		"event: done\n" +
+		"data: [DONE]\n" +
+		"\n"
+
+	res := httpRespWithBody(t, data, "text/event-stream")
+	decoder := NewDecoder(res)
+	stream := NewStream[Message](decoder, nil)
+
+	require.True(t, stream.Next(), "expected Next() true for first message")
+	current := stream.Current()
+	assert.Equal(t, "value1", current.Key)
+
+	require.True(t, stream.Next(), "expected Next() true for second message")
+	current = stream.Current()
+	assert.Equal(t, "value2", current.Key)
+
+	assert.False(t, stream.Next(), "expected Next() false after [DONE]")
+	assert.NoError(t, stream.Err())
+	assert.NoError(t, stream.Close())
+}
+
+// Ensures we handle media type parameters (e.g., charset)
+func TestDecoder_ContentTypeWithParams(t *testing.T) {
+	data := "event: message\n" +
+		"data: {\"ok\": true}\n\n"
+
+	res := httpRespWithBody(t, data, "text/event-stream; charset=utf-8")
+	decoder := NewDecoder(res)
+	require.NotNil(t, decoder)
+
+	require.True(t, decoder.Next())
+	ev := decoder.Event()
+	assert.Equal(t, "message", ev.Type)
+	assert.Equal(t, []byte("{\"ok\": true}"), ev.Data)
+
+	assert.False(t, decoder.Next())
+	assert.NoError(t, decoder.Err())
+	assert.NoError(t, decoder.Close())
+}
+
+// If stream ends without a trailing blank line, last event should still dispatch.
+func TestDecoder_FlushLastEventWithoutTrailingBlankLine(t *testing.T) {
+	data := "" +
+		"event: message\n" +
+		"data: {\"final\": true}\n" // no terminating blank line
+
+	res := httpRespWithBody(t, data, "text/event-stream")
+	decoder := NewDecoder(res)
+
+	require.True(t, decoder.Next())
+	ev := decoder.Event()
+	assert.Equal(t, "message", ev.Type)
+	assert.Equal(t, []byte("{\"final\": true}"), ev.Data)
+
+	assert.False(t, decoder.Next())
+	assert.NoError(t, decoder.Err())
+	assert.NoError(t, decoder.Close())
+}
+
+// Multiple data lines must be joined with a single '\n' between lines (no trailing newline).
+func TestDecoder_MultiDataLinesJoinWithLF(t *testing.T) {
+	data := "" +
+		"event: chunk\n" +
+		"data: hello\n" +
+		"data: world\n" +
+		"\n"
+
+	res := httpRespWithBody(t, data, "text/event-stream")
+	decoder := NewDecoder(res)
+
+	require.True(t, decoder.Next())
+	ev := decoder.Event()
+	assert.Equal(t, "chunk", ev.Type)
+	assert.Equal(t, []byte("hello\nworld"), ev.Data)
+
+	assert.False(t, decoder.Next())
+	assert.NoError(t, decoder.Err())
+	assert.NoError(t, decoder.Close())
+}
+
+// Comments and unknown fields are ignored per spec.
+func TestDecoder_CommentsAndUnknownFieldsIgnored(t *testing.T) {
+	data := "" +
+		": this is a comment\n" +
+		"retry: 10000\n" + // ignored
+		"id: 42\n" + // ignored by our parser
+		"event: ping\n" +
+		"data: {}\n" +
+		"\n"
+
+	res := httpRespWithBody(t, data, "text/event-stream")
+	decoder := NewDecoder(res)
+
+	require.True(t, decoder.Next())
+	ev := decoder.Event()
+	assert.Equal(t, "ping", ev.Type)
+	assert.Equal(t, []byte("{}"), ev.Data)
+
+	assert.False(t, decoder.Next())
+	assert.NoError(t, decoder.Err())
+	assert.NoError(t, decoder.Close())
+}
diff --git a/types_test.go b/types_test.go
index 7f7f9ca..fdc88d5 100644
--- a/types_test.go
+++ b/types_test.go
@@ -3,6 +3,7 @@ package meilisearch
 import (
 	"encoding/json"
 	"testing"
+	"time"
 
 	"github.com/stretchr/testify/require"
 )
@@ -83,3 +84,35 @@ func TestSearchRequest_validate(t *testing.T) {
 		require.Equal(t, "custom", sr.Hybrid.Embedder)
 	})
 }
+
+func TestTimestampz_String(t *testing.T) {
+	t.Parallel()
+
+	cases := []struct {
+		input    Timestampz
+		expected string
+	}{
+		{0, "1970-01-01T00:00:00Z"},
+		{-1, "1969-12-31T23:59:59Z"},
+	}
+
+	for _, c := range cases {
+		require.Equal(t, c.expected, c.input.String())
+	}
+}
+
+func TestTimestampz_ToTime(t *testing.T) {
+	t.Parallel()
+
+	cases := []struct {
+		input    Timestampz
+		expected time.Time
+	}{
+		{0, time.Unix(0, 0).UTC()},
+		{-1, time.Unix(-1, 0).UTC()},
+	}
+
+	for _, c := range cases {
+		require.Equal(t, c.expected, c.input.ToTime())
+	}
+}